"""Shared app-wide constants: palette, theme, cache paths. Palette and role-mapping deliberately mirror SaraView's (see SaraView/Backend/DefaultThemes/CatppuccinMocha.toml and Frontend/src/Styles/Theme.css): one accent colour (Purple/Mauve) used everywhere, not a mauve+lavender mix. """ import shutil import subprocess from dataclasses import asdict, dataclass, field from pathlib import Path import flet as ft import toml class Palette: """Live values for the active theme (see ApplyTheme). Starts as Catppuccin Mocha so anything constructed before ApplyTheme() runs still looks right. Role names/values are mutated in place from ~/.config/SaraSuite/Themes/*.toml, which uses the exact same [Palette] schema as SaraView's own theme files — see SaraView/Backend/DefaultThemes/*.toml and Frontend/src/ThemeRoles.js.""" Mauve = "#cba6f7" # SaraView's "Purple" role: the one accent colour Red = "#f38ba8" Peach = "#fab387" # SaraView's "Orange" Yellow = "#f9e2af" Green = "#a6e3a1" Blue = "#89b4fa" Text = "#cdd6f4" White = "#cdd6f4" Overlay0 = "#6c7086" # SaraView's "TextMuted" Surface1 = "#45475a" # SaraView's "Border" Surface0 = "#313244" # SaraView's "SurfaceAlt" Base = "#1e1e2e" # SaraView's "Background" Mantle = "#181825" # SaraView's "Surface" Crust = "#11111b" # SaraView's "Black" AppName = "SaraFem" # Bundled in assets/fonts and always registered, so it works as FontFamily # without being installed. Any other FontFamily is looked up among the # system's installed fonts. BundledFontFamily = "Fixedsys Excelsior" ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme CacheDir = Path.home() / ".cache" / "SaraFem" ThumbnailCacheDir = CacheDir / "Thumbnails" # Config moved from ~/.config/SaraFem/ into a SaraSuite-wide layout so other # Sara* programs (SaraTag, SaraView) can eventually read from the same place. SharedConfigDir = Path.home() / ".config" / "SaraSuite" ThemesDir = SharedConfigDir / "Themes" ConfigDir = SharedConfigDir / "SaraFem" ConfigPath = ConfigDir / "Config.toml" # Settings shared by every SaraSuite program. Same key names as the matching # fields in each program's own Config.toml. SharedConfigPath = SharedConfigDir / "Config.toml" BookmarksPath = ConfigDir / ".sfem_bookmarks.toml" _OldConfigDir = Path.home() / ".config" / "SaraFem" # The exact role names SaraView's theme files use, and the SaraFem Palette # attribute each one maps to. _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", } # Bundled so SaraFem has real theme choices even before SaraView (or anything # else) has populated the shared Themes directory. Copied directly from # SaraView/Backend/DefaultThemes/*.toml so the colours actually match. BuiltinThemes: dict[str, dict[str, str]] = { "CatppuccinMocha": { "Background": "#1e1e2e", "Surface": "#181825", "SurfaceAlt": "#313244", "Border": "#45475a", "Text": "#cdd6f4", "TextMuted": "#6c7086", "Black": "#11111b", "White": "#cdd6f4", "Red": "#f38ba8", "Orange": "#fab387", "Yellow": "#f9e2af", "Green": "#a6e3a1", "Blue": "#89b4fa", "Purple": "#cba6f7", }, "Forest": { "Background": "#1b2420", "Surface": "#141b17", "SurfaceAlt": "#243029", "Border": "#334539", "Text": "#d6e5d0", "TextMuted": "#7c9184", "Black": "#0d120f", "White": "#eef5ea", "Red": "#e0685a", "Orange": "#e0a458", "Yellow": "#e0c458", "Green": "#7fb069", "Blue": "#5c9e8f", "Purple": "#9b7fb0", }, "Gruvbox": { "Background": "#282828", "Surface": "#1d2021", "SurfaceAlt": "#3c3836", "Border": "#504945", "Text": "#ebdbb2", "TextMuted": "#a89984", "Black": "#1d2021", "White": "#fbf1c7", "Red": "#fb4934", "Orange": "#fe8019", "Yellow": "#fabd2f", "Green": "#b8bb26", "Blue": "#83a598", "Purple": "#d3869b", }, } def _MigrateOldConfig() -> None: """One-time move from the pre-SaraSuite ~/.config/SaraFem/ location.""" if ConfigDir.exists() or not _OldConfigDir.exists(): return ConfigDir.mkdir(parents=True, exist_ok=True) for FileName in ("Config.toml", ".sfem_bookmarks.toml"): OldFile = _OldConfigDir / FileName NewFile = ConfigDir / FileName if OldFile.exists() and not NewFile.exists(): NewFile.write_text(OldFile.read_text()) def EnsureSharedThemes() -> None: """Seed the shared Themes directory with the bundled defaults, so the location is actually populated for other SaraSuite programs to read.""" ThemesDir.mkdir(parents=True, exist_ok=True) for Name, RoleValues in BuiltinThemes.items(): ThemeFile = ThemesDir / f"{Name}.toml" if not ThemeFile.exists(): ThemeFile.write_text(toml.dumps({"Palette": RoleValues})) def AvailableThemes() -> list[str]: EnsureSharedThemes() return sorted(F.stem for F in ThemesDir.glob("*.toml")) def ApplyTheme(ThemeName: str) -> None: """Mutate Palette's attributes in place to the named theme. Must run before any control that reads Palette.X is constructed — Palette.X values are plain strings baked into controls at build time, not live references.""" EnsureSharedThemes() RoleValues = dict(BuiltinThemes.get(ThemeName, BuiltinThemes["CatppuccinMocha"])) ThemeFile = ThemesDir / f"{ThemeName}.toml" if ThemeFile.exists(): try: RoleValues.update(toml.loads(ThemeFile.read_text()).get("Palette", {})) except toml.TomlDecodeError: pass for RoleName, AttrName in _ThemeRoleMap.items(): if RoleName in RoleValues: setattr(Palette, AttrName, RoleValues[RoleName]) @dataclass class SaraTagIntegrationConfig: """Mirrors SaraView's Backend/SaraViewConf.toml [SaraTag] section, so both apps talk to SaraTag the same opt-in way. Disabled until pointed at a SaraTag checkout, e.g. CoreModulePath = "/home/sarah/Applications/SaraTag/.saratag".""" Enabled: bool = False CoreModulePath: str = "" HideInternalFiles: bool = True InternalPatterns: list[str] = field( default_factory=lambda: [".saratag", ".SaraTag*", "*.book.toml"] ) @dataclass class AppConfig: """Persisted user settings. Defaults match SaraFem's existing behaviour (nothing changes visually until the user opts in via Ctrl+B/P/H/T or edits Config.toml directly).""" # True: Theme, FontFamily and HideWindowTitleBar come from the shared # ~/.config/SaraSuite/Config.toml, like every other SaraSuite program. # False: SaraFem uses its own values below instead. UseSharedSettings: bool = True Theme: str = "CatppuccinMocha" # matches a file in ~/.config/SaraSuite/Themes/ FontFamily: str = BundledFontFamily HideWindowTitleBar: bool = False # the actual OS window title bar, not the toolbar below ShowDotfiles: bool = True ViewMode: str = "list" # "list" or "grid" # Batch-rename pattern for multi-select rename, syntax modeled on # SaraSortd's own placeholder characters (see ~/Applications/SaraSortd's # GlobalConf.toml [Variables]): ~ = typed base name, ! = auto-incrementing # counter (blank for the first item, " 1", " 2", ... after), ^ = the # original file's extension. BatchRenamePattern: str = "~!^" DeleteZipAfterExtract: bool = False DefaultArchiveFormat: str = "zip" # "zip" or "targz" — Compress's default format # The in-app toolbar: breadcrumbs and the button row share one bar now # (each taking half the width), positioned top or bottom, hideable as a whole. ShowToolbar: bool = True ShowBreadcrumbs: bool = True BreadcrumbsPosition: str = "top" # "top" or "bottom" ShowNewDirectoryButton: bool = True ShowPasteButton: bool = True ShowViewToggleButton: bool = True ShowOptionsButton: bool = True # The left-hand panel, which holds three independently-toggleable sections: # Bookmarks (flat, user-curated, Bookmarks.py), Devices (mounted removable # media, Devices.py) and Filetree (a real expandable directory tree, # Filetree.py). ShowSidebar: bool = True SidebarSide: str = "left" # "left" or "right" SidebarWidth: int = 220 ShowBookmarks: bool = True ShowDevices: bool = True AutoMountDevices: bool = True # mount USB sticks etc. as soon as they're plugged in ShowInternalDrives: bool = False # also list internal disks that aren't system mounts ShowFiletree: bool = True FiletreeRoot: str = "/" BookmarksMinHeight: int = 0 # 0 = no constraint BookmarksMaxHeight: int = 0 DevicesMinHeight: int = 0 DevicesMaxHeight: int = 0 FiletreeMinHeight: int = 0 FiletreeMaxHeight: int = 0 # Same idea as SplitPreviewTagsEvenly: proportional 50/50, overrides the # heights above, adapts to window size instead of a fixed pixel guess. SplitBookmarksFiletreeEvenly: bool = False ShowPreview: bool = False PreviewSide: str = "right" # "left" or "right" PreviewWidth: int = 320 # Only applied when Tags shares Preview's column (TagsPosition == "under_preview") — # otherwise Preview always fills the full height, per Sarah's clarification. PreviewMinHeight: int = 0 PreviewMaxHeight: int = 0 ShowTags: bool = False TagsPosition: str = "under_preview" # "under_preview", "top", or "bottom" TagsLayout: str = "flex" # "flex" (wraps horizontally) or "vertical" (one per row) TagMinWidth: int = 0 # 0 = no minimum, chip hugs its content TagMaxWidth: int = 0 # 0 = no maximum # The tags box's own height (not per-chip). Always applied, regardless of # TagsPosition — matters most in vertical layout, where it can grow tall. TagPanelMinHeight: int = 0 TagPanelMaxHeight: int = 0 # Proportional 50/50 split for Preview+Tags when stacked (TagsPosition == # "under_preview"), instead of fixed pixel heights — adapts to window size # automatically. Overrides PreviewMinHeight/MaxHeight and TagPanelMinHeight/ # MaxHeight when on, since a fixed height and an even flex split conflict. SplitPreviewTagsEvenly: bool = False SaraTag: SaraTagIntegrationConfig = field(default_factory=SaraTagIntegrationConfig) def Save(self) -> None: ConfigDir.mkdir(parents=True, exist_ok=True) ConfigPath.write_text(toml.dumps(asdict(self))) def LoadConfig() -> AppConfig: _MigrateOldConfig() if not ConfigPath.exists(): Config = AppConfig() Config.Save() return Config try: Data = toml.loads(ConfigPath.read_text()) except toml.TomlDecodeError: return AppConfig() Defaults = AppConfig() KnownFields = {Field for Field in Defaults.__dataclass_fields__} Filtered = {Key: Value for Key, Value in Data.items() if Key in KnownFields} SaraTagData = Filtered.pop("SaraTag", {}) KnownSaraTagFields = {Field for Field in SaraTagIntegrationConfig.__dataclass_fields__} SaraTagFiltered = {Key: Value for Key, Value in SaraTagData.items() if Key in KnownSaraTagFields} Filtered["SaraTag"] = SaraTagIntegrationConfig(**SaraTagFiltered) return AppConfig(**Filtered) @dataclass class SharedSettings: """~/.config/SaraSuite/Config.toml: the settings every SaraSuite program shares. A program whose own UseSharedSettings is off reads these same keys from its own Config.toml instead.""" Theme: str = "CatppuccinMocha" FontFamily: str = BundledFontFamily HideWindowTitleBar: bool = False def Save(self) -> None: SharedConfigDir.mkdir(parents=True, exist_ok=True) SharedConfigPath.write_text(toml.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 SaraFem's own values the first time so switching to it doesn't change anything visible.""" if not SharedConfigPath.exists(): Shared = _OwnSettings(Config) Shared.Save() return Shared Result = _OwnSettings(Config) # fallback for keys the file doesn't have try: Data = toml.loads(SharedConfigPath.read_text()) except toml.TomlDecodeError: return Result for Key in SharedSettings.__dataclass_fields__: if Key in Data: setattr(Result, Key, Data[Key]) return Result def EffectiveSettings(Config: AppConfig) -> SharedSettings: """What SaraFem actually uses: the shared settings, or its own.""" return LoadSharedSettings(Config) if Config.UseSharedSettings else _OwnSettings(Config) def ApplyAppearance(Settings: SharedSettings) -> None: """Theme colours and font. Must run before anything reads Palette.X or calls BuildTheme(). The window title bar is applied by Main.""" global ActiveFontFamily ApplyTheme(Settings.Theme) ActiveFontFamily = Settings.FontFamily or BundledFontFamily def InstalledFontFamilies() -> list[str]: """Font families fontconfig knows about, for the font picker. The bundled font is always first, since it isn't installed system-wide.""" try: Result = subprocess.run(["fc-list", ":", "family"], capture_output=True, text=True, timeout=5) Families = {Line.split(",")[0].strip() for Line in Result.stdout.splitlines() if Line.strip()} except (OSError, subprocess.SubprocessError): Families = set() Families.discard(BundledFontFamily) return [BundledFontFamily] + sorted(Families, key=str.lower) 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 ChipButtonStyle() -> ft.ButtonStyle: """SaraView's button look: SurfaceAlt fill, 1px Purple border, small radius.""" return ft.ButtonStyle( color=Palette.Text, bgcolor=Palette.Surface0, side=ft.BorderSide(1, Palette.Mauve), shape=ft.RoundedRectangleBorder(radius=4), ) def BlendHex(BaseHex: str, AccentHex: str, Ratio: float) -> str: """Solid colour mixing Base and Accent (0 = pure Base, 1 = pure Accent). Used for tinted-but-still-dark backgrounds derived from the theme's own roles (e.g. a success toast) instead of inventing colours outside it.""" 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 Kind of "success" or "error" toast, both derived from the theme's own Green/Red so it stays coherent.""" Accent = Palette.Green if Kind == "success" else Palette.Red Background = BlendHex(Palette.Mantle, Accent, 0.12) return Background, Accent def ApplyHeightBounds(Content: ft.Control, MinHeight: int, MaxHeight: int) -> ft.Control: """Wrap Content in a fixed-height box if a bound is set (Max wins if both are set, since Flet has no true min/max-range constraint — MaxHeight caps it with internal scrolling, MinHeight alone gives it a floor). 0/0 means natural sizing.""" if MaxHeight > 0: return ft.Container(content=Content, height=MaxHeight) if MinHeight > 0: return ft.Container(content=Content, height=MinHeight) return Content # The group tag shared by every Draggable source and DragTarget in the app, # so bookmarks/filetree only accept drops that actually originate from # SaraFem's own file list (not, say, a Chip or some future draggable widget). DragGroup = "sarafem_entry" def UniqueDestination(Destination: Path) -> Path: if not Destination.exists(): return Destination Stem, Suffix = Destination.stem, Destination.suffix Counter = 2 while True: Candidate = Destination.with_name(f"{Stem} (copy {Counter}){Suffix}") if not Candidate.exists(): return Candidate Counter += 1 def FormatBatchName(Pattern: str, BaseName: str, Index: int, OriginalSuffix: str = "") -> str: """SaraSortd-style placeholder substitution for batch rename (see ~/Applications/SaraSortd's GlobalConf.toml [Variables] for the reference syntax this mirrors): ~ = typed base name, ! = auto-incrementing counter (blank for the first item, " 1", " 2", ... after), ^ = the original file's extension, preserved unless the pattern omits it.""" CounterText = "" if Index == 0 else f" {Index}" return Pattern.replace("~", BaseName).replace("!", CounterText).replace("^", OriginalSuffix) def MoveIntoDirectory(SourcePath: Path, DestinationDir: Path) -> Path: """Move SourcePath into DestinationDir (drag-and-drop uses this). Returns the final destination path. Raises OSError if the move is invalid.""" if SourcePath.parent == DestinationDir: raise OSError(f'"{SourcePath.name}" is already in this directory.') ResolvedSource = SourcePath.resolve() if SourcePath.is_dir() and ( DestinationDir.resolve() == ResolvedSource or ResolvedSource in DestinationDir.resolve().parents ): raise OSError(f"Can't move \"{SourcePath.name}\" into itself or one of its own subdirectories.") Destination = UniqueDestination(DestinationDir / SourcePath.name) shutil.move(str(SourcePath), str(Destination)) return Destination