mirror of
https://git.srhtnin.garden/sarah/SaraSort.git
synced 2026-10-13 18:28:18 +02:00
338 lines
13 KiB
Python
338 lines
13 KiB
Python
"""Shared app-wide constants: paths, palette, theme, SaraSort's own config.
|
|
|
|
The palette, theming and shared settings mirror SaraSuite's and 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.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
from dataclasses import asdict, dataclass
|
|
from pathlib import Path
|
|
|
|
import flet as ft
|
|
import tomlkit
|
|
|
|
AppName = "SaraSort"
|
|
# 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 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")
|
|
else:
|
|
ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Home / ".config")
|
|
DataHome = Path(os.environ.get("XDG_DATA_HOME") or Home / ".local" / "share")
|
|
|
|
SharedConfigDir = ConfigHome / "SaraSuite"
|
|
ThemesDir = SharedConfigDir / "Themes"
|
|
SharedConfigPath = SharedConfigDir / "Config.toml"
|
|
ConfigDir = SharedConfigDir / AppName
|
|
ConfigPath = ConfigDir / "Config.toml"
|
|
LayoutPath = ConfigDir / "Layout.toml" # where flowchart boxes were dragged to
|
|
UserTemplatesDir = ConfigDir / "Templates"
|
|
|
|
# Inside a PyInstaller build, bundled files are unpacked to sys._MEIPASS.
|
|
BundleDir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
|
|
AssetsDir = BundleDir / "assets"
|
|
|
|
# Themes SaraFem seeds into ThemesDir. Only other themes travel with an
|
|
# export as full palettes (same rule as SaraFem's ExportSettings).
|
|
BuiltinThemeNames = {"CatppuccinMocha", "Forest", "Gruvbox"}
|
|
|
|
|
|
class Palette:
|
|
"""Live values for the active theme (see ApplyTheme). Same attribute names
|
|
and role mapping as SaraSuite's and 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.
|
|
Keeps the built-in Catppuccin Mocha values if the file is missing. 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])
|
|
|
|
|
|
def AvailableThemes() -> list[str]:
|
|
Names = {ThemePath.stem for ThemePath in ThemesDir.glob("*.toml")} if ThemesDir.is_dir() else set()
|
|
Names.add("CatppuccinMocha")
|
|
return sorted(Names)
|
|
|
|
|
|
@dataclass
|
|
class AppConfig:
|
|
# True: Theme, FontFamily and HideWindowTitleBar come from the shared
|
|
# ~/.config/SaraSuite/Config.toml. False: SaraSort's own values below.
|
|
UseSharedSettings: bool = True
|
|
Theme: str = "CatppuccinMocha"
|
|
FontFamily: str = BundledFontFamily
|
|
HideWindowTitleBar: bool = False
|
|
# Where SaraSuite installs SaraSortd: GlobalConf.toml and the two
|
|
# directory config templates live in SortdConfigDir, SaraSortd.py in
|
|
# SortdAppDir, and it runs as a systemd user unit (Linux) or a background
|
|
# service started from the Startup folder (Windows; see Service.py).
|
|
SortdConfigDir: str = (str(ConfigHome / "SaraSuite" / "SaraSortd") if IsWindows
|
|
else "~/.config/SaraSuite/SaraSortd")
|
|
SortdAppDir: str = (str(DataHome / "SaraSuite" / "Programs" / "SaraSortd" / "App") if IsWindows
|
|
else "~/.local/share/SaraSuite/Programs/SaraSortd/App")
|
|
ServiceName: str = "SaraSortd" if IsWindows else "SaraSortd.service"
|
|
# SaraSortd only reads GlobalConf.toml when it starts.
|
|
AutoRestart: bool = True
|
|
RefreshSeconds: int = 5 # status and log refresh on the Service page
|
|
LogLines: int = 500
|
|
LabelLength: int = 22 # arrow labels longer than this are shortened
|
|
StartPage: str = "Flow"
|
|
|
|
def Save(self) -> None:
|
|
ConfigDir.mkdir(parents=True, exist_ok=True)
|
|
ConfigPath.write_text(tomlkit.dumps(asdict(self)))
|
|
|
|
@property
|
|
def GlobalConfPath(self) -> Path:
|
|
return Path(self.SortdConfigDir).expanduser() / "GlobalConf.toml"
|
|
|
|
@property
|
|
def SortdScriptPath(self) -> Path:
|
|
return Path(self.SortdAppDir).expanduser() / "SaraSortd.py"
|
|
|
|
|
|
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 SaraSuite's and 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 SaraSort'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 ExportSettings(Config: AppConfig, DestPath: Path) -> None:
|
|
"""One portable file in the same format as SaraFem's export: the shared
|
|
settings under [SaraSuite] (with a custom theme's palette as ThemeData)
|
|
and SaraSort's own under [SaraSort]. Other programs ignore [SaraSort],
|
|
and SaraSort ignores their sections."""
|
|
Shared = LoadSharedSettings(Config)
|
|
SharedData = asdict(Shared)
|
|
if Shared.Theme not in BuiltinThemeNames:
|
|
ThemeFile = ThemesDir / f"{Shared.Theme}.toml"
|
|
if ThemeFile.exists():
|
|
try:
|
|
SharedData["ThemeData"] = tomlkit.parse(ThemeFile.read_text()).unwrap().get("Palette", {})
|
|
except tomlkit.exceptions.TOMLKitError:
|
|
pass
|
|
DestPath.write_text(tomlkit.dumps({"SaraSuite": SharedData, AppName: asdict(Config)}))
|
|
|
|
|
|
def ImportSettings(Config: AppConfig, SourcePath: Path) -> None:
|
|
"""Applies [SaraSuite] to the shared file and [SaraSort] onto Config, key
|
|
by key: keys the file doesn't mention stay as they are, 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 BuiltinThemeNames:
|
|
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 (systemctl, journalctl, xdg-open).
|
|
A PyInstaller build points LD_LIBRARY_PATH at its own bundled libraries;
|
|
system tools need the original back."""
|
|
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 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 BlendHex(BaseHex: str, AccentHex: str, Ratio: float) -> str:
|
|
"""Solid colour mixing Base and Accent (0 = pure Base, 1 = pure Accent),
|
|
for tinted-but-still-dark backgrounds. 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: "success" green, "error" red, plus "warning" yellow and "info"
|
|
mauve for SaraSort's restart-needed and plain notes."""
|
|
Accent = {"success": Palette.Green, "error": Palette.Red, "warning": Palette.Yellow}.get(Kind, Palette.Mauve)
|
|
return BlendHex(Palette.Mantle, Accent, 0.12), Accent
|
|
|
|
|
|
def ChipButtonStyle(Accent: str = "") -> ft.ButtonStyle:
|
|
"""SaraView's button look: SurfaceAlt fill, 1px accent border, small radius."""
|
|
return ft.ButtonStyle(
|
|
color=Palette.Text,
|
|
bgcolor=Palette.Surface0,
|
|
side=ft.BorderSide(1, Accent or Palette.Mauve),
|
|
shape=ft.RoundedRectangleBorder(radius=4),
|
|
)
|
|
|
|
|
|
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 Note(Text: str, Size: int = 12) -> ft.Text:
|
|
return ft.Text(Text, color=Palette.Overlay0, size=Size)
|
|
|
|
|
|
def Heading(Text: str) -> ft.Text:
|
|
return ft.Text(Text, color=Palette.Mauve, size=20)
|
|
|
|
|
|
def DisplayPath(PathText: str) -> str:
|
|
"""/home/sarah/Sorting -> ~/Sorting, for labels."""
|
|
HomeText = str(Home)
|
|
if PathText == HomeText or PathText.startswith(HomeText + "/"):
|
|
return "~" + PathText[len(HomeText):]
|
|
return PathText
|