#!/usr/bin/env python3 import asyncio import os import sys import shutil from pathlib import Path async def run_command(*args): process = await asyncio.create_subprocess_exec( *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) stdout, stderr = await process.communicate() if process.returncode != 0: print(f"Error running {' '.join(args)}: {stderr.decode().strip()}", file=sys.stderr) return stdout.decode().strip() def theme_exists(theme_name: str) -> bool: """Check if a GTK theme exists in common locations.""" search_paths = [ Path.home() / ".themes", Path.home() / ".local/share/themes", Path("/usr/share/themes"), Path("/usr/local/share/themes"), ] # Add paths from XDG_DATA_DIRS xdg_data_dirs = os.environ.get("XDG_DATA_DIRS", "") if xdg_data_dirs: for path in xdg_data_dirs.split(":"): if path: search_paths.append(Path(path) / "themes") for base_path in search_paths: if (base_path / theme_name).is_dir(): return True return False GTK_IMPORT = '@import url("noctalia.css");' def ensure_gtk_css_import(gtk_css: Path, colors_file: Path, label: str) -> bool: """ Append the noctalia.css import to gtk.css if not already present. If gtk.css doesn't exist, create it with the import. Does not overwrite user modifications (similar to niri template). """ if not colors_file.exists(): print(f"Error: {label} noctalia.css not found at {colors_file}", file=sys.stderr) return False if gtk_css.exists() or gtk_css.is_symlink(): content = gtk_css.read_text() # Already has the import (flexible: allow optional whitespace / different quoting) if "noctalia.css" in content and "@import" in content: return True # Need to modify — handle symlinks carefully target = gtk_css if gtk_css.is_symlink(): resolved = gtk_css.resolve() if os.access(resolved, os.W_OK): # Writable symlink (e.g. dotfiles): edit the target directly target = resolved else: # Read-only symlink (e.g. NixOS): convert to local file gtk_css.unlink() gtk_css.write_text(resolved.read_text()) # Append import to the end new_content = content.rstrip() if new_content and not new_content.endswith("\n"): new_content += "\n" new_content += "\n" + GTK_IMPORT + "\n" target.write_text(new_content) print(f"Appended {label} noctalia.css import to gtk.css") else: gtk_css.write_text(GTK_IMPORT + "\n") print(f"Created {label} gtk.css with noctalia.css import") return True async def apply_gtk3_colors(config_dir: Path): gtk3_dir = config_dir / "gtk-3.0" colors_file = gtk3_dir / "noctalia.css" gtk_css = gtk3_dir / "gtk.css" return ensure_gtk_css_import(gtk_css, colors_file, "GTK3") async def apply_gtk4_colors(config_dir: Path): gtk4_dir = config_dir / "gtk-4.0" colors_file = gtk4_dir / "noctalia.css" gtk_css = gtk4_dir / "gtk.css" return ensure_gtk_css_import(gtk_css, colors_file, "GTK4") async def sync_system_appearance(mode: str, *, update_gtk_theme: bool = True) -> None: """ Push light/dark to org.gnome.desktop.interface (gsettings or dconf fallback). Used by the GTK template post-hook and ColorSchemeService when "Sync system theme" is on (both set color-scheme and gtk-theme when themes exist). --appearance-only skips CSS and only updates color-scheme for narrow tooling use. """ has_gsettings = shutil.which("gsettings") has_dconf = shutil.which("dconf") if not has_gsettings and not has_dconf: print("No gsettings or dconf found, skip system appearance sync") return target_theme = "adw-gtk3" if mode == "light" else "adw-gtk3-dark" theme_available = update_gtk_theme and theme_exists(target_theme) if update_gtk_theme and not theme_available: print(f"Theme '{target_theme}' not found, skipping GTK theme set") if has_gsettings: schemas = await run_command("gsettings", "list-schemas") if schemas and "org.gnome.desktop.interface" in schemas: await run_command("gsettings", "set", "org.gnome.desktop.interface", "color-scheme", f"prefer-{mode}") if theme_available: await run_command("gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", f"{target_theme}") return if has_dconf: await run_command("dconf", "write", "/org/gnome/desktop/interface/color-scheme", f"'prefer-{mode}'") if theme_available: await run_command("dconf", "write", "/org/gnome/desktop/interface/gtk-theme", f"'{target_theme}'") async def get_config_dir() -> Path: # Returns the XDG config home (e.g. ~/.config) # GTK config lives at ~/.config/gtk-3.0/ and ~/.config/gtk-4.0/. # 1. XDG standard if value := os.environ.get("XDG_CONFIG_HOME"): return Path(value).expanduser() # 2. fallback return Path.home() / ".config" def parse_args(): argv = sys.argv[1:] appearance_only = False if argv and argv[0] == "--appearance-only": appearance_only = True argv = argv[1:] if len(argv) != 1 or argv[0] not in ("dark", "light"): print( "Usage: gtk-refresh.py [--appearance-only] (dark|light)", file=sys.stderr, ) sys.exit(1) return appearance_only, argv[0] async def main(): appearance_only, mode = parse_args() if appearance_only: await sync_system_appearance(mode, update_gtk_theme=False) return config_dir = await get_config_dir() if not config_dir.is_dir(): print(f"Error: Config directory not found: {config_dir}", file=sys.stderr) sys.exit(1) (config_dir / "gtk-3.0").mkdir(parents=True, exist_ok=True) (config_dir / "gtk-4.0").mkdir(parents=True, exist_ok=True) results = await asyncio.gather(apply_gtk3_colors(config_dir), apply_gtk4_colors(config_dir)) if all(results): await sync_system_appearance(mode, update_gtk_theme=True) print("GTK colors applied successfully") else: # Still push light/dark preference so portal/GTK apps follow the shell even when # gtk.css / noctalia.css setup failed. await sync_system_appearance(mode, update_gtk_theme=False) sys.exit(1) if __name__ == "__main__": asyncio.run(main())