"""Main file listing / navigation view: browsing, selection, keybinds, file ops, plus the breadcrumbs/preview/dotfiles toggles from Config.toml.""" import asyncio import ctypes import os import pwd import shutil import subprocess import tarfile import time import zipfile from datetime import datetime from pathlib import Path from typing import Callable, Optional import flet as ft import send2trash import toml from PIL import Image from Bookmarks import AddBookmark from Bookmarks import DefaultIcon as DefaultBookmarkIcon 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, ) from Preview import HumanSize, Preview, TextExtensions from SaraSortdInfo import GetSortRules 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) def _EntryDescription(Entry: Path) -> str: if Entry.is_dir(): return "Folder" Ext = Entry.suffix.lstrip(".").upper() return f"{Ext} File" if Ext else "File" def _DirectorySize(Entry: Path) -> int: Total = 0 try: for Sub in Entry.rglob("*"): try: if Sub.is_file(): Total += Sub.stat().st_size except OSError: continue # unreadable entry — skip it, don't fail the whole size except OSError: pass # permission denied walking the top directory itself return Total def _EntrySize(Entry: Path) -> str: try: if Entry.is_dir(): return HumanSize(_DirectorySize(Entry)) return HumanSize(Entry.stat().st_size) except OSError: return "" def _EntryModified(Entry: Path) -> str: try: return datetime.fromtimestamp(Entry.stat().st_mtime).strftime("%d/%m/%y %H:%M") except OSError: return "" def _EntryOwner(Entry: Path) -> str: try: return pwd.getpwuid(Entry.stat().st_uid).pw_name except (OSError, KeyError): return "" # --- Created (statx birth time) ------------------------------------------- # Plain os.stat() has no birth time on Linux (st_ctime is metadata-change # time, not creation time) — the statx(2) syscall does, on filesystems that # record it (btrfs, ext4, xfs...), so it's read directly via ctypes rather # than falling back to a value that would just be wrong. class _StatxTimestamp(ctypes.Structure): _fields_ = [("tv_sec", ctypes.c_int64), ("tv_nsec", ctypes.c_uint32), ("__reserved", ctypes.c_int32)] class _StatxStruct(ctypes.Structure): _fields_ = [ ("stx_mask", ctypes.c_uint32), ("stx_blksize", ctypes.c_uint32), ("stx_attributes", ctypes.c_uint64), ("stx_nlink", ctypes.c_uint32), ("stx_uid", ctypes.c_uint32), ("stx_gid", ctypes.c_uint32), ("stx_mode", ctypes.c_uint16), ("__spare0", ctypes.c_uint16), ("stx_ino", ctypes.c_uint64), ("stx_size", ctypes.c_uint64), ("stx_blocks", ctypes.c_uint64), ("stx_attributes_mask", ctypes.c_uint64), ("stx_atime", _StatxTimestamp), ("stx_btime", _StatxTimestamp), ("stx_ctime", _StatxTimestamp), ("stx_mtime", _StatxTimestamp), ("stx_rdev_major", ctypes.c_uint32), ("stx_rdev_minor", ctypes.c_uint32), ("stx_dev_major", ctypes.c_uint32), ("stx_dev_minor", ctypes.c_uint32), ("stx_mnt_id", ctypes.c_uint64), ("stx_dio_mem_align", ctypes.c_uint32), ("stx_dio_offset_align", ctypes.c_uint32), ("__spare3", ctypes.c_uint64 * 12), ] _AT_FDCWD = -100 _STATX_BTIME = 0x800 try: _Libc = ctypes.CDLL("libc.so.6", use_errno=True) except OSError: _Libc = None def _EntryCreated(Entry: Path) -> str: if _Libc is None: return "" Buf = _StatxStruct() try: Result = _Libc.statx(_AT_FDCWD, str(Entry).encode(), 0, _STATX_BTIME, ctypes.byref(Buf)) except (AttributeError, OSError): return "" # older glibc/kernel without statx(), or the call itself failed if Result != 0 or not (Buf.stx_mask & _STATX_BTIME) or Buf.stx_btime.tv_sec == 0: return "" # filesystem doesn't record birth time (e.g. some tmpfs/network mounts) return datetime.fromtimestamp(Buf.stx_btime.tv_sec).strftime("%d/%m/%y %H:%M") def _EntryContent(Entry: Path) -> str: try: if Entry.is_dir(): Count = sum(1 for _ in Entry.iterdir()) return f"{Count} item{'s' if Count != 1 else ''}" if IsThumbnailable(Entry): with Image.open(Entry) as Img: return f"{Img.width}x{Img.height}" if Entry.suffix.lower() in TextExtensions: with Entry.open("r", encoding="utf-8", errors="replace") as F: Lines = sum(1 for _ in F) return f"{Lines} line{'s' if Lines != 1 else ''}" except OSError: pass return "" def _EntryAccess(Entry: Path) -> str: Letters = "" if os.access(Entry, os.R_OK): Letters += "r" if os.access(Entry, os.W_OK): Letters += "w" if os.access(Entry, os.X_OK): Letters += "x" return Letters # Shared between _BuildListRow and _BuildListHeader so the two can't drift # out of sync — (Config flag, column label, value function). ListColumns = ( ("ListShowDescription", "Description", _EntryDescription), ("ListShowSize", "Size", _EntrySize), ("ListShowModified", "Modified", _EntryModified), ("ListShowOwner", "Owner", _EntryOwner), ("ListShowCreated", "Created", _EntryCreated), ("ListShowContent", "Content", _EntryContent), ("ListShowAccess", "Access", _EntryAccess), ) 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] = {} # A column resize used to rebuild every row from scratch on release # (recomputing metadata — a recursive dir-size walk, line-counting a # text file, etc. — and reconstructing every Container in a folder # that could have thousands of entries) — the lag was real. Tracked # here instead: OnDrag mutates every already-built cell's .width # directly (cheap — no metadata recompute, no rebuild), and # OnDragEnd just persists the number, nothing else. Reset each # _BuildEntries() call; cleared on navigation since a new directory's # entries make the cache meaningless. self.ColumnCells: dict[str, list[ft.Container]] = {} self.MetadataCache: dict[Path, tuple[float, dict[str, str]]] = {} 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(), ) # Bugs/Major: "Toolbar buttons get cut off due to 50% of both sides" — # scrolls instead of clipping once there isn't room for every button. # expand=True gives it a real viewport width to scroll within (its own # Container already gives it one); alignment=END keeps buttons at the # right edge, same as before, without needing a spacer child that # conflicts with scrolling on the same axis (see _BuildActionsToolbar). self.ActionsToolbar = ft.Row( self._BuildActionsToolbar(), scroll=ft.ScrollMode.AUTO, alignment=ft.MainAxisAlignment.END, expand=True, ) self.EntryList = self._MakeEntryListContainer() # SaraSortd integration (Features/Major): "*.png -> ~/Pictures" style # lines for the currently-browsed directory. Mutated in place by # _RefreshSortBanner on every NavigateTo, rather than rebuilt as part # of _BuildChrome, since a plain directory change doesn't otherwise # touch FileListColumn.controls. self.SortBanner = ft.Container( visible=False, padding=ft.Padding.symmetric(horizontal=12, vertical=6), bgcolor=Palette.Surface0, border=ft.Border.all(1, Palette.Surface1), content=ft.Column([], spacing=2), ) # 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._BuildEntryListContent(), 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. No # single fixed height to drag-resize here, so no divider. self.PreviewPane.expand = 1 self.TagPanel.expand = 1 StackItems: list[ft.Control] = [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). # Wrapped explicitly (not via ApplyHeightBounds, which skips # the wrapper when no bound is set yet) so the divider always # has a stable Container to resize. PreviewUnbounded = not (self.Config.PreviewMinHeight or self.Config.PreviewMaxHeight) PreviewBox = ft.Container( content=self.PreviewPane, height=self.Config.PreviewMaxHeight or self.Config.PreviewMinHeight or None, expand=1 if PreviewUnbounded else False, ) TagsBox = ft.Container( content=self.TagPanel, height=self.Config.TagPanelMaxHeight or self.Config.TagPanelMinHeight or None, ) StackItems = [PreviewBox, self._BuildPreviewTagsDivider(PreviewBox), TagsBox] # 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( StackItems, spacing=0, width=self.Config.PreviewWidth, horizontal_alignment=ft.CrossAxisAlignment.STRETCH, ) Divider = self._BuildPanelWidthDivider(PreviewBlock) if self.Config.PreviewSide == "left": return [PreviewBlock, Divider, self.FileListColumn] return [self.FileListColumn, Divider, PreviewBlock] def _BuildPanelWidthDivider(self, Block: ft.Control) -> ft.Control: """Between the file list and the preview panel, resizing the panel (Block) — a Container's own child stretch if the same self.PreviewPane alone, or the Column wrapping it and Tags together — not the file list, matching Main.py's sidebar-width divider, which likewise only ever touches the sidebar, never the file list on its other side.""" def OnDrag(Delta: float) -> None: # PreviewSide "right" puts the divider on Preview's *left*, so # dragging right (growing the file list) has to shrink Preview, # not grow it — the sign flips relative to the "left" case. Signed = -Delta if self.Config.PreviewSide == "right" else Delta NewWidth = max(120, (Block.width or self.Config.PreviewWidth) + Signed) Block.width = NewWidth self.PreviewPane.width = NewWidth Block.update() def OnDragEnd() -> None: self.Config.PreviewWidth = int(Block.width) self.Config.Save() # Length=None: this Row (Browser itself) already has # vertical_alignment=STRETCH — confirmed, see its __init__ — so an # explicit height here would fight that instead of matching it. return ResizeHandle("horizontal", None, OnDrag, OnDragEnd) def _BuildPreviewTagsDivider(self, PreviewBox: ft.Container) -> ft.Control: """Resizes Preview (the box above the divider); Tags reflows into whatever's left, same convention as the other dividers.""" def OnDrag(Delta: float) -> None: Current = PreviewBox.height or 200 PreviewBox.height = max(40, Current + Delta) PreviewBox.expand = False # dragging commits to a fixed height, same as typing one in Options self.update() # expand flipping off needs the parent Column to re-flow, not just this box def OnDragEnd() -> None: self.Config.PreviewMaxHeight = int(PreviewBox.height) self.Config.Save() return ResizeHandle("vertical", self.Config.PreviewWidth, OnDrag, OnDragEnd) def _BuildToolbarBar(self) -> ft.Control: """Breadcrumbs and the button toolbar are independently toggleable (Features/Minor: "remove ONLY toolbar, not toolbar and breadcrumbs") and, shown together, split the bar 50/50 — 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) if not self.Config.ShowToolbar: return ft.Container(self.Breadcrumbs, 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) ShowBar = self.Config.ShowToolbar or self.Config.ShowBreadcrumbs if ShowBar 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(self.SortBanner) 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 ShowBar 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]: # No leading expand=True spacer to push buttons right: `expand` needs # a bounded width to expand into, which conflicts with ActionsToolbar # now being horizontally scrollable (Bugs/Major, see there) — an # unbounded/scrollable axis and an expanding child on that same axis # don't mix. Right-alignment happens one level up instead, via the # Container ActionsToolbar sits in. if self.Config.ToolbarHamburger: return self._BuildHamburgerToolbar() Items: list[ft.Control] = [] 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 _BuildHamburgerToolbar(self) -> list[ft.Control]: """Same buttons, collapsed into one overflow menu (Features/Minor: "Hamburger menu option instead of buttons for toolbar").""" Items: list[ft.PopupMenuItem] = [] if self.Config.ShowNewDirectoryButton: Items.append( ft.PopupMenuItem( content="New Directory", icon=ft.Icons.CREATE_NEW_FOLDER_OUTLINED, on_click=lambda e: self._ShowNewDirectoryDialog(), ) ) if self.Config.ShowPasteButton: Items.append( ft.PopupMenuItem(content="Paste", icon=ft.Icons.CONTENT_PASTE_OUTLINED, on_click=lambda e: self._Paste()) ) if self.Config.ShowViewToggleButton: Items.append( ft.PopupMenuItem( 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, on_click=lambda e: self._ToggleViewMode(), ) ) if self.Config.ShowOptionsButton: Items.append( ft.PopupMenuItem( content="Options", icon=ft.Icons.SETTINGS_OUTLINED, on_click=lambda e: self._ShowSettingsDialog() ) ) if not Items: return [] return [ft.PopupMenuButton(icon=ft.Icons.MENU, items=Items)] def RefreshToolbarVisibility(self) -> None: self.ActionsToolbar.controls = self._BuildActionsToolbar() self.ActionsToolbar.update() def _MakeEntryListContainer(self) -> ft.Control: if self.Config.ViewMode == "grid": MaxExtent = self.Config.GridIconSize + 56 # room for label + padding around the icon return ft.GridView(expand=True, max_extent=MaxExtent, child_aspect_ratio=0.85, spacing=8, run_spacing=8) return ft.ListView(expand=True, spacing=2) def _BuildEntryListContent(self) -> ft.Control: """What EntryContextMenu wraps: self.EntryList (the ListView/GridView itself, whose .controls _RefreshEntries repopulates) alone in grid mode, or with the column header above it in list mode.""" if self.Config.ViewMode == "list": return ft.Column([self._BuildListHeader(), self.EntryList], spacing=0, expand=True) return self.EntryList 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.RefreshToolbarVisibility() # the hamburger menu's "Grid"/"List" label needs the same refresh self.EntryList = self._MakeEntryListContainer() self.EntryContextMenu.content = self._BuildEntryListContent() 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.MetadataCache = {} # a new directory's entries make the old cache meaningless 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._RefreshSortBanner() self._RefreshEntries() def _RefreshSortBanner(self) -> None: Lines = GetSortRules(self.CurrentPath, self.Config) if not Lines: self.SortBanner.visible = False return self.SortBanner.visible = True self.SortBanner.content = ft.Column( [ ft.Row( [ft.Icon(ft.Icons.SYNC_ALT, size=14, color=Palette.Mauve), ft.Text(Line, size=self.MetaFontSize, color=Palette.Text)], spacing=6, ) for Line in Lines ], spacing=2, ) 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 _CachedColumnValues(self, Entry: Path) -> dict[str, str]: """Computes each enabled metadata column's value once per (Entry, mtime) — a file's own edits still invalidate its cache entry, but a pure column-width resize or an unrelated refresh (toggling dotfiles, creating a sibling file, ...) reuses what's already known instead of re-walking directories / re-counting lines / re-reading image dimensions for every row again.""" try: Mtime = Entry.stat().st_mtime except OSError: Mtime = 0.0 Cached = self.MetadataCache.get(Entry) if Cached is not None and Cached[0] == Mtime: return Cached[1] Values = { ColumnName: ValueFn(Entry) for ConfigFlag, ColumnName, ValueFn in ListColumns if getattr(self.Config, ConfigFlag) } self.MetadataCache[Entry] = (Mtime, Values) return Values def _BuildEntries(self, DirPath: Path) -> list[ft.Control]: self.EntryTiles = {} self.ColumnCells = {} 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] MinColumnWidth = 40 # floor for header-divider drags, so a column can't vanish @property def MetaFontSize(self) -> int: """Secondary text (metadata columns, the header) scales with the configurable name font size but stays a couple sizes smaller, same visual hierarchy as the fixed 14/12 before it was configurable.""" return max(9, self.Config.FontSize - 2) @property def ListHeaderHeight(self) -> int: """Text line height + 2*6px vertical padding — see _BuildListHeader. Derived from MetaFontSize (not fixed) so the header's own resize divider still matches its actual height at any FontSize.""" return self.MetaFontSize + 16 def _MetadataCell(self, Text: str, Width: int) -> tuple[ft.Container, ft.GestureDetector]: """A fixed-width metadata column (Size/Modified/...): left-click selects the row like Name does, but right-click behaves like background, not the entry's own menu ("the values of the other things than name shouldn't be right-clickable... it should behave like you're right-clicking the background").""" Cell = ft.Container( content=ft.Text(Text, size=self.MetaFontSize, color=Palette.Overlay0, overflow=ft.TextOverflow.ELLIPSIS), width=Width, padding=ft.Padding.symmetric(horizontal=8, vertical=8), ) Wrapped = ft.GestureDetector( content=Cell, on_secondary_tap_down=lambda e: self._RequestMenu(None, e), ) return Cell, Wrapped def _BuildListRow(self, Entry: Path) -> ft.Control: # Fixed-width columns, not a full-width ListTile or content-hugging # Name: a ListTile spanning the whole list left no empty space to # click as "background" (deselect, right-click for New File/New # Directory/Paste); content-hugging Name instead left Description/ # Size/Modified unaligned between rows. Fixed widths solve both — # they line up like the reference screenshot, and whatever's left # after the last shown column, before the triple-dot (still pinned # right, as before), is the background gap. NameLabel = ft.Text( Entry.name, size=self.Config.FontSize, color=Palette.Text, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1, ) NameCell = ft.Container( content=ft.Row([self._LeadingFor(Entry, Size=self.Config.ListIconSize), NameLabel], spacing=12), padding=ft.Padding.symmetric(horizontal=12, vertical=8), bgcolor=Palette.Surface0 if Entry in self.SelectedPaths else None, width=self.Config.ListWidthName, ) self.ColumnCells.setdefault("Name", []).append(NameCell) HighlightCells = [NameCell] MetaCells: list[ft.Control] = [] CachedValues = self._CachedColumnValues(Entry) for ConfigFlag, ColumnName, _ValueFn in ListColumns: if getattr(self.Config, ConfigFlag): Width = getattr(self.Config, f"ListWidth{ColumnName}") Cell, Wrapped = self._MetadataCell(CachedValues[ColumnName], Width) self.ColumnCells.setdefault(ColumnName, []).append(Cell) HighlightCells.append(Cell) MetaCells.append(Wrapped) def SetSelected(IsSelected: bool) -> None: for C in HighlightCells: C.bgcolor = Palette.Surface0 if IsSelected else None self.EntryTiles[Entry] = _EntryHandle(NameCell, SetSelected) return ft.Container( content=ft.Row( [ ft.Draggable( group=DragGroup, data=str(Entry), content=ft.GestureDetector( content=NameCell, on_tap=lambda e, p=Entry: self._HandleTap(p), on_secondary_tap_down=lambda e, p=Entry: self._RequestMenu(p, e), ), ), *MetaCells, # expand=True has to sit on this Container (a Row child # Flet actually flexes) to reach the far right edge, not # on the GestureDetector wrapping it — same class of # non-propagation as the invisible-rows bug earlier, just # the opposite direction (collapsed instead of blown up). ft.Container( content=ft.GestureDetector( content=ft.Container(bgcolor=Palette.Base, height=48), on_tap=lambda e: self.Select(None), on_secondary_tap_down=lambda e: self._RequestMenu(None, e), ), expand=True, ), self._EntryMenu(Entry), ], spacing=0, ), expand=True, ) def _BuildColumnResizeHandle(self, FieldName: str, Cell: ft.Container) -> ft.Control: """Sits between Cell and the next header cell — drags resize Cell's column (the one to the divider's left), matching how spreadsheet/ table column dividers work. Mutates the header cell plus every currently-built row's matching cell directly on every drag frame — no rebuild, so a folder with thousands of entries reflows live instead of only the header moving while rows sit frozen. OnDragEnd only has a number to persist; the rows are already showing it.""" ConfigAttr = f"ListWidth{FieldName}" def OnDrag(Delta: float) -> None: NewWidth = max(self.MinColumnWidth, Cell.width + Delta) Cell.width = NewWidth Cell.update() for RowCell in self.ColumnCells.get(FieldName, []): RowCell.width = NewWidth self.EntryList.update() # one batched update for every row cell touched above def OnDragEnd() -> None: setattr(self.Config, ConfigAttr, int(Cell.width)) self.Config.Save() return ResizeHandle("horizontal", self.ListHeaderHeight, OnDrag, OnDragEnd) def _BuildListHeader(self) -> ft.Control: Fields = [("Name", "Name", 12)] for ConfigFlag, ColumnName, _ValueFn in ListColumns: if getattr(self.Config, ConfigFlag): Fields.append((ColumnName, ColumnName, 8)) RowItems: list[ft.Control] = [] PreviousCell: Optional[ft.Container] = None PreviousField: Optional[str] = None for Label, FieldName, LeftPad in Fields: Cell = ft.Container( ft.Text(Label, size=self.MetaFontSize, color=Palette.Overlay0), width=getattr(self.Config, f"ListWidth{FieldName}"), padding=ft.Padding.only(left=LeftPad, right=8), ) if PreviousCell is not None: RowItems.append(self._BuildColumnResizeHandle(PreviousField, PreviousCell)) RowItems.append(Cell) PreviousCell, PreviousField = Cell, FieldName return ft.Container( ft.Row(RowItems, spacing=0), padding=ft.Padding.symmetric(vertical=6), bgcolor=Palette.Mantle, border=ft.Border(bottom=ft.BorderSide(1, Palette.Surface1)), ) def _BuildGridCell(self, Entry: Path) -> ft.Control: IconControl = self._LeadingFor(Entry, Size=self.Config.GridIconSize) NameControl = ft.Text( Entry.name, size=self.Config.FontSize, text_align=ft.TextAlign.CENTER, max_lines=2, overflow=ft.TextOverflow.ELLIPSIS, ) CellItems = [IconControl, NameControl] if self.Config.GridNameBelowIcon else [NameControl, IconControl] Cell = ft.Container( content=ft.Column(CellItems, 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="Copy Path", on_click=lambda e, p=Entry: self._HandleCopyPath(p)), 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 Event.shift and self.SelectedPaths: self._HandleCopyPath(self.SelectedPath) 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 _HandleCopyPath(self, Entry: Optional[Path]) -> None: if Entry is None: return Targets = self._TargetSelection(Entry) Text = "\n".join(str(P) for P in Targets) self.page.run_task(self._DoCopyPath, Text, len(Targets)) async def _DoCopyPath(self, Text: str, Count: int) -> None: await ft.Clipboard().set(Text) self._Notify(f"Copied {Count} path(s)." if Count > 1 else "Copied path.", Kind="success") 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: # Selects just the stem for a file (keeps the extension untouched by # typing, matching most file managers' rename convention); the whole # name for a directory, which has no extension to preserve. SelectEnd = len(Entry.stem) if not Entry.is_dir() else len(Entry.name) NameField = ft.TextField( value=Entry.name, autofocus=True, selection=ft.TextSelection(base_offset=0, extent_offset=SelectEnd) ) 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: # Pre-selected, not just pre-filled ("It's also kinda hard making a # folder") — typing immediately replaces "New Directory" instead of # needing to select/clear it first, while Enter with no changes still # gives a quick, sensibly-named folder. NameField = ft.TextField( value="New Directory", autofocus=True, selection=ft.TextSelection(base_offset=0, extent_offset=13) ) 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: # Selects just the "New File" stem, not ".txt" — matches how most # file managers pre-select a rename field, so typing a name keeps the # extension instead of requiring it to be retyped too. NameField = ft.TextField( value="New File.txt", autofocus=True, selection=ft.TextSelection(base_offset=0, extent_offset=8) ) 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 _MiddleTabSnapshot(self) -> tuple: """Every "Middle" tab setting that changes what the entry list/header/ grid cells look like — see _ShowSettingsDialog.""" return ( self.Config.ViewMode, *(getattr(self.Config, ConfigFlag) for ConfigFlag, _Name, _Fn in ListColumns), self.Config.GridIconSize, self.Config.GridNameBelowIcon, self.Config.ListIconSize, self.Config.FontSize, ) def _ShowSettingsDialog(self) -> None: # Captured so OnSave only pays for a rebuild when a Middle-tab # setting actually changed. MiddleSnapshot = self._MiddleTabSnapshot() def OnSave() -> None: self._DismissDialog() self.PreviewPane.width = self.Config.PreviewWidth self.RefreshToolbarVisibility() if self._MiddleTabSnapshot() != MiddleSnapshot: # Same rebuild _ToggleViewMode does — needed even when only a # column/grid setting changed, not the mode itself, since the # header and EntryList both need fresh content either way. 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._BuildEntryListContent() self._RefreshSortBanner() # a just-toggled Enabled/GlobalConfPath should take effect immediately 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") def OnExport(PathText: str) -> None: if not PathText: return try: ExportSettings(self.Config, Path(PathText)) except OSError as Error: self._Notify(f"Couldn't export settings: {Error}") return self._Notify("Settings exported.", Kind="success") def OnImport(PathText: str) -> None: if not PathText: return try: ImportSettings(self.Config, Path(PathText)) except (OSError, toml.TomlDecodeError) as Error: self._Notify(f"Couldn't import settings: {Error}") return # The open dialog's widgets still show the pre-import values — # closing it rather than leaving it open to a stale Save that # would immediately overwrite what was just imported. self._DismissDialog() self._Notify("Settings imported — reopen Options to see them, restart for Theme/Font/Title bar.", Kind="success") Dialog = BuildSettingsDialog( self.Config, OnSave=OnSave, OnDismiss=self._DismissDialog, OnExport=OnExport, OnImport=OnImport ) 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=IconLabelOverrides.get(Name, 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, ) )