"""Shared app-wide constants: palette, theme, paths, SaraView client config. Palette, theming and the shared SaraSuite settings mirror 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. The role names are SaraView's own (Backend/DefaultThemes/*.toml).""" 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 tomlkit class Palette: """Live values for the active theme (see ApplyTheme). Starts as Catppuccin Mocha so anything constructed before ApplyTheme() runs still looks right. Same attribute names and role mapping as SaraFem's Palette.""" 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 = "SaraView" # 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 IsWindows = sys.platform == "win32" # Each system's own per-user folders, like SaraSuite's: ~/.config, ~/.cache # and ~/.local/share on Linux; %APPDATA% and %LOCALAPPDATA% on Windows. Home = Path.home() if IsWindows: ConfigHome = Path(os.environ.get("APPDATA") or Home / "AppData" / "Roaming") DataHome = Path(os.environ.get("LOCALAPPDATA") or Home / "AppData" / "Local") CacheHome = DataHome else: ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Home / ".config") DataHome = Path(os.environ.get("XDG_DATA_HOME") or Home / ".local" / "share") CacheHome = Path(os.environ.get("XDG_CACHE_HOME") or Home / ".cache") SharedConfigDir = ConfigHome / "SaraSuite" ThemesDir = SharedConfigDir / "Themes" SharedConfigPath = SharedConfigDir / "Config.toml" ConfigDir = SharedConfigDir / AppName ConfigPath = ConfigDir / "Config.toml" # The server's session token, kept out of Config.toml so a settings export # never carries it. SessionPath = ConfigDir / "Session.toml" # Server files opened in another program are downloaded here first. CacheDir = CacheHome / AppName / "Cache" if IsWindows else CacheHome / AppName # Inside a PyInstaller build, bundled files are unpacked to sys._MEIPASS. BundleDir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent)) AssetsDir = BundleDir / "assets" # Shared by the file list and the sidebar tree. FileIcons = { "markdown": ft.Icons.DESCRIPTION_OUTLINED, "text": ft.Icons.ARTICLE_OUTLINED, "config": ft.Icons.SETTINGS_OUTLINED, "html": ft.Icons.CODE, "image": ft.Icons.IMAGE_OUTLINED, "video": ft.Icons.MOVIE_OUTLINED, } _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", } # Same as SaraFem's BuiltinThemes, copied from Backend/DefaultThemes/*.toml. 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 _ReadToml(FilePath: Path) -> dict: """The file's data, or {} if it's missing or unreadable.""" try: return tomlkit.parse(FilePath.read_text()).unwrap() except (OSError, tomlkit.exceptions.TOMLKitError): return {} def EnsureSharedThemes() -> None: """Seed the shared Themes directory with the bundled defaults, like SaraFem.""" 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(tomlkit.dumps({"Palette": RoleValues})) def AvailableThemes() -> list[str]: EnsureSharedThemes() return sorted(ThemeFile.stem for ThemeFile in ThemesDir.glob("*.toml")) def ApplyTheme(ThemeName: str) -> None: """Mutate Palette in place to the named theme. Must run before any control that reads Palette.X is built.""" EnsureSharedThemes() RoleValues = dict(BuiltinThemes.get(ThemeName, BuiltinThemes["CatppuccinMocha"])) RoleValues.update(_ReadToml(ThemesDir / f"{ThemeName}.toml").get("Palette", {})) for RoleName, AttrName in _ThemeRoleMap.items(): if RoleName in RoleValues: setattr(Palette, AttrName, RoleValues[RoleName]) @dataclass class AppConfig: # True: Theme, FontFamily and HideWindowTitleBar come from the shared # ~/.config/SaraSuite/Config.toml. False: SaraView's own values below. UseSharedSettings: bool = True Theme: str = "CatppuccinMocha" FontFamily: str = BundledFontFamily HideWindowTitleBar: bool = False # "Local" browses LocalRoot on this computer; "Server" talks to a # SaraView server (the one on SpineS), with its users, locks and blame. Mode: str = "Local" LocalRoot: str = "~" ServerUrl: str = "" # e.g. http://spines:8420 Username: str = "" # last one logged in with, prefilled in the login dialog RememberLogin: bool = True # keep the session in Session.toml between starts # Local mode only; on a server these come from your SaraView user settings. ShowHiddenFolders: bool = False ShowSvFiles: bool = False # Same as the server's [SaraTag] HideInternalFiles/InternalPatterns. HideInternalFiles: bool = True InternalPatterns: list[str] = field(default_factory=lambda: [".saratag", ".SaraTag*", "*.book.toml"]) ConfirmDelete: bool = True ShowSidebar: bool = True SidebarWidth: int = 240 ShowFilesInTree: bool = True # False: the sidebar tree shows directories only FontSize: int = 14 # file list and viewer text # "Separate": Markdown opens rendered, with Edit/View to switch to the raw # text. "Live": rendered, and clicking a block shows its Markdown to edit # in place (like Obsidian's live preview). Only affects Markdown. MarkdownEditing: str = "Live" # Graph view: directories as dots too (off: only files and the references # between them), and the mouse button that pans ("Left" or "Right"; the # other one opens a dot). GraphShowDirectories: bool = True GraphPanButton: str = "Right" # Directory dots: heading colours by depth (off: all Mauve), and smaller # the deeper they are. GraphColourByDepth: bool = True GraphShrinkByDepth: bool = False # Double-click on a file: True opens it in SaraView's own viewer, False # hands it to the system's default program. The menu always offers the other. OpenFilesInApp: bool = True # Draw ```mermaid diagrams in local folders (LocalDiagrams.py); off removes # what it downloaded. On a server, the server draws them either way. LocalDiagrams: bool = True def Save(self) -> None: ConfigDir.mkdir(parents=True, exist_ok=True) ConfigPath.write_text(tomlkit.dumps(asdict(self))) @property def LocalRootPath(self) -> Path: return Path(os.path.expanduser(self.LocalRoot.strip() or "~")).resolve() def LoadConfig() -> AppConfig: if not ConfigPath.exists(): Config = AppConfig() Config.Save() return Config Data = _ReadToml(ConfigPath) return AppConfig(**{Key: Value for Key, Value in Data.items() if Key in AppConfig.__dataclass_fields__}) def LoadSessionToken(ServerUrl: str) -> str: """The saved session for ServerUrl, or "" (a session from another server is useless).""" Data = _ReadToml(SessionPath) return Data.get("Token", "") if Data.get("ServerUrl") == ServerUrl else "" def SaveSessionToken(ServerUrl: str, Token: str) -> None: if not Token: SessionPath.unlink(missing_ok=True) return ConfigDir.mkdir(parents=True, exist_ok=True) SessionPath.write_text(tomlkit.dumps({"ServerUrl": ServerUrl, "Token": Token})) SessionPath.chmod(0o600) @dataclass class SharedSettings: """~/.config/SaraSuite/Config.toml, shared by every SaraSuite program. Same schema and fallback rules as 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 SaraView'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 Data = _ReadToml(SharedConfigPath) 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 SaraFem's format: the shared settings under [SaraSuite] (with a custom theme's palette as ThemeData) and SaraView's own under [SaraView]. Other programs ignore [SaraView] and vice versa.""" Shared = LoadSharedSettings(Config) SharedData = asdict(Shared) if Shared.Theme not in BuiltinThemes: ThemeData = _ReadToml(ThemesDir / f"{Shared.Theme}.toml").get("Palette") if ThemeData: SharedData["ThemeData"] = ThemeData DestPath.write_text(tomlkit.dumps({"SaraSuite": SharedData, AppName: asdict(Config)})) def ImportSettings(Config: AppConfig, SourcePath: Path) -> None: """Applies [SaraSuite] to the shared file and [SaraView] onto Config, key by key; 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 BuiltinThemes: 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 (xdg-open, fc-list). A PyInstaller build points LD_LIBRARY_PATH at its own bundled libraries; system tools need the original back. Same as SaraSort's.""" 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) if IsWindows: Environment["PYTHONUTF8"] = "1" # every Sara program reads and writes UTF-8 return Environment def OpenExternally(FilePath: Path) -> None: """Hands a file to the system's default program. Raises OSError.""" if IsWindows: os.startfile(str(FilePath)) return subprocess.Popen(["xdg-open", str(FilePath)], env=ChildEnvironment(), start_new_session=True) 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, with the bundled font first.""" 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, env=ChildEnvironment(), ) 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(Accent: str = "") -> ft.ButtonStyle: """SaraView's button look: SurfaceAlt fill, 1px accent border, small radius. Disabled, it fades to the muted colours (plain colours would look enabled).""" return ft.ButtonStyle( color={ft.ControlState.DISABLED: Palette.Overlay0, ft.ControlState.DEFAULT: Palette.Text}, icon_color={ft.ControlState.DISABLED: Palette.Overlay0, ft.ControlState.DEFAULT: Palette.Text}, bgcolor={ft.ControlState.DISABLED: Palette.Mantle, ft.ControlState.DEFAULT: Palette.Surface0}, side={ ft.ControlState.DISABLED: ft.BorderSide(1, Palette.Surface1), ft.ControlState.DEFAULT: ft.BorderSide(1, Accent or Palette.Mauve), }, shape=ft.RoundedRectangleBorder(radius=4), ) def CodeStyleSheet() -> ft.MarkdownStyleSheet: """Code in the app's own font rather than Flutter's default monospace.""" return ft.MarkdownStyleSheet(code_text_style=ft.TextStyle(font_family=ActiveFontFamily)) def MarkdownSheet(FontSize: int) -> ft.MarkdownStyleSheet: """Rendered Markdown's colours, the same roles as the web view's .MarkdownBody (Frontend/src/Styles/Theme.css): rainbow headings, coloured bold/italic/code, a Purple bar on quotes.""" def Heading(Colour: str, Extra: int) -> ft.TextStyle: return ft.TextStyle(size=FontSize + Extra, color=Colour, weight=ft.FontWeight.BOLD) return ft.MarkdownStyleSheet( p_text_style=ft.TextStyle(size=FontSize, color=Palette.Text), h1_text_style=Heading(Palette.Mauve, 10), h2_text_style=Heading(Palette.Blue, 7), h3_text_style=Heading(Palette.Green, 4), h4_text_style=Heading(Palette.Yellow, 2), h5_text_style=Heading(Palette.Peach, 1), h6_text_style=Heading(Palette.Red, 0), strong_text_style=ft.TextStyle(color=Palette.Peach, weight=ft.FontWeight.BOLD), em_text_style=ft.TextStyle(color=Palette.Yellow, italic=True), del_text_style=ft.TextStyle(color=Palette.Overlay0, decoration=ft.TextDecoration.LINE_THROUGH), a_text_style=ft.TextStyle(color=Palette.Blue, decoration=ft.TextDecoration.UNDERLINE), code_text_style=ft.TextStyle(font_family=ActiveFontFamily, size=FontSize, color=Palette.Green, bgcolor=Palette.Surface0), blockquote_text_style=ft.TextStyle(size=FontSize, color=Palette.Overlay0), blockquote_decoration=ft.BoxDecoration( bgcolor=Palette.Mantle, border=ft.Border(left=ft.BorderSide(3, Palette.Mauve)), ), blockquote_padding=ft.Padding.only(left=12, top=6, right=8, bottom=6), list_bullet_text_style=ft.TextStyle(size=FontSize, color=Palette.Mauve), checkbox_text_style=ft.TextStyle(size=FontSize, color=Palette.Mauve), table_head_text_style=ft.TextStyle(size=FontSize, color=Palette.Mauve, weight=ft.FontWeight.BOLD), table_body_text_style=ft.TextStyle(size=FontSize, color=Palette.Text), table_cells_decoration=ft.BoxDecoration(bgcolor=Palette.Mantle), codeblock_decoration=ft.BoxDecoration(bgcolor=Palette.Mantle, border_radius=4), horizontal_rule_decoration=ft.BoxDecoration(border=ft.Border(top=ft.BorderSide(1, Palette.Surface1))), ) def MarkdownCodeTheme() -> ft.MarkdownCustomCodeTheme: """Syntax colours for code blocks from the palette roles (Catppuccin's own mapping), in place of a fixed highlight.js theme that ignored the active SaraSuite theme.""" def Style(Colour: str, **Extra) -> ft.TextStyle: return ft.TextStyle(color=Colour, **Extra) return ft.MarkdownCustomCodeTheme( root=ft.TextStyle(color=Palette.Text, bgcolor=Palette.Mantle, font_family=ActiveFontFamily), keyword=Style(Palette.Mauve), meta_keyword=Style(Palette.Mauve), doctag=Style(Palette.Mauve), section=Style(Palette.Mauve, weight=ft.FontWeight.BOLD), bullet=Style(Palette.Mauve), tag=Style(Palette.Mauve), string=Style(Palette.Green), meta_string=Style(Palette.Green), addition=Style(Palette.Green), number=Style(Palette.Peach), literal=Style(Palette.Peach), regexp=Style(Palette.Peach), meta=Style(Palette.Peach), built_in=Style(Palette.Yellow), builtin_name=Style(Palette.Yellow), type=Style(Palette.Yellow), class_name=Style(Palette.Yellow), attr=Style(Palette.Yellow), attribute=Style(Palette.Yellow), selector_class=Style(Palette.Yellow), function=Style(Palette.Blue), title=Style(Palette.Blue), name=Style(Palette.Blue), link=Style(Palette.Blue), selector_tag=Style(Palette.Blue), selector_id=Style(Palette.Blue), variable=Style(Palette.Red), template_variable=Style(Palette.Red), symbol=Style(Palette.Red), deletion=Style(Palette.Red), comment=Style(Palette.Overlay0, italic=True), quote=Style(Palette.Overlay0, italic=True), emphasis=ft.TextStyle(italic=True), strong=ft.TextStyle(weight=ft.FontWeight.BOLD), ) 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 BlendHex(BaseHex: str, AccentHex: str, Ratio: float) -> str: """Solid colour mixing Base and Accent (0 = pure Base, 1 = pure Accent). 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.""" Accent = {"success": Palette.Green, "error": Palette.Red, "warning": Palette.Yellow}.get(Kind, Palette.Mauve) return BlendHex(Palette.Mantle, Accent, 0.12), Accent def ResizeHandle(OnDrag: Callable[[float], None], OnDragEnd: Callable[[], None]) -> ft.GestureDetector: """A thin draggable divider between side-by-side panels (SaraFem's ResizeHandle, horizontal only). The parent Row must stretch its children vertically, or the handle has no height to drag.""" return ft.GestureDetector( content=ft.Container(width=6, bgcolor=Palette.Surface1), mouse_cursor=ft.MouseCursor.RESIZE_COLUMN, on_horizontal_drag_update=lambda e: OnDrag(e.primary_delta or 0), on_horizontal_drag_end=lambda e: OnDragEnd(), ) def HumanSize(NumBytes: Optional[int]) -> str: if NumBytes is None: return "" Size = float(NumBytes) for Unit in ("B", "KB", "MB", "GB"): if Size < 1024 or Unit == "GB": return f"{Size:.0f} {Unit}" if Unit == "B" else f"{Size:.1f} {Unit}" Size /= 1024 return f"{Size:.1f} TB" def DisplayPath(PathText: str) -> str: """/home/sarah/Notes -> ~/Notes, for labels (C:\\Users\\x\\Notes -> ~\\Notes).""" HomeText = str(Home) if PathText == HomeText or PathText.startswith(HomeText + os.sep): return "~" + PathText[len(HomeText):] return PathText def ClearCache() -> None: shutil.rmtree(CacheDir, ignore_errors=True)