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