From 6c48796dde01cfb89c440e618ab1b675bee401d4 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Tue, 29 Sep 2026 19:39:33 +0200 Subject: [PATCH] Add directory drop targets in the file list; fix Copy/Cut/Paste to use the real OS clipboard Drag-and-drop: directories in the middle file list (list and grid view) are now drop targets too, same MoveIntoDirectory sidebar bookmarks and the filetree already use. Clipboard: Copy/Cut/Paste now goes through Clipboard.set_files/get_files (the real OS clipboard) instead of private in-process Python state, so it works between separate SaraFem windows and interoperates with other apps. Cut-vs-copy has no OS clipboard equivalent, so that one bit lives in a small ~/.cache/SaraFem/ClipboardMode.txt sidecar every window reads fresh on pass. --- Browser.py | 98 +++++++++++++++++++++++++++++++++++++++--------------- Core.py | 22 ++++++++++++ 2 files changed, 94 insertions(+), 26 deletions(-) diff --git a/Browser.py b/Browser.py index 0af6bb0..5e5dd3f 100644 --- a/Browser.py +++ b/Browser.py @@ -25,8 +25,8 @@ from Bookmarks import IconChoices as BookmarkIconChoices from Bookmarks import IconLabelOverrides from Books import ClearSaraTagCache, IsInternalSaraTagFile from Core import ( - AppConfig, ApplyHeightBounds, ChipButtonStyle, DragGroup, ExportSettings, FormatBatchName, ImportSettings, - NotificationStyle, Palette, ResizeHandle, + AppConfig, ApplyHeightBounds, ChipButtonStyle, DragGroup, ExportSettings, FormatBatchName, GetClipboardMode, + ImportSettings, MoveIntoDirectory, NotificationStyle, Palette, ResizeHandle, SetClipboardMode, ) from Editor import EditorView from ImageView import ImageView @@ -282,14 +282,15 @@ class Browser(ft.Row): 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) + # No local disabled/enabled tracking any more — Copy/Cut/Paste live + # on the real OS clipboard now (see _DoSetClipboard/_DoPaste), shared + # across every SaraFem window and unreadable to check synchronously, + # so Paste just always looks clickable and says "Nothing to paste." + # if there's genuinely nothing there, same as most apps' Paste. self.PasteButton = ft.OutlinedButton( content="Paste", icon=ft.Icons.CONTENT_PASTE_OUTLINED, - disabled=True, style=ChipButtonStyle(), on_click=lambda e: self._Paste(), ) @@ -1101,7 +1102,7 @@ class Browser(ft.Row): self.EntryTiles[Entry] = _EntryHandle(NameCell, SetSelected) - return ft.Container( + RowControl = ft.Container( content=ft.Row( [ ft.Draggable( @@ -1133,6 +1134,19 @@ class Browser(ft.Row): ), expand=True, ) + if Entry.is_dir(): + # A directory row is both a drag source (like any other entry, so + # it can itself be moved elsewhere) and a drop target (so other + # entries can be dropped into it) — same MoveIntoDirectory Sidebar + # bookmarks and the filetree already use, just one more place to + # drop onto (Features/Major: "drag and drop... directories in + # folder you're in in middle"). + return ft.DragTarget( + group=DragGroup, + content=RowControl, + on_accept=lambda e, dest=Entry: self._OnEntryDroppedInList(e, dest), + ) + return RowControl def _BuildColumnResizeHandle(self, FieldName: str, Cell: ft.Container) -> ft.Control: """Sits between Cell and the next header cell — drags resize Cell's @@ -1202,7 +1216,7 @@ class Browser(ft.Row): self.EntryTiles[Entry] = _EntryHandle( Cell, lambda v, C=Cell: setattr(C, "bgcolor", Palette.Surface0 if v else None) ) - return ft.Draggable( + GridDraggable = ft.Draggable( group=DragGroup, data=str(Entry), # Right-click applies to the whole Stack (including over the @@ -1219,6 +1233,15 @@ class Browser(ft.Row): on_secondary_tap_down=lambda e, p=Entry: self._RequestMenu(p, e), ), ) + if Entry.is_dir(): + # Same drop-target treatment as the list view's directory rows — + # see _BuildListRow. + return ft.DragTarget( + group=DragGroup, + content=GridDraggable, + on_accept=lambda e, dest=Entry: self._OnEntryDroppedInList(e, dest), + ) + return GridDraggable def _LeadingFor(self, Entry: Path, Size: int = 32): if Entry.is_dir(): @@ -1229,6 +1252,20 @@ class Browser(ft.Row): 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 _OnEntryDroppedInList(self, Event: ft.DragTargetEvent, DestinationDir: Path) -> None: + """A file/folder dropped onto a directory row/cell in the file list + itself — the same MoveIntoDirectory Sidebar's bookmarks and the + filetree already use for their own drop targets (Features/Major: + "drag and drop... directories in folder you're in in middle").""" + SourcePath = Path(Event.src.data) + try: + MoveIntoDirectory(SourcePath, DestinationDir) + except OSError as Error: + self._Notify(str(Error)) + return + self._RefreshEntries() + self._Notify(f'Moved "{SourcePath.name}" to {DestinationDir.name or DestinationDir}.', Kind="success") + 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- @@ -1408,10 +1445,11 @@ class Browser(ft.Row): # --- Clipboard / paste -------------------------------------------------- def _SetClipboard(self, EntryPaths: list[Path], Mode: str) -> None: - self.ClipboardPaths = EntryPaths - self.ClipboardMode = Mode - self.PasteButton.disabled = False - self.update() + self.page.run_task(self._DoSetClipboard, EntryPaths, Mode) + + async def _DoSetClipboard(self, EntryPaths: list[Path], Mode: str) -> None: + await ft.Clipboard().set_files([str(P) for P in EntryPaths]) + SetClipboardMode(Mode) def _HandleCopyPath(self, Entry: Optional[Path]) -> None: if Entry is None: @@ -1425,23 +1463,32 @@ class Browser(ft.Row): self._Notify(f"Copied {Count} path(s)." if Count > 1 else "Copied path.", Kind="success") def _Paste(self) -> None: - if not self.ClipboardPaths: - return + self.page.run_task(self._DoPaste) - Missing = [P for P in self.ClipboardPaths if not P.exists()] + async def _DoPaste(self) -> None: + # The real OS clipboard (see _DoSetClipboard) — works whether the + # files were copied/cut from this same window, a different SaraFem + # window (each its own process), or another app entirely. Files + # copied from outside SaraFem have no cut/copy flag of their own, so + # GetClipboardMode's "copy" default is also the safe one: pasting + # never deletes something SaraFem itself didn't mark as cut. + Files = await ft.Clipboard().get_files() + if not Files: + self._Notify("Nothing to paste.") + return + ClipboardPaths = [Path(F) for F in Files] + Mode = GetClipboardMode() + + Missing = [P for P in 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: + for SourcePath in ClipboardPaths: Destination = self._UniqueDestination(self.CurrentPath / SourcePath.name) try: - if self.ClipboardMode == "copy": + if Mode == "copy": if SourcePath.is_dir(): shutil.copytree(SourcePath, Destination) else: @@ -1451,11 +1498,10 @@ class Browser(ft.Row): except OSError as Error: Failures.append(f"{SourcePath.name}: {Error}") - if self.ClipboardMode == "cut": - self.ClipboardPaths = [] - self.ClipboardMode = None - self.PasteButton.disabled = True - + # No clipboard-clearing after a cut-paste: a stale "cut" pointing at + # files that just moved is already handled above (Missing), and + # trying to clear a clipboard another window might have since + # overwritten would just be racing it for no benefit. if Failures: self._Notify("Couldn't paste: " + "; ".join(Failures)) diff --git a/Core.py b/Core.py index cd14975..95adfb0 100644 --- a/Core.py +++ b/Core.py @@ -48,6 +48,28 @@ ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildThe CacheDir = Path.home() / ".cache" / "SaraFem" ThumbnailCacheDir = CacheDir / "Thumbnails" +# Copy/Cut/Paste uses the real OS clipboard (Clipboard.set_files/get_files — +# see Browser._DoSetClipboard/_DoPaste) instead of private Python state, so +# it actually works between two separate SaraFem windows (each its own +# process) and interoperates with other apps' file managers. The one thing +# the OS file clipboard has no concept of is a cut-vs-copy flag, so that one +# bit lives here instead, in a tiny sidecar file every SaraFem window reads +# fresh on Paste rather than trusting its own possibly-stale local state. +ClipboardModePath = CacheDir / "ClipboardMode.txt" + + +def SetClipboardMode(Mode: str) -> None: + CacheDir.mkdir(parents=True, exist_ok=True) + ClipboardModePath.write_text(Mode) + + +def GetClipboardMode() -> str: + try: + Mode = ClipboardModePath.read_text().strip() + except OSError: + return "copy" + return Mode if Mode in ("copy", "cut") else "copy" + # 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"