mirror of
https://git.srhtnin.garden/sarah/SaraFem.git
synced 2026-10-13 18:18:11 +02:00
Initial commit
This commit is contained in:
commit
4c8d272719
31 files changed
+3870
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
build/
|
||||||
|
.flet/
|
||||||
|
dist/
|
||||||
|
*.AppImage
|
||||||
|
Packaging/appimage/AppDir/usr/bin/
|
||||||
+12
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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)
|
||||||
Executable
+6
@@ -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 "$@"
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[portal]
|
||||||
|
DBusName=org.freedesktop.impl.portal.desktop.SaraFem
|
||||||
|
Interfaces=org.freedesktop.impl.portal.FileChooser;
|
||||||
|
UseIn=COSMIC
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
sarafem.png
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
usr/bin/SaraFem
|
||||||
@@ -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 |
Executable
+96
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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),
|
||||||
|
]
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
@@ -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)]
|
||||||
@@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 775 B |
Binary file not shown.
@@ -0,0 +1,5 @@
|
|||||||
|
flet>=1.0,<2.0
|
||||||
|
pillow
|
||||||
|
send2trash
|
||||||
|
toml
|
||||||
|
dbus-next
|
||||||
Reference in new issue
Block a user