mirror of
https://git.srhtnin.garden/sarah/SaraFem.git
synced 2026-10-15 02:23:17 +02:00
Initial commit
This commit is contained in:
commit
4c8d272719
31 files changed
+3870
No files matched your search
@@ -0,0 +1,370 @@
|
||||
"""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
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
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"
|
||||
FontFamily = "Fixedsys Excelsior"
|
||||
|
||||
CacheDir = Path.home() / ".cache" / "SaraFem"
|
||||
ThumbnailCacheDir = CacheDir / "Thumbnails"
|
||||
|
||||
# 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"
|
||||
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 AppConfig:
|
||||
"""Persisted user settings. Defaults match SaraFem's existing behaviour
|
||||
(nothing changes visually until the user opts in via Ctrl+B/P/H or edits
|
||||
Config.toml directly)."""
|
||||
|
||||
Theme: str = "CatppuccinMocha" # matches a file in ~/.config/SaraSuite/Themes/
|
||||
ShowDotfiles: bool = True
|
||||
ViewMode: str = "list" # "list" or "grid"
|
||||
HideWindowTitleBar: bool = False # the actual OS window title bar, not the toolbar below
|
||||
|
||||
# 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
|
||||
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
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
return AppConfig(**Filtered)
|
||||
|
||||
|
||||
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=FontFamily,
|
||||
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=FontFamily),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# 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
|
||||
Reference in new issue
Block a user