mirror of
https://git.srhtnin.garden/sarah/SaraFem.git
synced 2026-10-13 18:18:11 +02:00
2421 lines
111 KiB
Python
2421 lines
111 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 pwd
|
|
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 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.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:
|
|
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,
|
|
)
|
|
)
|