From 31b6c554e6480f4e2ca2f3911b8173c22c497486 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Sat, 26 Sep 2026 17:22:43 +0200 Subject: [PATCH] List view metadata columns and drag-to-resize everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Bookmarks.py | 8 +- Browser.py | 525 ++++++++++++++++++++++++++++++++++++++++++++++----- Core.py | 62 ++++++ Main.py | 41 +++- Settings.py | 55 +++++- Sidebar.py | 97 +++++++--- 6 files changed, 699 insertions(+), 89 deletions(-) diff --git a/Bookmarks.py b/Bookmarks.py index dc0aca9..8dcf9e7 100644 --- a/Bookmarks.py +++ b/Bookmarks.py @@ -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: diff --git a/Browser.py b/Browser.py index 1c7cbaf..e17303a 100644 --- a/Browser.py +++ b/Browser.py @@ -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: diff --git a/Core.py b/Core.py index 0e6ee65..bd176c3 100644 --- a/Core.py +++ b/Core.py @@ -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). diff --git a/Main.py b/Main.py index 87fce0d..eb4fa80 100644 --- a/Main.py +++ b/Main.py @@ -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) diff --git a/Settings.py b/Settings.py index ffa6a07..6bbe3fe 100644 --- a/Settings.py +++ b/Settings.py @@ -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)) diff --git a/Sidebar.py b/Sidebar.py index 4d2c58c..53359ff 100644 --- a/Sidebar.py +++ b/Sidebar.py @@ -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: