commit 4c8d2727193d2f33409f70e24c47a2514350375d Author: SRHTNIN Date: Thu Sep 24 22:03:58 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf0543e --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.venv/ +__pycache__/ +*.pyc +build/ +.flet/ +dist/ +*.AppImage +Packaging/appimage/AppDir/usr/bin/ diff --git a/BookSync.py b/BookSync.py new file mode 100644 index 0000000..385d313 --- /dev/null +++ b/BookSync.py @@ -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") diff --git a/Bookmarks.py b/Bookmarks.py new file mode 100644 index 0000000..dc0aca9 --- /dev/null +++ b/Bookmarks.py @@ -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) diff --git a/Books.py b/Books.py new file mode 100644 index 0000000..36e9b0e --- /dev/null +++ b/Books.py @@ -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} diff --git a/Browser.py b/Browser.py new file mode 100644 index 0000000..4089041 --- /dev/null +++ b/Browser.py @@ -0,0 +1,1258 @@ +"""Main file listing / navigation view: browsing, selection, keybinds, file ops, +plus the breadcrumbs/preview/dotfiles toggles from Config.toml.""" + +import asyncio +import shutil +import subprocess +import tarfile +import time +import zipfile +from pathlib import Path +from typing import Callable, Optional + +import flet as ft +import send2trash + +from Bookmarks import AddBookmark +from Bookmarks import DefaultIcon as DefaultBookmarkIcon +from Bookmarks import IconChoices as BookmarkIconChoices +from Books import ClearSaraTagCache, IsInternalSaraTagFile +from Core import AppConfig, ApplyHeightBounds, ChipButtonStyle, DragGroup, FormatBatchName, NotificationStyle, Palette +from Preview import HumanSize, Preview +from Settings import BuildSettingsDialog +from Tags import TagPanel +from Thumbnails import GetThumbnail, IsThumbnailable + +DoubleClickWindow = 0.4 # seconds + +# Extensions readable via _ShowExtractDialog — zip via zipfile, everything else +# via tarfile's "r:*" auto-detecting mode (handles plain .tar too, uncompressed). +ArchiveExtensions = (".zip", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz") + + +def _IsArchive(EntryPath: Path) -> bool: + NameLower = EntryPath.name.lower() + return any(NameLower.endswith(Ext) for Ext in ArchiveExtensions) + + +class _EntryHandle: + """Wraps whatever control represents an entry (a ListTile in list view, a + Container in grid view) behind one uniform way to toggle selection, so + Select() doesn't need to know which view mode is active.""" + + def __init__(self, Control: ft.Control, SetSelected: Callable[[bool], None]): + self.Control = Control + self.SetSelected = SetSelected + + +class Browser(ft.Row): + def __init__( + self, + StartPath: Path, + Config: AppConfig, + OnPathChanged: Optional[Callable[[Path], None]] = None, + OnBookmarksChanged: Optional[Callable[[], None]] = None, + OnSidebarSettingsChanged: Optional[Callable[[], None]] = None, + EntryFilter: Optional[Callable[[Path], bool]] = None, + OnActivateFile: Optional[Callable[[Path], None]] = None, + OnSelectionChanged: Optional[Callable[[], None]] = None, + OnEscape: Optional[Callable[[], None]] = None, + ): + super().__init__(expand=True, spacing=0, vertical_alignment=ft.CrossAxisAlignment.STRETCH) + self.Config = Config + self.CurrentPath = StartPath + self.OnPathChanged = OnPathChanged + self.OnBookmarksChanged = OnBookmarksChanged + self.OnSidebarSettingsChanged = OnSidebarSettingsChanged + # Hooks for picker mode (Picker.py), all unused in the normal window: + # which entries to list, what double-click/Enter on a file does + # (instead of xdg-open), and what Escape does. + self.EntryFilter = EntryFilter + self.OnActivateFile = OnActivateFile + self.OnSelectionChanged = OnSelectionChanged + self.OnEscape = OnEscape + # Set while a text field outside the Browser has focus (the picker's + # file name), so typing there doesn't trigger Backspace/Delete/etc. + self.ShortcutsPaused = False + + # SelectedPath is the "primary" entry (last one clicked/toggled) — + # used for Preview/Tags when exactly one item is selected, and for + # F2/Enter. SelectedPaths is the full multi-selection; SelectionAnchor + # is where a Shift-range select starts from. CurrentEntries mirrors + # the current sorted/filtered listing order, so Shift-range can slice + # between two paths regardless of view mode. + self.SelectedPath: Optional[Path] = None + self.SelectedPaths: set[Path] = set() + self.SelectionAnchor: Optional[Path] = None + self.CurrentEntries: list[Path] = [] + self.ShiftHeld = False + self.CtrlHeld = False + + self.EntryTiles: dict[Path, _EntryHandle] = {} + self.LastClickPath: Optional[Path] = None + self.LastClickTime = 0.0 + self.BackStack: list[Path] = [] + self.ForwardStack: list[Path] = [] + self.DialogOpen = False + # Right-click menu state, see _RequestMenu. + self.MenuOpen = False + self.MenuOpenedAt = 0.0 + self.PendingMenu: Optional[tuple[Optional[Path], ft.Offset]] = None + + self.ClipboardPaths: list[Path] = [] + self.ClipboardMode: Optional[str] = None # "copy" or "cut" + + self.Breadcrumbs = ft.Row(spacing=0, wrap=True) + self.PasteButton = ft.OutlinedButton( + content="Paste", + icon=ft.Icons.CONTENT_PASTE_OUTLINED, + disabled=True, + style=ChipButtonStyle(), + on_click=lambda e: self._Paste(), + ) + self.NewDirectoryButton = ft.OutlinedButton( + content="New Directory", + icon=ft.Icons.CREATE_NEW_FOLDER_OUTLINED, + style=ChipButtonStyle(), + on_click=lambda e: self._ShowNewDirectoryDialog(), + ) + self.ViewToggleButton = ft.OutlinedButton( + content="Grid" if self.Config.ViewMode == "list" else "List", + icon=ft.Icons.GRID_VIEW_OUTLINED if self.Config.ViewMode == "list" else ft.Icons.VIEW_LIST_OUTLINED, + style=ChipButtonStyle(), + on_click=lambda e: self._ToggleViewMode(), + ) + self.OptionsButton = ft.OutlinedButton( + content="Options", + icon=ft.Icons.SETTINGS_OUTLINED, + style=ChipButtonStyle(), + on_click=lambda e: self._ShowSettingsDialog(), + ) + self.ActionsToolbar = ft.Row(self._BuildActionsToolbar()) + self.EntryList = self._MakeEntryListContainer() + # One shared popup host, opened programmatically (see _RequestMenu) so + # a right-click resolves to exactly one menu. Its built-in triggers + # can't be switched off: Flet 1.0.1's client reads a None trigger as + # its default (DOWN), so secondary_trigger=None still opened a menu on + # every right-click. LONG_PRESS with no per-button items leaves them + # inert (at most an empty "dismiss" event, which _OnMenuClosed ignores). + # Every extra open() first pops whatever menu is showing, so three at + # once (built-in + entry + background) popped the app's own page too: + # that was the "right-click turns the whole app black" bug. + self.EntryContextMenu = ft.ContextMenu( + content=self.EntryList, + expand=True, + secondary_trigger=ft.ContextMenuTrigger.LONG_PRESS, + tertiary_trigger=ft.ContextMenuTrigger.LONG_PRESS, + on_select=self._OnMenuClosed, + on_dismiss=self._OnMenuClosed, + ) + self.PreviewPane = Preview(Side=self.Config.PreviewSide, Config=self.Config) + self.TagPanel = TagPanel(Config=self.Config) + self.FileListColumn = ft.Column( + expand=True, + spacing=0, + horizontal_alignment=ft.CrossAxisAlignment.STRETCH, + controls=self._BuildChrome(), + ) + + self.controls = self._BuildLayout() + + def did_mount(self) -> None: + self.page.on_keyboard_event = self._OnKeyboardEvent + self._Refresh() + if self.OnPathChanged: + self.OnPathChanged(self.CurrentPath) + + # --- Layout (Config-driven) --------------------------------------------- + + def _BuildLayout(self) -> list[ft.Control]: + # As a direct Row child, the Row's own vertical_alignment=STRETCH fills + # the height, so Preview must NOT also expand=True there (expand on a + # Row child competes for horizontal space, fighting its fixed width — + # that was the "huge gap to the right" bug). As a Column child (below, + # sharing space with Tags) it's the opposite: expand=True is what makes + # it flex-fill the column's height. + self.PreviewPane.expand = False + + if not self.Config.ShowPreview: + return [self.FileListColumn] + + PreviewBlock: ft.Control = self.PreviewPane + if self.Config.ShowTags and self.Config.TagsPosition == "under_preview": + if self.Config.SplitPreviewTagsEvenly: + # Equal flex weight, not fixed pixel heights — adapts to window + # size automatically instead of needing a magic number that + # only happens to work for one particular window height. + self.PreviewPane.expand = 1 + self.TagPanel.expand = 1 + PreviewItem, TagsItem = self.PreviewPane, self.TagPanel + else: + # Preview and Tags share this column now, so both need height + # bounds to negotiate the space (unlike the top/bottom case, + # where Preview is alone and always stays full height). + self.PreviewPane.expand = not (self.Config.PreviewMinHeight or self.Config.PreviewMaxHeight) + self.TagPanel.expand = False + PreviewItem = ApplyHeightBounds( + self.PreviewPane, self.Config.PreviewMinHeight, self.Config.PreviewMaxHeight + ) + TagsItem = ApplyHeightBounds( + self.TagPanel, self.Config.TagPanelMinHeight, self.Config.TagPanelMaxHeight + ) + # No expand=True on this Column itself: the Row's own + # vertical_alignment=STRETCH now handles filling the height, so + # this can keep its fixed width instead of also competing for + # horizontal space (that was the "huge gap to the right" bug). + # horizontal_alignment=STRETCH makes Tags match Preview's width + # automatically — Column's default cross-axis alignment is CENTER, + # not stretch, so Tags was shrinking to fit "No tags." instead of + # filling the panel. No separate tag-box-width setting needed. + PreviewBlock = ft.Column( + [PreviewItem, TagsItem], + spacing=0, + width=self.Config.PreviewWidth, + horizontal_alignment=ft.CrossAxisAlignment.STRETCH, + ) + + if self.Config.PreviewSide == "left": + return [PreviewBlock, self.FileListColumn] + return [self.FileListColumn, PreviewBlock] + + def _BuildToolbarBar(self) -> ft.Control: + """Breadcrumbs and the button toolbar share one bar, each taking half + the width — not two separate full-width rows.""" + ToolbarContainer = ft.Container( + self.ActionsToolbar, padding=8, expand=1 if self.Config.ShowBreadcrumbs else True + ) + if not self.Config.ShowBreadcrumbs: + return ToolbarContainer + BreadcrumbsContainer = ft.Container(self.Breadcrumbs, padding=8, expand=1) + return ft.Row([BreadcrumbsContainer, ToolbarContainer]) + + def _BuildChrome(self) -> list[ft.Control]: + TagsBar = ft.Container( + ApplyHeightBounds(self.TagPanel, self.Config.TagPanelMinHeight, self.Config.TagPanelMaxHeight), + padding=4, + ) + ShowTagsAt = lambda Position: self.Config.ShowTags and self.Config.TagsPosition == Position + Items: list[ft.Control] = [] + + # No dividers directly touching TagsBar: it now carries its own + # Surface1 border on every side (matching Preview/Bookmarks/Filetree), + # so an adjacent divider would just double up the line. + if ShowTagsAt("top"): + Items.append(TagsBar) + + if self.Config.ShowToolbar and self.Config.BreadcrumbsPosition == "top": + Items += [self._BuildToolbarBar(), ft.Divider(height=1, color=Palette.Surface1)] + + # KeyboardListener tracks live Shift/Ctrl-held state for click + # modifiers (TapEvent carries no modifier info in Flet, unlike + # Page.on_keyboard_event, which is keydown-only and can't see releases — + # this is the one place that needs a genuine press/release pair). + # The outer Container is what makes the file area fill the column's + # height. KeyboardListener can't: in Flet 1.0.1 its client side never + # applies `expand` (only layout controls like Container do), so with + # it as the Column child the whole area was only as tall as its + # entries. Clicking the empty space below them did nothing, and a + # bottom toolbar sat right under the last entry. Inside that fixed + # height, the inner Container's bgcolor makes it paint, and so + # hit-test, everywhere, including below the last entry. + Items.append( + ft.Container( + expand=True, + content=ft.KeyboardListener( + content=ft.GestureDetector( + content=ft.Container(content=self.EntryContextMenu, bgcolor=Palette.Base, expand=True), + on_tap=lambda e: self.Select(None), + on_secondary_tap_down=lambda e: self._RequestMenu(None, e), + ), + on_key_down=self._OnListKeyDown, + on_key_up=self._OnListKeyUp, + autofocus=True, + ), + ) + ) + + if self.Config.ShowToolbar and self.Config.BreadcrumbsPosition == "bottom": + Items += [ft.Divider(height=1, color=Palette.Surface1), self._BuildToolbarBar()] + + if ShowTagsAt("bottom"): + Items.append(TagsBar) + + return Items + + def _ToggleBreadcrumbs(self) -> None: + self.Config.ShowBreadcrumbs = not self.Config.ShowBreadcrumbs + self.Config.Save() + self.FileListColumn.controls = self._BuildChrome() + self.FileListColumn.update() + + def _TogglePreview(self) -> None: + self.Config.ShowPreview = not self.Config.ShowPreview + self.Config.Save() + self.controls = self._BuildLayout() + self.update() + if self.Config.ShowPreview: + self._UpdatePreviewForSelection() + + def _ToggleTags(self) -> None: + self.Config.ShowTags = not self.Config.ShowTags + self.Config.Save() + self.FileListColumn.controls = self._BuildChrome() + self.FileListColumn.update() + self.controls = self._BuildLayout() + self.update() + if self.Config.ShowTags: + self._UpdatePreviewForSelection() + + def _ToggleDotfiles(self) -> None: + self.Config.ShowDotfiles = not self.Config.ShowDotfiles + self.Config.Save() + self._RefreshEntries() + + def _BuildActionsToolbar(self) -> list[ft.Control]: + Items: list[ft.Control] = [ft.Container(expand=True)] + if self.Config.ShowNewDirectoryButton: + Items.append(self.NewDirectoryButton) + if self.Config.ShowPasteButton: + Items.append(self.PasteButton) + if self.Config.ShowViewToggleButton: + Items.append(self.ViewToggleButton) + if self.Config.ShowOptionsButton: + Items.append(self.OptionsButton) + return Items + + def RefreshToolbarVisibility(self) -> None: + self.ActionsToolbar.controls = self._BuildActionsToolbar() + self.ActionsToolbar.update() + + def _MakeEntryListContainer(self) -> ft.Control: + if self.Config.ViewMode == "grid": + return ft.GridView(expand=True, max_extent=120, child_aspect_ratio=0.85, spacing=8, run_spacing=8) + return ft.ListView(expand=True, spacing=2) + + def _ToggleViewMode(self) -> None: + self.Config.ViewMode = "grid" if self.Config.ViewMode == "list" else "list" + self.Config.Save() + + self.ViewToggleButton.content = "List" if self.Config.ViewMode == "grid" else "Grid" + self.ViewToggleButton.icon = ( + ft.Icons.VIEW_LIST_OUTLINED if self.Config.ViewMode == "grid" else ft.Icons.GRID_VIEW_OUTLINED + ) + + self.EntryList = self._MakeEntryListContainer() + self.EntryContextMenu.content = self.EntryList + self.FileListColumn.controls = self._BuildChrome() + self.FileListColumn.update() + self._RefreshEntries() + + # --- Navigation ----------------------------------------------------- + + def NavigateTo(self, TargetPath: Path, RecordHistory: bool = True) -> None: + if RecordHistory and TargetPath != self.CurrentPath: + self.BackStack.append(self.CurrentPath) + self.ForwardStack.clear() + self.CurrentPath = TargetPath + self.SelectedPath = None + self.SelectedPaths = set() + self.SelectionAnchor = None + self._Refresh() + if self.Config.ShowPreview: + self.PreviewPane.ShowPath(None) + if self.Config.ShowTags: + self.TagPanel.ShowPath(None) + if self.OnPathChanged: + self.OnPathChanged(TargetPath) + if self.OnSelectionChanged: + self.OnSelectionChanged() + + def GoBack(self) -> None: + if not self.BackStack: + return + self.ForwardStack.append(self.CurrentPath) + self.NavigateTo(self.BackStack.pop(), RecordHistory=False) + + def GoForward(self) -> None: + if not self.ForwardStack: + return + self.BackStack.append(self.CurrentPath) + self.NavigateTo(self.ForwardStack.pop(), RecordHistory=False) + + def GoUp(self) -> None: + if self.CurrentPath.parent != self.CurrentPath: + self.NavigateTo(self.CurrentPath.parent) + + def _HandleTap(self, EntryPath: Path) -> None: + Now = time.monotonic() + IsDoubleClick = ( + EntryPath == self.LastClickPath + and (Now - self.LastClickTime) < DoubleClickWindow + and not self.ShiftHeld + and not self.CtrlHeld + ) + self.LastClickPath = None if IsDoubleClick else EntryPath + self.LastClickTime = Now + + if IsDoubleClick: + self.OpenEntry(EntryPath) + elif self.ShiftHeld: + self._SelectRange(EntryPath) + elif self.CtrlHeld: + self._ToggleSelect(EntryPath) + else: + self.Select(EntryPath) + + def Select(self, EntryPath: Optional[Path]) -> None: + """Plain click (or programmatic single-select/clear): replaces + whatever the selection was with just EntryPath (or nothing).""" + self.SelectedPaths = {EntryPath} if EntryPath is not None else set() + self.SelectionAnchor = EntryPath + self.SelectedPath = EntryPath + self._ApplySelectionVisuals() + self._UpdatePreviewForSelection() + + def _ToggleSelect(self, EntryPath: Path) -> None: + """Ctrl+click: add if not selected, remove if it is.""" + if EntryPath in self.SelectedPaths: + self.SelectedPaths.discard(EntryPath) + else: + self.SelectedPaths.add(EntryPath) + self.SelectionAnchor = EntryPath + self.SelectedPath = EntryPath if EntryPath in self.SelectedPaths else next(iter(self.SelectedPaths), None) + self._ApplySelectionVisuals() + self._UpdatePreviewForSelection() + + def _SelectRange(self, EntryPath: Path) -> None: + """Shift+click: select everything between the anchor and EntryPath, + based on the current sort order.""" + Anchor = self.SelectionAnchor if self.SelectionAnchor is not None else EntryPath + if Anchor not in self.CurrentEntries or EntryPath not in self.CurrentEntries: + self.Select(EntryPath) + return + StartIndex = self.CurrentEntries.index(Anchor) + EndIndex = self.CurrentEntries.index(EntryPath) + Lo, Hi = sorted((StartIndex, EndIndex)) + self.SelectedPaths = set(self.CurrentEntries[Lo : Hi + 1]) + self.SelectedPath = EntryPath + # SelectionAnchor deliberately unchanged — repeated Shift-clicks + # re-measure from the original range start, not the last click. + self._ApplySelectionVisuals() + self._UpdatePreviewForSelection() + + def _SelectAll(self) -> None: + self.SelectedPaths = set(self.CurrentEntries) + self.SelectedPath = self.CurrentEntries[-1] if self.CurrentEntries else None + self.SelectionAnchor = self.SelectedPath + self._ApplySelectionVisuals() + self._UpdatePreviewForSelection() + + def _InvertSelection(self) -> None: + self.SelectedPaths = set(self.CurrentEntries) - self.SelectedPaths + self.SelectedPath = next(iter(self.SelectedPaths), None) + self.SelectionAnchor = self.SelectedPath + self._ApplySelectionVisuals() + self._UpdatePreviewForSelection() + + def _ApplySelectionVisuals(self) -> None: + for EntryPath, Handle in self.EntryTiles.items(): + Handle.SetSelected(EntryPath in self.SelectedPaths) + self.update() + if self.OnSelectionChanged: + self.OnSelectionChanged() + + def _UpdatePreviewForSelection(self) -> None: + # Preview/Tags only make sense for exactly one item — with a + # multi-selection there's no single file to show, so both go blank + # rather than confusingly picking one of the selected items. + ShowPath = self.SelectedPath if len(self.SelectedPaths) <= 1 else None + if self.Config.ShowPreview: + self.PreviewPane.ShowPath(ShowPath) + if self.Config.ShowTags: + self.TagPanel.ShowPath(ShowPath) + + def _TargetSelection(self, Entry: Path) -> list[Path]: + """What a right-click/menu action on Entry should operate on: the + whole live selection if Entry is part of a multi-selection, otherwise + just Entry itself.""" + if Entry in self.SelectedPaths and len(self.SelectedPaths) > 1: + return [P for P in self.CurrentEntries if P in self.SelectedPaths] + return [Entry] + + def OpenEntry(self, EntryPath: Path) -> None: + if EntryPath.is_dir(): + self.NavigateTo(EntryPath) + return + if self.OnActivateFile: + self.OnActivateFile(EntryPath) + return + try: + subprocess.Popen(["xdg-open", str(EntryPath)]) + except OSError as Error: + self._Notify(f"Couldn't open {EntryPath.name}: {Error}") + + # --- Rendering -------------------------------------------------------- + + def Refresh(self) -> None: + """Public entry point for other components (Sidebar/Filetree) to ask + for a re-list after they move a file out from under the current view.""" + self._RefreshEntries() + + def _Refresh(self) -> None: + self.Breadcrumbs.controls = self._BuildBreadcrumbs(self.CurrentPath) + self._RefreshEntries() + + def _RefreshEntries(self) -> None: + self.EntryList.controls = self._BuildEntries(self.CurrentPath) + self.update() + + def _BuildBreadcrumbs(self, DirPath: Path) -> list[ft.Control]: + Parts = DirPath.parts + Controls: list[ft.Control] = [] + Cumulative = Path(Parts[0]) + Segments = [(Parts[0], Cumulative)] + for Part in Parts[1:]: + Cumulative = Cumulative / Part + Segments.append((Part, Cumulative)) + + for Index, (Label, TargetPath) in enumerate(Segments): + Controls.append( + ft.TextButton(content=Label, on_click=lambda e, p=TargetPath: self.NavigateTo(p)) + ) + if Index < len(Segments) - 1: + Controls.append(ft.Text("/", color=Palette.Overlay0)) + return Controls + + def _BuildEntries(self, DirPath: Path) -> list[ft.Control]: + self.EntryTiles = {} + try: + Entries = sorted(DirPath.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())) + except (PermissionError, FileNotFoundError): + self.CurrentEntries = [] + return [ft.Text("Can't read this folder.", italic=True)] + + if not self.Config.ShowDotfiles: + Entries = [Entry for Entry in Entries if not Entry.name.startswith(".")] + + Entries = [Entry for Entry in Entries if not IsInternalSaraTagFile(Entry, self.Config)] + if self.EntryFilter: + Entries = [Entry for Entry in Entries if self.EntryFilter(Entry)] + self.CurrentEntries = Entries + # A refresh (new/renamed/deleted files) can leave stale paths in the + # selection — drop anything that no longer exists in this listing. + self.SelectedPaths &= set(Entries) + if self.SelectedPath not in self.SelectedPaths: + self.SelectedPath = next(iter(self.SelectedPaths), None) + + if self.Config.ViewMode == "grid": + return [self._BuildGridCell(Entry) for Entry in Entries] + return [self._BuildListRow(Entry) for Entry in Entries] + + def _BuildListRow(self, Entry: Path) -> ft.Control: + Tile = ft.ListTile( + title=Entry.name, + leading=self._LeadingFor(Entry, Size=32), + trailing=self._EntryMenu(Entry), + selected=Entry in self.SelectedPaths, + selected_tile_color=Palette.Surface0, + ) + self.EntryTiles[Entry] = _EntryHandle(Tile, lambda v, T=Tile: setattr(T, "selected", v)) + return ft.Draggable( + group=DragGroup, + data=str(Entry), + content=ft.GestureDetector( + content=Tile, + on_tap=lambda e, p=Entry: self._HandleTap(p), + on_secondary_tap_down=lambda e, p=Entry: self._RequestMenu(p, e), + ), + ) + + def _BuildGridCell(self, Entry: Path) -> ft.Control: + Cell = ft.Container( + content=ft.Column( + [ + self._LeadingFor(Entry, Size=64), + ft.Text( + Entry.name, size=11, text_align=ft.TextAlign.CENTER, + max_lines=2, overflow=ft.TextOverflow.ELLIPSIS, + ), + ], + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + spacing=4, + ), + padding=8, + border_radius=6, + alignment=ft.Alignment.CENTER, + bgcolor=Palette.Surface0 if Entry in self.SelectedPaths else None, + ) + self.EntryTiles[Entry] = _EntryHandle( + Cell, lambda v, C=Cell: setattr(C, "bgcolor", Palette.Surface0 if v else None) + ) + return ft.Draggable( + group=DragGroup, + data=str(Entry), + # Right-click applies to the whole Stack (including over the + # triple-dot), but left-click-to-select is scoped to just Cell via + # the inner GestureDetector, so clicking the triple-dot doesn't + # also select the tile. + content=ft.GestureDetector( + content=ft.Stack( + [ + ft.GestureDetector(content=Cell, on_tap=lambda e, p=Entry: self._HandleTap(p)), + ft.Container(self._EntryMenu(Entry), right=0, top=0), + ] + ), + on_secondary_tap_down=lambda e, p=Entry: self._RequestMenu(p, e), + ), + ) + + def _LeadingFor(self, Entry: Path, Size: int = 32): + if Entry.is_dir(): + return ft.Icon(ft.Icons.FOLDER, size=Size, color=Palette.Mauve) + if IsThumbnailable(Entry): + ThumbnailPath = GetThumbnail(Entry, Size=(Size * 2, Size * 2)) + if ThumbnailPath is not None: + return ft.Image(src=str(ThumbnailPath), width=Size, height=Size, fit=ft.BoxFit.COVER) + return ft.Icon(ft.Icons.INSERT_DRIVE_FILE_OUTLINED, size=Size, color=Palette.Overlay0) + + def _MenuItems(self, Entry: Path) -> list[ft.PopupMenuItem]: + """Shared by the triple-dot button and the right-click context menu — + they're required to show the same items. Handlers resolve single-vs- + selection targets dynamically at click time (via _TargetSelection/ + _HandleX), so this list itself never needs rebuilding when the + selection changes.""" + Items = [ + ft.PopupMenuItem(content="Rename", on_click=lambda e, p=Entry: self._HandleRename(p)), + ft.PopupMenuItem( + content="Copy", on_click=lambda e, p=Entry: self._SetClipboard(self._TargetSelection(p), "copy") + ), + ft.PopupMenuItem( + content="Cut", on_click=lambda e, p=Entry: self._SetClipboard(self._TargetSelection(p), "cut") + ), + ft.PopupMenuItem(content="Properties", on_click=lambda e, p=Entry: self._HandleProperties(p)), + ft.PopupMenuItem(content="Delete", on_click=lambda e, p=Entry: self._HandleDelete(p)), + ft.PopupMenuItem(content="Compress", on_click=lambda e, p=Entry: self._HandleCompress(p)), + ] + if Entry.is_dir(): + Items.insert( + 0, + ft.PopupMenuItem( + content="Add to Bookmarks", on_click=lambda e, p=Entry: self._ShowAddBookmarkDialog(p) + ), + ) + if _IsArchive(Entry): + Items.append(ft.PopupMenuItem(content="Extract", on_click=lambda e, p=Entry: self._HandleExtract(p))) + return Items + + def _EntryMenu(self, Entry: Path) -> ft.PopupMenuButton: + return ft.PopupMenuButton(icon=ft.Icons.MORE_VERT, items=self._MenuItems(Entry)) + + def _RequestMenu(self, Entry: Optional[Path], Event: ft.TapEvent) -> None: + """Right-click on an entry (Entry) or the empty background (None). + An entry sits inside the background's GestureDetector, and Flutter + reports tap-down to *both* once a press outlasts its 100 ms deadline, + so this collects the right-click(s) for a moment and opens a single + menu, the entry's one winning over the background's.""" + if self.MenuOpen: + # Content can't be clicked while the menu's barrier covers it, so + # a click getting here means the menu is gone and its close event + # just hasn't arrived. The short grace period covers that race. + if time.monotonic() - self.MenuOpenedAt < 0.5: + return + self.MenuOpen = False + First = self.PendingMenu is None + if First or Entry is not None: + self.PendingMenu = (Entry, Event.global_position) + if First: + self.page.run_task(self._OpenPendingMenu) + + async def _OpenPendingMenu(self) -> None: + await asyncio.sleep(0.08) + Entry, Position = self.PendingMenu + self.PendingMenu = None + # open() shows `items` (not secondary_items), and a method call doesn't + # flush property changes, so the items are sent explicitly first. + self.EntryContextMenu.items = self._MenuItems(Entry) if Entry is not None else self._EmptySpaceMenuItems() + self.EntryContextMenu.update() + self.MenuOpen = True + self.MenuOpenedAt = time.monotonic() + try: + await self.EntryContextMenu.open(global_position=Position) + except Exception: + self.MenuOpen = False + raise + + def _OnMenuClosed(self, Event: ft.ContextMenuDismissEvent) -> None: + # button is None only for menus opened through open(); the inert + # built-in triggers report "secondary"/"tertiary". + if Event.button is None: + self.MenuOpen = False + + def _EmptySpaceMenuItems(self) -> list[ft.PopupMenuItem]: + """Right-click on blank space in the file list (not on an entry).""" + return [ + ft.PopupMenuItem(content="New File", on_click=lambda e: self._ShowNewFileDialog()), + ft.PopupMenuItem(content="New Directory", on_click=lambda e: self._ShowNewDirectoryDialog()), + ft.PopupMenuItem(content="Paste", on_click=lambda e: self._Paste()), + ft.PopupMenuItem(content="Select All", on_click=lambda e: self._SelectAll()), + ft.PopupMenuItem(content="Invert Selection", on_click=lambda e: self._InvertSelection()), + ft.PopupMenuItem(content="Show Hidden" if not self.Config.ShowDotfiles else "Hide Hidden", on_click=lambda e: self._ToggleDotfiles()), + ft.PopupMenuItem( + content="Folder Properties", on_click=lambda e: self._ShowPropertiesDialog(self.CurrentPath) + ), + ] + + # --- Keybinds ----------------------------------------------------------- + + def _OnListKeyDown(self, Event) -> None: + """Tracks live Shift/Ctrl-held state for click modifiers (see the + KeyboardListener comment in _BuildChrome).""" + KeyLower = Event.key.lower() + if "shift" in KeyLower: + self.ShiftHeld = True + if "control" in KeyLower or "ctrl" in KeyLower: + self.CtrlHeld = True + + def _OnListKeyUp(self, Event) -> None: + KeyLower = Event.key.lower() + if "shift" in KeyLower: + self.ShiftHeld = False + if "control" in KeyLower or "ctrl" in KeyLower: + self.CtrlHeld = False + + def _OnKeyboardEvent(self, Event: ft.KeyboardEvent) -> None: + if self.DialogOpen: + return + if self.ShortcutsPaused: + if Event.key == "Escape" and self.OnEscape: + self.OnEscape() + return + + if Event.key == "F2" and self.SelectedPath: + self._HandleRename(self.SelectedPath) + elif Event.key == "Delete" and self.SelectedPaths: + self._ConfirmDelete(sorted(self.SelectedPaths, key=lambda p: p.name.lower())) + elif Event.key == "Enter" and self.SelectedPath: + self.OpenEntry(self.SelectedPath) + elif Event.key == "Escape": + if self.OnEscape: + self.OnEscape() + else: + self.Select(None) + elif Event.key == "Backspace" and not Event.alt and not Event.ctrl: + self.GoUp() + elif Event.key == "Arrow Left" and Event.alt: + self.GoBack() + elif Event.key == "Arrow Right" and Event.alt: + self.GoForward() + elif Event.key == "A" and Event.ctrl: + self._SelectAll() + elif Event.key == "B" and Event.ctrl: + self._ToggleBreadcrumbs() + elif Event.key == "P" and Event.ctrl: + self._TogglePreview() + elif Event.key == "H" and Event.ctrl: + self._ToggleDotfiles() + elif Event.key == "T" and Event.ctrl: + self._ToggleTags() + elif Event.key == "C" and Event.ctrl and self.SelectedPaths: + self._SetClipboard(sorted(self.SelectedPaths, key=lambda p: p.name.lower()), "copy") + elif Event.key == "X" and Event.ctrl and self.SelectedPaths: + self._SetClipboard(sorted(self.SelectedPaths, key=lambda p: p.name.lower()), "cut") + elif Event.key == "V" and Event.ctrl: + self._Paste() + elif Event.key == "N" and Event.ctrl and Event.shift: + self._ShowNewDirectoryDialog() + elif Event.key == "N" and Event.ctrl: + self._ShowNewFileDialog() + + # --- Clipboard / paste -------------------------------------------------- + + def _SetClipboard(self, EntryPaths: list[Path], Mode: str) -> None: + self.ClipboardPaths = EntryPaths + self.ClipboardMode = Mode + self.PasteButton.disabled = False + self.update() + + def _Paste(self) -> None: + if not self.ClipboardPaths: + return + + Missing = [P for P in self.ClipboardPaths if not P.exists()] + if Missing: + self._Notify(f"{Missing[0].name} no longer exists.") + self.ClipboardPaths = [] + self.ClipboardMode = None + self.PasteButton.disabled = True + self.update() + return + + Failures: list[str] = [] + for SourcePath in self.ClipboardPaths: + Destination = self._UniqueDestination(self.CurrentPath / SourcePath.name) + try: + if self.ClipboardMode == "copy": + if SourcePath.is_dir(): + shutil.copytree(SourcePath, Destination) + else: + shutil.copy2(SourcePath, Destination) + else: # cut + shutil.move(str(SourcePath), str(Destination)) + except OSError as Error: + Failures.append(f"{SourcePath.name}: {Error}") + + if self.ClipboardMode == "cut": + self.ClipboardPaths = [] + self.ClipboardMode = None + self.PasteButton.disabled = True + + if Failures: + self._Notify("Couldn't paste: " + "; ".join(Failures)) + + self._RefreshEntries() + + def _UniqueDestination(self, 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 + + # --- Rename / new folder / delete --------------------------------------- + + def _ShowDialog(self, Dialog: ft.AlertDialog) -> None: + self.DialogOpen = True + self.page.show_dialog(Dialog) + + def _DismissDialog(self) -> None: + self.DialogOpen = False + self.page.pop_dialog() + + def _HandleRename(self, Entry: Path) -> None: + Targets = self._TargetSelection(Entry) + if len(Targets) == 1: + self._ShowRenameDialog(Targets[0]) + else: + self._ShowBatchRenameDialog(Targets) + + def _ShowRenameDialog(self, Entry: Path) -> None: + NameField = ft.TextField(value=Entry.name, autofocus=True) + + def DoRename(e=None) -> None: + NewName = NameField.value.strip() + self._DismissDialog() + if not NewName or NewName == Entry.name: + return + try: + Entry.rename(Entry.with_name(NewName)) + except OSError as Error: + self._Notify(f"Couldn't rename: {Error}") + return + self._RefreshEntries() + + Dialog = ft.AlertDialog( + title="Rename", + content=NameField, + actions=[ + ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: self._DismissDialog()), + ft.OutlinedButton(content="Rename", style=ChipButtonStyle(), on_click=DoRename), + ], + ) + NameField.on_submit = DoRename + self._ShowDialog(Dialog) + + def _ShowBatchRenameDialog(self, Entries: list[Path]) -> None: + """Renames everything in Entries using Config.BatchRenamePattern + (SaraSortd-style placeholders — see FormatBatchName in Core.py): the + first item gets the base name as typed, later ones get an + auto-incrementing suffix (e.g. "Lorem", "Lorem 1", "Lorem 2", ...).""" + NameField = ft.TextField(value="", autofocus=True, label="Base name") + PreviewText = ft.Text("", size=12, color=Palette.Overlay0) + + def UpdatePreview(e=None) -> None: + Base = NameField.value.strip() + if not Base: + PreviewText.value = "" + else: + SampleCount = min(3, len(Entries)) + Sample = [ + FormatBatchName(self.Config.BatchRenamePattern, Base, Index, Entries[Index].suffix) + for Index in range(SampleCount) + ] + PreviewText.value = ", ".join(Sample) + (", ..." if len(Entries) > SampleCount else "") + PreviewText.update() + + NameField.on_change = UpdatePreview + + def DoRename(e=None) -> None: + Base = NameField.value.strip() + self._DismissDialog() + if not Base: + return + Failures: list[str] = [] + for Index, Entry in enumerate(Entries): + NewName = FormatBatchName(self.Config.BatchRenamePattern, Base, Index, Entry.suffix) + if not NewName or NewName == Entry.name: + continue + try: + Entry.rename(Entry.with_name(NewName)) + except OSError as Error: + Failures.append(f"{Entry.name}: {Error}") + if Failures: + self._Notify("Couldn't rename: " + "; ".join(Failures)) + self._RefreshEntries() + + Dialog = ft.AlertDialog( + title=f"Rename {len(Entries)} items", + content=ft.Column([NameField, PreviewText], tight=True, width=360, spacing=8), + actions=[ + ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: self._DismissDialog()), + ft.OutlinedButton(content="Rename", style=ChipButtonStyle(), on_click=DoRename), + ], + ) + NameField.on_submit = DoRename + self._ShowDialog(Dialog) + + def _ShowNewDirectoryDialog(self) -> None: + NameField = ft.TextField(value="New Directory", autofocus=True) + + def DoCreate(e=None) -> None: + NewName = NameField.value.strip() + self._DismissDialog() + if not NewName: + return + try: + (self.CurrentPath / NewName).mkdir() + except OSError as Error: + self._Notify(f"Couldn't create directory: {Error}") + return + self._RefreshEntries() + + Dialog = ft.AlertDialog( + title="New Directory", + content=NameField, + actions=[ + ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: self._DismissDialog()), + ft.OutlinedButton(content="Create", style=ChipButtonStyle(), on_click=DoCreate), + ], + ) + NameField.on_submit = DoCreate + self._ShowDialog(Dialog) + + def _ShowNewFileDialog(self) -> None: + NameField = ft.TextField(value="New File.txt", autofocus=True) + + def DoCreate(e=None) -> None: + NewName = NameField.value.strip() + self._DismissDialog() + if not NewName: + return + try: + (self.CurrentPath / NewName).touch(exist_ok=False) + except OSError as Error: + self._Notify(f"Couldn't create file: {Error}") + return + self._RefreshEntries() + + Dialog = ft.AlertDialog( + title="New File", + content=NameField, + actions=[ + ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: self._DismissDialog()), + ft.OutlinedButton(content="Create", style=ChipButtonStyle(), on_click=DoCreate), + ], + ) + NameField.on_submit = DoCreate + self._ShowDialog(Dialog) + + def _HandleDelete(self, Entry: Path) -> None: + self._ConfirmDelete(self._TargetSelection(Entry)) + + def _ConfirmDelete(self, Entries: list[Path]) -> None: + def DoDelete(e=None) -> None: + self._DismissDialog() + Failures: list[str] = [] + for Entry in Entries: + try: + send2trash.send2trash(Entry) + except OSError as Error: + Failures.append(f"{Entry.name}: {Error}") + self.SelectedPaths -= set(Entries) + if self.SelectedPath in Entries: + self.SelectedPath = next(iter(self.SelectedPaths), None) + if Failures: + self._Notify("Couldn't delete: " + "; ".join(Failures)) + self._RefreshEntries() + + if len(Entries) == 1: + Message = f'"{Entries[0].name}" will be moved to the trash.' + else: + Message = f"{len(Entries)} items will be moved to the trash." + + Dialog = ft.AlertDialog( + title="Move to trash?", + content=ft.Text(Message), + actions=[ + ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: self._DismissDialog()), + ft.OutlinedButton(content="Delete", style=ChipButtonStyle(), on_click=DoDelete), + ], + ) + self._ShowDialog(Dialog) + + def _ShowSettingsDialog(self) -> None: + def OnSave() -> None: + self._DismissDialog() + self.PreviewPane.width = self.Config.PreviewWidth + self.RefreshToolbarVisibility() + self.FileListColumn.controls = self._BuildChrome() + self.FileListColumn.update() + self.controls = self._BuildLayout() + self.update() + self._UpdatePreviewForSelection() + self._RefreshEntries() + ClearSaraTagCache() # so an Enabled/CoreModulePath change takes effect immediately + if self.OnSidebarSettingsChanged: + self.OnSidebarSettingsChanged() + self._Notify("Options saved.", Kind="success") + + Dialog = BuildSettingsDialog(self.Config, OnSave=OnSave, OnDismiss=self._DismissDialog) + self._ShowDialog(Dialog) + + def _HandleProperties(self, Entry: Path) -> None: + Targets = self._TargetSelection(Entry) + if len(Targets) == 1: + self._ShowPropertiesDialog(Targets[0]) + else: + self._ShowAggregatePropertiesDialog(Targets) + + def _ShowPropertiesDialog(self, Entry: Path) -> None: + try: + Stat = Entry.stat() + except OSError as Error: + self._Notify(f"Couldn't read properties for {Entry.name}: {Error}") + return + + Rows: list[ft.Control] = [ + ft.Row( + [self._LeadingFor(Entry, Size=40), ft.Text(Entry.name, weight=ft.FontWeight.BOLD, size=16)], + spacing=12, + ), + ft.Divider(height=1, color=Palette.Surface1), + ft.Text(f"Path: {Entry}", selectable=True, size=12, color=Palette.Text), + ] + + if Entry.is_dir(): + try: + ItemCount = sum(1 for _ in Entry.iterdir()) + Rows.append(ft.Text(f"Contains: {ItemCount} item(s)", size=12, color=Palette.Text)) + except OSError: + Rows.append(ft.Text("Contains: can't read (permission denied)", size=12, color=Palette.Overlay0)) + else: + Rows.append(ft.Text(f"Size: {HumanSize(Stat.st_size)}", size=12, color=Palette.Text)) + + Dialog = ft.AlertDialog( + title="Properties", + content=ft.Column(Rows, tight=True, width=360, spacing=8), + actions=[ + ft.OutlinedButton(content="Close", style=ChipButtonStyle(), on_click=lambda e: self._DismissDialog()), + ], + ) + self._ShowDialog(Dialog) + + def _ShowAggregatePropertiesDialog(self, Entries: list[Path]) -> None: + TotalSize = 0 + FileCount = 0 + DirCount = 0 + Unreadable = 0 + for Entry in Entries: + try: + if Entry.is_dir(): + DirCount += 1 + for Sub in Entry.rglob("*"): + try: + if Sub.is_file(): + TotalSize += Sub.stat().st_size + except OSError: + Unreadable += 1 + else: + FileCount += 1 + TotalSize += Entry.stat().st_size + except OSError: + Unreadable += 1 + + Rows: list[ft.Control] = [ + ft.Text(f"{len(Entries)} items selected", weight=ft.FontWeight.BOLD, size=16), + ft.Divider(height=1, color=Palette.Surface1), + ft.Text(f"Files: {FileCount} Directories: {DirCount}", size=12, color=Palette.Text), + ft.Text(f"Total size: {HumanSize(TotalSize)}", size=12, color=Palette.Text), + ] + if Unreadable: + Rows.append( + ft.Text(f"{Unreadable} item(s) couldn't be read (permission denied).", size=12, color=Palette.Overlay0) + ) + + Dialog = ft.AlertDialog( + title="Properties", + content=ft.Column(Rows, tight=True, width=360, spacing=8), + actions=[ + ft.OutlinedButton(content="Close", style=ChipButtonStyle(), on_click=lambda e: self._DismissDialog()), + ], + ) + self._ShowDialog(Dialog) + + def _HandleExtract(self, Entry: Path) -> None: + ArchivePaths = [P for P in self._TargetSelection(Entry) if _IsArchive(P)] + if not ArchivePaths: + return + self._ShowExtractDialog(ArchivePaths) + + def _ShowExtractDialog(self, ArchivePaths: list[Path]) -> None: + DestField = ft.TextField(value=str(self.CurrentPath), autofocus=True, label="Extract to") + DeleteSwitch = ft.Switch( + label="Delete archive(s) after extracting", value=self.Config.DeleteZipAfterExtract + ) + + def DoExtract(e=None) -> None: + DestDir = Path(DestField.value.strip() or str(self.CurrentPath)) + self._DismissDialog() + try: + DestDir.mkdir(parents=True, exist_ok=True) + except OSError as Error: + self._Notify(f"Couldn't create destination: {Error}") + return + + Failures: list[str] = [] + for ArchivePath in ArchivePaths: + try: + if ArchivePath.name.lower().endswith(".zip"): + with zipfile.ZipFile(ArchivePath) as Archive: + Archive.extractall(DestDir) + else: + with tarfile.open(ArchivePath, "r:*") as Archive: + Archive.extractall(DestDir, filter="data") + except (OSError, zipfile.BadZipFile, tarfile.TarError) as Error: + Failures.append(f"{ArchivePath.name}: {Error}") + continue + if DeleteSwitch.value: + try: + send2trash.send2trash(ArchivePath) + except OSError as Error: + Failures.append(f"{ArchivePath.name} (delete): {Error}") + + if Failures: + self._Notify("Extract had problems: " + "; ".join(Failures)) + else: + self._Notify(f"Extracted {len(ArchivePaths)} archive(s).", Kind="success") + self._RefreshEntries() + + Title = "Extract" if len(ArchivePaths) == 1 else f"Extract {len(ArchivePaths)} archives" + Dialog = ft.AlertDialog( + title=Title, + content=ft.Column([DestField, DeleteSwitch], tight=True, width=380, spacing=12), + actions=[ + ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: self._DismissDialog()), + ft.OutlinedButton(content="Extract", style=ChipButtonStyle(), on_click=DoExtract), + ], + ) + self._ShowDialog(Dialog) + + def _HandleCompress(self, Entry: Path) -> None: + self._ShowCompressDialog(self._TargetSelection(Entry)) + + def _AddPathToZip(self, Archive: zipfile.ZipFile, Target: Path) -> None: + if not Target.is_dir(): + Archive.write(Target, arcname=Target.name) + return + Archive.write(Target, arcname=Target.name + "/") + for Sub in Target.rglob("*"): + ArcName = str(Path(Target.name) / Sub.relative_to(Target)) + Archive.write(Sub, arcname=ArcName + "/" if Sub.is_dir() else ArcName) + + def _ShowCompressDialog(self, Targets: list[Path]) -> None: + """Creates a new archive alongside the selection (in CurrentPath) — + format defaults from Config.DefaultArchiveFormat (Bugs/Major: "Cannot + zip directories... Default should be in config... Either zip or + tar.gz").""" + DefaultName = Targets[0].stem if len(Targets) == 1 else "Archive" + NameField = ft.TextField(value=DefaultName, autofocus=True, label="Archive name") + FormatDropdown = ft.Dropdown( + value=self.Config.DefaultArchiveFormat, + label="Format", + options=[ + ft.DropdownOption(key="zip", text="Zip (.zip)"), + ft.DropdownOption(key="targz", text="Tar.gz (.tar.gz)"), + ], + ) + + def DoCompress(e=None) -> None: + Format = FormatDropdown.value or "zip" + BaseName = NameField.value.strip() or DefaultName + Extension = ".zip" if Format == "zip" else ".tar.gz" + Destination = self._UniqueDestination(self.CurrentPath / f"{BaseName}{Extension}") + self._DismissDialog() + try: + if Format == "zip": + with zipfile.ZipFile(Destination, "w", zipfile.ZIP_DEFLATED) as Archive: + for Target in Targets: + self._AddPathToZip(Archive, Target) + else: + with tarfile.open(Destination, "w:gz") as Archive: + for Target in Targets: + Archive.add(Target, arcname=Target.name) + except OSError as Error: + self._Notify(f"Couldn't create archive: {Error}") + return + self._Notify(f'Created "{Destination.name}".', Kind="success") + self._RefreshEntries() + + Dialog = ft.AlertDialog( + title=f"Compress {len(Targets)} item(s)" if len(Targets) > 1 else "Compress", + content=ft.Column([NameField, FormatDropdown], tight=True, width=360, spacing=12), + actions=[ + ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: self._DismissDialog()), + ft.OutlinedButton(content="Compress", style=ChipButtonStyle(), on_click=DoCompress), + ], + ) + NameField.on_submit = DoCompress + self._ShowDialog(Dialog) + + def _ShowAddBookmarkDialog(self, TargetPath: Path) -> None: + NameField = ft.TextField(value=TargetPath.name, autofocus=True, label="Name") + IconDropdown = ft.Dropdown( + value=DefaultBookmarkIcon, + label="Icon", + options=[ft.DropdownOption(key=Name, text=Name.split("_")[0].title()) for Name in BookmarkIconChoices], + ) + + def DoAdd(e=None) -> None: + self._DismissDialog() + Name = NameField.value.strip() or TargetPath.name + AddBookmark(Name, TargetPath, IconDropdown.value or DefaultBookmarkIcon) + if self.OnBookmarksChanged: + self.OnBookmarksChanged() + self._Notify(f'Added "{Name}" to Bookmarks.', Kind="success") + + Dialog = ft.AlertDialog( + title="Add to Bookmarks", + content=ft.Column([NameField, IconDropdown], tight=True), + actions=[ + ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: self._DismissDialog()), + ft.OutlinedButton(content="Add", style=ChipButtonStyle(), on_click=DoAdd), + ], + ) + self._ShowDialog(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, + ) + ) diff --git a/Core.py b/Core.py new file mode 100644 index 0000000..d4be8d4 --- /dev/null +++ b/Core.py @@ -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 diff --git a/Devices.py b/Devices.py new file mode 100644 index 0000000..129acb5 --- /dev/null +++ b/Devices.py @@ -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/, 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/ 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 diff --git a/Filetree.py b/Filetree.py new file mode 100644 index 0000000..33c3373 --- /dev/null +++ b/Filetree.py @@ -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 diff --git a/Main.py b/Main.py new file mode 100644 index 0000000..c83b26d --- /dev/null +++ b/Main.py @@ -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) diff --git a/Packaging/Launch.sh b/Packaging/Launch.sh new file mode 100755 index 0000000..514065b --- /dev/null +++ b/Packaging/Launch.sh @@ -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 "$@" diff --git a/Packaging/SaraFem.desktop b/Packaging/SaraFem.desktop new file mode 100644 index 0000000..272b4d2 --- /dev/null +++ b/Packaging/SaraFem.desktop @@ -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 diff --git a/Packaging/SaraFem.portal b/Packaging/SaraFem.portal new file mode 100644 index 0000000..a2405a8 --- /dev/null +++ b/Packaging/SaraFem.portal @@ -0,0 +1,4 @@ +[portal] +DBusName=org.freedesktop.impl.portal.desktop.SaraFem +Interfaces=org.freedesktop.impl.portal.FileChooser; +UseIn=COSMIC diff --git a/Packaging/appimage/AppDir/.DirIcon b/Packaging/appimage/AppDir/.DirIcon new file mode 120000 index 0000000..0558a35 --- /dev/null +++ b/Packaging/appimage/AppDir/.DirIcon @@ -0,0 +1 @@ +sarafem.png \ No newline at end of file diff --git a/Packaging/appimage/AppDir/AppRun b/Packaging/appimage/AppDir/AppRun new file mode 120000 index 0000000..4e5d6dd --- /dev/null +++ b/Packaging/appimage/AppDir/AppRun @@ -0,0 +1 @@ +usr/bin/SaraFem \ No newline at end of file diff --git a/Packaging/appimage/AppDir/SaraFem.desktop b/Packaging/appimage/AppDir/SaraFem.desktop new file mode 100644 index 0000000..65a0fbf --- /dev/null +++ b/Packaging/appimage/AppDir/SaraFem.desktop @@ -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 diff --git a/Packaging/appimage/AppDir/sarafem.png b/Packaging/appimage/AppDir/sarafem.png new file mode 100644 index 0000000..600c02f Binary files /dev/null and b/Packaging/appimage/AppDir/sarafem.png differ diff --git a/Packaging/appimage/Icon.png b/Packaging/appimage/Icon.png new file mode 100644 index 0000000..600c02f Binary files /dev/null and b/Packaging/appimage/Icon.png differ diff --git a/Packaging/appimage/Install.sh b/Packaging/appimage/Install.sh new file mode 100755 index 0000000..9abbb2c --- /dev/null +++ b/Packaging/appimage/Install.sh @@ -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/-portals.conf, containing:" + echo " [preferred]" + echo " org.freedesktop.impl.portal.FileChooser=SaraFem;;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" diff --git a/Packaging/appimage/SaraFem.portal b/Packaging/appimage/SaraFem.portal new file mode 100644 index 0000000..a2405a8 --- /dev/null +++ b/Packaging/appimage/SaraFem.portal @@ -0,0 +1,4 @@ +[portal] +DBusName=org.freedesktop.impl.portal.desktop.SaraFem +Interfaces=org.freedesktop.impl.portal.FileChooser; +UseIn=COSMIC diff --git a/Packaging/org.freedesktop.impl.portal.desktop.SaraFem.service b/Packaging/org.freedesktop.impl.portal.desktop.SaraFem.service new file mode 100644 index 0000000..3062871 --- /dev/null +++ b/Packaging/org.freedesktop.impl.portal.desktop.SaraFem.service @@ -0,0 +1,3 @@ +[D-BUS Service] +Name=org.freedesktop.impl.portal.desktop.SaraFem +Exec=/home/sarah/Applications/SaraFem/Packaging/Launch.sh --portal-service diff --git a/Picker.py b/Picker.py new file mode 100644 index 0000000..ef9028d --- /dev/null +++ b/Picker.py @@ -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) diff --git a/PortalService.py b/PortalService.py new file mode 100644 index 0000000..47637f1 --- /dev/null +++ b/PortalService.py @@ -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) diff --git a/Preview.py b/Preview.py new file mode 100644 index 0000000..6155a00 --- /dev/null +++ b/Preview.py @@ -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), + ] diff --git a/SaraFem.spec b/SaraFem.spec new file mode 100644 index 0000000..40a3a9b --- /dev/null +++ b/SaraFem.spec @@ -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'], +) diff --git a/Settings.py b/Settings.py new file mode 100644 index 0000000..0a416ff --- /dev/null +++ b/Settings.py @@ -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), + ], + ) diff --git a/Sidebar.py b/Sidebar.py new file mode 100644 index 0000000..4d2c58c --- /dev/null +++ b/Sidebar.py @@ -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) diff --git a/Tags.py b/Tags.py new file mode 100644 index 0000000..9ca22ba --- /dev/null +++ b/Tags.py @@ -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)] diff --git a/Thumbnails.py b/Thumbnails.py new file mode 100644 index 0000000..4dcb089 --- /dev/null +++ b/Thumbnails.py @@ -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 diff --git a/assets/Icon.png b/assets/Icon.png new file mode 100644 index 0000000..600c02f Binary files /dev/null and b/assets/Icon.png differ diff --git a/assets/fonts/Fixedsys_Excelsior_Regular.ttf b/assets/fonts/Fixedsys_Excelsior_Regular.ttf new file mode 100644 index 0000000..a3dbddd Binary files /dev/null and b/assets/fonts/Fixedsys_Excelsior_Regular.ttf differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8935d41 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +flet>=1.0,<2.0 +pillow +send2trash +toml +dbus-next