mirror of
https://git.srhtnin.garden/sarah/SaraFem.git
synced 2026-10-13 18:18:11 +02:00
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:
1 parent
65f5b89908
commit
6c48796dde
2 files changed
+94
-26
No files matched your search
+72
-26
@@ -25,8 +25,8 @@ from Bookmarks import IconChoices as BookmarkIconChoices
|
|||||||
from Bookmarks import IconLabelOverrides
|
from Bookmarks import IconLabelOverrides
|
||||||
from Books import ClearSaraTagCache, IsInternalSaraTagFile
|
from Books import ClearSaraTagCache, IsInternalSaraTagFile
|
||||||
from Core import (
|
from Core import (
|
||||||
AppConfig, ApplyHeightBounds, ChipButtonStyle, DragGroup, ExportSettings, FormatBatchName, ImportSettings,
|
AppConfig, ApplyHeightBounds, ChipButtonStyle, DragGroup, ExportSettings, FormatBatchName, GetClipboardMode,
|
||||||
NotificationStyle, Palette, ResizeHandle,
|
ImportSettings, MoveIntoDirectory, NotificationStyle, Palette, ResizeHandle, SetClipboardMode,
|
||||||
)
|
)
|
||||||
from Editor import EditorView
|
from Editor import EditorView
|
||||||
from ImageView import ImageView
|
from ImageView import ImageView
|
||||||
@@ -282,14 +282,15 @@ class Browser(ft.Row):
|
|||||||
self.MenuOpenedAt = 0.0
|
self.MenuOpenedAt = 0.0
|
||||||
self.PendingMenu: Optional[tuple[Optional[Path], ft.Offset]] = None
|
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.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(
|
self.PasteButton = ft.OutlinedButton(
|
||||||
content="Paste",
|
content="Paste",
|
||||||
icon=ft.Icons.CONTENT_PASTE_OUTLINED,
|
icon=ft.Icons.CONTENT_PASTE_OUTLINED,
|
||||||
disabled=True,
|
|
||||||
style=ChipButtonStyle(),
|
style=ChipButtonStyle(),
|
||||||
on_click=lambda e: self._Paste(),
|
on_click=lambda e: self._Paste(),
|
||||||
)
|
)
|
||||||
@@ -1101,7 +1102,7 @@ class Browser(ft.Row):
|
|||||||
|
|
||||||
self.EntryTiles[Entry] = _EntryHandle(NameCell, SetSelected)
|
self.EntryTiles[Entry] = _EntryHandle(NameCell, SetSelected)
|
||||||
|
|
||||||
return ft.Container(
|
RowControl = ft.Container(
|
||||||
content=ft.Row(
|
content=ft.Row(
|
||||||
[
|
[
|
||||||
ft.Draggable(
|
ft.Draggable(
|
||||||
@@ -1133,6 +1134,19 @@ class Browser(ft.Row):
|
|||||||
),
|
),
|
||||||
expand=True,
|
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:
|
def _BuildColumnResizeHandle(self, FieldName: str, Cell: ft.Container) -> ft.Control:
|
||||||
"""Sits between Cell and the next header cell — drags resize Cell's
|
"""Sits between Cell and the next header cell — drags resize Cell's
|
||||||
@@ -1202,7 +1216,7 @@ class Browser(ft.Row):
|
|||||||
self.EntryTiles[Entry] = _EntryHandle(
|
self.EntryTiles[Entry] = _EntryHandle(
|
||||||
Cell, lambda v, C=Cell: setattr(C, "bgcolor", Palette.Surface0 if v else None)
|
Cell, lambda v, C=Cell: setattr(C, "bgcolor", Palette.Surface0 if v else None)
|
||||||
)
|
)
|
||||||
return ft.Draggable(
|
GridDraggable = ft.Draggable(
|
||||||
group=DragGroup,
|
group=DragGroup,
|
||||||
data=str(Entry),
|
data=str(Entry),
|
||||||
# Right-click applies to the whole Stack (including over the
|
# 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),
|
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):
|
def _LeadingFor(self, Entry: Path, Size: int = 32):
|
||||||
if Entry.is_dir():
|
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.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)
|
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]:
|
def _MenuItems(self, Entry: Path) -> list[ft.PopupMenuItem]:
|
||||||
"""Shared by the triple-dot button and the right-click context menu —
|
"""Shared by the triple-dot button and the right-click context menu —
|
||||||
they're required to show the same items. Handlers resolve single-vs-
|
they're required to show the same items. Handlers resolve single-vs-
|
||||||
@@ -1408,10 +1445,11 @@ class Browser(ft.Row):
|
|||||||
# --- Clipboard / paste --------------------------------------------------
|
# --- Clipboard / paste --------------------------------------------------
|
||||||
|
|
||||||
def _SetClipboard(self, EntryPaths: list[Path], Mode: str) -> None:
|
def _SetClipboard(self, EntryPaths: list[Path], Mode: str) -> None:
|
||||||
self.ClipboardPaths = EntryPaths
|
self.page.run_task(self._DoSetClipboard, EntryPaths, Mode)
|
||||||
self.ClipboardMode = Mode
|
|
||||||
self.PasteButton.disabled = False
|
async def _DoSetClipboard(self, EntryPaths: list[Path], Mode: str) -> None:
|
||||||
self.update()
|
await ft.Clipboard().set_files([str(P) for P in EntryPaths])
|
||||||
|
SetClipboardMode(Mode)
|
||||||
|
|
||||||
def _HandleCopyPath(self, Entry: Optional[Path]) -> None:
|
def _HandleCopyPath(self, Entry: Optional[Path]) -> None:
|
||||||
if Entry is 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")
|
self._Notify(f"Copied {Count} path(s)." if Count > 1 else "Copied path.", Kind="success")
|
||||||
|
|
||||||
def _Paste(self) -> None:
|
def _Paste(self) -> None:
|
||||||
if not self.ClipboardPaths:
|
self.page.run_task(self._DoPaste)
|
||||||
return
|
|
||||||
|
|
||||||
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:
|
if Missing:
|
||||||
self._Notify(f"{Missing[0].name} no longer exists.")
|
self._Notify(f"{Missing[0].name} no longer exists.")
|
||||||
self.ClipboardPaths = []
|
|
||||||
self.ClipboardMode = None
|
|
||||||
self.PasteButton.disabled = True
|
|
||||||
self.update()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
Failures: list[str] = []
|
Failures: list[str] = []
|
||||||
for SourcePath in self.ClipboardPaths:
|
for SourcePath in ClipboardPaths:
|
||||||
Destination = self._UniqueDestination(self.CurrentPath / SourcePath.name)
|
Destination = self._UniqueDestination(self.CurrentPath / SourcePath.name)
|
||||||
try:
|
try:
|
||||||
if self.ClipboardMode == "copy":
|
if Mode == "copy":
|
||||||
if SourcePath.is_dir():
|
if SourcePath.is_dir():
|
||||||
shutil.copytree(SourcePath, Destination)
|
shutil.copytree(SourcePath, Destination)
|
||||||
else:
|
else:
|
||||||
@@ -1451,11 +1498,10 @@ class Browser(ft.Row):
|
|||||||
except OSError as Error:
|
except OSError as Error:
|
||||||
Failures.append(f"{SourcePath.name}: {Error}")
|
Failures.append(f"{SourcePath.name}: {Error}")
|
||||||
|
|
||||||
if self.ClipboardMode == "cut":
|
# No clipboard-clearing after a cut-paste: a stale "cut" pointing at
|
||||||
self.ClipboardPaths = []
|
# files that just moved is already handled above (Missing), and
|
||||||
self.ClipboardMode = None
|
# trying to clear a clipboard another window might have since
|
||||||
self.PasteButton.disabled = True
|
# overwritten would just be racing it for no benefit.
|
||||||
|
|
||||||
if Failures:
|
if Failures:
|
||||||
self._Notify("Couldn't paste: " + "; ".join(Failures))
|
self._Notify("Couldn't paste: " + "; ".join(Failures))
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,28 @@ ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildThe
|
|||||||
CacheDir = Path.home() / ".cache" / "SaraFem"
|
CacheDir = Path.home() / ".cache" / "SaraFem"
|
||||||
ThumbnailCacheDir = CacheDir / "Thumbnails"
|
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
|
# Config moved from ~/.config/SaraFem/ into a SaraSuite-wide layout so other
|
||||||
# Sara* programs (SaraTag, SaraView) can eventually read from the same place.
|
# Sara* programs (SaraTag, SaraView) can eventually read from the same place.
|
||||||
SharedConfigDir = Path.home() / ".config" / "SaraSuite"
|
SharedConfigDir = Path.home() / ".config" / "SaraSuite"
|
||||||
|
|||||||
Reference in new issue
Block a user