"""Shared app-wide constants: paths, palette, theme, SaraSort's own config. The palette, theming and shared settings mirror SaraSuite's and SaraFem's Core.py and read the same ~/.config/SaraSuite files, so a theme picked for one Sara program looks the same in all of them. """ import os import sys from dataclasses import asdict, dataclass from pathlib import Path import flet as ft import tomlkit AppName = "SaraSort" # Bundled in assets/fonts and always registered, so it works as FontFamily # without being installed. BundledFontFamily = "Fixedsys Excelsior" ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme Home = Path.home() ConfigHome = Home / ".config" DataHome = Home / ".local" / "share" SharedConfigDir = ConfigHome / "SaraSuite" ThemesDir = SharedConfigDir / "Themes" SharedConfigPath = SharedConfigDir / "Config.toml" ConfigDir = SharedConfigDir / AppName ConfigPath = ConfigDir / "Config.toml" LayoutPath = ConfigDir / "Layout.toml" # where flowchart boxes were dragged to UserTemplatesDir = ConfigDir / "Templates" # Inside a PyInstaller build, bundled files are unpacked to sys._MEIPASS. BundleDir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent)) AssetsDir = BundleDir / "assets" # Themes SaraFem seeds into ThemesDir. Only other themes travel with an # export as full palettes (same rule as SaraFem's ExportSettings). BuiltinThemeNames = {"CatppuccinMocha", "Forest", "Gruvbox"} class Palette: """Live values for the active theme (see ApplyTheme). Same attribute names and role mapping as SaraSuite's and SaraFem's Palette.""" Mauve = "#cba6f7" Red = "#f38ba8" Peach = "#fab387" Yellow = "#f9e2af" Green = "#a6e3a1" Blue = "#89b4fa" Text = "#cdd6f4" White = "#cdd6f4" Overlay0 = "#6c7086" Surface1 = "#45475a" Surface0 = "#313244" Base = "#1e1e2e" Mantle = "#181825" Crust = "#11111b" _ThemeRoleMap = { "Background": "Base", "Surface": "Mantle", "SurfaceAlt": "Surface0", "Border": "Surface1", "Text": "Text", "TextMuted": "Overlay0", "Black": "Crust", "White": "White", "Red": "Red", "Orange": "Peach", "Yellow": "Yellow", "Green": "Green", "Blue": "Blue", "Purple": "Mauve", } def ApplyTheme(ThemeName: str) -> None: """Mutate Palette in place from ~/.config/SaraSuite/Themes/.toml. Keeps the built-in Catppuccin Mocha values if the file is missing. Must run before any control reads Palette.X.""" ThemeFile = ThemesDir / f"{ThemeName}.toml" if not ThemeFile.exists(): return try: RoleValues = tomlkit.parse(ThemeFile.read_text()).unwrap().get("Palette", {}) except tomlkit.exceptions.TOMLKitError: return for RoleName, AttrName in _ThemeRoleMap.items(): if RoleName in RoleValues: setattr(Palette, AttrName, RoleValues[RoleName]) def AvailableThemes() -> list[str]: Names = {ThemePath.stem for ThemePath in ThemesDir.glob("*.toml")} if ThemesDir.is_dir() else set() Names.add("CatppuccinMocha") return sorted(Names) @dataclass class AppConfig: # True: Theme, FontFamily and HideWindowTitleBar come from the shared # ~/.config/SaraSuite/Config.toml. False: SaraSort's own values below. UseSharedSettings: bool = True Theme: str = "CatppuccinMocha" FontFamily: str = BundledFontFamily HideWindowTitleBar: bool = False # Where SaraSuite installs SaraSortd: GlobalConf.toml and the two # directory config templates live in SortdConfigDir, SaraSortd.py in # SortdAppDir, and it runs as a systemd user unit. SortdConfigDir: str = "~/.config/SaraSuite/SaraSortd" SortdAppDir: str = "~/.local/share/SaraSuite/Programs/SaraSortd/App" ServiceName: str = "SaraSortd.service" # SaraSortd only reads GlobalConf.toml when it starts. AutoRestart: bool = True RefreshSeconds: int = 5 # status and log refresh on the Service page LogLines: int = 500 LabelLength: int = 22 # arrow labels longer than this are shortened StartPage: str = "Flow" def Save(self) -> None: ConfigDir.mkdir(parents=True, exist_ok=True) ConfigPath.write_text(tomlkit.dumps(asdict(self))) @property def GlobalConfPath(self) -> Path: return Path(self.SortdConfigDir).expanduser() / "GlobalConf.toml" @property def SortdScriptPath(self) -> Path: return Path(self.SortdAppDir).expanduser() / "SaraSortd.py" def LoadConfig() -> AppConfig: if not ConfigPath.exists(): Config = AppConfig() Config.Save() return Config try: Data = tomlkit.parse(ConfigPath.read_text()).unwrap() except tomlkit.exceptions.TOMLKitError: return AppConfig() KnownFields = set(AppConfig.__dataclass_fields__) return AppConfig(**{Key: Value for Key, Value in Data.items() if Key in KnownFields}) @dataclass class SharedSettings: """~/.config/SaraSuite/Config.toml, shared by every SaraSuite program. Same schema and fallback rules as SaraSuite's and SaraFem's.""" Theme: str = "CatppuccinMocha" FontFamily: str = BundledFontFamily HideWindowTitleBar: bool = False def Save(self) -> None: SharedConfigDir.mkdir(parents=True, exist_ok=True) SharedConfigPath.write_text(tomlkit.dumps(asdict(self))) def _OwnSettings(Config: AppConfig) -> SharedSettings: return SharedSettings(Config.Theme, Config.FontFamily, Config.HideWindowTitleBar) def LoadSharedSettings(Config: AppConfig) -> SharedSettings: """The shared file, created from SaraSort's own values if missing.""" if not SharedConfigPath.exists(): Shared = _OwnSettings(Config) Shared.Save() return Shared Result = _OwnSettings(Config) # fallback for keys the file doesn't have try: Data = tomlkit.parse(SharedConfigPath.read_text()).unwrap() except tomlkit.exceptions.TOMLKitError: return Result for Key in SharedSettings.__dataclass_fields__: if Key in Data: setattr(Result, Key, Data[Key]) return Result def EffectiveSettings(Config: AppConfig) -> SharedSettings: return LoadSharedSettings(Config) if Config.UseSharedSettings else _OwnSettings(Config) def ExportSettings(Config: AppConfig, DestPath: Path) -> None: """One portable file in the same format as SaraFem's export: the shared settings under [SaraSuite] (with a custom theme's palette as ThemeData) and SaraSort's own under [SaraSort]. Other programs ignore [SaraSort], and SaraSort ignores their sections.""" Shared = LoadSharedSettings(Config) SharedData = asdict(Shared) if Shared.Theme not in BuiltinThemeNames: ThemeFile = ThemesDir / f"{Shared.Theme}.toml" if ThemeFile.exists(): try: SharedData["ThemeData"] = tomlkit.parse(ThemeFile.read_text()).unwrap().get("Palette", {}) except tomlkit.exceptions.TOMLKitError: pass DestPath.write_text(tomlkit.dumps({"SaraSuite": SharedData, AppName: asdict(Config)})) def ImportSettings(Config: AppConfig, SourcePath: Path) -> None: """Applies [SaraSuite] to the shared file and [SaraSort] onto Config, key by key: keys the file doesn't mention stay as they are, unknown sections and keys are skipped. Raises TOMLKitError on an unreadable file.""" Data = tomlkit.parse(SourcePath.read_text()).unwrap() SharedData = Data.get("SaraSuite") if isinstance(SharedData, dict): ThemeData = SharedData.pop("ThemeData", None) Merged = asdict(LoadSharedSettings(Config)) Merged.update({Key: Value for Key, Value in SharedData.items() if Key in SharedSettings.__dataclass_fields__}) ImportedTheme = Merged.get("Theme") if isinstance(ThemeData, dict) and ImportedTheme and ImportedTheme not in BuiltinThemeNames: ThemesDir.mkdir(parents=True, exist_ok=True) (ThemesDir / f"{ImportedTheme}.toml").write_text(tomlkit.dumps({"Palette": ThemeData})) SharedSettings(**Merged).Save() OwnData = Data.get(AppName) if isinstance(OwnData, dict): for Key, Value in OwnData.items(): if Key in AppConfig.__dataclass_fields__: setattr(Config, Key, Value) Config.Save() def ApplyAppearance(Settings: SharedSettings) -> None: """Theme colours and font; must run before anything reads Palette.X.""" global ActiveFontFamily ApplyTheme(Settings.Theme) ActiveFontFamily = Settings.FontFamily or BundledFontFamily def ChildEnvironment() -> dict[str, str]: """The environment for child processes (systemctl, journalctl, xdg-open). A PyInstaller build points LD_LIBRARY_PATH at its own bundled libraries; system tools need the original back.""" Environment = dict(os.environ) if getattr(sys, "frozen", False): Original = Environment.pop("LD_LIBRARY_PATH_ORIG", None) if Original is not None: Environment["LD_LIBRARY_PATH"] = Original else: Environment.pop("LD_LIBRARY_PATH", None) for Key in ("PYTHONHOME", "PYTHONPATH"): Environment.pop(Key, None) return Environment def BuildTheme() -> ft.Theme: ColorScheme = ft.ColorScheme( primary=Palette.Mauve, on_primary=Palette.Crust, primary_container=Palette.Surface0, on_primary_container=Palette.Mauve, secondary=Palette.Mauve, on_secondary=Palette.Crust, secondary_container=Palette.Surface0, on_secondary_container=Palette.Mauve, surface=Palette.Base, on_surface=Palette.Text, on_surface_variant=Palette.Overlay0, surface_container=Palette.Mantle, surface_container_low=Palette.Mantle, surface_container_high=Palette.Surface0, surface_container_highest=Palette.Surface1, outline=Palette.Surface1, outline_variant=Palette.Surface1, error=Palette.Red, on_error=Palette.Crust, ) return ft.Theme( font_family=ActiveFontFamily, color_scheme=ColorScheme, use_material3=True, scaffold_bgcolor=Palette.Base, canvas_color=Palette.Base, dialog_theme=ft.DialogTheme( bgcolor=Palette.Mantle, shape=ft.RoundedRectangleBorder(radius=8, side=ft.BorderSide(1, Palette.Mauve)), title_text_style=ft.TextStyle(color=Palette.Mauve, size=18, font_family=ActiveFontFamily), ), ) def BlendHex(BaseHex: str, AccentHex: str, Ratio: float) -> str: """Solid colour mixing Base and Accent (0 = pure Base, 1 = pure Accent), for tinted-but-still-dark backgrounds. Same as SaraFem's.""" BaseRGB = tuple(int(BaseHex.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4)) AccentRGB = tuple(int(AccentHex.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4)) Mixed = (round(B + (A - B) * Ratio) for B, A in zip(BaseRGB, AccentRGB)) return "#" + "".join(f"{C:02x}" for C in Mixed) def NotificationStyle(Kind: str) -> tuple[str, str]: """(Background, AccentColor) for a toast, from the theme's own colours like SaraFem's: "success" green, "error" red, plus "warning" yellow and "info" mauve for SaraSort's restart-needed and plain notes.""" Accent = {"success": Palette.Green, "error": Palette.Red, "warning": Palette.Yellow}.get(Kind, Palette.Mauve) return BlendHex(Palette.Mantle, Accent, 0.12), Accent def ChipButtonStyle(Accent: str = "") -> ft.ButtonStyle: """SaraView's button look: SurfaceAlt fill, 1px accent border, small radius.""" return ft.ButtonStyle( color=Palette.Text, bgcolor=Palette.Surface0, side=ft.BorderSide(1, Accent or Palette.Mauve), shape=ft.RoundedRectangleBorder(radius=4), ) def ChipButton(Text: str, OnClick, Icon=None, Accent: str = "", Disabled: bool = False) -> ft.OutlinedButton: return ft.OutlinedButton(content=Text, icon=Icon, style=ChipButtonStyle(Accent), on_click=OnClick, disabled=Disabled) def Note(Text: str, Size: int = 12) -> ft.Text: return ft.Text(Text, color=Palette.Overlay0, size=Size) def Heading(Text: str) -> ft.Text: return ft.Text(Text, color=Palette.Mauve, size=20) def DisplayPath(PathText: str) -> str: """/home/sarah/Sorting -> ~/Sorting, for labels.""" HomeText = str(Home) if PathText == HomeText or PathText.startswith(HomeText + "/"): return "~" + PathText[len(HomeText):] return PathText