mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-13 18:18:12 +02:00
280 lines
11 KiB
Python
280 lines
11 KiB
Python
"""Shared app-wide constants: paths, palette, theme.
|
|
|
|
Paths follow each system's own per-user conventions (no admin needed):
|
|
Linux Windows
|
|
~/.config %APPDATA% (settings; roams with the user)
|
|
~/.local/share %LOCALAPPDATA% (installed programs, SaraSuite's data)
|
|
~/.cache %LOCALAPPDATA% (caches, in a Cache/Downloads subfolder)
|
|
so the SaraSuite layout below them (SaraSuite/<Program>/...) is the same on both.
|
|
|
|
The palette and theming mirror SaraFem's Core.py (which mirrors SaraView's),
|
|
and read the same shared ~/.config/SaraSuite/Themes/*.toml 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, field
|
|
from pathlib import Path
|
|
|
|
import flet as ft
|
|
import tomlkit
|
|
|
|
AppName = "SaraSuite"
|
|
# Compared with the latest Gitea release to offer self-updates (SelfUpdate.py).
|
|
# Bump it, then run Packaging/Release.py, to publish a new SaraSuite.
|
|
AppVersion = "1.1.1"
|
|
IsFrozen = bool(getattr(sys, "frozen", False)) # running from the AppImage / .exe
|
|
IsWindows = sys.platform == "win32"
|
|
Platform = "Windows" if IsWindows else "Linux" # matches manifests' Platforms / Platform
|
|
# 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
|
|
|
|
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
|
|
# Start Menu shortcuts, and the Startup folder (run at login: SaraSortd's
|
|
# stand-in for a systemd user service).
|
|
StartMenuDir = ConfigHome / "Microsoft" / "Windows" / "Start Menu" / "Programs"
|
|
StartupDir = StartMenuDir / "Startup"
|
|
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")
|
|
StartMenuDir = StartupDir = None
|
|
|
|
SharedConfigDir = ConfigHome / "SaraSuite"
|
|
ThemesDir = SharedConfigDir / "Themes"
|
|
ConfigDir = SharedConfigDir / "SaraSuite"
|
|
ConfigPath = ConfigDir / "Config.toml"
|
|
# Settings shared by every SaraSuite program (theme, font, window title bar).
|
|
# Same key names as the matching fields in each program's own Config.toml.
|
|
SharedConfigPath = SharedConfigDir / "Config.toml"
|
|
|
|
# Everything SaraSuite itself keeps about installed programs. Nothing in here
|
|
# is user-edited, so it's all fair game to delete when a program is removed.
|
|
DataDir = DataHome / "SaraSuite"
|
|
ProgramsDir = DataDir / "Programs" # installed program files + venvs
|
|
ReceiptsDir = DataDir / "Receipts" # what each install placed, for exact removal
|
|
PristineDir = DataDir / "Pristine" # the default configs as last installed (merge base)
|
|
BackupsDir = DataDir / "Backups" # displaced files and pre-merge config copies
|
|
DownloadsDir = CacheHome / "SaraSuite" / "Downloads" # extracted source archives
|
|
ServicesDir = DataDir / "Services" # Windows: <Name>.pid of each running background service
|
|
|
|
# Bundled read-only files (program manifests, fonts). Inside a PyInstaller
|
|
# build they're unpacked to sys._MEIPASS rather than next to the code.
|
|
BundleDir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
|
|
BundledManifestsDir = BundleDir / "Programs" # the AppImage's own copy; see Catalog.py
|
|
AssetsDir = BundleDir / "assets"
|
|
|
|
|
|
class Palette:
|
|
"""Live values for the active theme (see ApplyTheme). Same attribute names
|
|
and SaraView role mapping as 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/<ThemeName>.toml.
|
|
Falls back to the built-in Catppuccin Mocha values if the file is missing
|
|
(SaraFem seeds that directory; SaraSuite only reads it). 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])
|
|
|
|
|
|
@dataclass
|
|
class AppConfig:
|
|
# True: Theme, FontFamily and HideWindowTitleBar come from the shared
|
|
# ~/.config/SaraSuite/Config.toml (edited in SaraFem's Settings, SaraSuite
|
|
# tab). False: SaraSuite 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
|
|
# Where the Sara repos live (Gitea on SpineS); manifests
|
|
# use it as {GitHost}.
|
|
GitHost: str = "https://git.srhtnin.garden/sarah"
|
|
# What the install dialog starts with, per program, overriding the
|
|
# manifest's defaults: {Program: {OptionId: ticked}} and
|
|
# {Program: {QuestionId: answer}}. Set in Settings.
|
|
OptionDefaults: dict[str, dict[str, bool]] = field(default_factory=dict)
|
|
AnswerDefaults: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
|
|
def Save(self) -> None:
|
|
ConfigDir.mkdir(parents=True, exist_ok=True)
|
|
ConfigPath.write_text(tomlkit.dumps(asdict(self)))
|
|
|
|
|
|
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 SaraFem's SharedSettings."""
|
|
|
|
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 this program'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 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, pip, python3 -m venv).
|
|
A PyInstaller build points LD_LIBRARY_PATH at its own bundled libraries
|
|
and keeps the original in LD_LIBRARY_PATH_ORIG; system tools need the
|
|
original back or they can load the wrong libraries."""
|
|
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:
|
|
# Python on Windows otherwise reads/writes text in the old ANSI code
|
|
# page; every Sara program assumes UTF-8 (their TOML files).
|
|
Environment["PYTHONUTF8"] = "1"
|
|
return Environment
|
|
|
|
|
|
def CurrentDesktop() -> str:
|
|
"""First entry of $XDG_CURRENT_DESKTOP as-is (e.g. "COSMIC"), used for
|
|
portal descriptors' UseIn= and the <desktop>-portals.conf file name.
|
|
Empty on Windows."""
|
|
return os.environ.get("XDG_CURRENT_DESKTOP", "").split(":")[0]
|
|
|
|
|
|
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.
|
|
Accent defaults to Mauve; Remove buttons pass Palette.Red. Disabled buttons
|
|
are greyed out (muted text, plain border, no fill) so it's obvious they
|
|
can't be pressed."""
|
|
Disabled = ft.ControlState.DISABLED
|
|
Default = ft.ControlState.DEFAULT
|
|
return ft.ButtonStyle(
|
|
color={Disabled: Palette.Overlay0, Default: Palette.Text},
|
|
bgcolor={Disabled: Palette.Mantle, Default: Palette.Surface0},
|
|
side={Disabled: ft.BorderSide(1, Palette.Surface1), Default: ft.BorderSide(1, Accent or Palette.Mauve)},
|
|
shape=ft.RoundedRectangleBorder(radius=4),
|
|
)
|