Files
noctalia-shell/Scripts/python/src/theming/gtk-refresh.py
T

189 lines
6.5 KiB
Python

#!/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())