"""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 os import shutil import subprocess import sys from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Callable, Optional 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 IsWindows = sys.platform == "win32" # Each system's own per-user folders, like SaraSuite's: ~/.config and ~/.cache # on Linux; %APPDATA% (settings) and %LOCALAPPDATA% (caches) on Windows. if IsWindows: ConfigHome = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") CacheDir = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") / "SaraFem" / "Cache" # Where the file tree starts by default: the system drive (C:\), not "/". DefaultFiletreeRoot = (os.environ.get("SystemDrive") or "C:") + "\\" else: ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") CacheDir = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "SaraFem" DefaultFiletreeRoot = "/" ThumbnailCacheDir = CacheDir / "Thumbnails" # Copy/Cut/Paste uses the real OS clipboard (Clipboard.set_files/get_files — # see Browser._DoSetClipboard/_DoPaste) instead of private Python state, so # it actually works between two separate SaraFem windows (each its own # process) and interoperates with other apps' file managers. The one thing # the OS file clipboard has no concept of is a cut-vs-copy flag, so that one # bit lives here instead, in a tiny sidecar file every SaraFem window reads # fresh on Paste rather than trusting its own possibly-stale local state. ClipboardModePath = CacheDir / "ClipboardMode.txt" def SetClipboardMode(Mode: str) -> None: CacheDir.mkdir(parents=True, exist_ok=True) ClipboardModePath.write_text(Mode) def GetClipboardMode() -> str: try: Mode = ClipboardModePath.read_text().strip() except OSError: return "copy" return Mode if Mode in ("copy", "cut") else "copy" # Config moved from ~/.config/SaraFem/ into a SaraSuite-wide layout so other # Sara* programs (SaraTag, SaraView) can eventually read from the same place. SharedConfigDir = ConfigHome / "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 FolderIconBand: """One slice of the folder-fullness icon system (Features: "turning on different variants depending on how full a directory is"). N configured bands split 0-100% (by item count, against FolderFullnessThreshold) into N equal slices — band 0 covers the emptiest. ImagePath wins over Icon when set (a custom image instead of a built-in Material icon).""" Icon: str = "FOLDER_OUTLINED" ImagePath: str = "" @dataclass class SaraSortdIntegrationConfig: """Points at a SaraSortd checkout's GlobalConf.toml (read-only — SaraFem never writes to it). Disabled until pointed at one, e.g. GlobalConfPath = "/home/sarah/Applications/SaraSortd/GlobalConf.toml".""" Enabled: bool = False GlobalConfPath: str = "" # Separate from Enabled (which is "is this configured at all") — lets # the integration stay wired up without the banner itself showing # (Bugs/Major: "SaraSortd integration header should be able to be # hidden"). CondenseBanner collapses every destination with more than # one matching pattern into one line with a >/v expand toggle, on by # default (Bugs/Major: "...needs to also use the width... condense # outputs and inputs into > then v button things"). ShowBanner: bool = True CondenseBanner: bool = True @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" # List view: which metadata columns show, beyond the always-present Name. ListShowDescription: bool = False ListShowSize: bool = True ListShowModified: bool = True ListShowOwner: bool = False ListShowCreated: bool = False ListShowContent: bool = False ListShowAccess: bool = False # List view column widths — drag the header dividers to set these, or # type a number in Options; both write here. ListWidthName: int = 220 ListWidthDescription: int = 90 ListWidthSize: int = 80 ListWidthModified: int = 130 ListWidthOwner: int = 90 ListWidthCreated: int = 130 ListWidthContent: int = 90 ListWidthAccess: int = 60 # Grid view. GridIconSize: int = 64 GridNameBelowIcon: bool = True ListIconSize: int = 32 # Features: "Add custom icons for directories... in settings, there # should be settings for default, and turning on different variants # depending on how full a directory is." A single band (the default) # is exactly today's static FOLDER icon everywhere — nothing changes # until more bands get added in Settings. Per-directory overrides (set # via Properties) live separately, see FolderIcons.py — they always win # over this and never vary by fullness. FolderIconBands: list[FolderIconBand] = field(default_factory=lambda: [FolderIconBand()]) FolderFullnessThreshold: int = 200 # item count counted as "100% full" FontSize: int = 14 # entry name text, list and grid alike; metadata/header text scales with it ToolbarHamburger: bool = False # collapse the toolbar's buttons into one overflow menu # Double-click on a text/config/markup file: True opens it in SaraFem's own # viewer/editor (Editor.py); False (matches pre-existing behaviour) hands it # to xdg-open instead. Either way, the per-entry "Open" menu item does the # opposite of this default, so both are always one click away. OpenFilesInApp: bool = False # Live filter for the file list, below the breadcrumbs/toolbar bar. # ShowSearch is the master on/off; SearchAlwaysVisible picks between the # bar always being there or only appearing on Ctrl+F (and dismissing on # Escape). Match Case/Whole Word are session-only (Browser.MatchCase/ # MatchWholeWord), not persisted here — they're live search options, not # a durable preference, same idea as a browser's find bar. ShowSearch: bool = False SearchAlwaysVisible: bool = False # Opt-in: clears the search filter on every NavigateTo instead of # carrying it over into the next directory (Features/Minor: "Option to # reset search to be blank upon changing directory"). SearchResetOnNavigate: bool = False # Sidebar sort control ("[Sort: Name] v" + a separate ascending/ # descending arrow button next to it — see Sidebar._BuildSortControl). # Directories always sort before files regardless of field/direction, # matching the existing list order; the field/direction only reorders # within each of those two groups. ShowSort: bool = False SortField: str = "Name" # "Name", "Size", "Modified", or "Type" SortAscending: bool = True # 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 ShowRefreshButton: 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 # Requires ShowInternalDrives too — additionally lists the main drive # itself (mounted at "/" specifically, not /boot/efi or other system # partitions udisks2 reports as their own separate block devices). The # Devices panel never offers to eject/unmount this one regardless (see # Device.IsSystemMount) — doing that to your own running root # filesystem would crash the system. ShowSystemDrive: bool = False # Opt-in: collapses the Devices section (box + its own divider, see # Sidebar._OnDevicesEmptyChanged) when nothing's plugged in, instead of # always showing it with a "No devices." placeholder. AutoHideEmptyDevices: bool = False ShowFiletree: bool = True FiletreeRoot: str = DefaultFiletreeRoot 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) SaraSortd: SaraSortdIntegrationConfig = field(default_factory=SaraSortdIntegrationConfig) 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) SaraSortdData = Filtered.pop("SaraSortd", {}) KnownSaraSortdFields = {Field for Field in SaraSortdIntegrationConfig.__dataclass_fields__} SaraSortdFiltered = {Key: Value for Key, Value in SaraSortdData.items() if Key in KnownSaraSortdFields} Filtered["SaraSortd"] = SaraSortdIntegrationConfig(**SaraSortdFiltered) # A list of nested dataclasses doesn't round-trip through toml.loads() # on its own — each entry lands as a plain dict, not a FolderIconBand, # which would break attribute access (.Icon) everywhere this gets read. BandsData = Filtered.pop("FolderIconBands", None) if isinstance(BandsData, list): KnownBandFields = {Field for Field in FolderIconBand.__dataclass_fields__} Bands = [ FolderIconBand(**{K: V for K, V in Entry.items() if K in KnownBandFields}) for Entry in BandsData if isinstance(Entry, dict) ] Filtered["FolderIconBands"] = Bands or [FolderIconBand()] 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 ExportSettings(Config: AppConfig, DestPath: Path) -> None: """One portable file bundling both the cross-program shared settings (Theme/Font/TitleBar) and SaraFem's own, under separate top-level sections (Features/Major: "Exporting and importing settings as one file... applies to every other SaraSuite program"). A future SaraView/ SaraTag/etc. import would read the same [SaraSuite] section plus its own top-level one, ignoring [SaraFem] — the same convention ImportSettings below uses to ignore sections that aren't its own. A custom (non-built-in) theme's actual palette rides along under ThemeData (Bugs/Major: "Cannot export custom theme... theme can be fully exported") — a theme is just a small local TOML file, nothing stops it traveling with the export, unlike a font, which stays a name only (the font itself needs installing some other way regardless of what this file says).""" OwnSettings = _OwnSettings(Config) SharedData = asdict(OwnSettings) if OwnSettings.Theme not in BuiltinThemes: ThemeFile = ThemesDir / f"{OwnSettings.Theme}.toml" if ThemeFile.exists(): try: SharedData["ThemeData"] = toml.loads(ThemeFile.read_text()).get("Palette", {}) except toml.TomlDecodeError: pass Data = {"SaraSuite": SharedData, "SaraFem": asdict(Config)} DestPath.write_text(toml.dumps(Data)) def ImportSettings(Config: AppConfig, SrcPath: Path) -> None: """Applies a [SaraSuite] section (if present) to the shared settings file and a [SaraFem] section (if present) onto Config in place, field by field — a field the import doesn't mention is left as it was, rather than reset to default, and unknown sections/fields (another program's, or a newer SaraFem version's) are silently skipped: the "should stack fine with others if installed or not... one file works anywhere on the same version no matter what you have installed" compatibility the feature asked for. Raises toml.TomlDecodeError on an unreadable file — callers show that to the user rather than silently doing nothing.""" Data = toml.loads(SrcPath.read_text()) SharedData = Data.get("SaraSuite") if isinstance(SharedData, dict): # Popped before the known-fields filter below (same reason as the # SaraTag/SaraSortd/FolderIconBands nested data elsewhere here) — # ThemeData isn't a SharedSettings field, it's the custom theme's # actual palette, written out as that theme's own local file so the # name being imported alongside it actually resolves to something. ThemeData = SharedData.pop("ThemeData", None) KnownSharedFields = {F for F in SharedSettings.__dataclass_fields__} Filtered = {K: V for K, V in SharedData.items() if K in KnownSharedFields} Merged = asdict(_OwnSettings(Config)) Merged.update(Filtered) ImportedTheme = Merged.get("Theme") if isinstance(ThemeData, dict) and ImportedTheme and ImportedTheme not in BuiltinThemes: ThemesDir.mkdir(parents=True, exist_ok=True) (ThemesDir / f"{ImportedTheme}.toml").write_text(toml.dumps({"Palette": ThemeData})) SharedSettings(**Merged).Save() OwnData = Data.get("SaraFem") if isinstance(OwnData, dict): KnownFields = {F for F in AppConfig.__dataclass_fields__} Filtered = {K: V for K, V in OwnData.items() if K in KnownFields} SaraTagData = Filtered.pop("SaraTag", None) if isinstance(SaraTagData, dict): KnownSaraTagFields = {F for F in SaraTagIntegrationConfig.__dataclass_fields__} SaraTagFiltered = {K: V for K, V in SaraTagData.items() if K in KnownSaraTagFields} for Key, Value in SaraTagFiltered.items(): setattr(Config.SaraTag, Key, Value) SaraSortdData = Filtered.pop("SaraSortd", None) if isinstance(SaraSortdData, dict): KnownSaraSortdFields = {F for F in SaraSortdIntegrationConfig.__dataclass_fields__} SaraSortdFiltered = {K: V for K, V in SaraSortdData.items() if K in KnownSaraSortdFields} for Key, Value in SaraSortdFiltered.items(): setattr(Config.SaraSortd, Key, Value) BandsData = Filtered.pop("FolderIconBands", None) if isinstance(BandsData, list): KnownBandFields = {F for F in FolderIconBand.__dataclass_fields__} Bands = [ FolderIconBand(**{K: V for K, V in Entry.items() if K in KnownBandFields}) for Entry in BandsData if isinstance(Entry, dict) ] if Bands: Filtered["FolderIconBands"] = Bands for Key, Value in Filtered.items(): setattr(Config, Key, Value) Config.Save() 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 _WindowsFonts() -> set[str]: """Font families from the system and per-user Fonts registry keys ("Arial Bold (TrueType)" -> "Arial").""" import re import winreg Families: set[str] = set() Key = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" for Root in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER): try: with winreg.OpenKey(Root, Key) as Fonts: Index = 0 while True: try: Name = winreg.EnumValue(Fonts, Index)[0] except OSError: break Index += 1 Name = re.sub(r"\s*\(.*\)$", "", Name) Name = re.sub(r"\s+(Bold|Italic|Light|Semibold|SemiBold|Black|Regular|Medium|Thin)\b.*$", "", Name) if Name and "&" not in Name: Families.add(Name.strip()) except OSError: continue return Families def InstalledFontFamilies() -> list[str]: """Font families fontconfig (or, on Windows, the registry) knows about, for the font picker. The bundled font is always first, since it isn't installed system-wide.""" if IsWindows: Families = _WindowsFonts() Families.discard(BundledFontFamily) return [BundledFontFamily] + sorted(Families, key=str.lower) 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 def ResizeHandle( Axis: str, Length: Optional[int], OnDrag: Callable[[float], None], OnDragEnd: Callable[[], None], ) -> ft.GestureDetector: """A thin draggable divider, shared by the file list's column headers, the sidebar/preview section splits, and the sidebar/preview *panel* widths. "horizontal" resizes side-by-side things (drag left/right, e.g. a column or a whole side panel) and needs Length = the row's height, not just 6px wide with an unset (collapses to zero) height; "vertical" resizes stacked things (drag up/down, e.g. Bookmarks vs Devices) and needs Length = the panel's width, for the same reason on the other axis. Pass Length=None instead when the immediate parent Row/Column already stretches its children on that cross axis (confirmed, not assumed — Flet doesn't do this by default) — then an explicit value would fight that stretch instead of matching it. OnDrag fires with the incremental delta on every move — cheap for a single header cell or a sidebar section, but callers with many redraws to do (the file list's rows) should treat it as a live *preview* and do the expensive rebuild in OnDragEnd instead, which fires once on release; that's also where Config gets saved, so a drag isn't disk I/O on every pixel of movement.""" IsHorizontal = Axis == "horizontal" Handle = ft.GestureDetector( content=ft.Container( width=6 if IsHorizontal else Length, height=Length if IsHorizontal else 6, bgcolor=Palette.Surface1, ), mouse_cursor=ft.MouseCursor.RESIZE_COLUMN if IsHorizontal else ft.MouseCursor.RESIZE_ROW, ) if IsHorizontal: Handle.on_horizontal_drag_update = lambda e: OnDrag(e.primary_delta or 0) Handle.on_horizontal_drag_end = lambda e: OnDragEnd() else: Handle.on_vertical_drag_update = lambda e: OnDrag(e.primary_delta or 0) Handle.on_vertical_drag_end = lambda e: OnDragEnd() return Handle # 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