"""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, OnToggleSidebar: 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 self.OnToggleSidebar = OnToggleSidebar # Ctrl+B; the sidebar lives in Main.py, not here # 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.""" if not self.Config.ShowBreadcrumbs: # Alone, the toolbar sits straight in the file column, where # `expand` means height, not width: with it, the toolbar took half # the window. The column already stretches it to full width. return ft.Container(self.ActionsToolbar, padding=8) ToolbarContainer = ft.Container(self.ActionsToolbar, padding=8, expand=1) 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._TagsShown(): 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._TagsShown(): self.TagPanel.ShowPath(ShowPath) def _TagsShown(self) -> bool: """Whether the tags panel is actually in the window. Tags placed "under_preview" live in the preview column, so hiding the preview (Ctrl+P) hides them too even with ShowTags on; updating the panel then crashed with "TagPanel must be added to the page first".""" return self.Config.ShowTags and (self.Config.TagsPosition != "under_preview" or self.Config.ShowPreview) 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 and Event.shift: self._ToggleBreadcrumbs() elif Event.key == "B" and Event.ctrl: if self.OnToggleSidebar: self.OnToggleSidebar() 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, ) )