mirror of
https://git.srhtnin.garden/sarah/SaraFem.git
synced 2026-10-14 02:18:11 +02:00
List view metadata columns and drag-to-resize everywhere
- New optional list-view columns: Description, Size (now includes directories), Modified, Owner, Created (via statx birth time), Content (item/line count or image dimensions), Access (rwx) - Drag-to-resize: list view column headers, sidebar sections (Bookmarks/ Devices/Filetree), Preview/Tags split, and the sidebar/preview side panels themselves — all commit to Config on release, not per drag frame - Column resizing mutates existing row cells live instead of rebuilding, with metadata cached per (file, mtime) — fixes lag in large folders - Fix list view background click (rows were full ListTile width with no empty space to click); bookmark selected-background now covers the triple-dot menu too; batch rename/new-file dialogs pre-select their default text instead of just pre-filling it - Toolbar scrolls instead of clipping when buttons don't fit; toolbar and breadcrumbs are independently toggleable - Add a "Server" bookmark icon Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
a5f5c5ca79
commit
31b6c554e6
6 files changed
+699
-89
No files matched your search
+7
-1
@@ -18,9 +18,15 @@ _TrashMigrationMarker = ConfigDir / ".trash_bookmark_migrated"
|
||||
IconChoices = [
|
||||
"BOOKMARK_OUTLINE", "HOME_OUTLINED", "FOLDER_OUTLINED", "DESCRIPTION_OUTLINED",
|
||||
"DOWNLOAD_OUTLINED", "IMAGE_OUTLINED", "MUSIC_NOTE_OUTLINED", "VIDEOCAM_OUTLINED",
|
||||
"CODE_OUTLINED", "STAR_OUTLINE", "FAVORITE_OUTLINE", "WORK_OUTLINE",
|
||||
"CODE_OUTLINED", "STAR_OUTLINE", "FAVORITE_OUTLINE", "WORK_OUTLINE", "DNS_OUTLINED",
|
||||
]
|
||||
|
||||
# The dropdown label is normally derived from the icon name itself
|
||||
# (Name.split("_")[0].title()) — DNS_OUTLINED is Material's stock "server
|
||||
# rack" glyph, but "Dns" reads as nothing to most people, so it gets an
|
||||
# explicit label instead.
|
||||
IconLabelOverrides = {"DNS_OUTLINED": "Server"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Bookmark:
|
||||
|
||||
+473
-52
@@ -2,23 +2,32 @@
|
||||
plus the breadcrumbs/preview/dotfiles toggles from Config.toml."""
|
||||
|
||||
import asyncio
|
||||
import ctypes
|
||||
import os
|
||||
import pwd
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
import flet as ft
|
||||
import send2trash
|
||||
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, FormatBatchName, NotificationStyle, Palette
|
||||
from Preview import HumanSize, Preview
|
||||
from Core import (
|
||||
AppConfig, ApplyHeightBounds, ChipButtonStyle, DragGroup, FormatBatchName, NotificationStyle, Palette,
|
||||
ResizeHandle,
|
||||
)
|
||||
from Preview import HumanSize, Preview, TextExtensions
|
||||
from Settings import BuildSettingsDialog
|
||||
from Tags import TagPanel
|
||||
from Thumbnails import GetThumbnail, IsThumbnailable
|
||||
@@ -35,6 +44,142 @@ def _IsArchive(EntryPath: Path) -> bool:
|
||||
return any(NameLower.endswith(Ext) for Ext in ArchiveExtensions)
|
||||
|
||||
|
||||
|
||||
|
||||
def _EntryDescription(Entry: Path) -> str:
|
||||
if Entry.is_dir():
|
||||
return "Folder"
|
||||
Ext = Entry.suffix.lstrip(".").upper()
|
||||
return f"{Ext} File" if Ext else "File"
|
||||
|
||||
|
||||
def _DirectorySize(Entry: Path) -> int:
|
||||
Total = 0
|
||||
try:
|
||||
for Sub in Entry.rglob("*"):
|
||||
try:
|
||||
if Sub.is_file():
|
||||
Total += Sub.stat().st_size
|
||||
except OSError:
|
||||
continue # unreadable entry — skip it, don't fail the whole size
|
||||
except OSError:
|
||||
pass # permission denied walking the top directory itself
|
||||
return Total
|
||||
|
||||
|
||||
def _EntrySize(Entry: Path) -> str:
|
||||
try:
|
||||
if Entry.is_dir():
|
||||
return HumanSize(_DirectorySize(Entry))
|
||||
return HumanSize(Entry.stat().st_size)
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _EntryModified(Entry: Path) -> str:
|
||||
try:
|
||||
return datetime.fromtimestamp(Entry.stat().st_mtime).strftime("%d/%m/%y %H:%M")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _EntryOwner(Entry: Path) -> str:
|
||||
try:
|
||||
return pwd.getpwuid(Entry.stat().st_uid).pw_name
|
||||
except (OSError, KeyError):
|
||||
return ""
|
||||
|
||||
|
||||
# --- Created (statx birth time) -------------------------------------------
|
||||
# Plain os.stat() has no birth time on Linux (st_ctime is metadata-change
|
||||
# time, not creation time) — the statx(2) syscall does, on filesystems that
|
||||
# record it (btrfs, ext4, xfs...), so it's read directly via ctypes rather
|
||||
# than falling back to a value that would just be wrong.
|
||||
|
||||
|
||||
class _StatxTimestamp(ctypes.Structure):
|
||||
_fields_ = [("tv_sec", ctypes.c_int64), ("tv_nsec", ctypes.c_uint32), ("__reserved", ctypes.c_int32)]
|
||||
|
||||
|
||||
class _StatxStruct(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("stx_mask", ctypes.c_uint32), ("stx_blksize", ctypes.c_uint32), ("stx_attributes", ctypes.c_uint64),
|
||||
("stx_nlink", ctypes.c_uint32), ("stx_uid", ctypes.c_uint32), ("stx_gid", ctypes.c_uint32),
|
||||
("stx_mode", ctypes.c_uint16), ("__spare0", ctypes.c_uint16),
|
||||
("stx_ino", ctypes.c_uint64), ("stx_size", ctypes.c_uint64), ("stx_blocks", ctypes.c_uint64),
|
||||
("stx_attributes_mask", ctypes.c_uint64),
|
||||
("stx_atime", _StatxTimestamp), ("stx_btime", _StatxTimestamp),
|
||||
("stx_ctime", _StatxTimestamp), ("stx_mtime", _StatxTimestamp),
|
||||
("stx_rdev_major", ctypes.c_uint32), ("stx_rdev_minor", ctypes.c_uint32),
|
||||
("stx_dev_major", ctypes.c_uint32), ("stx_dev_minor", ctypes.c_uint32),
|
||||
("stx_mnt_id", ctypes.c_uint64), ("stx_dio_mem_align", ctypes.c_uint32), ("stx_dio_offset_align", ctypes.c_uint32),
|
||||
("__spare3", ctypes.c_uint64 * 12),
|
||||
]
|
||||
|
||||
|
||||
_AT_FDCWD = -100
|
||||
_STATX_BTIME = 0x800
|
||||
|
||||
try:
|
||||
_Libc = ctypes.CDLL("libc.so.6", use_errno=True)
|
||||
except OSError:
|
||||
_Libc = None
|
||||
|
||||
|
||||
def _EntryCreated(Entry: Path) -> str:
|
||||
if _Libc is None:
|
||||
return ""
|
||||
Buf = _StatxStruct()
|
||||
try:
|
||||
Result = _Libc.statx(_AT_FDCWD, str(Entry).encode(), 0, _STATX_BTIME, ctypes.byref(Buf))
|
||||
except (AttributeError, OSError):
|
||||
return "" # older glibc/kernel without statx(), or the call itself failed
|
||||
if Result != 0 or not (Buf.stx_mask & _STATX_BTIME) or Buf.stx_btime.tv_sec == 0:
|
||||
return "" # filesystem doesn't record birth time (e.g. some tmpfs/network mounts)
|
||||
return datetime.fromtimestamp(Buf.stx_btime.tv_sec).strftime("%d/%m/%y %H:%M")
|
||||
|
||||
|
||||
def _EntryContent(Entry: Path) -> str:
|
||||
try:
|
||||
if Entry.is_dir():
|
||||
Count = sum(1 for _ in Entry.iterdir())
|
||||
return f"{Count} item{'s' if Count != 1 else ''}"
|
||||
if IsThumbnailable(Entry):
|
||||
with Image.open(Entry) as Img:
|
||||
return f"{Img.width}x{Img.height}"
|
||||
if Entry.suffix.lower() in TextExtensions:
|
||||
with Entry.open("r", encoding="utf-8", errors="replace") as F:
|
||||
Lines = sum(1 for _ in F)
|
||||
return f"{Lines} line{'s' if Lines != 1 else ''}"
|
||||
except OSError:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _EntryAccess(Entry: Path) -> str:
|
||||
Letters = ""
|
||||
if os.access(Entry, os.R_OK):
|
||||
Letters += "r"
|
||||
if os.access(Entry, os.W_OK):
|
||||
Letters += "w"
|
||||
if os.access(Entry, os.X_OK):
|
||||
Letters += "x"
|
||||
return Letters
|
||||
|
||||
|
||||
# Shared between _BuildListRow and _BuildListHeader so the two can't drift
|
||||
# out of sync — (Config flag, column label, value function).
|
||||
ListColumns = (
|
||||
("ListShowDescription", "Description", _EntryDescription),
|
||||
("ListShowSize", "Size", _EntrySize),
|
||||
("ListShowModified", "Modified", _EntryModified),
|
||||
("ListShowOwner", "Owner", _EntryOwner),
|
||||
("ListShowCreated", "Created", _EntryCreated),
|
||||
("ListShowContent", "Content", _EntryContent),
|
||||
("ListShowAccess", "Access", _EntryAccess),
|
||||
)
|
||||
|
||||
|
||||
class _EntryHandle:
|
||||
"""Wraps whatever control represents an entry (a ListTile in list view, a
|
||||
Container in grid view) behind one uniform way to toggle selection, so
|
||||
@@ -91,6 +236,17 @@ class Browser(ft.Row):
|
||||
self.CtrlHeld = False
|
||||
|
||||
self.EntryTiles: dict[Path, _EntryHandle] = {}
|
||||
# A column resize used to rebuild every row from scratch on release
|
||||
# (recomputing metadata — a recursive dir-size walk, line-counting a
|
||||
# text file, etc. — and reconstructing every Container in a folder
|
||||
# that could have thousands of entries) — the lag was real. Tracked
|
||||
# here instead: OnDrag mutates every already-built cell's .width
|
||||
# directly (cheap — no metadata recompute, no rebuild), and
|
||||
# OnDragEnd just persists the number, nothing else. Reset each
|
||||
# _BuildEntries() call; cleared on navigation since a new directory's
|
||||
# entries make the cache meaningless.
|
||||
self.ColumnCells: dict[str, list[ft.Container]] = {}
|
||||
self.MetadataCache: dict[Path, tuple[float, dict[str, str]]] = {}
|
||||
self.LastClickPath: Optional[Path] = None
|
||||
self.LastClickTime = 0.0
|
||||
self.BackStack: list[Path] = []
|
||||
@@ -130,7 +286,18 @@ class Browser(ft.Row):
|
||||
style=ChipButtonStyle(),
|
||||
on_click=lambda e: self._ShowSettingsDialog(),
|
||||
)
|
||||
self.ActionsToolbar = ft.Row(self._BuildActionsToolbar())
|
||||
# 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()
|
||||
# One shared popup host, opened programmatically (see _RequestMenu) so
|
||||
# a right-click resolves to exactly one menu. Its built-in triggers
|
||||
@@ -142,7 +309,7 @@ class Browser(ft.Row):
|
||||
# once (built-in + entry + background) popped the app's own page too:
|
||||
# that was the "right-click turns the whole app black" bug.
|
||||
self.EntryContextMenu = ft.ContextMenu(
|
||||
content=self.EntryList,
|
||||
content=self._BuildEntryListContent(),
|
||||
expand=True,
|
||||
secondary_trigger=ft.ContextMenuTrigger.LONG_PRESS,
|
||||
tertiary_trigger=ft.ContextMenuTrigger.LONG_PRESS,
|
||||
@@ -185,22 +352,29 @@ class Browser(ft.Row):
|
||||
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.
|
||||
# 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
|
||||
PreviewItem, TagsItem = self.PreviewPane, self.TagPanel
|
||||
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).
|
||||
self.PreviewPane.expand = not (self.Config.PreviewMinHeight or self.Config.PreviewMaxHeight)
|
||||
self.TagPanel.expand = False
|
||||
PreviewItem = ApplyHeightBounds(
|
||||
self.PreviewPane, self.Config.PreviewMinHeight, self.Config.PreviewMaxHeight
|
||||
# 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,
|
||||
)
|
||||
TagsItem = ApplyHeightBounds(
|
||||
self.TagPanel, self.Config.TagPanelMinHeight, self.Config.TagPanelMaxHeight
|
||||
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
|
||||
@@ -210,24 +384,71 @@ class Browser(ft.Row):
|
||||
# not stretch, so Tags was shrinking to fit "No tags." instead of
|
||||
# filling the panel. No separate tag-box-width setting needed.
|
||||
PreviewBlock = ft.Column(
|
||||
[PreviewItem, TagsItem],
|
||||
StackItems,
|
||||
spacing=0,
|
||||
width=self.Config.PreviewWidth,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
||||
)
|
||||
|
||||
Divider = self._BuildPanelWidthDivider(PreviewBlock)
|
||||
if self.Config.PreviewSide == "left":
|
||||
return [PreviewBlock, self.FileListColumn]
|
||||
return [self.FileListColumn, PreviewBlock]
|
||||
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 share one bar, each taking half
|
||||
the width — not two separate full-width rows."""
|
||||
"""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])
|
||||
@@ -246,7 +467,8 @@ class Browser(ft.Row):
|
||||
if ShowTagsAt("top"):
|
||||
Items.append(TagsBar)
|
||||
|
||||
if self.Config.ShowToolbar and self.Config.BreadcrumbsPosition == "top":
|
||||
ShowBar = self.Config.ShowToolbar or self.Config.ShowBreadcrumbs
|
||||
if ShowBar and self.Config.BreadcrumbsPosition == "top":
|
||||
Items += [self._BuildToolbarBar(), ft.Divider(height=1, color=Palette.Surface1)]
|
||||
|
||||
# KeyboardListener tracks live Shift/Ctrl-held state for click
|
||||
@@ -277,7 +499,7 @@ class Browser(ft.Row):
|
||||
)
|
||||
)
|
||||
|
||||
if self.Config.ShowToolbar and self.Config.BreadcrumbsPosition == "bottom":
|
||||
if ShowBar and self.Config.BreadcrumbsPosition == "bottom":
|
||||
Items += [ft.Divider(height=1, color=Palette.Surface1), self._BuildToolbarBar()]
|
||||
|
||||
if ShowTagsAt("bottom"):
|
||||
@@ -315,7 +537,13 @@ class Browser(ft.Row):
|
||||
self._RefreshEntries()
|
||||
|
||||
def _BuildActionsToolbar(self) -> list[ft.Control]:
|
||||
Items: list[ft.Control] = [ft.Container(expand=True)]
|
||||
# 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.
|
||||
Items: list[ft.Control] = []
|
||||
if self.Config.ShowNewDirectoryButton:
|
||||
Items.append(self.NewDirectoryButton)
|
||||
if self.Config.ShowPasteButton:
|
||||
@@ -332,9 +560,18 @@ class Browser(ft.Row):
|
||||
|
||||
def _MakeEntryListContainer(self) -> ft.Control:
|
||||
if self.Config.ViewMode == "grid":
|
||||
return ft.GridView(expand=True, max_extent=120, child_aspect_ratio=0.85, spacing=8, run_spacing=8)
|
||||
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()
|
||||
@@ -345,7 +582,7 @@ class Browser(ft.Row):
|
||||
)
|
||||
|
||||
self.EntryList = self._MakeEntryListContainer()
|
||||
self.EntryContextMenu.content = self.EntryList
|
||||
self.EntryContextMenu.content = self._BuildEntryListContent()
|
||||
self.FileListColumn.controls = self._BuildChrome()
|
||||
self.FileListColumn.update()
|
||||
self._RefreshEntries()
|
||||
@@ -360,6 +597,7 @@ class Browser(ft.Row):
|
||||
self.SelectedPath = None
|
||||
self.SelectedPaths = set()
|
||||
self.SelectionAnchor = None
|
||||
self.MetadataCache = {} # a new directory's entries make the old cache meaningless
|
||||
self._Refresh()
|
||||
if self.Config.ShowPreview:
|
||||
self.PreviewPane.ShowPath(None)
|
||||
@@ -533,8 +771,31 @@ class Browser(ft.Row):
|
||||
Controls.append(ft.Text("/", color=Palette.Overlay0))
|
||||
return Controls
|
||||
|
||||
def _CachedColumnValues(self, Entry: Path) -> dict[str, str]:
|
||||
"""Computes each enabled metadata column's value once per (Entry,
|
||||
mtime) — a file's own edits still invalidate its cache entry, but a
|
||||
pure column-width resize or an unrelated refresh (toggling
|
||||
dotfiles, creating a sibling file, ...) reuses what's already known
|
||||
instead of re-walking directories / re-counting lines / re-reading
|
||||
image dimensions for every row again."""
|
||||
try:
|
||||
Mtime = Entry.stat().st_mtime
|
||||
except OSError:
|
||||
Mtime = 0.0
|
||||
Cached = self.MetadataCache.get(Entry)
|
||||
if Cached is not None and Cached[0] == Mtime:
|
||||
return Cached[1]
|
||||
Values = {
|
||||
ColumnName: ValueFn(Entry)
|
||||
for ConfigFlag, ColumnName, ValueFn in ListColumns
|
||||
if getattr(self.Config, ConfigFlag)
|
||||
}
|
||||
self.MetadataCache[Entry] = (Mtime, Values)
|
||||
return Values
|
||||
|
||||
def _BuildEntries(self, DirPath: Path) -> list[ft.Control]:
|
||||
self.EntryTiles = {}
|
||||
self.ColumnCells = {}
|
||||
try:
|
||||
Entries = sorted(DirPath.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
||||
except (PermissionError, FileNotFoundError):
|
||||
@@ -558,38 +819,155 @@ class Browser(ft.Row):
|
||||
return [self._BuildGridCell(Entry) for Entry in Entries]
|
||||
return [self._BuildListRow(Entry) for Entry in Entries]
|
||||
|
||||
def _BuildListRow(self, Entry: Path) -> ft.Control:
|
||||
Tile = ft.ListTile(
|
||||
title=Entry.name,
|
||||
leading=self._LeadingFor(Entry, Size=32),
|
||||
trailing=self._EntryMenu(Entry),
|
||||
selected=Entry in self.SelectedPaths,
|
||||
selected_tile_color=Palette.Surface0,
|
||||
MinColumnWidth = 40 # floor for header-divider drags, so a column can't vanish
|
||||
ListHeaderHeight = 28 # 12px text + 2*6px vertical padding — see _BuildListHeader
|
||||
|
||||
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=12, color=Palette.Overlay0, overflow=ft.TextOverflow.ELLIPSIS),
|
||||
width=Width,
|
||||
padding=ft.Padding.symmetric(horizontal=8, vertical=8),
|
||||
)
|
||||
self.EntryTiles[Entry] = _EntryHandle(Tile, lambda v, T=Tile: setattr(T, "selected", v))
|
||||
return ft.Draggable(
|
||||
group=DragGroup,
|
||||
data=str(Entry),
|
||||
content=ft.GestureDetector(
|
||||
content=Tile,
|
||||
on_tap=lambda e, p=Entry: self._HandleTap(p),
|
||||
on_secondary_tap_down=lambda e, p=Entry: self._RequestMenu(p, e),
|
||||
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=14, color=Palette.Text, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1
|
||||
)
|
||||
NameCell = ft.Container(
|
||||
content=ft.Row([self._LeadingFor(Entry, Size=32), NameLabel], spacing=12),
|
||||
padding=ft.Padding.symmetric(horizontal=12, vertical=8),
|
||||
bgcolor=Palette.Surface0 if Entry in self.SelectedPaths else None,
|
||||
width=self.Config.ListWidthName,
|
||||
)
|
||||
self.ColumnCells.setdefault("Name", []).append(NameCell)
|
||||
HighlightCells = [NameCell]
|
||||
MetaCells: list[ft.Control] = []
|
||||
CachedValues = self._CachedColumnValues(Entry)
|
||||
for ConfigFlag, ColumnName, _ValueFn in ListColumns:
|
||||
if getattr(self.Config, ConfigFlag):
|
||||
Width = getattr(self.Config, f"ListWidth{ColumnName}")
|
||||
Cell, Wrapped = self._MetadataCell(CachedValues[ColumnName], Width)
|
||||
self.ColumnCells.setdefault(ColumnName, []).append(Cell)
|
||||
HighlightCells.append(Cell)
|
||||
MetaCells.append(Wrapped)
|
||||
|
||||
def SetSelected(IsSelected: bool) -> None:
|
||||
for C in HighlightCells:
|
||||
C.bgcolor = Palette.Surface0 if IsSelected else None
|
||||
|
||||
self.EntryTiles[Entry] = _EntryHandle(NameCell, SetSelected)
|
||||
|
||||
return ft.Container(
|
||||
content=ft.Row(
|
||||
[
|
||||
ft.Draggable(
|
||||
group=DragGroup,
|
||||
data=str(Entry),
|
||||
content=ft.GestureDetector(
|
||||
content=NameCell,
|
||||
on_tap=lambda e, p=Entry: self._HandleTap(p),
|
||||
on_secondary_tap_down=lambda e, p=Entry: self._RequestMenu(p, e),
|
||||
),
|
||||
),
|
||||
*MetaCells,
|
||||
# expand=True has to sit on this Container (a Row child
|
||||
# Flet actually flexes) to reach the far right edge, not
|
||||
# on the GestureDetector wrapping it — same class of
|
||||
# non-propagation as the invisible-rows bug earlier, just
|
||||
# the opposite direction (collapsed instead of blown up).
|
||||
ft.Container(
|
||||
content=ft.GestureDetector(
|
||||
content=ft.Container(bgcolor=Palette.Base, height=48),
|
||||
on_tap=lambda e: self.Select(None),
|
||||
on_secondary_tap_down=lambda e: self._RequestMenu(None, e),
|
||||
),
|
||||
expand=True,
|
||||
),
|
||||
self._EntryMenu(Entry),
|
||||
],
|
||||
spacing=0,
|
||||
),
|
||||
expand=True,
|
||||
)
|
||||
|
||||
def _BuildColumnResizeHandle(self, FieldName: str, Cell: ft.Container) -> ft.Control:
|
||||
"""Sits between Cell and the next header cell — drags resize Cell's
|
||||
column (the one to the divider's left), matching how spreadsheet/
|
||||
table column dividers work. Mutates the header cell plus every
|
||||
currently-built row's matching cell directly on every drag frame —
|
||||
no rebuild, so a folder with thousands of entries reflows live
|
||||
instead of only the header moving while rows sit frozen. OnDragEnd
|
||||
only has a number to persist; the rows are already showing it."""
|
||||
ConfigAttr = f"ListWidth{FieldName}"
|
||||
|
||||
def OnDrag(Delta: float) -> None:
|
||||
NewWidth = max(self.MinColumnWidth, Cell.width + Delta)
|
||||
Cell.width = NewWidth
|
||||
Cell.update()
|
||||
for RowCell in self.ColumnCells.get(FieldName, []):
|
||||
RowCell.width = NewWidth
|
||||
self.EntryList.update() # one batched update for every row cell touched above
|
||||
|
||||
def OnDragEnd() -> None:
|
||||
setattr(self.Config, ConfigAttr, int(Cell.width))
|
||||
self.Config.Save()
|
||||
|
||||
return ResizeHandle("horizontal", self.ListHeaderHeight, OnDrag, OnDragEnd)
|
||||
|
||||
def _BuildListHeader(self) -> ft.Control:
|
||||
Fields = [("Name", "Name", 12)]
|
||||
for ConfigFlag, ColumnName, _ValueFn in ListColumns:
|
||||
if getattr(self.Config, ConfigFlag):
|
||||
Fields.append((ColumnName, ColumnName, 8))
|
||||
|
||||
RowItems: list[ft.Control] = []
|
||||
PreviousCell: Optional[ft.Container] = None
|
||||
PreviousField: Optional[str] = None
|
||||
for Label, FieldName, LeftPad in Fields:
|
||||
Cell = ft.Container(
|
||||
ft.Text(Label, size=12, 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=11, 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(
|
||||
[
|
||||
self._LeadingFor(Entry, Size=64),
|
||||
ft.Text(
|
||||
Entry.name, size=11, text_align=ft.TextAlign.CENTER,
|
||||
max_lines=2, overflow=ft.TextOverflow.ELLIPSIS,
|
||||
),
|
||||
],
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
spacing=4,
|
||||
),
|
||||
content=ft.Column(CellItems, horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=4),
|
||||
padding=8,
|
||||
border_radius=6,
|
||||
alignment=ft.Alignment.CENTER,
|
||||
@@ -853,7 +1231,13 @@ class Browser(ft.Row):
|
||||
self._ShowBatchRenameDialog(Targets)
|
||||
|
||||
def _ShowRenameDialog(self, Entry: Path) -> None:
|
||||
NameField = ft.TextField(value=Entry.name, autofocus=True)
|
||||
# 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()
|
||||
@@ -931,7 +1315,13 @@ class Browser(ft.Row):
|
||||
self._ShowDialog(Dialog)
|
||||
|
||||
def _ShowNewDirectoryDialog(self) -> None:
|
||||
NameField = ft.TextField(value="New Directory", autofocus=True)
|
||||
# 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()
|
||||
@@ -957,7 +1347,12 @@ class Browser(ft.Row):
|
||||
self._ShowDialog(Dialog)
|
||||
|
||||
def _ShowNewFileDialog(self) -> None:
|
||||
NameField = ft.TextField(value="New File.txt", autofocus=True)
|
||||
# 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()
|
||||
@@ -1016,11 +1411,34 @@ class Browser(ft.Row):
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
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.FileListColumn.controls = self._BuildChrome()
|
||||
self.FileListColumn.update()
|
||||
self.controls = self._BuildLayout()
|
||||
@@ -1238,7 +1656,10 @@ class Browser(ft.Row):
|
||||
IconDropdown = ft.Dropdown(
|
||||
value=DefaultBookmarkIcon,
|
||||
label="Icon",
|
||||
options=[ft.DropdownOption(key=Name, text=Name.split("_")[0].title()) for Name in BookmarkIconChoices],
|
||||
options=[
|
||||
ft.DropdownOption(key=Name, text=IconLabelOverrides.get(Name, Name.split("_")[0].title()))
|
||||
for Name in BookmarkIconChoices
|
||||
],
|
||||
)
|
||||
|
||||
def DoAdd(e=None) -> None:
|
||||
|
||||
@@ -9,6 +9,7 @@ import shutil
|
||||
import subprocess
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
import flet as ft
|
||||
import toml
|
||||
@@ -170,6 +171,27 @@ class AppConfig:
|
||||
|
||||
ShowDotfiles: bool = True
|
||||
ViewMode: str = "list" # "list" or "grid"
|
||||
# List view: which metadata columns show, beyond the always-present Name.
|
||||
ListShowDescription: bool = False
|
||||
ListShowSize: bool = True
|
||||
ListShowModified: bool = True
|
||||
ListShowOwner: bool = False
|
||||
ListShowCreated: bool = False
|
||||
ListShowContent: bool = False
|
||||
ListShowAccess: bool = False
|
||||
# List view column widths — drag the header dividers to set these, or
|
||||
# type a number in Options; both write here.
|
||||
ListWidthName: int = 220
|
||||
ListWidthDescription: int = 90
|
||||
ListWidthSize: int = 80
|
||||
ListWidthModified: int = 130
|
||||
ListWidthOwner: int = 90
|
||||
ListWidthCreated: int = 130
|
||||
ListWidthContent: int = 90
|
||||
ListWidthAccess: int = 60
|
||||
# Grid view.
|
||||
GridIconSize: int = 64
|
||||
GridNameBelowIcon: bool = True
|
||||
|
||||
# Batch-rename pattern for multi-select rename, syntax modeled on
|
||||
# SaraSortd's own placeholder characters (see ~/Applications/SaraSortd's
|
||||
@@ -403,6 +425,46 @@ def ApplyHeightBounds(Content: ft.Control, MinHeight: int, MaxHeight: int) -> ft
|
||||
return Content
|
||||
|
||||
|
||||
def ResizeHandle(
|
||||
Axis: str,
|
||||
Length: Optional[int],
|
||||
OnDrag: Callable[[float], None],
|
||||
OnDragEnd: Callable[[], None],
|
||||
) -> ft.GestureDetector:
|
||||
"""A thin draggable divider, shared by the file list's column headers,
|
||||
the sidebar/preview section splits, and the sidebar/preview *panel*
|
||||
widths. "horizontal" resizes side-by-side things (drag left/right, e.g.
|
||||
a column or a whole side panel) and needs Length = the row's height, not
|
||||
just 6px wide with an unset (collapses to zero) height; "vertical"
|
||||
resizes stacked things (drag up/down, e.g. Bookmarks vs Devices) and
|
||||
needs Length = the panel's width, for the same reason on the other axis.
|
||||
Pass Length=None instead when the immediate parent Row/Column already
|
||||
stretches its children on that cross axis (confirmed, not assumed —
|
||||
Flet doesn't do this by default) — then an explicit value would fight
|
||||
that stretch instead of matching it. OnDrag fires with the incremental
|
||||
delta on every move — cheap for a single header cell or a sidebar
|
||||
section, but callers with many redraws to do (the file list's rows)
|
||||
should treat it as a live *preview* and do the expensive rebuild in
|
||||
OnDragEnd instead, which fires once on release; that's also where
|
||||
Config gets saved, so a drag isn't disk I/O on every pixel of movement."""
|
||||
IsHorizontal = Axis == "horizontal"
|
||||
Handle = ft.GestureDetector(
|
||||
content=ft.Container(
|
||||
width=6 if IsHorizontal else Length,
|
||||
height=Length if IsHorizontal else 6,
|
||||
bgcolor=Palette.Surface1,
|
||||
),
|
||||
mouse_cursor=ft.MouseCursor.RESIZE_COLUMN if IsHorizontal else ft.MouseCursor.RESIZE_ROW,
|
||||
)
|
||||
if IsHorizontal:
|
||||
Handle.on_horizontal_drag_update = lambda e: OnDrag(e.primary_delta or 0)
|
||||
Handle.on_horizontal_drag_end = lambda e: OnDragEnd()
|
||||
else:
|
||||
Handle.on_vertical_drag_update = lambda e: OnDrag(e.primary_delta or 0)
|
||||
Handle.on_vertical_drag_end = lambda e: OnDragEnd()
|
||||
return Handle
|
||||
|
||||
|
||||
# The group tag shared by every Draggable source and DragTarget in the app,
|
||||
# so bookmarks/filetree only accept drops that actually originate from
|
||||
# SaraFem's own file list (not, say, a Chip or some future draggable widget).
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import Optional
|
||||
import flet as ft
|
||||
|
||||
from Browser import Browser
|
||||
from Core import AppName, ApplyAppearance, BuildTheme, BundledFontFamily, EffectiveSettings, LoadConfig
|
||||
from Core import AppName, ApplyAppearance, BuildTheme, BundledFontFamily, EffectiveSettings, LoadConfig, ResizeHandle
|
||||
from Sidebar import Sidebar
|
||||
|
||||
|
||||
@@ -75,8 +75,14 @@ def _BuildWindow(Config, StartPath: Path, **BrowserHooks) -> tuple[ft.Row, Brows
|
||||
"""The normal SaraFem window (sidebar + browser). The file picker is this
|
||||
same window with a PickerBar under it, so the two always look alike;
|
||||
BrowserHooks are the picker's Browser hooks (see Picker.py)."""
|
||||
OuterRow = ft.Row(expand=True, spacing=0)
|
||||
# STRETCH so the sidebar-width divider (a bare GestureDetector, no
|
||||
# layout control of its own) gets a real height to be draggable across —
|
||||
# confirmed here, not assumed; Flet doesn't stretch Row children by
|
||||
# default. Sidebar/BrowserView already fill full height regardless
|
||||
# (their own content drives that), so this doesn't change how they look.
|
||||
OuterRow = ft.Row(expand=True, spacing=0, vertical_alignment=ft.CrossAxisAlignment.STRETCH)
|
||||
SidebarView: Optional[Sidebar] = None
|
||||
SidebarDivider: Optional[ft.Control] = None
|
||||
|
||||
def OnPathChanged(NewPath: Path) -> None:
|
||||
if SidebarView is not None:
|
||||
@@ -86,19 +92,41 @@ def _BuildWindow(Config, StartPath: Path, **BrowserHooks) -> tuple[ft.Row, Brows
|
||||
if SidebarView is not None:
|
||||
SidebarView.RefreshBookmarks()
|
||||
|
||||
def _BuildSidebarWidthDivider() -> ft.Control:
|
||||
"""Resizes the sidebar, never BrowserView on its other side — same
|
||||
"only touch your own side" convention as the preview panel's
|
||||
divider in Browser.py, and the two are independent."""
|
||||
|
||||
def OnDrag(Delta: float) -> None:
|
||||
# SidebarSide "right" puts the divider on the sidebar's *left*,
|
||||
# so dragging right (growing the file list) has to shrink the
|
||||
# sidebar — sign flips relative to the "left" case.
|
||||
Signed = -Delta if Config.SidebarSide == "right" else Delta
|
||||
NewWidth = max(120, (SidebarView.width or Config.SidebarWidth) + Signed)
|
||||
SidebarView.width = NewWidth
|
||||
SidebarView.update()
|
||||
|
||||
def OnDragEnd() -> None:
|
||||
Config.SidebarWidth = int(SidebarView.width)
|
||||
Config.Save()
|
||||
|
||||
return ResizeHandle("horizontal", None, OnDrag, OnDragEnd)
|
||||
|
||||
def BuildOuterLayout() -> list[ft.Control]:
|
||||
nonlocal SidebarView
|
||||
nonlocal SidebarView, SidebarDivider
|
||||
Layout: list[ft.Control] = [BrowserView]
|
||||
if Config.ShowSidebar and (Config.ShowBookmarks or Config.ShowDevices or Config.ShowFiletree):
|
||||
SidebarView = Sidebar(
|
||||
OnNavigate=BrowserView.NavigateTo, Config=Config, OnFilesMoved=BrowserView.Refresh
|
||||
)
|
||||
SidebarDivider = _BuildSidebarWidthDivider()
|
||||
if Config.SidebarSide == "left":
|
||||
Layout.insert(0, SidebarView)
|
||||
Layout[0:0] = [SidebarView, SidebarDivider]
|
||||
else:
|
||||
Layout.append(SidebarView)
|
||||
Layout += [SidebarDivider, SidebarView]
|
||||
else:
|
||||
SidebarView = None
|
||||
SidebarDivider = None
|
||||
return Layout
|
||||
|
||||
def OnToggleSidebar() -> None:
|
||||
@@ -111,6 +139,9 @@ def _BuildWindow(Config, StartPath: Path, **BrowserHooks) -> tuple[ft.Row, Brows
|
||||
return
|
||||
SidebarView.visible = Config.ShowSidebar
|
||||
SidebarView.update()
|
||||
if SidebarDivider is not None:
|
||||
SidebarDivider.visible = Config.ShowSidebar
|
||||
SidebarDivider.update()
|
||||
if Config.ShowSidebar:
|
||||
SidebarView.UpdateSelection(BrowserView.CurrentPath)
|
||||
|
||||
|
||||
+54
-1
@@ -17,7 +17,7 @@ from Core import (
|
||||
# Settings Browser can already hot-apply live (dotfiles, breadcrumbs, preview, tags).
|
||||
# The window title bar, sidebar (bookmarks/filetree) layout, theme, and SaraTag's
|
||||
# dynamically-loaded module need a restart to take effect.
|
||||
RestartRequiredNote = "Window title bar, Theme, Font, Sidebar and SaraTag settings need a restart to take effect."
|
||||
RestartRequiredNote = "Window title bar, Theme, Font and SaraTag settings need a restart to take effect."
|
||||
|
||||
DialogWidth = 700
|
||||
DialogHeight = 520
|
||||
@@ -91,6 +91,27 @@ def BuildSettingsDialog(
|
||||
SharedFontDropdown = FontPicker(Shared.FontFamily)
|
||||
SharedTitleBarSwitch = ft.Switch(label="Hide window title bar", value=Shared.HideWindowTitleBar)
|
||||
|
||||
# "Middle" tab: the main file-list pane itself, not the panels around it.
|
||||
ViewModePicker, GetViewMode = _OptionPicker(
|
||||
[("list", "List", "VIEW_LIST_OUTLINED"), ("grid", "Grid", "GRID_VIEW_OUTLINED")], Config.ViewMode
|
||||
)
|
||||
ListDescriptionSwitch = ft.Switch(label="Description", value=Config.ListShowDescription)
|
||||
ListSizeSwitch = ft.Switch(label="Size", value=Config.ListShowSize)
|
||||
ListModifiedSwitch = ft.Switch(label="Modified", value=Config.ListShowModified)
|
||||
ListOwnerSwitch = ft.Switch(label="Owner", value=Config.ListShowOwner)
|
||||
ListCreatedSwitch = ft.Switch(label="Created", value=Config.ListShowCreated)
|
||||
ListContentSwitch = ft.Switch(
|
||||
label="Content (item count / line count / image size)", value=Config.ListShowContent
|
||||
)
|
||||
ListAccessSwitch = ft.Switch(label="Access (rwx)", value=Config.ListShowAccess)
|
||||
GridIconSizeField = ft.TextField(
|
||||
value=str(Config.GridIconSize), label="Grid icon size", keyboard_type=ft.KeyboardType.NUMBER
|
||||
)
|
||||
GridNamePositionPicker, GetGridNamePosition = _OptionPicker(
|
||||
[("below", "Below", "SOUTH"), ("above", "Above", "NORTH")],
|
||||
"below" if Config.GridNameBelowIcon else "above",
|
||||
)
|
||||
|
||||
# SaraFem's own values ("General" tab), only used with shared settings off.
|
||||
ThemeDropdown = ThemePicker(Config.Theme)
|
||||
FontDropdown = FontPicker(Config.FontFamily)
|
||||
@@ -234,6 +255,28 @@ def BuildSettingsDialog(
|
||||
spacing=12,
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
),
|
||||
"Middle": ft.Column(
|
||||
[
|
||||
_SectionLabel("View"),
|
||||
ViewModePicker,
|
||||
ft.Divider(height=1, color=Palette.Surface1),
|
||||
_SectionLabel("List columns (besides Name)"),
|
||||
ListDescriptionSwitch,
|
||||
ListSizeSwitch,
|
||||
ListModifiedSwitch,
|
||||
ListOwnerSwitch,
|
||||
ListCreatedSwitch,
|
||||
ListContentSwitch,
|
||||
ListAccessSwitch,
|
||||
ft.Divider(height=1, color=Palette.Surface1),
|
||||
_SectionLabel("Grid"),
|
||||
GridIconSizeField,
|
||||
_SectionLabel("Name position"),
|
||||
GridNamePositionPicker,
|
||||
],
|
||||
spacing=12,
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
),
|
||||
"Toolbar": ft.Column(
|
||||
[
|
||||
ToolbarSwitch,
|
||||
@@ -326,6 +369,15 @@ def BuildSettingsDialog(
|
||||
)
|
||||
|
||||
def DoSave(e=None) -> None:
|
||||
Config.ViewMode = GetViewMode()
|
||||
Config.ListShowDescription = ListDescriptionSwitch.value
|
||||
Config.ListShowSize = ListSizeSwitch.value
|
||||
Config.ListShowModified = ListModifiedSwitch.value
|
||||
Config.ListShowOwner = ListOwnerSwitch.value
|
||||
Config.ListShowCreated = ListCreatedSwitch.value
|
||||
Config.ListShowContent = ListContentSwitch.value
|
||||
Config.ListShowAccess = ListAccessSwitch.value
|
||||
Config.GridNameBelowIcon = GetGridNamePosition() == "below"
|
||||
Config.UseSharedSettings = UseSharedSwitch.value
|
||||
Config.Theme = ThemeDropdown.value
|
||||
Config.FontFamily = FontDropdown.value or Config.FontFamily
|
||||
@@ -384,6 +436,7 @@ def BuildSettingsDialog(
|
||||
(TagMaxWidthField, "TagMaxWidth"),
|
||||
(TagPanelMinHeightField, "TagPanelMinHeight"),
|
||||
(TagPanelMaxHeightField, "TagPanelMaxHeight"),
|
||||
(GridIconSizeField, "GridIconSize"),
|
||||
):
|
||||
try:
|
||||
setattr(Config, TargetAttr, int(Field.value))
|
||||
|
||||
+67
-30
@@ -9,15 +9,15 @@ from typing import Callable, Optional
|
||||
|
||||
import flet as ft
|
||||
|
||||
from Bookmarks import IconChoices, LoadBookmarks, SaveBookmarks
|
||||
from Bookmarks import IconChoices, IconLabelOverrides, LoadBookmarks, SaveBookmarks
|
||||
from Core import (
|
||||
AppConfig,
|
||||
ApplyHeightBounds,
|
||||
ChipButtonStyle,
|
||||
DragGroup,
|
||||
MoveIntoDirectory,
|
||||
NotificationStyle,
|
||||
Palette,
|
||||
ResizeHandle,
|
||||
)
|
||||
from Devices import DevicesPanel
|
||||
from Filetree import Filetree
|
||||
@@ -40,6 +40,11 @@ class Sidebar(ft.Container):
|
||||
|
||||
Sections: list[ft.Control] = []
|
||||
SplitEvenly = Config.SplitBookmarksFiletreeEvenly and Config.ShowBookmarks and Config.ShowFiletree
|
||||
# Section boxes a resize-drag can mutate .height on directly — only
|
||||
# built (and only get a drag handle between them) outside SplitEvenly,
|
||||
# which is a 50/50 flex split with no single fixed height to drag.
|
||||
BookmarksBox: Optional[ft.Container] = None
|
||||
DevicesBox: Optional[ft.Container] = None
|
||||
|
||||
if Config.ShowBookmarks:
|
||||
self.BookmarksListView = ft.ReorderableListView(
|
||||
@@ -52,21 +57,28 @@ class Sidebar(ft.Container):
|
||||
self.BookmarksListView.expand = 1
|
||||
Sections.append(self.BookmarksListView)
|
||||
else:
|
||||
Sections.append(
|
||||
ApplyHeightBounds(self.BookmarksListView, Config.BookmarksMinHeight, Config.BookmarksMaxHeight)
|
||||
BookmarksBox = ft.Container(
|
||||
content=self.BookmarksListView,
|
||||
height=Config.BookmarksMaxHeight or Config.BookmarksMinHeight or None,
|
||||
)
|
||||
Sections.append(BookmarksBox)
|
||||
|
||||
if Config.ShowBookmarks and (Config.ShowDevices or Config.ShowFiletree):
|
||||
Sections.append(ft.Divider(height=1, color=Palette.Surface1))
|
||||
if BookmarksBox is not None:
|
||||
Sections.append(self._BuildSectionDivider(BookmarksBox, "BookmarksMaxHeight"))
|
||||
else:
|
||||
Sections.append(ft.Divider(height=1, color=Palette.Surface1))
|
||||
|
||||
if Config.ShowDevices:
|
||||
self.DevicesView = DevicesPanel(OnNavigate=OnNavigate, Config=Config)
|
||||
Sections.append(
|
||||
ApplyHeightBounds(self.DevicesView, Config.DevicesMinHeight, Config.DevicesMaxHeight)
|
||||
DevicesBox = ft.Container(
|
||||
content=self.DevicesView,
|
||||
height=Config.DevicesMaxHeight or Config.DevicesMinHeight or None,
|
||||
)
|
||||
Sections.append(DevicesBox)
|
||||
|
||||
if Config.ShowDevices and Config.ShowFiletree:
|
||||
Sections.append(ft.Divider(height=1, color=Palette.Surface1))
|
||||
Sections.append(self._BuildSectionDivider(DevicesBox, "DevicesMaxHeight"))
|
||||
|
||||
if Config.ShowFiletree:
|
||||
self.FiletreeView = Filetree(
|
||||
@@ -77,11 +89,9 @@ class Sidebar(ft.Container):
|
||||
)
|
||||
if SplitEvenly:
|
||||
self.FiletreeView.expand = 1
|
||||
Sections.append(self.FiletreeView)
|
||||
else:
|
||||
Sections.append(
|
||||
ApplyHeightBounds(self.FiletreeView, Config.FiletreeMinHeight, Config.FiletreeMaxHeight)
|
||||
)
|
||||
self.FiletreeView.height = Config.FiletreeMaxHeight or Config.FiletreeMinHeight or None
|
||||
Sections.append(self.FiletreeView)
|
||||
|
||||
BorderSide = ft.BorderSide(1, Palette.Surface1)
|
||||
Border = ft.Border(left=BorderSide) if Config.SidebarSide == "right" else ft.Border(right=BorderSide)
|
||||
@@ -94,6 +104,25 @@ class Sidebar(ft.Container):
|
||||
border=Border,
|
||||
)
|
||||
|
||||
DefaultSectionHeight = 150 # starting point for a drag when a section had no explicit height yet
|
||||
|
||||
def _BuildSectionDivider(self, Box: ft.Container, MaxHeightAttr: str) -> ft.Control:
|
||||
"""A resize handle for the section above it (Box) — drags resize that
|
||||
section, whatever's below just reflows into the space that leaves,
|
||||
same "resize what's above the divider" convention as the file list's
|
||||
column dividers resize the column to their left."""
|
||||
|
||||
def OnDrag(Delta: float) -> None:
|
||||
Current = Box.height or self.DefaultSectionHeight
|
||||
Box.height = max(40, Current + Delta)
|
||||
Box.update()
|
||||
|
||||
def OnDragEnd() -> None:
|
||||
setattr(self.Config, MaxHeightAttr, int(Box.height))
|
||||
self.Config.Save()
|
||||
|
||||
return ResizeHandle("vertical", self.Config.SidebarWidth, OnDrag, OnDragEnd)
|
||||
|
||||
def _BuildBookmarkTiles(self) -> list[ft.Control]:
|
||||
self.LocationTiles = {}
|
||||
return [self._BuildBookmarkTile(B) for B in LoadBookmarks()]
|
||||
@@ -117,6 +146,10 @@ class Sidebar(ft.Container):
|
||||
# Not a ListTile: the drag-to-reorder handle needs to wrap everything
|
||||
# except the triple-dot menu, which ListTile's fixed leading/title/
|
||||
# trailing layout doesn't let us carve out — so this is a plain Row.
|
||||
# The selected background lives on OuterContainer (wrapping the whole
|
||||
# Row, triple-dot included) rather than just RowContent — it used to
|
||||
# stop short of the triple-dot, leaving its area unstyled ("I think
|
||||
# the background of the button should continue to the menu").
|
||||
NameLabel = ft.Text(Bookmark.Name, size=14, color=Palette.Text)
|
||||
RowContent = ft.Container(
|
||||
content=ft.Row(
|
||||
@@ -127,34 +160,35 @@ class Sidebar(ft.Container):
|
||||
spacing=12,
|
||||
),
|
||||
padding=ft.Padding.symmetric(horizontal=16, vertical=10),
|
||||
border_radius=4,
|
||||
ink=True,
|
||||
on_click=lambda e, p=Bookmark.Path: self.OnNavigate(p),
|
||||
expand=True,
|
||||
)
|
||||
|
||||
MenuButton = ft.PopupMenuButton(
|
||||
icon=ft.Icons.MORE_VERT,
|
||||
items=[
|
||||
ft.PopupMenuItem(content="Edit", on_click=lambda e, b=Bookmark: self._ShowEditBookmarkDialog(b)),
|
||||
],
|
||||
)
|
||||
|
||||
OuterContainer = ft.Container(
|
||||
content=ft.Row(
|
||||
[ft.Container(ft.ReorderableDragHandle(content=RowContent), expand=True), MenuButton],
|
||||
spacing=0,
|
||||
),
|
||||
border_radius=4,
|
||||
)
|
||||
|
||||
def SetSelected(IsSelected: bool) -> None:
|
||||
RowContent.bgcolor = Palette.Surface1 if IsSelected else None
|
||||
OuterContainer.bgcolor = Palette.Surface1 if IsSelected else None
|
||||
NameLabel.color = Palette.Mauve if IsSelected else Palette.Text
|
||||
|
||||
self.LocationTiles[Bookmark.Path] = SetSelected
|
||||
|
||||
Row = ft.Row(
|
||||
[
|
||||
ft.Container(ft.ReorderableDragHandle(content=RowContent), expand=True),
|
||||
ft.PopupMenuButton(
|
||||
icon=ft.Icons.MORE_VERT,
|
||||
items=[
|
||||
ft.PopupMenuItem(
|
||||
content="Edit", on_click=lambda e, b=Bookmark: self._ShowEditBookmarkDialog(b)
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
spacing=0,
|
||||
)
|
||||
return ft.DragTarget(
|
||||
group=DragGroup,
|
||||
content=Row,
|
||||
content=OuterContainer,
|
||||
on_accept=lambda e, dest=Bookmark.Path: self._OnEntryDropped(e, dest),
|
||||
)
|
||||
|
||||
@@ -178,7 +212,10 @@ class Sidebar(ft.Container):
|
||||
IconDropdown = ft.Dropdown(
|
||||
value=Bookmark.Icon,
|
||||
label="Icon",
|
||||
options=[ft.DropdownOption(key=Name, text=Name.split("_")[0].title()) for Name in IconChoices],
|
||||
options=[
|
||||
ft.DropdownOption(key=Name, text=IconLabelOverrides.get(Name, Name.split("_")[0].title()))
|
||||
for Name in IconChoices
|
||||
],
|
||||
)
|
||||
|
||||
def DoSave(e=None) -> None:
|
||||
|
||||
Reference in new issue
Block a user