Initial commit

This commit is contained in:
sarah committed 2026-09-24 22:03:58 +02:00
commit 4c8d272719
31 files changed
+3870

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
.venv/
__pycache__/
*.pyc
build/
.flet/
dist/
*.AppImage
Packaging/appimage/AppDir/usr/bin/
+12
View File
@@ -0,0 +1,12 @@
"""Keeps SaraView "Book" entries pointed at the right file when it moves.
Stub: SaraView's Book storage format/API isn't settled yet (see Sara/Ecosystem.md).
Fill in `OnFileMoved` once that's decided.
"""
from pathlib import Path
def OnFileMoved(OldPath: Path, NewPath: Path) -> None:
"""Update any Book entry referencing OldPath to point at NewPath instead."""
raise NotImplementedError("Needs SaraView's Book API/storage format")
+86
View File
@@ -0,0 +1,86 @@
"""Sidebar bookmarks, stored in ~/.config/SaraFem/.sfem_bookmarks.toml."""
from dataclasses import dataclass
from pathlib import Path
import toml
from Core import BookmarksPath, ConfigDir
DefaultIcon = "BOOKMARK_OUTLINE"
TrashPath = Path.home() / ".local" / "share" / "Trash" / "files"
# One-time marker: without it, re-adding the Trash bookmark to old files (see
# LoadBookmarks) would run on every load and undo the user removing it.
_TrashMigrationMarker = ConfigDir / ".trash_bookmark_migrated"
# A small curated set for the icon picker, rather than the whole Material set.
IconChoices = [
"BOOKMARK_OUTLINE", "HOME_OUTLINED", "FOLDER_OUTLINED", "DESCRIPTION_OUTLINED",
"DOWNLOAD_OUTLINED", "IMAGE_OUTLINED", "MUSIC_NOTE_OUTLINED", "VIDEOCAM_OUTLINED",
"CODE_OUTLINED", "STAR_OUTLINE", "FAVORITE_OUTLINE", "WORK_OUTLINE",
]
@dataclass
class Bookmark:
Name: str
Path: Path
Icon: str = DefaultIcon
def DefaultBookmarks() -> list[Bookmark]:
return [
Bookmark("Home", Path.home(), "HOME_OUTLINED"),
Bookmark("Documents", Path.home() / "Documents", "DESCRIPTION_OUTLINED"),
Bookmark("Downloads", Path.home() / "Downloads", "DOWNLOAD_OUTLINED"),
Bookmark("Pictures", Path.home() / "Pictures", "IMAGE_OUTLINED"),
Bookmark("Trash", TrashPath, "DELETE_OUTLINE"),
]
def SaveBookmarks(Bookmarks: list[Bookmark]) -> None:
ConfigDir.mkdir(parents=True, exist_ok=True)
Data = {"Bookmark": [{"Name": B.Name, "Path": str(B.Path), "Icon": B.Icon} for B in Bookmarks]}
BookmarksPath.write_text(toml.dumps(Data))
def LoadBookmarks() -> list[Bookmark]:
if not BookmarksPath.exists():
Bookmarks = DefaultBookmarks()
SaveBookmarks(Bookmarks)
return Bookmarks
try:
Data = toml.loads(BookmarksPath.read_text())
except toml.TomlDecodeError:
return DefaultBookmarks()
Entries = Data.get("Bookmark", [])
Bookmarks = [
Bookmark(Entry["Name"], Path(Entry["Path"]), Entry.get("Icon", DefaultIcon))
for Entry in Entries
if "Name" in Entry and "Path" in Entry
]
if not Bookmarks:
return DefaultBookmarks()
# Trash used to be a hardcoded Sidebar entry, not a real bookmark (so it
# couldn't be edited). Migrate existing bookmark files that predate that —
# but only once, so deliberately removing Trash afterwards actually sticks.
if not _TrashMigrationMarker.exists():
if not any(B.Path == TrashPath for B in Bookmarks):
Bookmarks.append(Bookmark("Trash", TrashPath, "DELETE_OUTLINE"))
SaveBookmarks(Bookmarks)
ConfigDir.mkdir(parents=True, exist_ok=True)
_TrashMigrationMarker.touch()
return Bookmarks
def AddBookmark(Name: str, TargetPath: Path, Icon: str = DefaultIcon) -> None:
Bookmarks = LoadBookmarks()
if any(B.Path == TargetPath for B in Bookmarks):
return # already bookmarked
Bookmarks.append(Bookmark(Name, TargetPath, Icon))
SaveBookmarks(Bookmarks)
+83
View File
@@ -0,0 +1,83 @@
"""Read-only SaraTag Book-sidecar support, mirroring SaraView's Backend/BookReader.py
so all three Sara* apps talk to SaraTag the same way (dynamically loaded, opt-in via
Config.toml's [SaraTag] section, since SaraTag isn't an installable package)."""
import fnmatch
import importlib.util
import tomllib
from pathlib import Path
from types import ModuleType
from typing import Any, Optional
from Core import AppConfig
_CachedSaraTagCore: Optional[ModuleType] = None
_CachedSaraTagCoreLoaded = False
def ClearSaraTagCache() -> None:
"""Called after Options is saved so an Enabled/CoreModulePath change takes
effect on the next Book read, instead of needing a restart."""
global _CachedSaraTagCore, _CachedSaraTagCoreLoaded
_CachedSaraTagCore = None
_CachedSaraTagCoreLoaded = False
def GetSaraTagCore(Config: AppConfig) -> Optional[ModuleType]:
global _CachedSaraTagCore, _CachedSaraTagCoreLoaded
if _CachedSaraTagCoreLoaded:
return _CachedSaraTagCore
_CachedSaraTagCoreLoaded = True
if not Config.SaraTag.Enabled or not Config.SaraTag.CoreModulePath:
return None
CoreFilePath = Path(Config.SaraTag.CoreModulePath).expanduser().resolve() / "Core.py"
if not CoreFilePath.is_file():
return None
ModuleSpec = importlib.util.spec_from_file_location("SaraTagCore", CoreFilePath)
if ModuleSpec is None or ModuleSpec.loader is None:
return None
SaraTagCoreModule = importlib.util.module_from_spec(ModuleSpec)
ModuleSpec.loader.exec_module(SaraTagCoreModule)
_CachedSaraTagCore = SaraTagCoreModule
return _CachedSaraTagCore
def IsInternalSaraTagFile(EntryPath: Path, Config: AppConfig) -> bool:
"""Whether this entry is SaraTag plumbing (a Book sidecar, its data dir, ...)
that should stay out of the file listing, matching SaraView's HideInternalFiles."""
if not Config.SaraTag.Enabled or not Config.SaraTag.HideInternalFiles:
return False
return any(fnmatch.fnmatch(EntryPath.name, Pattern) for Pattern in Config.SaraTag.InternalPatterns)
def ReadBookForFile(FilePath: Path, Config: AppConfig) -> Optional[dict[str, Any]]:
"""Return {"Parent": ..., "Tags": {...}, "Definitions": {...}} or None."""
SaraTagCore = GetSaraTagCore(Config)
if SaraTagCore is None:
return None
BookPath = SaraTagCore.GetBookPath(FilePath)
if not BookPath.is_file():
return None
with BookPath.open("rb") as BookFile:
Metadata = tomllib.load(BookFile)
ParentValue = Metadata.get("Parent")
Definitions: dict[str, Any] = {}
if isinstance(ParentValue, str):
try:
LexiconPath = SaraTagCore.ResolveBookLexiconPath(FilePath.parent, ParentValue)
Definitions = SaraTagCore.ResolveLexiconTags(LexiconPath)
except Exception:
Definitions = {}
Tags = {Key: Value for Key, Value in Metadata.items() if Key != "Parent"}
return {"Parent": ParentValue, "Tags": Tags, "Definitions": Definitions}
+1258
View File
File diff suppressed because it is too large. Load diff
+370
View File
@@ -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
+322
View File
@@ -0,0 +1,322 @@
"""Removable media section for the sidebar (Features/Major:
"Show mounted stuff, e.g. USB sticks being plugged in or out").
Talks to udisks2 over D-Bus (dbus-next, already used by PortalService.py),
the same service other file managers use:
- Plugged-in devices show up whether or not they're mounted yet. The old
version only listed folders under /run/media/<user>, so a stick appeared
only once something else (e.g. PCManFM) had mounted it, and only on the
next 3-second poll.
- Changes arrive as udisks2 signals, so the list updates straight away.
- Clicking an unmounted device mounts it (no password for removable media)
and opens it; the eject button unmounts and powers it off.
- With AutoMountDevices on, a newly plugged-in device is mounted right away.
Falls back to polling /run/media/<user> if udisks2 isn't reachable.
Everything runs on the page's own event loop (Page.run_task), so calling
.update() from here is safe."""
import asyncio
import getpass
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
import flet as ft
from dbus_next import BusType, Message, MessageType, Variant
from dbus_next.aio import MessageBus
from Core import AppConfig, NotificationStyle, Palette
UDisks = "org.freedesktop.UDisks2"
UDisksPath = "/org/freedesktop/UDisks2"
BlockInterface = f"{UDisks}.Block"
FilesystemInterface = f"{UDisks}.Filesystem"
DriveInterface = f"{UDisks}.Drive"
PollInterval = 3.0 # seconds, only for the no-udisks2 fallback
MountRoots = [Path("/run/media") / getpass.getuser(), Path("/media") / getpass.getuser()]
@dataclass
class Device:
Key: str # udisks2 block object path, or the mount path in fallback mode
Label: str
MountPoint: Optional[Path]
DrivePath: str = ""
CanPowerOff: bool = False
def _HumanSize(Bytes: int) -> str:
Size = float(Bytes)
for Unit in ("B", "KB", "MB", "GB", "TB"):
if Size < 1000 or Unit == "TB":
return f"{Size:.0f} {Unit}" if Unit == "B" else f"{Size:.1f} {Unit}"
Size /= 1000
return f"{Bytes} B"
def _Value(Properties: dict, Name: str, Default=None):
Item = Properties.get(Name)
return Item.value if isinstance(Item, Variant) else Default
def _UserMount(MountPoint: Path) -> bool:
return any(Root in MountPoint.parents for Root in MountRoots)
def ListFallbackDevices() -> list[Device]:
Devices: list[Device] = []
for Root in MountRoots:
if not Root.is_dir():
continue
try:
Devices += [Device(str(P), P.name, P) for P in Root.iterdir() if P.is_dir()]
except OSError:
pass
return sorted(Devices, key=lambda D: D.Label.lower())
class UDisksClient:
def __init__(self, Bus: MessageBus):
self.Bus = Bus
@classmethod
async def Connect(cls) -> "UDisksClient":
return cls(await MessageBus(bus_type=BusType.SYSTEM).connect())
async def _Call(self, ObjectPath: str, Interface: str, Member: str, Signature: str = "", Body: Optional[list] = None):
Reply = await self.Bus.call(Message(
destination=UDisks, path=ObjectPath, interface=Interface, member=Member,
signature=Signature, body=Body or [],
))
if Reply.message_type == MessageType.ERROR:
raise OSError(Reply.body[0] if Reply.body else Reply.error_name)
return Reply.body
async def ListDevices(self, ShowInternal: bool) -> list[Device]:
Objects = (await self._Call(UDisksPath, "org.freedesktop.DBus.ObjectManager", "GetManagedObjects"))[0]
Devices = []
for ObjectPath, Interfaces in Objects.items():
if FilesystemInterface not in Interfaces or BlockInterface not in Interfaces:
continue
Block = Interfaces[BlockInterface]
DrivePath = _Value(Block, "Drive", "/")
if _Value(Block, "HintIgnore", False) or DrivePath == "/":
continue # hidden, or not backed by a real drive (loop devices etc.)
MountPoints = [
Path(bytes(Raw).rstrip(b"\0").decode(errors="replace"))
for Raw in _Value(Interfaces[FilesystemInterface], "MountPoints", [])
]
MountPoint = MountPoints[0] if MountPoints else None
if _Value(Block, "HintSystem", False):
# Internal disks: only unmounted ones or ones mounted like
# removable media, never "/", "/home", "/boot" and friends.
if not ShowInternal or (MountPoint is not None and not _UserMount(MountPoint)):
continue
Drive = Objects.get(DrivePath, {}).get(DriveInterface, {})
Label = _Value(Block, "IdLabel", "") or _Value(Block, "HintName", "")
if not Label:
Model = " ".join(Part for Part in (_Value(Drive, "Vendor", ""), _Value(Drive, "Model", "")) if Part)
Label = f"{_HumanSize(_Value(Block, 'Size', 0))} {Model or 'Volume'}".strip()
Devices.append(Device(
Key=ObjectPath,
Label=Label,
MountPoint=MountPoint,
DrivePath=DrivePath,
CanPowerOff=bool(_Value(Drive, "CanPowerOff", False)),
))
return sorted(Devices, key=lambda D: D.Label.lower())
async def Watch(self, OnChange: Callable[[], None]) -> None:
"""Call OnChange for any udisks2 signal: devices added/removed
(InterfacesAdded/Removed) and mounts changing (PropertiesChanged)."""
await self.Bus.call(Message(
destination="org.freedesktop.DBus", path="/org/freedesktop/DBus",
interface="org.freedesktop.DBus", member="AddMatch", signature="s",
body=[f"type='signal',sender='{UDisks}'"],
))
def Handler(Received: Message):
if Received.message_type == MessageType.SIGNAL and (Received.path or "").startswith(UDisksPath):
OnChange()
return None
self.Bus.add_message_handler(Handler)
async def Mount(self, ObjectPath: str) -> Path:
return Path((await self._Call(ObjectPath, FilesystemInterface, "Mount", "a{sv}", [{}]))[0])
async def Unmount(self, ObjectPath: str) -> None:
await self._Call(ObjectPath, FilesystemInterface, "Unmount", "a{sv}", [{}])
async def PowerOff(self, DrivePath: str) -> None:
await self._Call(DrivePath, DriveInterface, "PowerOff", "a{sv}", [{}])
class DevicesPanel(ft.Container):
def __init__(self, OnNavigate: Callable[[Path], None], Config: AppConfig):
self.OnNavigate = OnNavigate
self.Config = Config
self.Client: Optional[UDisksClient] = None
self.CurrentDevices: list[Device] = []
self.CurrentPath: Optional[Path] = None
self.DeviceTiles: dict[str, ft.ListTile] = {}
self.RefreshScheduled = False
self.Busy: set[str] = set() # devices being mounted/ejected right now
self.List = ft.ListView(spacing=0)
self.List.controls = self._BuildTiles()
super().__init__(content=self.List, padding=0)
def did_mount(self) -> None:
self.page.run_task(self._Start)
async def _Start(self) -> None:
try:
self.Client = await UDisksClient.Connect()
await self.Client.Watch(self._ScheduleRefresh)
except Exception:
self.Client = None
await self._PollLoop()
return
await self._Refresh(IsStartup=True)
async def _PollLoop(self) -> None:
while True:
self._Show(ListFallbackDevices())
await asyncio.sleep(PollInterval)
def _ScheduleRefresh(self) -> None:
# One plug-in fires a burst of signals; refresh once per burst.
if not self.RefreshScheduled:
self.RefreshScheduled = True
self.page.run_task(self._DelayedRefresh)
async def _DelayedRefresh(self) -> None:
await asyncio.sleep(0.25)
self.RefreshScheduled = False
await self._Refresh()
async def _Refresh(self, IsStartup: bool = False) -> None:
try:
Devices = await self.Client.ListDevices(self.Config.ShowInternalDrives)
except Exception:
return
Known = {D.Key for D in self.CurrentDevices}
self._Show(Devices, Force=IsStartup)
if IsStartup or not self.Config.AutoMountDevices:
return
for NewDevice in Devices:
if NewDevice.Key not in Known and NewDevice.MountPoint is None:
await self._MountDevice(NewDevice, Open=False)
def _Show(self, Devices: list[Device], Force: bool = False) -> None:
if Devices == self.CurrentDevices and not Force:
return
self.CurrentDevices = Devices
self.List.controls = self._BuildTiles()
if self.CurrentPath is not None:
self._ApplySelection()
self.update()
def _BuildTiles(self) -> list[ft.Control]:
self.DeviceTiles = {}
if not self.CurrentDevices:
return [ft.Container(ft.Text("No devices.", color=Palette.Overlay0, size=12), padding=8)]
Tiles = []
for Current in self.CurrentDevices:
Mounted = Current.MountPoint is not None
Tile = ft.ListTile(
title=Current.Label,
leading=ft.Icon(
ft.Icons.USB if Mounted else ft.Icons.USB_OUTLINED,
color=Palette.Mauve if Mounted else Palette.Overlay0,
),
trailing=ft.IconButton(
icon=ft.Icons.EJECT,
icon_color=Palette.Overlay0,
tooltip="Eject",
visible=self.Client is not None and (Mounted or Current.CanPowerOff),
on_click=lambda e, D=Current: self.page.run_task(self._EjectDevice, D),
),
tooltip=str(Current.MountPoint) if Mounted else "Not mounted: click to mount",
on_click=lambda e, D=Current: self.page.run_task(self._OpenDevice, D),
selected_tile_color=Palette.Surface1,
selected_color=Palette.Mauve,
)
self.DeviceTiles[Current.Key] = Tile
Tiles.append(Tile)
return Tiles
async def _OpenDevice(self, Target: Device) -> None:
if Target.MountPoint is not None:
self.OnNavigate(Target.MountPoint)
else:
await self._MountDevice(Target, Open=True)
async def _MountDevice(self, Target: Device, Open: bool) -> None:
if self.Client is None or Target.Key in self.Busy:
return
self.Busy.add(Target.Key)
try:
MountPoint = await self.Client.Mount(Target.Key)
except OSError as Error:
self._Notify(f"Couldn't mount {Target.Label}: {Error}")
return
finally:
self.Busy.discard(Target.Key)
await self._Refresh()
if Open:
self.OnNavigate(MountPoint)
async def _EjectDevice(self, Target: Device) -> None:
if self.Client is None or Target.Key in self.Busy:
return
self.Busy.add(Target.Key)
try:
if Target.MountPoint is not None:
# Step out of the device first, or the unmount is "busy".
if self.CurrentPath is not None and (
self.CurrentPath == Target.MountPoint or Target.MountPoint in self.CurrentPath.parents
):
self.OnNavigate(Path.home())
await self.Client.Unmount(Target.Key)
if Target.CanPowerOff:
await self.Client.PowerOff(Target.DrivePath)
self._Notify(f"{Target.Label} can be removed now.", Kind="success")
except OSError as Error:
self._Notify(f"Couldn't eject {Target.Label}: {Error}")
finally:
self.Busy.discard(Target.Key)
await self._Refresh()
def _Notify(self, Message: str, Kind: str = "error") -> None:
Background, Accent = NotificationStyle(Kind)
self.page.show_dialog(ft.SnackBar(
content=ft.Text(Message, color=Accent),
bgcolor=Background,
shape=ft.RoundedRectangleBorder(radius=4, side=ft.BorderSide(1, Accent)),
open=True,
))
def UpdateSelection(self, CurrentPath: Path) -> None:
self.CurrentPath = CurrentPath
if self._ApplySelection():
self.update()
def _ApplySelection(self) -> bool:
Changed = False
for Current in self.CurrentDevices:
Tile = self.DeviceTiles.get(Current.Key)
if Tile is None:
continue
NewValue = Current.MountPoint is not None and (
self.CurrentPath == Current.MountPoint or Current.MountPoint in self.CurrentPath.parents
)
if Tile.selected != NewValue:
Tile.selected = NewValue
Changed = True
return Changed
+159
View File
@@ -0,0 +1,159 @@
"""Real hierarchical directory tree for the sidebar, separate from Bookmarks
(see the mockup: sidebar splits into a bookmarks section and a filetree section).
Children are loaded lazily on expand, since the tree can be rooted at "/".
Each node also accepts drops from the Browser's file list, moving the dropped
entry into that directory."""
from pathlib import Path
from typing import Callable, Optional
import flet as ft
from Core import AppConfig, DragGroup, MoveIntoDirectory, NotificationStyle, Palette
class _FiletreeNode:
def __init__(
self,
EntryPath: Path,
Config: AppConfig,
OnNavigate: Callable[[Path], None],
Depth: int,
OnFilesMoved: Optional[Callable[[], None]] = None,
):
self.EntryPath = EntryPath
self.Config = Config
self.OnNavigate = OnNavigate
self.Depth = Depth
self.OnFilesMoved = OnFilesMoved
self.ChildrenLoaded = False
self.ChildNodes: dict[Path, "_FiletreeNode"] = {}
self.IsSelected = False
self.NameLabel = ft.Text(EntryPath.name or str(EntryPath), size=13, color=Palette.Text)
self.NameContainer = ft.Container(
content=ft.GestureDetector(
content=self.NameLabel,
on_tap=lambda e: self.OnNavigate(self.EntryPath),
),
padding=4,
border_radius=4,
)
self.Tile = ft.ExpansionTile(
title=ft.DragTarget(group=DragGroup, content=self.NameContainer, on_accept=self._OnEntryDropped),
leading=ft.Icon(ft.Icons.FOLDER_OUTLINED, size=16, color=Palette.Overlay0),
controls=[],
tile_padding=ft.Padding.only(left=8 + Depth * 14, right=8),
icon_color=Palette.Overlay0,
collapsed_icon_color=Palette.Overlay0,
on_change=self._OnExpansionChanged,
)
def _OnExpansionChanged(self, e) -> None:
if self.Tile.expanded and not self.ChildrenLoaded:
self._LoadChildren()
def _LoadChildren(self) -> None:
try:
SubDirs = sorted(
(p for p in self.EntryPath.iterdir() if p.is_dir()),
key=lambda p: p.name.lower(),
)
except (PermissionError, FileNotFoundError, OSError):
SubDirs = []
if not self.Config.ShowDotfiles:
SubDirs = [p for p in SubDirs if not p.name.startswith(".")]
self.ChildNodes = {}
Controls = []
for SubDir in SubDirs:
Node = _FiletreeNode(SubDir, self.Config, self.OnNavigate, self.Depth + 1, self.OnFilesMoved)
self.ChildNodes[SubDir] = Node
Controls.append(Node.Tile)
self.Tile.controls = Controls
self.ChildrenLoaded = True
self.Tile.update()
def _OnEntryDropped(self, e: ft.DragTargetEvent) -> None:
SourcePath = Path(e.src.data)
try:
MoveIntoDirectory(SourcePath, self.EntryPath)
except OSError as Error:
self._Notify(str(Error))
return
if self.ChildrenLoaded:
self._LoadChildren()
if self.OnFilesMoved:
self.OnFilesMoved()
self._Notify(f'Moved "{SourcePath.name}" to {self.EntryPath.name or self.EntryPath}.', Kind="success")
def _Notify(self, Message: str, Kind: str = "error") -> None:
Page = self.NameContainer.page
Background, Accent = NotificationStyle(Kind)
Page.show_dialog(
ft.SnackBar(
content=ft.Text(Message, color=Accent),
bgcolor=Background,
shape=ft.RoundedRectangleBorder(radius=4, side=ft.BorderSide(1, Accent)),
open=True,
)
)
def SetSelected(self, IsSelected: bool) -> None:
self.IsSelected = IsSelected
self.NameContainer.bgcolor = Palette.Surface1 if IsSelected else None
self.NameLabel.color = Palette.Mauve if IsSelected else Palette.Text
self.NameContainer.update()
class Filetree(ft.Container):
def __init__(
self,
OnNavigate: Callable[[Path], None],
Config: AppConfig,
RootPath: Optional[Path] = None,
OnFilesMoved: Optional[Callable[[], None]] = None,
):
self.OnNavigate = OnNavigate
self.Config = Config
self.RootPath = RootPath or Path("/")
self.OnFilesMoved = OnFilesMoved
self.SelectedNode: Optional[_FiletreeNode] = None
self.RootNode = _FiletreeNode(self.RootPath, Config, OnNavigate, Depth=0, OnFilesMoved=OnFilesMoved)
super().__init__(
content=ft.ListView(controls=[self.RootNode.Tile], expand=True),
expand=True,
bgcolor=Palette.Mantle,
)
def RevealPath(self, TargetPath: Path) -> None:
"""Expand + highlight the tree down to TargetPath, lazily loading as needed."""
if self.SelectedNode:
self.SelectedNode.SetSelected(False)
self.SelectedNode = None
try:
Relative = TargetPath.relative_to(self.RootPath)
except ValueError:
return # TargetPath isn't under this tree's root
Node = self.RootNode
Parts = Relative.parts if str(Relative) != "." else ()
for Part in Parts:
if not Node.ChildrenLoaded:
Node._LoadChildren()
NextPath = Node.EntryPath / Part
NextNode = Node.ChildNodes.get(NextPath)
if NextNode is None:
return # hidden, permission denied, or not a directory
Node.Tile.expanded = True
Node.Tile.update()
Node = NextNode
Node.SetSelected(True)
self.SelectedNode = Node
+214
View File
@@ -0,0 +1,214 @@
"""SaraFem entry point."""
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Optional
import flet as ft
from Browser import Browser
from Core import AppName, ApplyTheme, BuildTheme, FontFamily, LoadConfig
from Sidebar import Sidebar
def _SetCursorThemeFallback() -> None:
"""GTK's cursor lookup reads XCURSOR_THEME directly; it isn't always inherited
from the desktop session (shows as 'Unable to load ... from the cursor theme').
Fill it in from gsettings if missing, so the warning has nowhere to come from."""
if os.environ.get("XCURSOR_THEME"):
return
try:
Result = subprocess.run(
["gsettings", "get", "org.gnome.desktop.interface", "cursor-theme"],
capture_output=True, text=True, timeout=2,
)
Theme = Result.stdout.strip().strip("'")
except (OSError, subprocess.SubprocessError):
Theme = ""
os.environ["XCURSOR_THEME"] = Theme or "Adwaita"
def _ResolveStartPath() -> Path:
"""A directory (or file — its parent is used) passed on argv, so SaraFem
can be launched already pointed at a location — e.g. a desktop entry's
"Open with SaraFem" / "set as default file manager" handoff. Falls back
to home if there's no argument or it doesn't resolve to anything real."""
if len(sys.argv) > 1:
Candidate = Path(sys.argv[1]).expanduser()
try:
Candidate = Candidate.resolve()
except OSError:
Candidate = None
if Candidate is not None:
if Candidate.is_dir():
return Candidate
if Candidate.exists():
return Candidate.parent
return Path.home()
def _ParsePickerArgs() -> argparse.Namespace:
"""Only meaningful for the file-picker path (see Picker.py) — a plain
launch never sets any of these, so _ResolveStartPath's own positional-arg
handling is untouched. parse_known_args so unrecognized args never crash
a normal launch."""
Parser = argparse.ArgumentParser(add_help=False)
PickGroup = Parser.add_mutually_exclusive_group()
PickGroup.add_argument("--pick-open", action="store_true")
PickGroup.add_argument("--pick-save", action="store_true")
PickGroup.add_argument("--pick-folder", action="store_true")
Parser.add_argument("--multiple", action="store_true")
Parser.add_argument("--title", default=None)
Parser.add_argument("--suggested-name", default="")
Parser.add_argument("--result-file", default=None)
Parser.add_argument("--filter", action="append", default=[])
Parser.add_argument("start_path", nargs="?", default=None)
Args, _Unknown = Parser.parse_known_args(sys.argv[1:])
return Args
def _BuildWindow(Config, StartPath: Path, **BrowserHooks) -> tuple[ft.Row, Browser]:
"""The normal SaraFem window (sidebar + browser). The file picker is this
same window with a PickerBar under it, so the two always look alike;
BrowserHooks are the picker's Browser hooks (see Picker.py)."""
OuterRow = ft.Row(expand=True, spacing=0)
SidebarView: Optional[Sidebar] = None
def OnPathChanged(NewPath: Path) -> None:
if SidebarView is not None:
SidebarView.UpdateSelection(NewPath)
def OnBookmarksChanged() -> None:
if SidebarView is not None:
SidebarView.RefreshBookmarks()
def BuildOuterLayout() -> list[ft.Control]:
nonlocal SidebarView
Layout: list[ft.Control] = [BrowserView]
if Config.ShowSidebar and (Config.ShowBookmarks or Config.ShowDevices or Config.ShowFiletree):
SidebarView = Sidebar(
OnNavigate=BrowserView.NavigateTo, Config=Config, OnFilesMoved=BrowserView.Refresh
)
if Config.SidebarSide == "left":
Layout.insert(0, SidebarView)
else:
Layout.append(SidebarView)
else:
SidebarView = None
return Layout
def OnSidebarSettingsChanged() -> None:
# Sidebar's presence/side/width and its sections aren't owned by
# Browser, so rebuilding it lives here rather than needing a restart.
OuterRow.controls = BuildOuterLayout()
OuterRow.update()
if SidebarView is not None:
SidebarView.UpdateSelection(BrowserView.CurrentPath)
BrowserView = Browser(
StartPath=StartPath,
Config=Config,
OnPathChanged=OnPathChanged,
OnBookmarksChanged=OnBookmarksChanged,
OnSidebarSettingsChanged=OnSidebarSettingsChanged,
**BrowserHooks,
)
OuterRow.controls = BuildOuterLayout()
return OuterRow, BrowserView
def _RunPickerMode(Page: ft.Page, Config, Args: argparse.Namespace) -> None:
from Picker import PickerBar, PickRequest
StartPath = Path.home()
if Args.start_path:
Candidate = Path(Args.start_path).expanduser()
if Candidate.exists():
StartPath = Candidate if Candidate.is_dir() else Candidate.parent
Filters: list[tuple[str, list[str]]] = []
for RawFilter in Args.filter:
Label, _, PatternsRaw = RawFilter.partition(":")
Patterns = [P.strip() for P in PatternsRaw.split(",") if P.strip()]
if Patterns:
Filters.append((Label or "Files", Patterns))
Kind = "save" if Args.pick_save else "open"
DefaultTitle = "Save File" if Args.pick_save else "Select Folder" if Args.pick_folder else "Open File"
Request = PickRequest(
Kind=Kind,
Multiple=Args.multiple,
Directory=Args.pick_folder,
Title=Args.title or DefaultTitle,
SuggestedName=Args.suggested_name,
StartPath=StartPath,
Filters=Filters,
)
ResultPath = Path(Args.result_file) if Args.result_file else None
Page.title = Request.Title
def OnComplete(Paths: Optional[list[Path]]) -> None:
if ResultPath is not None:
if Paths is None:
ResultPath.write_text(json.dumps({"cancelled": True}))
else:
ResultPath.write_text(json.dumps({"paths": [str(P) for P in Paths]}))
Page.run_task(Page.window.destroy)
Bar = PickerBar(Request=Request, OnComplete=OnComplete)
OuterRow, BrowserView = _BuildWindow(
Config,
Request.StartPath,
EntryFilter=Bar.Filter,
OnActivateFile=Bar.Activate,
OnSelectionChanged=Bar.SelectionChanged,
OnEscape=Bar.Cancel,
)
Bar.Attach(BrowserView)
Page.add(ft.Column([OuterRow, Bar], expand=True, spacing=0))
def Main(Page: ft.Page) -> None:
Config = LoadConfig()
ApplyTheme(Config.Theme) # must run before anything reads Palette.X
Page.theme_mode = ft.ThemeMode.DARK
Page.theme = BuildTheme()
Page.dark_theme = BuildTheme()
Page.fonts = {FontFamily: "fonts/Fixedsys_Excelsior_Regular.ttf"}
Page.padding = 0
Page.window.width = 1100
Page.window.height = 700
Page.window.title_bar_hidden = Config.HideWindowTitleBar
PickerArgs = _ParsePickerArgs()
if PickerArgs.pick_open or PickerArgs.pick_save or PickerArgs.pick_folder:
_RunPickerMode(Page, Config, PickerArgs)
return
Page.title = AppName
OuterRow, _BrowserView = _BuildWindow(Config, _ResolveStartPath())
Page.add(OuterRow)
if __name__ == "__main__":
if "--portal-service" in sys.argv:
# No Flet page at all here — the D-Bus FileChooser backend is a
# plain asyncio service (see PortalService.py) that
# spawns *this same executable* again with --pick-* flags for the
# actual picker UI. Packaged installs ship one executable that
# covers all three modes (GUI / --pick-* / --portal-service) rather
# than a separate binary per role.
import asyncio
from PortalService import Run as RunPortalService
asyncio.run(RunPortalService())
else:
_SetCursorThemeFallback()
ft.run(Main)
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# Desktop-entry launcher: resolves the venv relative to this script rather
# than the caller's cwd, since .desktop Exec= lines are invoked with no
# guaranteed working directory.
cd "$(dirname "$(readlink -f "$0")")/.." || exit 1
exec .venv/bin/python Main.py "$@"
+10
View File
@@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Name=SaraFem
Comment=A file manager for the SaraSuite
Exec=/home/sarah/Applications/SaraFem/Packaging/Launch.sh %f
Icon=/home/sarah/Applications/SaraFem/assets/Icon.png
Terminal=false
Categories=System;FileManager;
MimeType=inode/directory;
StartupNotify=true
+4
View File
@@ -0,0 +1,4 @@
[portal]
DBusName=org.freedesktop.impl.portal.desktop.SaraFem
Interfaces=org.freedesktop.impl.portal.FileChooser;
UseIn=COSMIC
+1
View File
@@ -0,0 +1 @@
sarafem.png
+1
View File
@@ -0,0 +1 @@
usr/bin/SaraFem
+10
View File
@@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Name=SaraFem
Comment=A file manager for the SaraSuite
Exec=SaraFem %f
Icon=sarafem
Terminal=false
Categories=System;FileManager;
MimeType=inode/directory;
StartupNotify=true
Binary file not shown.

After

Width:  |  Height:  |  Size: 775 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 775 B

+96
View File
@@ -0,0 +1,96 @@
#!/bin/bash
# Installs SaraFem's AppImage on a fresh machine: moves the AppImage into
# place, writes the desktop entry + icon, and sets up the file-picker portal
# integration — everything that was done by hand on the dev machine,
# scripted here for reuse elsewhere. Run this from inside the same folder
# as SaraFem-x86_64.AppImage, Icon.png and SaraFem.portal (copy the whole
# Packaging/appimage/ folder over, not just this script).
set -euo pipefail
ScriptDir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
AppImageSource="$ScriptDir/SaraFem-x86_64.AppImage"
IconSource="$ScriptDir/Icon.png"
PortalSource="$ScriptDir/SaraFem.portal"
InstallDir="$HOME/Applications/SaraFem"
AppImageDest="$InstallDir/SaraFem.AppImage"
for Required in "$AppImageSource" "$IconSource" "$PortalSource"; do
if [ ! -f "$Required" ]; then
echo "Error: $(basename "$Required") not found next to this script ($ScriptDir)." >&2
echo "Copy the whole Packaging/appimage/ folder over, not just Install.sh." >&2
exit 1
fi
done
echo "Installing SaraFem to $InstallDir ..."
mkdir -p "$InstallDir"
cp "$AppImageSource" "$AppImageDest"
chmod +x "$AppImageDest"
echo "Installing icon..."
mkdir -p "$HOME/.local/share/icons/hicolor/256x256/apps"
cp "$IconSource" "$HOME/.local/share/icons/hicolor/256x256/apps/sarafem.png"
echo "Installing desktop entry..."
mkdir -p "$HOME/.local/share/applications"
cat > "$HOME/.local/share/applications/SaraFem.desktop" << EOF
[Desktop Entry]
Type=Application
Name=SaraFem
Comment=A file manager for the SaraSuite
Exec=$AppImageDest %f
Icon=sarafem
Terminal=false
Categories=System;FileManager;
MimeType=inode/directory;
StartupNotify=true
EOF
update-desktop-database "$HOME/.local/share/applications" &>/dev/null || true
gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" &>/dev/null || true
echo "Installing file-picker D-Bus service..."
mkdir -p "$HOME/.local/share/dbus-1/services"
cat > "$HOME/.local/share/dbus-1/services/org.freedesktop.impl.portal.desktop.SaraFem.service" << EOF
[D-BUS Service]
Name=org.freedesktop.impl.portal.desktop.SaraFem
Exec=$AppImageDest --portal-service
EOF
# The preference file is named after the desktop environment (see
# portals.conf(5)) — read from $XDG_CURRENT_DESKTOP so this works on
# whatever DE the other machine runs, not just COSMIC.
DesktopEnv="$(echo "${XDG_CURRENT_DESKTOP:-}" | cut -d: -f1 | tr '[:upper:]' '[:lower:]')"
if [ -z "$DesktopEnv" ]; then
echo "Warning: \$XDG_CURRENT_DESKTOP isn't set — skipping the file-picker preference file."
echo "Set it by hand later: ~/.config/xdg-desktop-portal/<your-desktop>-portals.conf, containing:"
echo " [preferred]"
echo " org.freedesktop.impl.portal.FileChooser=SaraFem;<your-desktop>;gtk;"
else
echo "Setting SaraFem as the preferred file picker for $DesktopEnv..."
mkdir -p "$HOME/.config/xdg-desktop-portal"
PrefFile="$HOME/.config/xdg-desktop-portal/${DesktopEnv}-portals.conf"
if [ -f "$PrefFile" ] && grep -q "FileChooser" "$PrefFile" 2>/dev/null; then
echo " $PrefFile already has a FileChooser preference — leaving it as-is."
echo " Add SaraFem to it yourself if you want it preferred: org.freedesktop.impl.portal.FileChooser=SaraFem;..."
else
printf '[preferred]\norg.freedesktop.impl.portal.FileChooser=SaraFem;%s;gtk;\n' "$DesktopEnv" >> "$PrefFile"
fi
fi
echo
echo "One step needs root — placing the portal descriptor in a system directory:"
echo " sudo cp \"$PortalSource\" /usr/share/xdg-desktop-portal/portals/SaraFem.portal"
read -r -p "Run that now? [y/N] " Reply
if [[ "$Reply" =~ ^[Yy]$ ]]; then
sudo cp "$PortalSource" /usr/share/xdg-desktop-portal/portals/SaraFem.portal
systemctl --user restart xdg-desktop-portal.service &>/dev/null || true
echo "Done — SaraFem is registered as a file-picker option."
else
echo "Skipped. SaraFem still works as a normal app; run that command later for the picker integration."
fi
echo
echo "Install complete. Launch SaraFem from your application menu, or directly:"
echo " $AppImageDest"
+4
View File
@@ -0,0 +1,4 @@
[portal]
DBusName=org.freedesktop.impl.portal.desktop.SaraFem
Interfaces=org.freedesktop.impl.portal.FileChooser;
UseIn=COSMIC
@@ -0,0 +1,3 @@
[D-BUS Service]
Name=org.freedesktop.impl.portal.desktop.SaraFem
Exec=/home/sarah/Applications/SaraFem/Packaging/Launch.sh --portal-service
+195
View File
@@ -0,0 +1,195 @@
"""File-picker mode. The picker is the normal SaraFem window (sidebar,
toolbar, list/grid, preview, the same menus and shortcuts) with this bar
along the bottom: a file name field when saving, a file type filter when
the app asked for one, and Cancel/confirm. Used two ways:
- `python Main.py --pick-open/--pick-save/--pick-folder ...` directly, for
scripts.
- Launched as a subprocess by PortalService.py, which is what actually
makes SaraFem show up as the system file picker (the
org.freedesktop.impl.portal.FileChooser backend); see that file for the
D-Bus side of this.
Either way, the result goes to the OnComplete callback (Main.py writes it to
--result-file as JSON); nothing here talks D-Bus directly."""
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Callable, Optional
import flet as ft
from Core import ChipButtonStyle, Palette
if TYPE_CHECKING:
from Browser import Browser
@dataclass
class PickRequest:
Kind: str # "open" or "save"
Multiple: bool = False
Directory: bool = False
Title: str = "Select a File"
SuggestedName: str = ""
StartPath: Path = field(default_factory=Path.home)
# (filter label, [glob patterns]), offered in a dropdown with the first
# one selected, like other pickers. Empty list means "show everything".
Filters: list[tuple[str, list[str]]] = field(default_factory=list)
class PickerBar(ft.Container):
def __init__(self, Request: PickRequest, OnComplete: Callable[[Optional[list[Path]]], None]):
self.Request = Request
self.OnComplete = OnComplete
self.BrowserView: Optional["Browser"] = None
self.FilterIndex = 0 if Request.Filters else -1
self.Completed = False
Controls: list[ft.Control] = []
self.NameField: Optional[ft.TextField] = None
if Request.Kind == "save":
self.NameField = ft.TextField(
value=Request.SuggestedName, label="File name", expand=True, autofocus=True,
on_submit=lambda e: self.Confirm(),
on_focus=lambda e: self._PauseShortcuts(True),
on_blur=lambda e: self._PauseShortcuts(False),
)
Controls.append(self.NameField)
else:
Controls.append(ft.Container(expand=True))
if Request.Filters and not Request.Directory:
Controls.append(ft.Dropdown(
width=260,
value="0",
options=[
ft.DropdownOption(key=str(Index), text=f"{Label} ({', '.join(Patterns)})")
for Index, (Label, Patterns) in enumerate(Request.Filters)
] + [ft.DropdownOption(key="-1", text="All files")],
on_select=self._OnFilterSelected,
))
Controls.append(ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: self.Cancel()))
Controls.append(ft.OutlinedButton(content=self._ConfirmLabel(), style=ChipButtonStyle(), on_click=lambda e: self.Confirm()))
super().__init__(
content=ft.Row(Controls, spacing=8, vertical_alignment=ft.CrossAxisAlignment.CENTER),
padding=8,
bgcolor=Palette.Mantle,
border=ft.Border.only(top=ft.BorderSide(1, Palette.Surface1)),
)
def Attach(self, BrowserView: "Browser") -> None:
self.BrowserView = BrowserView
self._PauseShortcuts(self.NameField is not None) # the name field starts focused
def _PauseShortcuts(self, Paused: bool) -> None:
if self.BrowserView is not None:
self.BrowserView.ShortcutsPaused = Paused
def _ConfirmLabel(self) -> str:
if self.Request.Kind == "save":
return "Save"
if self.Request.Directory:
return "Select Folder"
return "Open"
# --- Hooks the Browser calls ------------------------------------------
def Filter(self, EntryPath: Path) -> bool:
if EntryPath.is_dir():
return True
if self.Request.Directory:
return False
if self.FilterIndex < 0:
return True
_Label, Patterns = self.Request.Filters[self.FilterIndex]
NameLower = EntryPath.name.lower()
return any(Path(NameLower).match(Pattern.lower()) for Pattern in Patterns)
def Activate(self, EntryPath: Path) -> None:
"""Double-click/Enter on a file."""
if self.Request.Kind == "save" and self.NameField is not None:
self.NameField.value = EntryPath.name
self.Confirm()
elif not self.Request.Directory:
self._Finish([EntryPath])
def SelectionChanged(self) -> None:
# Picking an existing file while saving fills in its name, like other pickers.
if self.Request.Kind != "save" or self.NameField is None or self.BrowserView is None:
return
Selected = self.BrowserView.SelectedPath
if Selected is not None and not Selected.is_dir() and self.NameField.value != Selected.name:
self.NameField.value = Selected.name
self.NameField.update()
def _OnFilterSelected(self, Event) -> None:
self.FilterIndex = int(Event.control.value)
if self.BrowserView is not None:
self.BrowserView.Refresh()
# --- Confirm / cancel ---------------------------------------------------
def Confirm(self) -> None:
View = self.BrowserView
if View is None:
return
Selected = [Entry for Entry in View.CurrentEntries if Entry in View.SelectedPaths]
if self.Request.Kind == "save":
Name = (self.NameField.value or "").strip()
if not Name:
return
Target = View.CurrentPath / Name
if Target.is_dir():
View.NavigateTo(Target)
elif Target.exists():
self._ConfirmOverwrite(Target)
else:
self._Finish([Target])
return
if self.Request.Directory:
Folders = [Entry for Entry in Selected if Entry.is_dir()]
if not self.Request.Multiple:
Folders = Folders[:1]
self._Finish(Folders or [View.CurrentPath])
return
if not self.Request.Multiple:
Selected = [View.SelectedPath] if View.SelectedPath is not None else []
if len(Selected) == 1 and Selected[0].is_dir():
View.NavigateTo(Selected[0]) # "Open" on a folder goes into it
return
Files = [Entry for Entry in Selected if not Entry.is_dir()]
if Files:
self._Finish(Files)
def _ConfirmOverwrite(self, Target: Path) -> None:
def Replace(e) -> None:
self.page.pop_dialog()
self._Finish([Target])
self.page.show_dialog(ft.AlertDialog(
title="Replace file?",
content=ft.Text(f'"{Target.name}" already exists in {Target.parent}. Replace it?'),
actions=[
ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: self.page.pop_dialog()),
ft.OutlinedButton(content="Replace", style=ChipButtonStyle(), on_click=Replace),
],
))
def _Finish(self, Paths: list[Path]) -> None:
# Enter in the name field fires both its on_submit and the page's
# keyboard handler, so this can be reached twice for one confirm.
if self.Completed:
return
self.Completed = True
self.OnComplete(Paths)
def Cancel(self) -> None:
if not self.Completed:
self.Completed = True
self.OnComplete(None)
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""org.freedesktop.impl.portal.FileChooser backend, so SaraFem shows up as
the actual system file picker (e.g. a browser's "choose an image to upload"
dialog), not just an app you open manually.
Registered via Packaging/SaraFem.portal + Packaging/org.freedesktop.impl.portal.desktop.SaraFem.service
and preferred for FileChooser specifically via
~/.config/xdg-desktop-portal/cosmic-portals.conf — only that one interface is
overridden, so a bug here can't affect any other portal (Screenshot,
Settings, etc.), and the override is one file to delete to revert.
A sibling module (not under Packaging/) so PyInstaller's static import
analysis bundles it — and its own dbus_next import — automatically, the same
way it already bundles Core.py/Browser.py/etc, with no special build flags.
Runnable two ways: `python Main.py --portal-service` (dev venv or a packaged
build) invokes Run() directly; Packaging/org.freedesktop.impl.portal.desktop.SaraFem.service's
Exec= does exactly that via Packaging/Launch.sh.
Each request here spawns the picker UI (Picker.py, via Main.py --pick-*) and
waits for it to exit, reading back the JSON result file it writes — the
D-Bus plumbing lives entirely in this file, not in the GUI.
"""
import asyncio
import json
import sys
import tempfile
import urllib.parse
from pathlib import Path
from typing import Optional
from dbus_next import BusType, Variant
from dbus_next.aio import MessageBus
from dbus_next.service import ServiceInterface, method
BusName = "org.freedesktop.impl.portal.desktop.SaraFem"
ObjectPath = "/org/freedesktop/portal/desktop"
RepoRoot = Path(__file__).resolve().parent
Launcher = RepoRoot / "Packaging" / "Launch.sh"
def _PickerCommand() -> list[str]:
"""A packaged (PyInstaller-frozen) install has no venv and no separate
Launch.sh — just this one executable, which re-invokes itself with
--pick-* flags for the picker UI (see Main.py's --portal-service
branch). Running from source still goes through Launch.sh."""
if getattr(sys, "frozen", False):
return [sys.executable]
return [str(Launcher)]
def _PathToUri(EntryPath: Path) -> str:
return "file://" + urllib.parse.quote(str(EntryPath))
def _DecodeNulPath(RawBytes: bytes) -> Optional[Path]:
Text = bytes(RawBytes).rstrip(b"\x00").decode(errors="replace")
return Path(Text) if Text else None
class _RequestObject(ServiceInterface):
"""Minimal org.freedesktop.impl.portal.Request: lets the calling app
(via the portal frontend) cancel a pending pick — e.g. it closed its own
upload dialog while SaraFem's picker was still open — by killing our
picker subprocess instead of leaving it orphaned."""
def __init__(self, Bus: MessageBus, HandlePath: str):
super().__init__("org.freedesktop.impl.portal.Request")
self.Bus = Bus
self.HandlePath = HandlePath
self.Process: Optional[asyncio.subprocess.Process] = None
self.Cancelled = asyncio.Event()
Bus.export(HandlePath, self)
@method()
def Close(self) -> None:
self.Cancelled.set()
if self.Process is not None and self.Process.returncode is None:
self.Process.terminate()
def Unexport(self) -> None:
try:
self.Bus.unexport(self.HandlePath, self)
except Exception:
pass
class FileChooserPortal(ServiceInterface):
def __init__(self, Bus: MessageBus):
super().__init__("org.freedesktop.impl.portal.FileChooser")
self.Bus = Bus
async def _RunPicker(
self, HandlePath: str, Title: str, ExtraArgs: list[str], StartPath: Optional[Path]
) -> tuple[int, dict]:
Request = _RequestObject(self.Bus, HandlePath)
ResultFile = tempfile.NamedTemporaryFile(prefix="sarafem_pick_", suffix=".json", delete=False)
ResultPath = Path(ResultFile.name)
ResultFile.close()
Args = [*_PickerCommand(), *ExtraArgs, "--title", Title, "--result-file", str(ResultPath)]
if StartPath is not None:
Args.append(str(StartPath))
try:
Process = await asyncio.create_subprocess_exec(*Args)
Request.Process = Process
await Process.wait()
finally:
Request.Unexport()
if Request.Cancelled.is_set():
ResultPath.unlink(missing_ok=True)
return 2, {}
try:
Data = json.loads(ResultPath.read_text())
except (OSError, ValueError):
ResultPath.unlink(missing_ok=True)
return 2, {}
ResultPath.unlink(missing_ok=True)
if Data.get("cancelled"):
return 1, {}
Uris = [_PathToUri(Path(P)) for P in Data.get("paths", [])]
if not Uris:
return 1, {}
return 0, {"uris": Variant("as", Uris)}
def _FilterArgs(self, Options: dict) -> list[str]:
FiltersVariant = Options.get("filters")
if FiltersVariant is None:
return []
Args = []
for Label, Patterns in FiltersVariant.value:
# Pattern type 0 = glob, 1 = mimetype (see the FileChooser portal
# spec) — only globs translate directly to Picker.py's filters;
# mimetype-only filter groups are skipped (files stay unfiltered
# for that group rather than guessing a glob from a MIME type).
Globs = [Pattern for (Kind, Pattern) in Patterns if Kind == 0]
if Globs:
Args += ["--filter", f"{Label}:{','.join(Globs)}"]
return Args
@method()
async def OpenFile(
self, handle: "o", app_id: "s", parent_window: "s", title: "s", options: "a{sv}"
) -> "ua{sv}":
Multiple = options.get("multiple", Variant("b", False)).value
Directory = options.get("directory", Variant("b", False)).value
CurrentFolder = options.get("current_folder")
StartPath = _DecodeNulPath(CurrentFolder.value) if CurrentFolder is not None else None
ExtraArgs = ["--pick-folder"] if Directory else ["--pick-open"]
if Multiple:
ExtraArgs.append("--multiple")
ExtraArgs += self._FilterArgs(options)
Response, Results = await self._RunPicker(handle, title or "Open File", ExtraArgs, StartPath)
return [Response, Results]
@method()
async def SaveFile(
self, handle: "o", app_id: "s", parent_window: "s", title: "s", options: "a{sv}"
) -> "ua{sv}":
CurrentName = options.get("current_name")
SuggestedName = CurrentName.value if CurrentName is not None else ""
CurrentFolder = options.get("current_folder")
StartPath = _DecodeNulPath(CurrentFolder.value) if CurrentFolder is not None else None
ExtraArgs = ["--pick-save"]
if SuggestedName:
ExtraArgs += ["--suggested-name", SuggestedName]
Response, Results = await self._RunPicker(handle, title or "Save File", ExtraArgs, StartPath)
return [Response, Results]
@method()
async def SaveFiles(
self, handle: "o", app_id: "s", parent_window: "s", title: "s", options: "a{sv}"
) -> "ua{sv}":
# SaraFem's picker only does a single Save destination — still a
# reasonable fallback for a rarely-used multi-file-save call.
return await self.SaveFile(handle, app_id, parent_window, title, options)
async def Run() -> None:
Bus = await MessageBus(bus_type=BusType.SESSION).connect()
Bus.export(ObjectPath, FileChooserPortal(Bus))
await Bus.request_name(BusName)
await Bus.wait_for_disconnect()
if __name__ == "__main__":
try:
asyncio.run(Run())
except KeyboardInterrupt:
sys.exit(0)
+96
View File
@@ -0,0 +1,96 @@
"""Preview pane: shows the content of the currently selected file.
SaraTag tags live in their own panel now (Tags.py), not here."""
from pathlib import Path
from typing import Optional
import flet as ft
from Core import AppConfig, Palette
from Thumbnails import GetThumbnail, IsThumbnailable
TextExtensions = {
".txt", ".md", ".py", ".json", ".toml", ".yaml", ".yml", ".js", ".ts",
".css", ".html", ".htm", ".csv", ".log", ".sh", ".fish", ".cfg", ".ini",
".scad", ".xml", ".ipynb",
}
TextPreviewCharLimit = 4000
def HumanSize(NumBytes: int) -> str:
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"
class Preview(ft.Container):
def __init__(self, Config: AppConfig, Side: str = "right"):
self.Config = Config
self.Body = ft.Column(
expand=True,
alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
)
BorderSide = ft.BorderSide(1, Palette.Surface1)
Border = ft.Border(right=BorderSide) if Side == "left" else ft.Border(left=BorderSide)
self.Body.controls = self._BuildContent(None)
super().__init__(
content=self.Body,
width=Config.PreviewWidth,
padding=16,
bgcolor=Palette.Mantle,
border=Border,
)
def ShowPath(self, EntryPath: Optional[Path]) -> None:
self.Body.controls = self._BuildContent(EntryPath)
self.update()
def _BuildContent(self, EntryPath: Optional[Path]) -> list[ft.Control]:
if EntryPath is None:
return [ft.Text("Nothing selected.", color=Palette.Overlay0)]
if not EntryPath.exists():
return [ft.Text("This no longer exists.", color=Palette.Overlay0)]
if EntryPath.is_dir():
return [
ft.Icon(ft.Icons.FOLDER, size=48, color=Palette.Mauve),
ft.Text(EntryPath.name, weight=ft.FontWeight.BOLD, text_align=ft.TextAlign.CENTER),
ft.Text("Directory", color=Palette.Overlay0),
]
if IsThumbnailable(EntryPath):
ThumbnailPath = GetThumbnail(EntryPath, Size=(512, 512))
if ThumbnailPath is not None:
return [
ft.Image(src=str(ThumbnailPath), fit=ft.BoxFit.CONTAIN, expand=True),
ft.Text(EntryPath.name, text_align=ft.TextAlign.CENTER),
]
if EntryPath.suffix.lower() in TextExtensions:
try:
Content = EntryPath.read_text(errors="replace")[:TextPreviewCharLimit]
except OSError:
Content = None
if Content is not None:
return [
ft.Text(EntryPath.name, weight=ft.FontWeight.BOLD),
ft.Container(
content=ft.Text(Content, selectable=True, size=12),
expand=True,
alignment=ft.Alignment.TOP_LEFT,
),
]
Stat = EntryPath.stat()
return [
ft.Icon(ft.Icons.INSERT_DRIVE_FILE_OUTLINED, size=48, color=Palette.Overlay0),
ft.Text(EntryPath.name, weight=ft.FontWeight.BOLD, text_align=ft.TextAlign.CENTER),
ft.Text(HumanSize(Stat.st_size), color=Palette.Overlay0),
]
+39
View File
@@ -0,0 +1,39 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['Main.py'],
pathex=[],
binaries=[],
datas=[('assets', 'assets')],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='SaraFem',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=['assets/Icon.png'],
)
+348
View File
@@ -0,0 +1,348 @@
"""In-app preferences dialog (Features/Major: "Edit preferences in SaraFem").
Laid out as a category sidebar + detail panel (Features/Minor: "Make settings
look good... sidebar with areas, then a much bigger main box"), with the
left/right/top/bottom-style choices as icon-button pickers instead of text
dropdowns ("some way to choose layout in a more graphical... way")."""
from typing import Callable
import flet as ft
from Core import AppConfig, AvailableThemes, ChipButtonStyle, Palette
# Settings Browser can already hot-apply live (dotfiles, breadcrumbs, preview, tags).
# The window title bar, sidebar (bookmarks/filetree) layout, theme, and SaraTag's
# dynamically-loaded module need a restart to take effect.
RestartRequiredNote = "Window title bar, Theme, Sidebar and SaraTag settings need a restart to take effect."
DialogWidth = 700
DialogHeight = 520
CategoryListWidth = 150
def _MinMaxRow(Label: str, MinValue: int, MaxValue: int) -> tuple[ft.Row, ft.TextField, ft.TextField]:
MinField = ft.TextField(value=str(MinValue), label=f"{Label} min", keyboard_type=ft.KeyboardType.NUMBER, expand=True)
MaxField = ft.TextField(value=str(MaxValue), label=f"{Label} max", keyboard_type=ft.KeyboardType.NUMBER, expand=True)
return ft.Row([MinField, MaxField]), MinField, MaxField
def _SectionLabel(Text: str) -> ft.Text:
return ft.Text(Text, color=Palette.Overlay0, size=12)
def _UnselectedPickerStyle() -> ft.ButtonStyle:
return ft.ButtonStyle(color=Palette.Overlay0, shape=ft.RoundedRectangleBorder(radius=4))
def _OptionPicker(Options: list[tuple[str, str, str]], Initial: str) -> tuple[ft.Control, Callable[[], str]]:
"""A row of icon buttons standing in for a text dropdown — Options is a
list of (key, label, Icons-attribute-name). Returns (control, get_value)."""
State = {"value": Initial}
Buttons: dict[str, ft.OutlinedButton] = {}
def Select(Key: str) -> None:
State["value"] = Key
for OptionKey, Button in Buttons.items():
Button.style = ChipButtonStyle() if OptionKey == Key else _UnselectedPickerStyle()
Row.update()
for Key, Label, IconName in Options:
Buttons[Key] = ft.OutlinedButton(
content=Label,
icon=getattr(ft.Icons, IconName),
style=ChipButtonStyle() if Key == Initial else _UnselectedPickerStyle(),
on_click=lambda e, k=Key: Select(k),
)
Row = ft.Row(list(Buttons.values()), wrap=True)
return Row, lambda: State["value"]
def BuildSettingsDialog(
Config: AppConfig,
OnSave: Callable[[], None],
OnDismiss: Callable[[], None],
) -> ft.AlertDialog:
ThemeDropdown = ft.Dropdown(
value=Config.Theme,
label="Theme (shared across SaraSuite)",
options=[ft.DropdownOption(key=Name, text=Name) for Name in AvailableThemes()],
)
DotfilesSwitch = ft.Switch(label="Show dotfiles", value=Config.ShowDotfiles)
TitleBarSwitch = ft.Switch(label="Hide window title bar", value=Config.HideWindowTitleBar)
BatchRenamePatternField = ft.TextField(
value=Config.BatchRenamePattern,
label="Batch rename pattern (~ = name, ! = counter, ^ = extension)",
)
DeleteZipSwitch = ft.Switch(
label="Delete zip after extracting, by default", value=Config.DeleteZipAfterExtract
)
ArchiveFormatPicker, GetArchiveFormat = _OptionPicker(
[("zip", "Zip", "FOLDER_ZIP_OUTLINED"), ("targz", "Tar.gz", "ARCHIVE_OUTLINED")],
Config.DefaultArchiveFormat,
)
NewDirectoryButtonSwitch = ft.Switch(label="Show New Directory button", value=Config.ShowNewDirectoryButton)
PasteButtonSwitch = ft.Switch(label="Show Paste button", value=Config.ShowPasteButton)
ViewToggleButtonSwitch = ft.Switch(label="Show Grid/List button", value=Config.ShowViewToggleButton)
OptionsButtonSwitch = ft.Switch(label="Show Options button", value=Config.ShowOptionsButton)
ToolbarSwitch = ft.Switch(label="Show toolbar (breadcrumbs + buttons bar)", value=Config.ShowToolbar)
BreadcrumbsSwitch = ft.Switch(label="Show breadcrumbs", value=Config.ShowBreadcrumbs)
BreadcrumbsPositionPicker, GetBreadcrumbsPosition = _OptionPicker(
[("top", "Top", "NORTH"), ("bottom", "Bottom", "SOUTH")], Config.BreadcrumbsPosition
)
SidebarSwitch = ft.Switch(label="Show sidebar", value=Config.ShowSidebar)
SidebarSidePicker, GetSidebarSide = _OptionPicker(
[("left", "Left", "WEST"), ("right", "Right", "EAST")], Config.SidebarSide
)
SidebarWidthField = ft.TextField(
value=str(Config.SidebarWidth), label="Sidebar width", keyboard_type=ft.KeyboardType.NUMBER
)
BookmarksSwitch = ft.Switch(label="Show bookmarks section", value=Config.ShowBookmarks)
BookmarksHeightRow, BookmarksMinHeightField, BookmarksMaxHeightField = _MinMaxRow(
"Bookmarks height", Config.BookmarksMinHeight, Config.BookmarksMaxHeight
)
DevicesSwitch = ft.Switch(label="Show devices section", value=Config.ShowDevices)
AutoMountSwitch = ft.Switch(label="Mount devices when plugged in", value=Config.AutoMountDevices)
InternalDrivesSwitch = ft.Switch(label="Also show internal drives", value=Config.ShowInternalDrives)
DevicesHeightRow, DevicesMinHeightField, DevicesMaxHeightField = _MinMaxRow(
"Devices height", Config.DevicesMinHeight, Config.DevicesMaxHeight
)
FiletreeSwitch = ft.Switch(label="Show filetree section", value=Config.ShowFiletree)
FiletreeRootField = ft.TextField(value=Config.FiletreeRoot, label="Filetree root", hint_text="/")
FiletreeHeightRow, FiletreeMinHeightField, FiletreeMaxHeightField = _MinMaxRow(
"Filetree height", Config.FiletreeMinHeight, Config.FiletreeMaxHeight
)
SplitSidebarEvenlySwitch = ft.Switch(
label="Split Bookmarks/Filetree evenly (overrides the heights above)",
value=Config.SplitBookmarksFiletreeEvenly,
)
PreviewSwitch = ft.Switch(label="Show preview pane", value=Config.ShowPreview)
PreviewSidePicker, GetPreviewSide = _OptionPicker(
[("left", "Left", "WEST"), ("right", "Right", "EAST")], Config.PreviewSide
)
PreviewWidthField = ft.TextField(
value=str(Config.PreviewWidth), label="Preview width", keyboard_type=ft.KeyboardType.NUMBER
)
PreviewHeightRow, PreviewMinHeightField, PreviewMaxHeightField = _MinMaxRow(
"Preview height (only when Tags is under preview)", Config.PreviewMinHeight, Config.PreviewMaxHeight
)
SplitEvenlySwitch = ft.Switch(
label="Split Preview/Tags evenly (overrides the heights above)",
value=Config.SplitPreviewTagsEvenly,
)
TagsSwitch = ft.Switch(label="Show SaraTag tags", value=Config.ShowTags)
TagsPositionPicker, GetTagsPosition = _OptionPicker(
[
("under_preview", "Under Preview", "VIEW_AGENDA_OUTLINED"),
("top", "Top", "NORTH"),
("bottom", "Bottom", "SOUTH"),
],
Config.TagsPosition,
)
TagsLayoutPicker, GetTagsLayout = _OptionPicker(
[("flex", "Flex", "VIEW_STREAM_OUTLINED"), ("vertical", "Vertical", "VIEW_COLUMN_OUTLINED")],
Config.TagsLayout,
)
TagMinWidthField = ft.TextField(
value=str(Config.TagMinWidth), label="Tag min width (0 = none)", keyboard_type=ft.KeyboardType.NUMBER
)
TagMaxWidthField = ft.TextField(
value=str(Config.TagMaxWidth), label="Tag max width (0 = none)", keyboard_type=ft.KeyboardType.NUMBER
)
TagPanelHeightRow, TagPanelMinHeightField, TagPanelMaxHeightField = _MinMaxRow(
"Tags box height", Config.TagPanelMinHeight, Config.TagPanelMaxHeight
)
SaraTagSwitch = ft.Switch(label="Enable SaraTag integration", value=Config.SaraTag.Enabled)
SaraTagPathField = ft.TextField(
value=Config.SaraTag.CoreModulePath,
label="SaraTag .saratag path",
hint_text="/home/sarah/Applications/SaraTag/.saratag",
)
Categories: dict[str, ft.Control] = {
"General": ft.Column(
[
ThemeDropdown,
DotfilesSwitch,
TitleBarSwitch,
BatchRenamePatternField,
DeleteZipSwitch,
_SectionLabel("Default compress format"),
ArchiveFormatPicker,
],
spacing=12,
),
"Toolbar": ft.Column(
[
ToolbarSwitch,
BreadcrumbsSwitch,
_SectionLabel("Position"),
BreadcrumbsPositionPicker,
ft.Divider(height=1, color=Palette.Surface1),
NewDirectoryButtonSwitch,
PasteButtonSwitch,
ViewToggleButtonSwitch,
OptionsButtonSwitch,
],
spacing=12,
),
"Sidebar": ft.Column(
[
SidebarSwitch,
_SectionLabel("Side"),
SidebarSidePicker,
SidebarWidthField,
ft.Divider(height=1, color=Palette.Surface1),
BookmarksSwitch,
BookmarksHeightRow,
ft.Divider(height=1, color=Palette.Surface1),
DevicesSwitch,
AutoMountSwitch,
InternalDrivesSwitch,
DevicesHeightRow,
ft.Divider(height=1, color=Palette.Surface1),
FiletreeSwitch,
FiletreeRootField,
FiletreeHeightRow,
ft.Divider(height=1, color=Palette.Surface1),
SplitSidebarEvenlySwitch,
],
spacing=12,
scroll=ft.ScrollMode.AUTO,
),
"Preview": ft.Column(
[
PreviewSwitch,
_SectionLabel("Side"),
PreviewSidePicker,
PreviewWidthField,
PreviewHeightRow,
],
spacing=12,
),
"Tags": ft.Column(
[
TagsSwitch,
_SectionLabel("Position"),
TagsPositionPicker,
_SectionLabel("Layout"),
TagsLayoutPicker,
TagMinWidthField,
TagMaxWidthField,
TagPanelHeightRow,
SplitEvenlySwitch,
],
spacing=12,
scroll=ft.ScrollMode.AUTO,
),
"SaraTag": ft.Column([SaraTagSwitch, SaraTagPathField], spacing=12),
}
DetailArea = ft.Container(Categories["General"], expand=True, padding=ft.Padding.only(left=12))
CategoryTiles: dict[str, ft.ListTile] = {}
def SelectCategory(Name: str) -> None:
for CategoryName, Tile in CategoryTiles.items():
Tile.selected = CategoryName == Name
DetailArea.content = Categories[Name]
DetailArea.update()
CategoryListView.update()
for Name in Categories:
CategoryTiles[Name] = ft.ListTile(
title=Name,
selected=Name == "General",
selected_tile_color=Palette.Surface1,
selected_color=Palette.Mauve,
on_click=lambda e, n=Name: SelectCategory(n),
)
CategoryListView = ft.Column(list(CategoryTiles.values()), spacing=0)
CategorySidebar = ft.Container(
CategoryListView, width=CategoryListWidth, bgcolor=Palette.Surface0, border_radius=4, padding=4
)
def DoSave(e=None) -> None:
Config.Theme = ThemeDropdown.value
Config.ShowDotfiles = DotfilesSwitch.value
Config.HideWindowTitleBar = TitleBarSwitch.value
Config.BatchRenamePattern = BatchRenamePatternField.value.strip() or "~!^"
Config.DeleteZipAfterExtract = DeleteZipSwitch.value
Config.DefaultArchiveFormat = GetArchiveFormat()
Config.ShowNewDirectoryButton = NewDirectoryButtonSwitch.value
Config.ShowPasteButton = PasteButtonSwitch.value
Config.ShowViewToggleButton = ViewToggleButtonSwitch.value
Config.ShowOptionsButton = OptionsButtonSwitch.value
Config.ShowToolbar = ToolbarSwitch.value
Config.ShowBreadcrumbs = BreadcrumbsSwitch.value
Config.BreadcrumbsPosition = GetBreadcrumbsPosition()
Config.ShowSidebar = SidebarSwitch.value
Config.SidebarSide = GetSidebarSide()
Config.ShowBookmarks = BookmarksSwitch.value
Config.ShowDevices = DevicesSwitch.value
Config.AutoMountDevices = AutoMountSwitch.value
Config.ShowInternalDrives = InternalDrivesSwitch.value
Config.ShowFiletree = FiletreeSwitch.value
Config.SplitBookmarksFiletreeEvenly = SplitSidebarEvenlySwitch.value
Config.FiletreeRoot = FiletreeRootField.value.strip() or "/"
Config.PreviewSide = GetPreviewSide()
Config.ShowPreview = PreviewSwitch.value
Config.SplitPreviewTagsEvenly = SplitEvenlySwitch.value
Config.TagsPosition = GetTagsPosition()
Config.TagsLayout = GetTagsLayout()
Config.ShowTags = TagsSwitch.value
Config.SaraTag.Enabled = SaraTagSwitch.value
Config.SaraTag.CoreModulePath = SaraTagPathField.value.strip()
for Field, TargetAttr in (
(SidebarWidthField, "SidebarWidth"),
(BookmarksMinHeightField, "BookmarksMinHeight"),
(BookmarksMaxHeightField, "BookmarksMaxHeight"),
(DevicesMinHeightField, "DevicesMinHeight"),
(DevicesMaxHeightField, "DevicesMaxHeight"),
(FiletreeMinHeightField, "FiletreeMinHeight"),
(FiletreeMaxHeightField, "FiletreeMaxHeight"),
(PreviewWidthField, "PreviewWidth"),
(PreviewMinHeightField, "PreviewMinHeight"),
(PreviewMaxHeightField, "PreviewMaxHeight"),
(TagMinWidthField, "TagMinWidth"),
(TagMaxWidthField, "TagMaxWidth"),
(TagPanelMinHeightField, "TagPanelMinHeight"),
(TagPanelMaxHeightField, "TagPanelMaxHeight"),
):
try:
setattr(Config, TargetAttr, int(Field.value))
except ValueError:
pass # keep the previous value rather than crash on bad input
Config.Save()
OnSave()
return ft.AlertDialog(
title="Options",
content=ft.Column(
[
ft.Row([CategorySidebar, DetailArea], expand=True),
ft.Divider(height=1, color=Palette.Surface1),
ft.Text(RestartRequiredNote, color=Palette.Overlay0, size=12),
],
width=DialogWidth,
height=DialogHeight,
),
actions=[
ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: OnDismiss()),
ft.OutlinedButton(content="Save", style=ChipButtonStyle(), on_click=DoSave),
],
)
+232
View File
@@ -0,0 +1,232 @@
"""Left-hand panel: Bookmarks (flat, user-curated — Trash is just one of
them, and they're drag-reorderable), Devices (removable media via udisks2,
Devices.py) and a real Filetree (Filetree.py), each independently shown/
hidden via Config. Bookmarks and Filetree both accept file/directory drops
from the Browser's file list to move things into them."""
from pathlib import Path
from typing import Callable, Optional
import flet as ft
from Bookmarks import IconChoices, LoadBookmarks, SaveBookmarks
from Core import (
AppConfig,
ApplyHeightBounds,
ChipButtonStyle,
DragGroup,
MoveIntoDirectory,
NotificationStyle,
Palette,
)
from Devices import DevicesPanel
from Filetree import Filetree
class Sidebar(ft.Container):
def __init__(
self,
OnNavigate: Callable[[Path], None],
Config: AppConfig,
OnFilesMoved: Optional[Callable[[], None]] = None,
):
self.OnNavigate = OnNavigate
self.Config = Config
self.OnFilesMoved = OnFilesMoved
self.LocationTiles: dict[Path, Callable[[bool], None]] = {}
self.BookmarksListView: Optional[ft.ReorderableListView] = None
self.DevicesView: Optional[DevicesPanel] = None
self.FiletreeView: Optional[Filetree] = None
Sections: list[ft.Control] = []
SplitEvenly = Config.SplitBookmarksFiletreeEvenly and Config.ShowBookmarks and Config.ShowFiletree
if Config.ShowBookmarks:
self.BookmarksListView = ft.ReorderableListView(
controls=self._BuildBookmarkTiles(),
spacing=0,
show_default_drag_handles=False,
on_reorder=self._OnBookmarksReordered,
)
if SplitEvenly:
self.BookmarksListView.expand = 1
Sections.append(self.BookmarksListView)
else:
Sections.append(
ApplyHeightBounds(self.BookmarksListView, Config.BookmarksMinHeight, Config.BookmarksMaxHeight)
)
if Config.ShowBookmarks and (Config.ShowDevices or Config.ShowFiletree):
Sections.append(ft.Divider(height=1, color=Palette.Surface1))
if Config.ShowDevices:
self.DevicesView = DevicesPanel(OnNavigate=OnNavigate, Config=Config)
Sections.append(
ApplyHeightBounds(self.DevicesView, Config.DevicesMinHeight, Config.DevicesMaxHeight)
)
if Config.ShowDevices and Config.ShowFiletree:
Sections.append(ft.Divider(height=1, color=Palette.Surface1))
if Config.ShowFiletree:
self.FiletreeView = Filetree(
OnNavigate=OnNavigate,
Config=Config,
RootPath=Path(Config.FiletreeRoot or "/"),
OnFilesMoved=OnFilesMoved,
)
if SplitEvenly:
self.FiletreeView.expand = 1
Sections.append(self.FiletreeView)
else:
Sections.append(
ApplyHeightBounds(self.FiletreeView, Config.FiletreeMinHeight, Config.FiletreeMaxHeight)
)
BorderSide = ft.BorderSide(1, Palette.Surface1)
Border = ft.Border(left=BorderSide) if Config.SidebarSide == "right" else ft.Border(right=BorderSide)
super().__init__(
content=ft.Column(Sections, spacing=0, expand=True),
width=Config.SidebarWidth,
padding=0,
bgcolor=Palette.Mantle,
border=Border,
)
def _BuildBookmarkTiles(self) -> list[ft.Control]:
self.LocationTiles = {}
return [self._BuildBookmarkTile(B) for B in LoadBookmarks()]
def RefreshBookmarks(self) -> None:
"""Called after add/edit/remove/reorder so the sidebar updates
immediately — no restart needed for bookmark changes."""
if self.BookmarksListView is None:
return
self.BookmarksListView.controls = self._BuildBookmarkTiles()
self.BookmarksListView.update()
def _OnBookmarksReordered(self, e: ft.OnReorderEvent) -> None:
Bookmarks = LoadBookmarks()
Moved = Bookmarks.pop(e.old_index)
Bookmarks.insert(e.new_index, Moved)
SaveBookmarks(Bookmarks)
self.RefreshBookmarks()
def _BuildBookmarkTile(self, Bookmark) -> ft.Control:
# Not a ListTile: the drag-to-reorder handle needs to wrap everything
# except the triple-dot menu, which ListTile's fixed leading/title/
# trailing layout doesn't let us carve out — so this is a plain Row.
NameLabel = ft.Text(Bookmark.Name, size=14, color=Palette.Text)
RowContent = ft.Container(
content=ft.Row(
[
ft.Icon(getattr(ft.Icons, Bookmark.Icon, ft.Icons.BOOKMARK_OUTLINE), size=20, color=Palette.Overlay0),
NameLabel,
],
spacing=12,
),
padding=ft.Padding.symmetric(horizontal=16, vertical=10),
border_radius=4,
ink=True,
on_click=lambda e, p=Bookmark.Path: self.OnNavigate(p),
)
def SetSelected(IsSelected: bool) -> None:
RowContent.bgcolor = Palette.Surface1 if IsSelected else None
NameLabel.color = Palette.Mauve if IsSelected else Palette.Text
self.LocationTiles[Bookmark.Path] = SetSelected
Row = ft.Row(
[
ft.Container(ft.ReorderableDragHandle(content=RowContent), expand=True),
ft.PopupMenuButton(
icon=ft.Icons.MORE_VERT,
items=[
ft.PopupMenuItem(
content="Edit", on_click=lambda e, b=Bookmark: self._ShowEditBookmarkDialog(b)
),
],
),
],
spacing=0,
)
return ft.DragTarget(
group=DragGroup,
content=Row,
on_accept=lambda e, dest=Bookmark.Path: self._OnEntryDropped(e, dest),
)
def _OnEntryDropped(self, e: ft.DragTargetEvent, DestinationDir: Path) -> None:
SourcePath = Path(e.src.data)
try:
MoveIntoDirectory(SourcePath, DestinationDir)
except OSError as Error:
self._Notify(str(Error))
return
self.RefreshBookmarks()
if self.FiletreeView is not None:
self.FiletreeView.RevealPath(DestinationDir)
if self.OnFilesMoved:
self.OnFilesMoved()
self._Notify(f'Moved "{SourcePath.name}" to {DestinationDir.name or DestinationDir}.', Kind="success")
def _ShowEditBookmarkDialog(self, Bookmark) -> None:
NameField = ft.TextField(value=Bookmark.Name, autofocus=True, label="Name")
PathField = ft.TextField(value=str(Bookmark.Path), label="Path")
IconDropdown = ft.Dropdown(
value=Bookmark.Icon,
label="Icon",
options=[ft.DropdownOption(key=Name, text=Name.split("_")[0].title()) for Name in IconChoices],
)
def DoSave(e=None) -> None:
self.page.pop_dialog()
Bookmarks = LoadBookmarks()
for B in Bookmarks:
if B.Path == Bookmark.Path:
B.Name = NameField.value.strip() or Bookmark.Name
B.Path = Path(PathField.value.strip() or Bookmark.Path)
B.Icon = IconDropdown.value or Bookmark.Icon
SaveBookmarks(Bookmarks)
self.RefreshBookmarks()
self._Notify("Bookmark updated.", Kind="success")
def DoRemove(e=None) -> None:
self.page.pop_dialog()
SaveBookmarks([B for B in LoadBookmarks() if B.Path != Bookmark.Path])
self.RefreshBookmarks()
self._Notify("Bookmark removed.", Kind="success")
Dialog = ft.AlertDialog(
title="Edit Bookmark",
content=ft.Column([NameField, PathField, IconDropdown], tight=True, width=320),
actions=[
ft.OutlinedButton(content="Remove", style=ChipButtonStyle(), on_click=DoRemove),
ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: self.page.pop_dialog()),
ft.OutlinedButton(content="Save", style=ChipButtonStyle(), on_click=DoSave),
],
)
self.page.show_dialog(Dialog)
def _Notify(self, Message: str, Kind: str = "error") -> None:
Background, Accent = NotificationStyle(Kind)
self.page.show_dialog(
ft.SnackBar(
content=ft.Text(Message, color=Accent),
bgcolor=Background,
shape=ft.RoundedRectangleBorder(radius=4, side=ft.BorderSide(1, Accent)),
open=True,
)
)
def UpdateSelection(self, CurrentPath: Path) -> None:
for LocationPath, SetSelected in self.LocationTiles.items():
SetSelected(LocationPath == CurrentPath)
if self.LocationTiles:
self.update()
if self.DevicesView is not None:
self.DevicesView.UpdateSelection(CurrentPath)
if self.FiletreeView is not None:
self.FiletreeView.RevealPath(CurrentPath)
+72
View File
@@ -0,0 +1,72 @@
"""Standalone SaraTag Book-tag panel. Split out from Preview.py (Bugs/Minor:
"SaraTag only shows in preview pane") so it can be shown/hidden and positioned
independently — see AppConfig.ShowTags / TagsPosition / TagsLayout."""
from pathlib import Path
from typing import Optional
import flet as ft
from Books import ReadBookForFile
from Core import AppConfig, Palette
# Fixedsys Excelsior is fixed-width, so a per-character estimate is accurate
# enough to size chips without a real text-measurement API.
EstimatedCharWidth = 7.5
ChipPadding = 32
def _EstimateChipWidth(Label: str, Config: AppConfig) -> Optional[float]:
if not Config.TagMinWidth and not Config.TagMaxWidth:
return None # let the chip hug its content, no constraint requested
Natural = len(Label) * EstimatedCharWidth + ChipPadding
Lower = Config.TagMinWidth or 0
Upper = Config.TagMaxWidth or Natural
return max(Lower, min(Natural, Upper))
def _MakeChip(Key: str, Value, Config: AppConfig) -> ft.Chip:
Label = f"{Key}: {Value}"
return ft.Chip(
label=Label,
label_text_style=ft.TextStyle(color=Palette.Text, size=12),
bgcolor=Palette.Surface0,
shadow_color=Palette.Surface0,
elevation=0,
elevation_on_click=0,
show_checkmark=False,
width=_EstimateChipWidth(Label, Config),
)
class TagPanel(ft.Container):
def __init__(self, Config: AppConfig):
self.Config = Config
self.Body = ft.Column(spacing=0)
self.Body.controls = self._BuildContent(None)
BorderSide = ft.BorderSide(1, Palette.Surface1)
super().__init__(
content=self.Body,
padding=8,
bgcolor=Palette.Mantle,
border=ft.Border(top=BorderSide, right=BorderSide, bottom=BorderSide, left=BorderSide),
)
def ShowPath(self, EntryPath: Optional[Path]) -> None:
self.Body.controls = self._BuildContent(EntryPath)
self.update()
def _BuildContent(self, EntryPath: Optional[Path]) -> list[ft.Control]:
if EntryPath is None or EntryPath.is_dir():
return [ft.Text("No tags.", color=Palette.Overlay0, size=12)]
Book = ReadBookForFile(EntryPath, self.Config)
if not Book or not Book["Tags"]:
return [ft.Text("No tags.", color=Palette.Overlay0, size=12)]
Chips = [_MakeChip(Key, Value, self.Config) for Key, Value in Book["Tags"].items()]
if self.Config.TagsLayout == "vertical":
return [ft.Column(Chips, spacing=4, horizontal_alignment=ft.CrossAxisAlignment.START)]
return [ft.Row(Chips, wrap=True, spacing=4, run_spacing=4)]
+37
View File
@@ -0,0 +1,37 @@
"""Thumbnail generation and on-disk caching (~/.cache/SaraFem/Thumbnails)."""
import hashlib
from pathlib import Path
from PIL import Image
from Core import ThumbnailCacheDir
ImageExtensions = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}
def IsThumbnailable(EntryPath: Path) -> bool:
return EntryPath.suffix.lower() in ImageExtensions
def GetThumbnail(EntryPath: Path, Size: tuple[int, int] = (128, 128)) -> Path | None:
"""Return a cached thumbnail path for `EntryPath`, generating it if stale or missing."""
if not IsThumbnailable(EntryPath):
return None
Stat = EntryPath.stat()
CacheKey = hashlib.sha256(f"{EntryPath}:{Stat.st_mtime}:{Size}".encode()).hexdigest()
CachePath = ThumbnailCacheDir / f"{CacheKey}.png"
if CachePath.exists():
return CachePath
ThumbnailCacheDir.mkdir(parents=True, exist_ok=True)
try:
with Image.open(EntryPath) as Img:
Img.thumbnail(Size)
Img.convert("RGB").save(CachePath, "PNG")
except Exception:
return None
return CachePath
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 775 B

Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
flet>=1.0,<2.0
pillow
send2trash
toml
dbus-next