Add Flet desktop client

This commit is contained in:
sarah committed 2026-10-08 23:25:41 +02:00
1 parent 16787aba14
commit 0eecfba224
18 files changed
+2588

No files matched your search

+414
View File
@@ -0,0 +1,414 @@
"""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
Home = Path.home()
SharedConfigDir = Home / ".config" / "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 = Home / ".cache" / AppName
DataHome = Home / ".local" / "share"
# Inside a PyInstaller build, bundled files are unpacked to sys._MEIPASS.
BundleDir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
AssetsDir = BundleDir / "assets"
_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
FontSize: int = 14 # file list and viewer text
# 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
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)
return Environment
def OpenExternally(FilePath: Path) -> None:
"""Hands a file to the system's default program. Raises OSError."""
subprocess.Popen(["xdg-open", str(FilePath)], env=ChildEnvironment(), start_new_session=True)
def InstalledFontFamilies() -> list[str]:
"""Font families fontconfig knows about, with the bundled font first."""
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 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."""
HomeText = str(Home)
if PathText == HomeText or PathText.startswith(HomeText + "/"):
return "~" + PathText[len(HomeText):]
return PathText
def ClearCache() -> None:
shutil.rmtree(CacheDir, ignore_errors=True)