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.
This commit is contained in:
sarah committed 2026-09-29 19:39:33 +02:00
1 parent 65f5b89908
commit 6c48796dde
2 files changed
+94 -26

No files matched your search

+72 -26
View File
@@ -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))
+22
View File
@@ -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"