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:
sarahandClaude Sonnet 5 committed 2026-09-26 17:22:55 +02:00
1 parent a5f5c5ca79
commit 31b6c554e6
6 files changed
+699 -89

No files matched your search

+7 -1
View File
@@ -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
View File
@@ -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:
+62
View File
@@ -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).
+36 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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: