Files
SaraFem/Browser.py
T
2026-10-09 14:30:59 +02:00

2431 lines
112 KiB
Python

"""Main file listing / navigation view: browsing, selection, keybinds, file ops,
plus the breadcrumbs/preview/dotfiles toggles from Config.toml."""
import asyncio
import ctypes
import fnmatch
import os
import sys
import re
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, FolderIconBand, FormatBatchName,
GetClipboardMode, ImportSettings, MoveIntoDirectory, NotificationStyle, Palette, ResizeHandle, SetClipboardMode,
)
from Editor import EditorView
from FolderIcons import FolderIconChoices, FolderIconLabelOverrides, FolderIconOverride, LoadFolderIconOverrides, SetFolderIconOverride
from ImageView import ImageView
from Preview import HumanSize, Preview, TextExtensions
from SaraSortdInfo import GetSortRules
from Settings import BuildSettingsDialog
from Tags import TagPanel
from Thumbnails import GetThumbnail, ImageExtensions, 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 _IsInAppViewable(EntryPath: Path) -> bool:
"""Everything Config.OpenFilesInApp can route to (Editor.py's EditorView
for text/config/markup, ImageView.py's ImageView for images)."""
Suffix = EntryPath.suffix.lower()
return Suffix in TextExtensions or Suffix in ImageExtensions
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:
# A directory's "size" used to mean a full recursive rglob() walk of
# every descendant file — right here, once per directory, every single
# time the parent folder was listed. That was most of Bugs/Major's
# "load times for list view are horrid": a folder with a few large
# subdirectories (node_modules, Pictures, ...) could block the whole
# list for seconds on every navigation. Real size is still available
# on demand via Properties (_ShowPropertiesDialog), which already pays
# that cost just once, for the one directory actually asked about —
# matching how Nautilus/Dolphin handle this by default.
try:
if Entry.is_dir():
return "—"
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 Entry.owner() # works on Windows too (Python 3.13+), unlike pwd
except (OSError, KeyError, NotImplementedError):
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:
# Windows (and macOS) record it in plain stat() as st_birthtime.
try:
BirthTime = getattr(Entry.stat(), "st_birthtime", None)
except OSError:
BirthTime = None
if BirthTime:
return datetime.fromtimestamp(BirthTime).strftime("%d/%m/%y %H:%M")
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.FolderIconOverrides: dict[Path, FolderIconOverride] = LoadFolderIconOverrides()
# Bumped by _RefreshEntries every time it rebuilds the list, so a
# background _PopulateEntriesAsync pass from a superseded build can
# tell it's stale (navigated away, refreshed again) and stop instead
# of writing into a newer list's cells.
self.EntriesGeneration = 0
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.Breadcrumbs = ft.Row(spacing=0, wrap=True)
# No local disabled/enabled tracking any more — Copy/Cut/Paste live
# on the real OS clipboard now (see _DoSetClipboard/_DoPaste), shared
# across every SaraFem window and unreadable to check synchronously,
# so Paste just always looks clickable and says "Nothing to paste."
# if there's genuinely nothing there, same as most apps' Paste.
self.PasteButton = ft.OutlinedButton(
content="Paste",
icon=ft.Icons.CONTENT_PASTE_OUTLINED,
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.RefreshButton = ft.OutlinedButton(
content="Refresh",
icon=ft.Icons.REFRESH,
style=ChipButtonStyle(),
# _Refresh (not the public Refresh() Sidebar/Filetree use after
# moving a file) — a user asking to refresh wants everything
# re-synced, breadcrumbs and the SaraSortd banner included, not
# just the entry list.
on_click=lambda e: self._Refresh(),
)
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()
# None: the file list shows. A Path: EditorView fills the file area
# instead (Features/Major: "Different display in middle").
self.OpenFilePath: Optional[Path] = None
# Live filter below the breadcrumbs/toolbar bar (Ctrl+F). Session-
# only — Match Case/Whole Word aren't durable preferences (same idea
# as a browser's find bar), and SearchBarVisible only starts from
# Config.SearchAlwaysVisible; toggling it never touches Config.
self.SearchText = ""
self.MatchCase = False
self.MatchWholeWord = False
self.SearchBarVisible = self.Config.SearchAlwaysVisible
self.SearchField = ft.TextField(
hint_text="Search this folder... * and ? wildcards work, e.g. *.png",
dense=True,
expand=True,
border=ft.InputBorder.OUTLINE,
on_change=self._OnSearchChanged,
on_submit=self._OnSearchSubmit,
on_focus=lambda e: self._SetShortcutsPaused(True),
on_blur=lambda e: self._SetShortcutsPaused(False),
)
self.MatchCaseButton = ft.OutlinedButton(
content=ft.Text("A", weight=ft.FontWeight.BOLD, size=13),
tooltip="Match case",
style=self._SearchToggleStyle(False),
on_click=self._ToggleMatchCase,
)
self.MatchWholeWordButton = ft.OutlinedButton(
content=ft.Text("Aa", size=13, style=ft.TextStyle(decoration=ft.TextDecoration.UNDERLINE)),
tooltip="Match whole word",
style=self._SearchToggleStyle(False),
on_click=self._ToggleMatchWholeWord,
)
self.SearchBar = ft.Container(
visible=self.SearchBarVisible,
padding=ft.Padding.symmetric(horizontal=8, vertical=6),
content=ft.Row([self.SearchField, self.MatchCaseButton, self.MatchWholeWordButton], spacing=4),
)
# 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)]
# Always directly above the file area (below breadcrumbs in the
# default top position; top of the file area either way if
# breadcrumbs are set to "bottom" instead).
if self.Config.ShowSearch:
Items.append(self.SearchBar)
# 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.
if self.OpenFilePath is not None:
# A file is open in-app (Features/Major: "Different display in
# middle") — it takes over the whole file area, SortBanner
# included, same as it takes over the KeyboardListener/entry list
# tree below.
if self.OpenFilePath.suffix.lower() in ImageExtensions:
Viewer: ft.Control = ImageView(self.OpenFilePath, OnClose=self._CloseEditor)
else:
Viewer = EditorView(
self.OpenFilePath,
FontFamily=self.Config.FontFamily,
OnClose=self._CloseEditor,
OnNotify=self._Notify,
OnFocusChange=self._SetShortcutsPaused,
)
Items.append(Viewer)
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
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.ShowRefreshButton:
Items.append(self.RefreshButton)
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.ShowRefreshButton:
Items.append(
ft.PopupMenuItem(content="Refresh", icon=ft.Icons.REFRESH, on_click=lambda e: self._Refresh())
)
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
if self.Config.ShowSearch and self.Config.SearchResetOnNavigate and self.SearchText:
# No separate update() here — the trailing self._Refresh() below
# already does a full-tree update, same convention as Breadcrumbs.
self.SearchText = ""
self.SearchField.value = ""
if self.OpenFilePath is not None: # navigating away closes whatever's open in-app
self.OpenFilePath = None
self.ShortcutsPaused = False # its TextField won't be around to fire on_blur
self.FileListColumn.controls = self._BuildChrome()
self.FileListColumn.update()
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, ForceInApp: Optional[bool] = None) -> None:
"""ForceInApp overrides Config.OpenFilesInApp for this one call — used
by the per-entry "Open" menu item, which always does the opposite of
whatever double-click currently does (Features/Major: "Different
display in middle")."""
if EntryPath.is_dir():
self.NavigateTo(EntryPath)
return
if self.OnActivateFile:
self.OnActivateFile(EntryPath)
return
WantInApp = self.Config.OpenFilesInApp if ForceInApp is None else ForceInApp
if WantInApp and _IsInAppViewable(EntryPath):
self._OpenInEditor(EntryPath)
return
try:
if sys.platform == "win32":
os.startfile(str(EntryPath)) # the default app
else:
subprocess.Popen(["xdg-open", str(EntryPath)])
except OSError as Error:
self._Notify(f"Couldn't open {EntryPath.name}: {Error}")
def _OpenInEditor(self, EntryPath: Path) -> None:
self.OpenFilePath = EntryPath
self.FileListColumn.controls = self._BuildChrome()
self.FileListColumn.update()
def _CloseEditor(self) -> None:
self.OpenFilePath = None
self.ShortcutsPaused = False # its TextField won't be around to fire on_blur
self.FileListColumn.controls = self._BuildChrome()
self.FileListColumn.update()
# --- 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:
# Bugs/Major: "SaraSortd integration header should be able to be
# hidden" — separate from Config.SaraSortd.Enabled (which also
# gates GetSortRules itself), so the integration can stay wired up
# without the banner necessarily showing.
if not self.Config.SaraSortd.ShowBanner:
self.SortBanner.visible = False
return
Rules = GetSortRules(self.CurrentPath, self.Config)
if not Rules:
self.SortBanner.visible = False
return
self.SortBanner.visible = True
# Group by destination: Bugs/Major: "...is way too long for some
# stuff, needs to also use the width... condense outputs and inputs
# into > then v button things" — e.g. Downloads -> Pictures getting
# both *.png and *.jpg collapses into one row instead of two.
Groups: dict[tuple[str, str], list[str]] = {}
for Rule in Rules:
Groups.setdefault((Rule.Direction, Rule.Other), []).append(Rule.Pattern)
self.SortBanner.content = ft.Column(
[self._BuildSortBannerRow(Direction, Other, Patterns) for (Direction, Other), Patterns in Groups.items()],
spacing=2,
)
def _BuildSortBannerRow(self, Direction: str, Other: str, Patterns: list[str]) -> ft.Control:
"""One SortBanner line. A single-pattern destination (or
CondenseBanner off) always shows its pattern directly — nothing to
condense. Multiple patterns to the same destination collapse to a
count by default ("on by default"), with a >/v toggle next to it
that expands to the comma-joined pattern list on click."""
JoinedPatterns = ", ".join(Patterns)
if len(Patterns) == 1 or not self.Config.SaraSortd.CondenseBanner:
return ft.Row(
[
ft.Icon(ft.Icons.SYNC_ALT, size=14, color=Palette.Mauve),
ft.Text(f"{JoinedPatterns} {Direction} {Other}", size=self.MetaFontSize, color=Palette.Text),
],
spacing=6,
)
IsExpanded = [False] # mutable cell the closures below can flip
Label = ft.Text(f"{len(Patterns)} types {Direction} {Other}", size=self.MetaFontSize, color=Palette.Text)
ToggleIcon = ft.Icon(ft.Icons.CHEVRON_RIGHT, size=16, color=Palette.Overlay0)
def OnToggle(Event: ft.TapEvent) -> None:
IsExpanded[0] = not IsExpanded[0]
if IsExpanded[0]:
Label.value = f"{JoinedPatterns} {Direction} {Other}"
ToggleIcon.icon = ft.Icons.ARROW_DROP_DOWN
else:
Label.value = f"{len(Patterns)} types {Direction} {Other}"
ToggleIcon.icon = ft.Icons.CHEVRON_RIGHT
Row.update()
Row = ft.Row(
[
ft.GestureDetector(content=ToggleIcon, on_tap=OnToggle),
ft.Icon(ft.Icons.SYNC_ALT, size=14, color=Palette.Mauve),
Label,
],
spacing=6,
)
return Row
def _RefreshEntries(self) -> None:
self.EntriesGeneration += 1
self.EntryList.controls = self._BuildEntries(self.CurrentPath)
self.update()
if self.Config.ViewMode == "list" and self.CurrentEntries:
self.page.run_task(self._PopulateEntriesAsync, self.EntriesGeneration, list(self.CurrentEntries))
def _SearchToggleStyle(self, Active: bool) -> ft.ButtonStyle:
return ft.ButtonStyle(
color=Palette.Mauve if Active else Palette.Text,
bgcolor=Palette.Surface1 if Active else Palette.Surface0,
side=ft.BorderSide(1, Palette.Mauve),
shape=ft.RoundedRectangleBorder(radius=4),
)
def _OnSearchChanged(self, Event: ft.ControlEvent) -> None:
self.SearchText = self.SearchField.value or ""
self._RefreshEntries()
def _OnSearchSubmit(self, Event: ft.ControlEvent) -> None:
"""Enter in the search field. The filter itself is already live
(on_change, every keystroke) — this is purely about Enter visibly
*doing* something instead of nothing: opens the first match, the
same "type to filter, Enter to go" pattern as VS Code's Quick Open
or a browser's address bar. (Feedback: "I can type my query, but
nothing happens when I hit enter" — true before this, the field had
no on_submit handler at all.)"""
if self.SearchText and self.CurrentEntries:
self.OpenEntry(self.CurrentEntries[0])
def _ToggleMatchCase(self, Event: ft.ControlEvent) -> None:
self.MatchCase = not self.MatchCase
self.MatchCaseButton.style = self._SearchToggleStyle(self.MatchCase)
self.MatchCaseButton.update()
if self.SearchText:
self._RefreshEntries()
def _ToggleMatchWholeWord(self, Event: ft.ControlEvent) -> None:
self.MatchWholeWord = not self.MatchWholeWord
self.MatchWholeWordButton.style = self._SearchToggleStyle(self.MatchWholeWord)
self.MatchWholeWordButton.update()
if self.SearchText:
self._RefreshEntries()
def _ToggleSearchBar(self) -> None:
"""Ctrl+F. With SearchAlwaysVisible on, the bar's already there —
this just (re)focuses it. Otherwise it opens/closes the bar itself;
closing also clears the filter, same as dismissing a find bar.
Resets ShiftHeld/CtrlHeld synchronously up front rather than only
relying on SearchField's on_focus (_SetShortcutsPaused) to do it —
that only fires once Flutter's async focus round-trip actually
completes, and Ctrl+F reaching here at all means Ctrl was just held
down for this same keypress (see _SetShortcutsPaused for the full
why)."""
if not self.Config.ShowSearch:
return
self.ShiftHeld = False
self.CtrlHeld = False
if self.Config.SearchAlwaysVisible:
self.page.run_task(self.SearchField.focus)
return
self.SearchBarVisible = not self.SearchBarVisible
self.SearchBar.visible = self.SearchBarVisible
if self.SearchBarVisible:
self.SearchBar.update()
self.page.run_task(self.SearchField.focus)
else:
self.SearchText = ""
self.SearchField.value = ""
self.SearchBar.update()
self._RefreshEntries()
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 _PeekCachedColumnValues(self, Entry: Path) -> dict[str, str]:
"""Like _CachedColumnValues but never computes on a cache miss — used
by the fast (synchronous) row-building pass, so a big/unvisited
directory's rows appear instantly with blank metadata rather than
blocking on it. _PopulateEntriesAsync fills the real values in
afterwards, off the UI thread. A cache hit (e.g. a same-directory
refresh after a rename) still shows immediately, no flicker."""
try:
Mtime = Entry.stat().st_mtime
except OSError:
return {}
Cached = self.MetadataCache.get(Entry)
if Cached is not None and Cached[0] == Mtime:
return Cached[1]
return {}
def _RenderFolderIcon(self, IconName: str, ImagePath: str, Size: int) -> ft.Control:
if ImagePath:
try:
ImgPath = Path(ImagePath).expanduser()
except (OSError, RuntimeError):
ImgPath = None
if ImgPath is not None and ImgPath.is_file():
return ft.Image(src=str(ImgPath), width=Size, height=Size, fit=ft.BoxFit.CONTAIN)
return ft.Icon(getattr(ft.Icons, IconName, ft.Icons.FOLDER), size=Size, color=Palette.Mauve)
def _ResolveFolderIconFast(self, Entry: Path, Size: int) -> ft.Control:
"""No I/O beyond the already-loaded overrides dict — used for the
synchronous placeholder pass. A per-directory override (Properties)
is already the final answer, no I/O needed either way; otherwise
this shows band[0] (identical to today's plain FOLDER icon when
only one band is configured) until the async pass can afford an
item-count for real fullness-based selection."""
Override = self.FolderIconOverrides.get(Entry)
if Override is not None:
return self._RenderFolderIcon(Override.Icon, Override.ImagePath, Size)
Bands = self.Config.FolderIconBands or [FolderIconBand()]
return self._RenderFolderIcon(Bands[0].Icon, Bands[0].ImagePath, Size)
def _ResolveFolderIconFull(self, Entry: Path, Size: int) -> ft.Control:
"""The real answer, including an item-count-based band lookup when
more than one band is configured — run off the UI thread (see
_ComputeRowExtras), the same place thumbnails get resolved, so a
folder full of subdirectories doesn't reintroduce the per-row I/O
Bugs/Major's "Load times for list view are horrid" fix removed."""
Override = self.FolderIconOverrides.get(Entry)
if Override is not None:
return self._RenderFolderIcon(Override.Icon, Override.ImagePath, Size)
Bands = self.Config.FolderIconBands or [FolderIconBand()]
if len(Bands) == 1:
Band = Bands[0]
else:
try:
ItemCount = sum(1 for _ in Entry.iterdir())
except OSError:
ItemCount = 0
Fraction = min(ItemCount / max(self.Config.FolderFullnessThreshold, 1), 1.0)
Index = min(int(Fraction * len(Bands)), len(Bands) - 1)
Band = Bands[Index]
return self._RenderFolderIcon(Band.Icon, Band.ImagePath, Size)
def _PlaceholderIcon(self, Entry: Path, Size: int) -> ft.Control:
"""What _BuildListRow shows before _PopulateEntriesAsync's background
pass resolves the real icon. Identical to _LeadingFor's non-
thumbnail branches, so only images visibly change once their
thumbnail loads — everything else never flickers."""
if Entry.is_dir():
return self._ResolveFolderIconFast(Entry, Size)
return ft.Icon(ft.Icons.INSERT_DRIVE_FILE_OUTLINED, size=Size, color=Palette.Overlay0)
def _ComputeRowExtras(self, Entry: Path) -> tuple[ft.Control, dict[str, str]]:
"""The actual work _PlaceholderIcon/_PeekCachedColumnValues defer —
run off the UI thread by _PopulateEntriesAsync via asyncio.to_thread,
one row at a time."""
return self._LeadingFor(Entry, Size=self.Config.ListIconSize), self._CachedColumnValues(Entry)
async def _PopulateEntriesAsync(self, Generation: int, Entries: list[Path]) -> None:
"""Fills in real icons/metadata for a list-view row of entries that
_BuildEntries rendered with placeholders, batching UI updates rather
than doing one per row. Generation is this._EntriesGeneration at the
time _BuildEntries ran — checked before every write so a stale pass
(navigated away, refreshed again) bails out instead of corrupting a
newer list's cells, which _BuildEntries always rebuilds from scratch."""
BatchSize = 12
SinceUpdate = 0
NameCells = self.ColumnCells.get("Name", [])
for Index, Entry in enumerate(Entries):
if Generation != self.EntriesGeneration:
return
try:
Icon, Values = await asyncio.to_thread(self._ComputeRowExtras, Entry)
except Exception:
continue # one unreadable/broken entry shouldn't kill the whole pass
if Generation != self.EntriesGeneration:
return
if Index < len(NameCells):
NameCells[Index].content.controls[0] = Icon
for ColumnName, Text in Values.items():
Cells = self.ColumnCells.get(ColumnName, [])
if Index < len(Cells):
Cells[Index].content.value = Text
SinceUpdate += 1
if SinceUpdate >= BatchSize:
self.EntryList.update()
SinceUpdate = 0
if SinceUpdate:
self.EntryList.update()
def _MatchesSearch(self, Name: str) -> bool:
"""*.png / *sarah* work as literal fnmatch wildcards; a bare word
with neither implicitly becomes a substring search (the common case,
typing to filter). Match Whole Word only affects that bare-word case
(a wildcard pattern already says exactly what it wants to match) —
bounded so "cat" doesn't match "concatenate", same sense as a find
bar's whole-word option. Regex \\b treats "_" as a word character
(no boundary there), which would wrongly reject "my_cat_photo" — so
this uses its own not-alphanumeric lookaround instead, treating "_"/
"-"/"." and the rest the same as filenames actually delimit words."""
Pattern = self.SearchText
if not Pattern:
return True
Flags = 0 if self.MatchCase else re.IGNORECASE
if "*" in Pattern or "?" in Pattern:
return re.match(fnmatch.translate(Pattern), Name, Flags) is not None
if self.MatchWholeWord:
Boundary = rf"(?<![A-Za-z0-9]){re.escape(Pattern)}(?![A-Za-z0-9])"
return re.search(Boundary, Name, Flags) is not None
return re.search(re.escape(Pattern), Name, Flags) is not None
def _SortEntries(self, Entries: list[Path]) -> list[Path]:
"""Directories always sort before files (matches the previous fixed
order) — SortField/SortAscending only reorder within each group, so
switching to Size/Modified/Type can't suddenly interleave folders
and files. Size never walks a directory recursively (see _EntrySize
— that recursion was Bugs/Major's "load times... are horrid"):
directories are all tied at 0 for this sort and fall back to name."""
Field = self.Config.SortField
def KeyFn(Entry: Path) -> tuple:
NameKey = Entry.name.lower()
if Field == "Size":
try:
Primary = 0 if Entry.is_dir() else Entry.stat().st_size
except OSError:
Primary = 0
elif Field == "Modified":
try:
Primary = Entry.stat().st_mtime
except OSError:
Primary = 0.0
elif Field == "Type":
Primary = "" if Entry.is_dir() else Entry.suffix.lower()
else: # "Name"
Primary = NameKey
return (Primary, NameKey)
Reverse = not self.Config.SortAscending
Dirs = sorted((E for E in Entries if E.is_dir()), key=KeyFn, reverse=Reverse)
Files = sorted((E for E in Entries if not E.is_dir()), key=KeyFn, reverse=Reverse)
return Dirs + Files
def _BuildEntries(self, DirPath: Path) -> list[ft.Control]:
self.EntryTiles = {}
self.ColumnCells = {}
# Reloaded fresh each build (cheap — one small TOML file, not a
# per-row read) so a Properties change takes effect on the very
# next refresh, same as any other entry list change.
self.FolderIconOverrides = LoadFolderIconOverrides()
try:
Entries = list(DirPath.iterdir())
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)]
if self.Config.ShowSearch and self.SearchText:
# Directories filter by name same as files (feedback: "Search
# doesn't affect directories"). They used to be exempted here as
# a (wrong) fix for "cannot go into directories if there is a
# search bar" — that bug's real cause was Ctrl+F leaving CtrlHeld
# stuck (see _SetShortcutsPaused/_ToggleSearchBar), not this
# filter; with that fixed at the root, there's no reason left to
# keep directories unfilterable.
Entries = [Entry for Entry in Entries if self._MatchesSearch(Entry.name)]
Entries = self._SortEntries(Entries)
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._PlaceholderIcon(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] = []
# Blank (or already-cached) here — _PopulateEntriesAsync fills the
# real icon/values in afterwards, off the UI thread, so a big or
# never-visited directory still paints its row list instantly
# (Bugs/Major: "Load times for list view are horrid").
CachedValues = self._PeekCachedColumnValues(Entry)
for ConfigFlag, ColumnName, _ValueFn in ListColumns:
if getattr(self.Config, ConfigFlag):
Width = getattr(self.Config, f"ListWidth{ColumnName}")
Cell, Wrapped = self._MetadataCell(CachedValues.get(ColumnName, ""), Width)
self.ColumnCells.setdefault(ColumnName, []).append(Cell)
HighlightCells.append(Cell)
# Matches the 6px ResizeHandle _BuildListHeader puts between
# every pair of header cells — without an equal-width spacer
# here, rows have no such gap, so each header cell past the
# first drifts further right of its column's actual content
# (Bugs/Minor: "Headers in list view get offset compared to
# content"), worse the more columns are enabled.
MetaCells.append(ft.Container(width=6))
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)
RowControl = 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,
)
if Entry.is_dir():
# A directory row is both a drag source (like any other entry, so
# it can itself be moved elsewhere) and a drop target (so other
# entries can be dropped into it) — same MoveIntoDirectory Sidebar
# bookmarks and the filetree already use, just one more place to
# drop onto (Features/Major: "drag and drop... directories in
# folder you're in in middle").
return ft.DragTarget(
group=DragGroup,
content=RowControl,
on_accept=lambda e, dest=Entry: self._OnEntryDroppedInList(e, dest),
)
return RowControl
def _BuildColumnResizeHandle(self, FieldName: str, Cell: ft.Container) -> ft.Control:
"""Sits between Cell and the next header cell — drags resize Cell's
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)
)
GridDraggable = 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),
),
)
if Entry.is_dir():
# Same drop-target treatment as the list view's directory rows —
# see _BuildListRow.
return ft.DragTarget(
group=DragGroup,
content=GridDraggable,
on_accept=lambda e, dest=Entry: self._OnEntryDroppedInList(e, dest),
)
return GridDraggable
def _LeadingFor(self, Entry: Path, Size: int = 32):
if Entry.is_dir():
return self._ResolveFolderIconFull(Entry, Size)
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 _OnEntryDroppedInList(self, Event: ft.DragTargetEvent, DestinationDir: Path) -> None:
"""A file/folder dropped onto a directory row/cell in the file list
itself — the same MoveIntoDirectory Sidebar's bookmarks and the
filetree already use for their own drop targets (Features/Major:
"drag and drop... directories in folder you're in in middle")."""
SourcePath = Path(Event.src.data)
try:
MoveIntoDirectory(SourcePath, DestinationDir)
except OSError as Error:
self._Notify(str(Error))
return
self._RefreshEntries()
self._Notify(f'Moved "{SourcePath.name}" to {DestinationDir.name or DestinationDir}.', Kind="success")
def _MenuItems(self, Entry: Path) -> list[ft.PopupMenuItem]:
"""Shared by the triple-dot button and the right-click context menu —
they're required to show the same items. Handlers resolve single-vs-
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)),
]
if not Entry.is_dir():
if _IsInAppViewable(Entry):
# Always the opposite of what double-click currently does —
# see OpenEntry's ForceInApp and Config.OpenFilesInApp.
Label = "Open Externally" if self.Config.OpenFilesInApp else "Open in App"
OpenHandler = lambda e, p=Entry: self.OpenEntry(p, ForceInApp=not self.Config.OpenFilesInApp)
else:
Label = "Open"
OpenHandler = lambda e, p=Entry: self.OpenEntry(p)
Items.insert(0, ft.PopupMenuItem(content=Label, on_click=OpenHandler))
Items += [
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 _SetShortcutsPaused(self, Paused: bool) -> None:
"""Same mechanism Picker.py already uses for its own inline text
field (see EditorView's OnFocusChange): page.on_keyboard_event is
global, not focus-scoped, so without this, typing in the in-app
editor also triggered Backspace-navigates-up / Enter-reopens-file.
Pausing (a field just gained focus) also resets ShiftHeld/CtrlHeld,
the same fix _ShowDialog already needed and for the identical
reason: those two are only ever cleared by _OnListKeyUp, which only
fires while the file list's own KeyboardListener has focus. Ctrl+F
(opening/focusing the search bar) sets CtrlHeld via _OnListKeyDown
and then immediately moves focus to the search field — the matching
key-up never arrives, CtrlHeld stays stuck True, and every later
plain click gets treated as a Ctrl-click instead of a normal select
(reported as "can't double-click into a directory" once the search
bar's been opened, even with it left empty)."""
self.ShortcutsPaused = Paused
if Paused:
self.ShiftHeld = False
self.CtrlHeld = False
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":
if self.OnEscape:
self.OnEscape()
elif self.SearchBarVisible and not self.Config.SearchAlwaysVisible:
self._ToggleSearchBar()
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 == "F" and Event.ctrl:
self._ToggleSearchBar()
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 == "R" and Event.ctrl:
self._Refresh()
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.page.run_task(self._DoSetClipboard, EntryPaths, Mode)
async def _DoSetClipboard(self, EntryPaths: list[Path], Mode: str) -> None:
await ft.Clipboard().set_files([str(P) for P in EntryPaths])
SetClipboardMode(Mode)
def _HandleCopyPath(self, Entry: Optional[Path]) -> None:
if Entry is None:
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:
self.page.run_task(self._DoPaste)
async def _DoPaste(self) -> None:
# The real OS clipboard (see _DoSetClipboard) — works whether the
# files were copied/cut from this same window, a different SaraFem
# window (each its own process), or another app entirely. Files
# copied from outside SaraFem have no cut/copy flag of their own, so
# GetClipboardMode's "copy" default is also the safe one: pasting
# never deletes something SaraFem itself didn't mark as cut.
Files = await ft.Clipboard().get_files()
if not Files:
self._Notify("Nothing to paste.")
return
ClipboardPaths = [Path(F) for F in Files]
Mode = GetClipboardMode()
Missing = [P for P in ClipboardPaths if not P.exists()]
if Missing:
self._Notify(f"{Missing[0].name} no longer exists.")
return
Failures: list[str] = []
for SourcePath in ClipboardPaths:
Destination = self._UniqueDestination(self.CurrentPath / SourcePath.name)
try:
if Mode == "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}")
# No clipboard-clearing after a cut-paste: a stale "cut" pointing at
# files that just moved is already handled above (Missing), and
# trying to clear a clipboard another window might have since
# overwritten would just be racing it for no benefit.
if Failures:
self._Notify("Couldn't paste: " + "; ".join(Failures))
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
# Bugs/Major: "Stuff stops working after creating new directory,
# have to re-open SaraFem." ShiftHeld/CtrlHeld are only ever cleared
# by _OnListKeyUp, which only fires while the file list's own
# KeyboardListener has focus. A shortcut like Ctrl+Shift+N opens
# this dialog mid-keypress — its field autofocuses immediately, so
# the Ctrl/Shift key-up that follows never reaches the list, and
# both flags stay stuck True forever. Every later plain click then
# gets treated as a Ctrl/Shift-click (toggle/range-select) instead
# of a normal select, which looks exactly like "stuff stops
# working" with no error anywhere. Opening any dialog means the
# list's own modifier tracking is about to go stale regardless of
# which keys are technically still physically held, so reset both
# here rather than trying to catch the key-up that's never coming.
self.ShiftHeld = False
self.CtrlHeld = False
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),
]
SizeText: Optional[ft.Text] = None
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))
# Feedback: "if a directory is big, entering properties takes
# forever... maybe do the calculation while showing the
# properties window?" — the dialog now opens immediately with a
# placeholder; the one remaining full recursive walk (still only
# a single, deliberate "show me" click, never per list row — see
# _EntrySize) runs off the UI thread afterward and fills this in
# once it's done, same pattern as thumbnails/folder fullness.
SizeText = ft.Text("Size: Calculating…", size=12, color=Palette.Text)
Rows.append(SizeText)
else:
Rows.append(ft.Text(f"Size: {HumanSize(Stat.st_size)}", size=12, color=Palette.Text))
Actions: list[ft.Control] = []
if Entry.is_dir():
# Features: "Add custom icons for directories. Should be able
# to be changed in properties." A blank selection here (icon
# AND image path both empty) means "no override" — falls back
# to Config.FolderIconBands' fullness-variant system.
Override = self.FolderIconOverrides.get(Entry)
IconDropdown = ft.Dropdown(
value=Override.Icon if Override else "",
label="Custom icon",
options=[ft.DropdownOption(key="", text="(Use default)")] + [
ft.DropdownOption(
key=Name,
text=FolderIconLabelOverrides.get(Name, Name.split("_")[0].title()),
leading_icon=getattr(ft.Icons, Name),
)
for Name in FolderIconChoices
],
)
ImagePathField = ft.TextField(
value=Override.ImagePath if Override else "",
label="Or custom image path (overrides icon choice)",
)
def DoSaveIcon(e=None) -> None:
self._DismissDialog()
SetFolderIconOverride(Entry, IconDropdown.value or "", ImagePathField.value.strip())
self._RefreshEntries()
self._Notify("Folder icon updated.", Kind="success")
Rows += [ft.Divider(height=1, color=Palette.Surface1), IconDropdown, ImagePathField]
Actions.append(ft.OutlinedButton(content="Save Icon", style=ChipButtonStyle(), on_click=DoSaveIcon))
Actions.append(
ft.OutlinedButton(content="Close", style=ChipButtonStyle(), on_click=lambda e: self._DismissDialog())
)
Dialog = ft.AlertDialog(
title="Properties",
content=ft.Column(Rows, tight=True, width=360, spacing=8),
actions=Actions,
)
self._ShowDialog(Dialog)
if SizeText is not None:
self.page.run_task(self._FillDirectorySizeAsync, Entry, SizeText)
async def _FillDirectorySizeAsync(self, Entry: Path, SizeText: ft.Text) -> None:
Size = await asyncio.to_thread(_DirectorySize, Entry)
SizeText.value = f"Size: {HumanSize(Size)}"
try:
SizeText.update()
except Exception:
pass # the dialog was closed before the walk finished — nothing to update
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()),
leading_icon=getattr(ft.Icons, Name),
)
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,
)
)