from __future__ import annotations import re import shutil import tomllib from pathlib import Path from typing import Any import tomli_w from Core import ApplicationDirectory, Config PaletteRoles = [ "Background", "Surface", "SurfaceAlt", "Border", "Text", "TextMuted", "Black", "White", "Red", "Orange", "Yellow", "Green", "Blue", "Purple", ] DefaultSettings: dict[str, Any] = { "FontFamily": "Fixedsys Excelsior", "FontSize": 14, "ThemeName": "CatppuccinMocha", "ShowHiddenFolders": False, "ShowSvFiles": False, "ShowBreadcrumbs": True, "UseHyphenBullets": True, "UseAsciiFolderIcons": True, "NavigateIntoDirectories": False, "ShowGitBlame": False, "NewEntryButtonsAtTop": False, "StickyToolbar": True, "ConfirmBeforeDelete": True, "VisibleInBlame": True, "ShowBlameAvatars": False, } DefaultThemesDirectory = ApplicationDirectory / "DefaultThemes" def SanitizeThemeName(ThemeName: str) -> str: """Reject theme names that could escape the Themes directory.""" if not ThemeName or "/" in ThemeName or "\\" in ThemeName or ThemeName in (".", ".."): raise ValueError(f"Invalid theme name: {ThemeName}") return ThemeName def SanitizeUsernameForPath(Username: str) -> str: """Usernames are validated at account-creation time, but this is a second line of defense against a settings path ever escaping its directory. """ if not Username or not re.fullmatch(r"[A-Za-z0-9_.-]+", Username): raise ValueError(f"Invalid username: {Username}") return Username def GetDataDirectory() -> Path: StorageConfig = Config.get("Storage", {}) RawPath = StorageConfig.get("DataDirectory", str(ApplicationDirectory / "Data")) return Path(RawPath).expanduser().resolve() def GetSettingsDirectory() -> Path: return GetDataDirectory() / "Settings" def GetSettingsPath(Username: str) -> Path: Username = SanitizeUsernameForPath(Username) return GetSettingsDirectory() / f"{Username}.toml" def GetThemesDirectory() -> Path: return GetDataDirectory() / "Themes" def EnsureDataDirectory() -> None: """Create the data directory and seed default themes, if missing.""" DataDirectory = GetDataDirectory() ThemesDirectory = GetThemesDirectory() DataDirectory.mkdir(parents=True, exist_ok=True) GetSettingsDirectory().mkdir(parents=True, exist_ok=True) ThemesDirectory.mkdir(parents=True, exist_ok=True) if DefaultThemesDirectory.is_dir(): for ThemeFile in DefaultThemesDirectory.glob("*.toml"): TargetPath = ThemesDirectory / ThemeFile.name if not TargetPath.is_file(): shutil.copy(ThemeFile, TargetPath) def LoadSettings(Username: str) -> dict[str, Any]: """Load one user's settings, seeding their file with the defaults on first use. Settings are per-user; there is no shared/global settings file anymore. """ EnsureDataDirectory() SettingsPath = GetSettingsPath(Username) if not SettingsPath.is_file(): with SettingsPath.open("wb") as SettingsFile: tomli_w.dump(DefaultSettings, SettingsFile) return dict(DefaultSettings) with SettingsPath.open("rb") as SettingsFile: Loaded = tomllib.load(SettingsFile) return {**DefaultSettings, **Loaded} def SaveSettings(Username: str, NewSettings: dict[str, Any]) -> dict[str, Any]: EnsureDataDirectory() SettingsPath = GetSettingsPath(Username) Current: dict[str, Any] = {} if SettingsPath.is_file(): with SettingsPath.open("rb") as SettingsFile: Current = tomllib.load(SettingsFile) Merged = {**DefaultSettings, **Current, **NewSettings} with SettingsPath.open("wb") as SettingsFile: tomli_w.dump(Merged, SettingsFile) return Merged def RenameUserSettings(OldUsername: str, NewUsername: str) -> None: OldPath = GetSettingsPath(OldUsername) if OldPath.is_file(): OldPath.rename(GetSettingsPath(NewUsername)) def ListThemeNames() -> list[str]: EnsureDataDirectory() return sorted(ThemeFile.stem for ThemeFile in GetThemesDirectory().glob("*.toml")) def DeleteTheme(ThemeName: str) -> None: EnsureDataDirectory() ThemeName = SanitizeThemeName(ThemeName) ThemePath = GetThemesDirectory() / f"{ThemeName}.toml" if not ThemePath.is_file(): raise FileNotFoundError(f"Unknown theme: {ThemeName}") ThemePath.unlink() def LoadTheme(ThemeName: str) -> dict[str, str]: EnsureDataDirectory() ThemeName = SanitizeThemeName(ThemeName) ThemePath = GetThemesDirectory() / f"{ThemeName}.toml" if not ThemePath.is_file(): raise FileNotFoundError(f"Unknown theme: {ThemeName}") with ThemePath.open("rb") as ThemeFile: Loaded = tomllib.load(ThemeFile) return Loaded.get("Palette", {}) def SaveTheme(ThemeName: str, Palette: dict[str, str]) -> None: """Create or overwrite a custom theme. Missing roles fall back to the active Catppuccin Mocha default so a partially-specified custom theme still renders sensibly. """ EnsureDataDirectory() ThemeName = SanitizeThemeName(ThemeName) MissingRoles = [Role for Role in PaletteRoles if Role not in Palette] if MissingRoles: FallbackPalette = LoadTheme("CatppuccinMocha") for Role in MissingRoles: Palette[Role] = FallbackPalette.get(Role, "#ff00ff") ThemePath = GetThemesDirectory() / f"{ThemeName}.toml" with ThemePath.open("wb") as ThemeFile: tomli_w.dump({"Palette": Palette}, ThemeFile)