mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-13 18:28:14 +02:00
584 lines
25 KiB
Python
584 lines
25 KiB
Python
"""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
|
|
# New File / New Directory / Upload: above the sidebar tree, or below it
|
|
# (the web view's setting of the same name).
|
|
NewEntryButtonsAtTop: bool = False
|
|
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
|
|
# Clicking a directory (in the tree or the list) also opens its .sv_ note,
|
|
# like the web view's "Open directory file on click".
|
|
NavigateIntoDirectories: bool = False
|
|
# 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 HardLineBreaks(Text: str) -> str:
|
|
"""Every line break shows, like the web view's Markdown (breaks: true):
|
|
Flet's joins a paragraph's lines into one. Code blocks stay as they are."""
|
|
Lines = Text.split("\n")
|
|
InFence = False
|
|
for Index, Line in enumerate(Lines[:-1]):
|
|
if Line.lstrip().startswith(("```", "~~~")):
|
|
InFence = not InFence
|
|
continue
|
|
if InFence or not Line.strip() or not Lines[Index + 1].strip() or Line.endswith((" ", "\\")):
|
|
continue
|
|
Lines[Index] = Line + " "
|
|
return "\n".join(Lines)
|
|
|
|
|
|
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 HumanDuration(Seconds: float) -> str:
|
|
"""Roughly, for "about ... left": 8 s, 3 min, 1 h, 1 h 20 min."""
|
|
Seconds = max(1, round(Seconds))
|
|
if Seconds < 60:
|
|
return f"{Seconds} s"
|
|
Hours, Minutes = divmod(round(Seconds / 60), 60)
|
|
if not Hours:
|
|
return f"{Minutes} min"
|
|
return f"{Hours} h {Minutes} min" if Minutes else f"{Hours} h"
|
|
|
|
|
|
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)
|