mirror of
https://git.srhtnin.garden/sarah/SaraFem.git
synced 2026-10-13 18:18:11 +02:00
723 lines
32 KiB
Python
723 lines
32 KiB
Python
"""Shared app-wide constants: palette, theme, cache paths.
|
|
|
|
Palette and role-mapping deliberately mirror SaraView's (see
|
|
SaraView/Backend/DefaultThemes/CatppuccinMocha.toml and Frontend/src/Styles/Theme.css):
|
|
one accent colour (Purple/Mauve) used everywhere, not a mauve+lavender mix.
|
|
"""
|
|
|
|
import shutil
|
|
import subprocess
|
|
from dataclasses import asdict, dataclass, field
|
|
from pathlib import Path
|
|
from typing import Callable, Optional
|
|
|
|
import flet as ft
|
|
import toml
|
|
|
|
|
|
class Palette:
|
|
"""Live values for the active theme (see ApplyTheme). Starts as Catppuccin
|
|
Mocha so anything constructed before ApplyTheme() runs still looks right.
|
|
Role names/values are mutated in place from ~/.config/SaraSuite/Themes/*.toml,
|
|
which uses the exact same [Palette] schema as SaraView's own theme files —
|
|
see SaraView/Backend/DefaultThemes/*.toml and Frontend/src/ThemeRoles.js."""
|
|
|
|
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 = "SaraFem"
|
|
# 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
|
|
|
|
CacheDir = Path.home() / ".cache" / "SaraFem"
|
|
ThumbnailCacheDir = CacheDir / "Thumbnails"
|
|
|
|
# Copy/Cut/Paste uses the real OS clipboard (Clipboard.set_files/get_files —
|
|
# see Browser._DoSetClipboard/_DoPaste) instead of private Python state, so
|
|
# it actually works between two separate SaraFem windows (each its own
|
|
# process) and interoperates with other apps' file managers. The one thing
|
|
# the OS file clipboard has no concept of is a cut-vs-copy flag, so that one
|
|
# bit lives here instead, in a tiny sidecar file every SaraFem window reads
|
|
# fresh on Paste rather than trusting its own possibly-stale local state.
|
|
ClipboardModePath = CacheDir / "ClipboardMode.txt"
|
|
|
|
|
|
def SetClipboardMode(Mode: str) -> None:
|
|
CacheDir.mkdir(parents=True, exist_ok=True)
|
|
ClipboardModePath.write_text(Mode)
|
|
|
|
|
|
def GetClipboardMode() -> str:
|
|
try:
|
|
Mode = ClipboardModePath.read_text().strip()
|
|
except OSError:
|
|
return "copy"
|
|
return Mode if Mode in ("copy", "cut") else "copy"
|
|
|
|
# Config moved from ~/.config/SaraFem/ into a SaraSuite-wide layout so other
|
|
# Sara* programs (SaraTag, SaraView) can eventually read from the same place.
|
|
SharedConfigDir = Path.home() / ".config" / "SaraSuite"
|
|
ThemesDir = SharedConfigDir / "Themes"
|
|
ConfigDir = SharedConfigDir / "SaraFem"
|
|
ConfigPath = ConfigDir / "Config.toml"
|
|
# Settings shared by every SaraSuite program. Same key names as the matching
|
|
# fields in each program's own Config.toml.
|
|
SharedConfigPath = SharedConfigDir / "Config.toml"
|
|
BookmarksPath = ConfigDir / ".sfem_bookmarks.toml"
|
|
|
|
_OldConfigDir = Path.home() / ".config" / "SaraFem"
|
|
|
|
# The exact role names SaraView's theme files use, and the SaraFem Palette
|
|
# attribute each one maps to.
|
|
_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",
|
|
}
|
|
|
|
# Bundled so SaraFem has real theme choices even before SaraView (or anything
|
|
# else) has populated the shared Themes directory. Copied directly from
|
|
# SaraView/Backend/DefaultThemes/*.toml so the colours actually match.
|
|
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 _MigrateOldConfig() -> None:
|
|
"""One-time move from the pre-SaraSuite ~/.config/SaraFem/ location."""
|
|
if ConfigDir.exists() or not _OldConfigDir.exists():
|
|
return
|
|
ConfigDir.mkdir(parents=True, exist_ok=True)
|
|
for FileName in ("Config.toml", ".sfem_bookmarks.toml"):
|
|
OldFile = _OldConfigDir / FileName
|
|
NewFile = ConfigDir / FileName
|
|
if OldFile.exists() and not NewFile.exists():
|
|
NewFile.write_text(OldFile.read_text())
|
|
|
|
|
|
def EnsureSharedThemes() -> None:
|
|
"""Seed the shared Themes directory with the bundled defaults, so the
|
|
location is actually populated for other SaraSuite programs to read."""
|
|
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(toml.dumps({"Palette": RoleValues}))
|
|
|
|
|
|
def AvailableThemes() -> list[str]:
|
|
EnsureSharedThemes()
|
|
return sorted(F.stem for F in ThemesDir.glob("*.toml"))
|
|
|
|
|
|
def ApplyTheme(ThemeName: str) -> None:
|
|
"""Mutate Palette's attributes in place to the named theme. Must run before
|
|
any control that reads Palette.X is constructed — Palette.X values are
|
|
plain strings baked into controls at build time, not live references."""
|
|
EnsureSharedThemes()
|
|
RoleValues = dict(BuiltinThemes.get(ThemeName, BuiltinThemes["CatppuccinMocha"]))
|
|
|
|
ThemeFile = ThemesDir / f"{ThemeName}.toml"
|
|
if ThemeFile.exists():
|
|
try:
|
|
RoleValues.update(toml.loads(ThemeFile.read_text()).get("Palette", {}))
|
|
except toml.TomlDecodeError:
|
|
pass
|
|
|
|
for RoleName, AttrName in _ThemeRoleMap.items():
|
|
if RoleName in RoleValues:
|
|
setattr(Palette, AttrName, RoleValues[RoleName])
|
|
|
|
|
|
@dataclass
|
|
class SaraTagIntegrationConfig:
|
|
"""Mirrors SaraView's Backend/SaraViewConf.toml [SaraTag] section, so both
|
|
apps talk to SaraTag the same opt-in way. Disabled until pointed at a
|
|
SaraTag checkout, e.g. CoreModulePath = "/home/sarah/Applications/SaraTag/.saratag"."""
|
|
|
|
Enabled: bool = False
|
|
CoreModulePath: str = ""
|
|
HideInternalFiles: bool = True
|
|
InternalPatterns: list[str] = field(
|
|
default_factory=lambda: [".saratag", ".SaraTag*", "*.book.toml"]
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class FolderIconBand:
|
|
"""One slice of the folder-fullness icon system (Features: "turning on
|
|
different variants depending on how full a directory is"). N configured
|
|
bands split 0-100% (by item count, against FolderFullnessThreshold)
|
|
into N equal slices — band 0 covers the emptiest. ImagePath wins over
|
|
Icon when set (a custom image instead of a built-in Material icon)."""
|
|
|
|
Icon: str = "FOLDER_OUTLINED"
|
|
ImagePath: str = ""
|
|
|
|
|
|
@dataclass
|
|
class SaraSortdIntegrationConfig:
|
|
"""Points at a SaraSortd checkout's GlobalConf.toml (read-only — SaraFem
|
|
never writes to it). Disabled until pointed at one, e.g.
|
|
GlobalConfPath = "/home/sarah/Applications/SaraSortd/GlobalConf.toml"."""
|
|
|
|
Enabled: bool = False
|
|
GlobalConfPath: str = ""
|
|
# Separate from Enabled (which is "is this configured at all") — lets
|
|
# the integration stay wired up without the banner itself showing
|
|
# (Bugs/Major: "SaraSortd integration header should be able to be
|
|
# hidden"). CondenseBanner collapses every destination with more than
|
|
# one matching pattern into one line with a >/v expand toggle, on by
|
|
# default (Bugs/Major: "...needs to also use the width... condense
|
|
# outputs and inputs into > then v button things").
|
|
ShowBanner: bool = True
|
|
CondenseBanner: bool = True
|
|
|
|
|
|
@dataclass
|
|
class AppConfig:
|
|
"""Persisted user settings. Defaults match SaraFem's existing behaviour
|
|
(nothing changes visually until the user opts in via Ctrl+B/P/H/T or edits
|
|
Config.toml directly)."""
|
|
|
|
# True: Theme, FontFamily and HideWindowTitleBar come from the shared
|
|
# ~/.config/SaraSuite/Config.toml, like every other SaraSuite program.
|
|
# False: SaraFem 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 # the actual OS window title bar, not the toolbar below
|
|
|
|
ShowDotfiles: bool = True
|
|
ViewMode: str = "list" # "list" or "grid"
|
|
# List view: which metadata columns show, beyond the always-present Name.
|
|
ListShowDescription: bool = False
|
|
ListShowSize: bool = True
|
|
ListShowModified: bool = True
|
|
ListShowOwner: bool = False
|
|
ListShowCreated: bool = False
|
|
ListShowContent: bool = False
|
|
ListShowAccess: bool = False
|
|
# List view column widths — drag the header dividers to set these, or
|
|
# type a number in Options; both write here.
|
|
ListWidthName: int = 220
|
|
ListWidthDescription: int = 90
|
|
ListWidthSize: int = 80
|
|
ListWidthModified: int = 130
|
|
ListWidthOwner: int = 90
|
|
ListWidthCreated: int = 130
|
|
ListWidthContent: int = 90
|
|
ListWidthAccess: int = 60
|
|
# Grid view.
|
|
GridIconSize: int = 64
|
|
GridNameBelowIcon: bool = True
|
|
ListIconSize: int = 32
|
|
# Features: "Add custom icons for directories... in settings, there
|
|
# should be settings for default, and turning on different variants
|
|
# depending on how full a directory is." A single band (the default)
|
|
# is exactly today's static FOLDER icon everywhere — nothing changes
|
|
# until more bands get added in Settings. Per-directory overrides (set
|
|
# via Properties) live separately, see FolderIcons.py — they always win
|
|
# over this and never vary by fullness.
|
|
FolderIconBands: list[FolderIconBand] = field(default_factory=lambda: [FolderIconBand()])
|
|
FolderFullnessThreshold: int = 200 # item count counted as "100% full"
|
|
FontSize: int = 14 # entry name text, list and grid alike; metadata/header text scales with it
|
|
ToolbarHamburger: bool = False # collapse the toolbar's buttons into one overflow menu
|
|
# Double-click on a text/config/markup file: True opens it in SaraFem's own
|
|
# viewer/editor (Editor.py); False (matches pre-existing behaviour) hands it
|
|
# to xdg-open instead. Either way, the per-entry "Open" menu item does the
|
|
# opposite of this default, so both are always one click away.
|
|
OpenFilesInApp: bool = False
|
|
|
|
# Live filter for the file list, below the breadcrumbs/toolbar bar.
|
|
# ShowSearch is the master on/off; SearchAlwaysVisible picks between the
|
|
# bar always being there or only appearing on Ctrl+F (and dismissing on
|
|
# Escape). Match Case/Whole Word are session-only (Browser.MatchCase/
|
|
# MatchWholeWord), not persisted here — they're live search options, not
|
|
# a durable preference, same idea as a browser's find bar.
|
|
ShowSearch: bool = False
|
|
SearchAlwaysVisible: bool = False
|
|
# Opt-in: clears the search filter on every NavigateTo instead of
|
|
# carrying it over into the next directory (Features/Minor: "Option to
|
|
# reset search to be blank upon changing directory").
|
|
SearchResetOnNavigate: bool = False
|
|
|
|
# Sidebar sort control ("[Sort: Name] v" + a separate ascending/
|
|
# descending arrow button next to it — see Sidebar._BuildSortControl).
|
|
# Directories always sort before files regardless of field/direction,
|
|
# matching the existing list order; the field/direction only reorders
|
|
# within each of those two groups.
|
|
ShowSort: bool = False
|
|
SortField: str = "Name" # "Name", "Size", "Modified", or "Type"
|
|
SortAscending: bool = True
|
|
|
|
# Batch-rename pattern for multi-select rename, syntax modeled on
|
|
# SaraSortd's own placeholder characters (see ~/Applications/SaraSortd's
|
|
# GlobalConf.toml [Variables]): ~ = typed base name, ! = auto-incrementing
|
|
# counter (blank for the first item, " 1", " 2", ... after), ^ = the
|
|
# original file's extension.
|
|
BatchRenamePattern: str = "~!^"
|
|
DeleteZipAfterExtract: bool = False
|
|
DefaultArchiveFormat: str = "zip" # "zip" or "targz" — Compress's default format
|
|
|
|
# The in-app toolbar: breadcrumbs and the button row share one bar now
|
|
# (each taking half the width), positioned top or bottom, hideable as a whole.
|
|
ShowToolbar: bool = True
|
|
ShowBreadcrumbs: bool = True
|
|
BreadcrumbsPosition: str = "top" # "top" or "bottom"
|
|
ShowNewDirectoryButton: bool = True
|
|
ShowPasteButton: bool = True
|
|
ShowRefreshButton: bool = True
|
|
ShowViewToggleButton: bool = True
|
|
ShowOptionsButton: bool = True
|
|
|
|
# The left-hand panel, which holds three independently-toggleable sections:
|
|
# Bookmarks (flat, user-curated, Bookmarks.py), Devices (mounted removable
|
|
# media, Devices.py) and Filetree (a real expandable directory tree,
|
|
# Filetree.py).
|
|
ShowSidebar: bool = True
|
|
SidebarSide: str = "left" # "left" or "right"
|
|
SidebarWidth: int = 220
|
|
ShowBookmarks: bool = True
|
|
ShowDevices: bool = True
|
|
AutoMountDevices: bool = True # mount USB sticks etc. as soon as they're plugged in
|
|
ShowInternalDrives: bool = False # also list internal disks that aren't system mounts
|
|
# Requires ShowInternalDrives too — additionally lists the main drive
|
|
# itself (mounted at "/" specifically, not /boot/efi or other system
|
|
# partitions udisks2 reports as their own separate block devices). The
|
|
# Devices panel never offers to eject/unmount this one regardless (see
|
|
# Device.IsSystemMount) — doing that to your own running root
|
|
# filesystem would crash the system.
|
|
ShowSystemDrive: bool = False
|
|
# Opt-in: collapses the Devices section (box + its own divider, see
|
|
# Sidebar._OnDevicesEmptyChanged) when nothing's plugged in, instead of
|
|
# always showing it with a "No devices." placeholder.
|
|
AutoHideEmptyDevices: bool = False
|
|
ShowFiletree: bool = True
|
|
FiletreeRoot: str = "/"
|
|
BookmarksMinHeight: int = 0 # 0 = no constraint
|
|
BookmarksMaxHeight: int = 0
|
|
DevicesMinHeight: int = 0
|
|
DevicesMaxHeight: int = 0
|
|
FiletreeMinHeight: int = 0
|
|
FiletreeMaxHeight: int = 0
|
|
# Same idea as SplitPreviewTagsEvenly: proportional 50/50, overrides the
|
|
# heights above, adapts to window size instead of a fixed pixel guess.
|
|
SplitBookmarksFiletreeEvenly: bool = False
|
|
|
|
ShowPreview: bool = False
|
|
PreviewSide: str = "right" # "left" or "right"
|
|
PreviewWidth: int = 320
|
|
# Only applied when Tags shares Preview's column (TagsPosition == "under_preview") —
|
|
# otherwise Preview always fills the full height, per Sarah's clarification.
|
|
PreviewMinHeight: int = 0
|
|
PreviewMaxHeight: int = 0
|
|
ShowTags: bool = False
|
|
TagsPosition: str = "under_preview" # "under_preview", "top", or "bottom"
|
|
TagsLayout: str = "flex" # "flex" (wraps horizontally) or "vertical" (one per row)
|
|
TagMinWidth: int = 0 # 0 = no minimum, chip hugs its content
|
|
TagMaxWidth: int = 0 # 0 = no maximum
|
|
# The tags box's own height (not per-chip). Always applied, regardless of
|
|
# TagsPosition — matters most in vertical layout, where it can grow tall.
|
|
TagPanelMinHeight: int = 0
|
|
TagPanelMaxHeight: int = 0
|
|
# Proportional 50/50 split for Preview+Tags when stacked (TagsPosition ==
|
|
# "under_preview"), instead of fixed pixel heights — adapts to window size
|
|
# automatically. Overrides PreviewMinHeight/MaxHeight and TagPanelMinHeight/
|
|
# MaxHeight when on, since a fixed height and an even flex split conflict.
|
|
SplitPreviewTagsEvenly: bool = False
|
|
SaraTag: SaraTagIntegrationConfig = field(default_factory=SaraTagIntegrationConfig)
|
|
SaraSortd: SaraSortdIntegrationConfig = field(default_factory=SaraSortdIntegrationConfig)
|
|
|
|
def Save(self) -> None:
|
|
ConfigDir.mkdir(parents=True, exist_ok=True)
|
|
ConfigPath.write_text(toml.dumps(asdict(self)))
|
|
|
|
|
|
def LoadConfig() -> AppConfig:
|
|
_MigrateOldConfig()
|
|
|
|
if not ConfigPath.exists():
|
|
Config = AppConfig()
|
|
Config.Save()
|
|
return Config
|
|
|
|
try:
|
|
Data = toml.loads(ConfigPath.read_text())
|
|
except toml.TomlDecodeError:
|
|
return AppConfig()
|
|
|
|
Defaults = AppConfig()
|
|
KnownFields = {Field for Field in Defaults.__dataclass_fields__}
|
|
Filtered = {Key: Value for Key, Value in Data.items() if Key in KnownFields}
|
|
|
|
SaraTagData = Filtered.pop("SaraTag", {})
|
|
KnownSaraTagFields = {Field for Field in SaraTagIntegrationConfig.__dataclass_fields__}
|
|
SaraTagFiltered = {Key: Value for Key, Value in SaraTagData.items() if Key in KnownSaraTagFields}
|
|
Filtered["SaraTag"] = SaraTagIntegrationConfig(**SaraTagFiltered)
|
|
|
|
SaraSortdData = Filtered.pop("SaraSortd", {})
|
|
KnownSaraSortdFields = {Field for Field in SaraSortdIntegrationConfig.__dataclass_fields__}
|
|
SaraSortdFiltered = {Key: Value for Key, Value in SaraSortdData.items() if Key in KnownSaraSortdFields}
|
|
Filtered["SaraSortd"] = SaraSortdIntegrationConfig(**SaraSortdFiltered)
|
|
|
|
# A list of nested dataclasses doesn't round-trip through toml.loads()
|
|
# on its own — each entry lands as a plain dict, not a FolderIconBand,
|
|
# which would break attribute access (.Icon) everywhere this gets read.
|
|
BandsData = Filtered.pop("FolderIconBands", None)
|
|
if isinstance(BandsData, list):
|
|
KnownBandFields = {Field for Field in FolderIconBand.__dataclass_fields__}
|
|
Bands = [
|
|
FolderIconBand(**{K: V for K, V in Entry.items() if K in KnownBandFields})
|
|
for Entry in BandsData if isinstance(Entry, dict)
|
|
]
|
|
Filtered["FolderIconBands"] = Bands or [FolderIconBand()]
|
|
|
|
return AppConfig(**Filtered)
|
|
|
|
|
|
@dataclass
|
|
class SharedSettings:
|
|
"""~/.config/SaraSuite/Config.toml: the settings every SaraSuite program
|
|
shares. A program whose own UseSharedSettings is off reads these same
|
|
keys from its own Config.toml instead."""
|
|
|
|
Theme: str = "CatppuccinMocha"
|
|
FontFamily: str = BundledFontFamily
|
|
HideWindowTitleBar: bool = False
|
|
|
|
def Save(self) -> None:
|
|
SharedConfigDir.mkdir(parents=True, exist_ok=True)
|
|
SharedConfigPath.write_text(toml.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 SaraFem's own values the first time so
|
|
switching to it doesn't change anything visible."""
|
|
if not SharedConfigPath.exists():
|
|
Shared = _OwnSettings(Config)
|
|
Shared.Save()
|
|
return Shared
|
|
Result = _OwnSettings(Config) # fallback for keys the file doesn't have
|
|
try:
|
|
Data = toml.loads(SharedConfigPath.read_text())
|
|
except toml.TomlDecodeError:
|
|
return Result
|
|
for Key in SharedSettings.__dataclass_fields__:
|
|
if Key in Data:
|
|
setattr(Result, Key, Data[Key])
|
|
return Result
|
|
|
|
|
|
def EffectiveSettings(Config: AppConfig) -> SharedSettings:
|
|
"""What SaraFem actually uses: the shared settings, or its own."""
|
|
return LoadSharedSettings(Config) if Config.UseSharedSettings else _OwnSettings(Config)
|
|
|
|
|
|
def ExportSettings(Config: AppConfig, DestPath: Path) -> None:
|
|
"""One portable file bundling both the cross-program shared settings
|
|
(Theme/Font/TitleBar) and SaraFem's own, under separate top-level
|
|
sections (Features/Major: "Exporting and importing settings as one
|
|
file... applies to every other SaraSuite program"). A future SaraView/
|
|
SaraTag/etc. import would read the same [SaraSuite] section plus its
|
|
own top-level one, ignoring [SaraFem] — the same convention
|
|
ImportSettings below uses to ignore sections that aren't its own.
|
|
|
|
A custom (non-built-in) theme's actual palette rides along under
|
|
ThemeData (Bugs/Major: "Cannot export custom theme... theme can be
|
|
fully exported") — a theme is just a small local TOML file, nothing
|
|
stops it traveling with the export, unlike a font, which stays a name
|
|
only (the font itself needs installing some other way regardless of
|
|
what this file says)."""
|
|
OwnSettings = _OwnSettings(Config)
|
|
SharedData = asdict(OwnSettings)
|
|
if OwnSettings.Theme not in BuiltinThemes:
|
|
ThemeFile = ThemesDir / f"{OwnSettings.Theme}.toml"
|
|
if ThemeFile.exists():
|
|
try:
|
|
SharedData["ThemeData"] = toml.loads(ThemeFile.read_text()).get("Palette", {})
|
|
except toml.TomlDecodeError:
|
|
pass
|
|
Data = {"SaraSuite": SharedData, "SaraFem": asdict(Config)}
|
|
DestPath.write_text(toml.dumps(Data))
|
|
|
|
|
|
def ImportSettings(Config: AppConfig, SrcPath: Path) -> None:
|
|
"""Applies a [SaraSuite] section (if present) to the shared settings
|
|
file and a [SaraFem] section (if present) onto Config in place, field
|
|
by field — a field the import doesn't mention is left as it was, rather
|
|
than reset to default, and unknown sections/fields (another program's,
|
|
or a newer SaraFem version's) are silently skipped: the "should stack
|
|
fine with others if installed or not... one file works anywhere on the
|
|
same version no matter what you have installed" compatibility the
|
|
feature asked for. Raises toml.TomlDecodeError on an unreadable file —
|
|
callers show that to the user rather than silently doing nothing."""
|
|
Data = toml.loads(SrcPath.read_text())
|
|
|
|
SharedData = Data.get("SaraSuite")
|
|
if isinstance(SharedData, dict):
|
|
# Popped before the known-fields filter below (same reason as the
|
|
# SaraTag/SaraSortd/FolderIconBands nested data elsewhere here) —
|
|
# ThemeData isn't a SharedSettings field, it's the custom theme's
|
|
# actual palette, written out as that theme's own local file so the
|
|
# name being imported alongside it actually resolves to something.
|
|
ThemeData = SharedData.pop("ThemeData", None)
|
|
KnownSharedFields = {F for F in SharedSettings.__dataclass_fields__}
|
|
Filtered = {K: V for K, V in SharedData.items() if K in KnownSharedFields}
|
|
Merged = asdict(_OwnSettings(Config))
|
|
Merged.update(Filtered)
|
|
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(toml.dumps({"Palette": ThemeData}))
|
|
SharedSettings(**Merged).Save()
|
|
|
|
OwnData = Data.get("SaraFem")
|
|
if isinstance(OwnData, dict):
|
|
KnownFields = {F for F in AppConfig.__dataclass_fields__}
|
|
Filtered = {K: V for K, V in OwnData.items() if K in KnownFields}
|
|
|
|
SaraTagData = Filtered.pop("SaraTag", None)
|
|
if isinstance(SaraTagData, dict):
|
|
KnownSaraTagFields = {F for F in SaraTagIntegrationConfig.__dataclass_fields__}
|
|
SaraTagFiltered = {K: V for K, V in SaraTagData.items() if K in KnownSaraTagFields}
|
|
for Key, Value in SaraTagFiltered.items():
|
|
setattr(Config.SaraTag, Key, Value)
|
|
|
|
SaraSortdData = Filtered.pop("SaraSortd", None)
|
|
if isinstance(SaraSortdData, dict):
|
|
KnownSaraSortdFields = {F for F in SaraSortdIntegrationConfig.__dataclass_fields__}
|
|
SaraSortdFiltered = {K: V for K, V in SaraSortdData.items() if K in KnownSaraSortdFields}
|
|
for Key, Value in SaraSortdFiltered.items():
|
|
setattr(Config.SaraSortd, Key, Value)
|
|
|
|
BandsData = Filtered.pop("FolderIconBands", None)
|
|
if isinstance(BandsData, list):
|
|
KnownBandFields = {F for F in FolderIconBand.__dataclass_fields__}
|
|
Bands = [
|
|
FolderIconBand(**{K: V for K, V in Entry.items() if K in KnownBandFields})
|
|
for Entry in BandsData if isinstance(Entry, dict)
|
|
]
|
|
if Bands:
|
|
Filtered["FolderIconBands"] = Bands
|
|
|
|
for Key, Value in Filtered.items():
|
|
setattr(Config, Key, Value)
|
|
Config.Save()
|
|
|
|
|
|
def ApplyAppearance(Settings: SharedSettings) -> None:
|
|
"""Theme colours and font. Must run before anything reads Palette.X or
|
|
calls BuildTheme(). The window title bar is applied by Main."""
|
|
global ActiveFontFamily
|
|
ApplyTheme(Settings.Theme)
|
|
ActiveFontFamily = Settings.FontFamily or BundledFontFamily
|
|
|
|
|
|
def InstalledFontFamilies() -> list[str]:
|
|
"""Font families fontconfig knows about, for the font picker. The
|
|
bundled font is always first, since it isn't installed system-wide."""
|
|
try:
|
|
Result = subprocess.run(["fc-list", ":", "family"], capture_output=True, text=True, timeout=5)
|
|
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() -> ft.ButtonStyle:
|
|
"""SaraView's button look: SurfaceAlt fill, 1px Purple border, small radius."""
|
|
return ft.ButtonStyle(
|
|
color=Palette.Text,
|
|
bgcolor=Palette.Surface0,
|
|
side=ft.BorderSide(1, Palette.Mauve),
|
|
shape=ft.RoundedRectangleBorder(radius=4),
|
|
)
|
|
|
|
|
|
def BlendHex(BaseHex: str, AccentHex: str, Ratio: float) -> str:
|
|
"""Solid colour mixing Base and Accent (0 = pure Base, 1 = pure Accent).
|
|
Used for tinted-but-still-dark backgrounds derived from the theme's own
|
|
roles (e.g. a success toast) instead of inventing colours outside it."""
|
|
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 Kind of "success" or "error" toast,
|
|
both derived from the theme's own Green/Red so it stays coherent."""
|
|
Accent = Palette.Green if Kind == "success" else Palette.Red
|
|
Background = BlendHex(Palette.Mantle, Accent, 0.12)
|
|
return Background, Accent
|
|
|
|
|
|
def ApplyHeightBounds(Content: ft.Control, MinHeight: int, MaxHeight: int) -> ft.Control:
|
|
"""Wrap Content in a fixed-height box if a bound is set (Max wins if both are
|
|
set, since Flet has no true min/max-range constraint — MaxHeight caps it with
|
|
internal scrolling, MinHeight alone gives it a floor). 0/0 means natural sizing."""
|
|
if MaxHeight > 0:
|
|
return ft.Container(content=Content, height=MaxHeight)
|
|
if MinHeight > 0:
|
|
return ft.Container(content=Content, height=MinHeight)
|
|
return Content
|
|
|
|
|
|
def ResizeHandle(
|
|
Axis: str,
|
|
Length: Optional[int],
|
|
OnDrag: Callable[[float], None],
|
|
OnDragEnd: Callable[[], None],
|
|
) -> ft.GestureDetector:
|
|
"""A thin draggable divider, shared by the file list's column headers,
|
|
the sidebar/preview section splits, and the sidebar/preview *panel*
|
|
widths. "horizontal" resizes side-by-side things (drag left/right, e.g.
|
|
a column or a whole side panel) and needs Length = the row's height, not
|
|
just 6px wide with an unset (collapses to zero) height; "vertical"
|
|
resizes stacked things (drag up/down, e.g. Bookmarks vs Devices) and
|
|
needs Length = the panel's width, for the same reason on the other axis.
|
|
Pass Length=None instead when the immediate parent Row/Column already
|
|
stretches its children on that cross axis (confirmed, not assumed —
|
|
Flet doesn't do this by default) — then an explicit value would fight
|
|
that stretch instead of matching it. OnDrag fires with the incremental
|
|
delta on every move — cheap for a single header cell or a sidebar
|
|
section, but callers with many redraws to do (the file list's rows)
|
|
should treat it as a live *preview* and do the expensive rebuild in
|
|
OnDragEnd instead, which fires once on release; that's also where
|
|
Config gets saved, so a drag isn't disk I/O on every pixel of movement."""
|
|
IsHorizontal = Axis == "horizontal"
|
|
Handle = ft.GestureDetector(
|
|
content=ft.Container(
|
|
width=6 if IsHorizontal else Length,
|
|
height=Length if IsHorizontal else 6,
|
|
bgcolor=Palette.Surface1,
|
|
),
|
|
mouse_cursor=ft.MouseCursor.RESIZE_COLUMN if IsHorizontal else ft.MouseCursor.RESIZE_ROW,
|
|
)
|
|
if IsHorizontal:
|
|
Handle.on_horizontal_drag_update = lambda e: OnDrag(e.primary_delta or 0)
|
|
Handle.on_horizontal_drag_end = lambda e: OnDragEnd()
|
|
else:
|
|
Handle.on_vertical_drag_update = lambda e: OnDrag(e.primary_delta or 0)
|
|
Handle.on_vertical_drag_end = lambda e: OnDragEnd()
|
|
return Handle
|
|
|
|
|
|
# The group tag shared by every Draggable source and DragTarget in the app,
|
|
# so bookmarks/filetree only accept drops that actually originate from
|
|
# SaraFem's own file list (not, say, a Chip or some future draggable widget).
|
|
DragGroup = "sarafem_entry"
|
|
|
|
|
|
def UniqueDestination(Destination: Path) -> Path:
|
|
if not Destination.exists():
|
|
return Destination
|
|
Stem, Suffix = Destination.stem, Destination.suffix
|
|
Counter = 2
|
|
while True:
|
|
Candidate = Destination.with_name(f"{Stem} (copy {Counter}){Suffix}")
|
|
if not Candidate.exists():
|
|
return Candidate
|
|
Counter += 1
|
|
|
|
|
|
def FormatBatchName(Pattern: str, BaseName: str, Index: int, OriginalSuffix: str = "") -> str:
|
|
"""SaraSortd-style placeholder substitution for batch rename (see
|
|
~/Applications/SaraSortd's GlobalConf.toml [Variables] for the reference
|
|
syntax this mirrors): ~ = typed base name, ! = auto-incrementing counter
|
|
(blank for the first item, " 1", " 2", ... after), ^ = the original
|
|
file's extension, preserved unless the pattern omits it."""
|
|
CounterText = "" if Index == 0 else f" {Index}"
|
|
return Pattern.replace("~", BaseName).replace("!", CounterText).replace("^", OriginalSuffix)
|
|
|
|
|
|
def MoveIntoDirectory(SourcePath: Path, DestinationDir: Path) -> Path:
|
|
"""Move SourcePath into DestinationDir (drag-and-drop uses this). Returns
|
|
the final destination path. Raises OSError if the move is invalid."""
|
|
if SourcePath.parent == DestinationDir:
|
|
raise OSError(f'"{SourcePath.name}" is already in this directory.')
|
|
ResolvedSource = SourcePath.resolve()
|
|
if SourcePath.is_dir() and (
|
|
DestinationDir.resolve() == ResolvedSource or ResolvedSource in DestinationDir.resolve().parents
|
|
):
|
|
raise OSError(f"Can't move \"{SourcePath.name}\" into itself or one of its own subdirectories.")
|
|
Destination = UniqueDestination(DestinationDir / SourcePath.name)
|
|
shutil.move(str(SourcePath), str(Destination))
|
|
return Destination
|