Add Refresh button/Ctrl+R; add custom folder icons (per-directory override + global fullness-variant system)

This commit is contained in:
sarah committed 2026-10-07 17:28:58 +02:00
1 parent 1c2acf3f16
commit c542134f62
7 files changed
+581 -48

No files matched your search

+192 -16
View File
@@ -27,10 +27,11 @@ from Bookmarks import IconChoices as BookmarkIconChoices
from Bookmarks import IconLabelOverrides
from Books import ClearSaraTagCache, IsInternalSaraTagFile
from Core import (
AppConfig, ApplyHeightBounds, ChipButtonStyle, DragGroup, ExportSettings, FormatBatchName, GetClipboardMode,
ImportSettings, MoveIntoDirectory, NotificationStyle, Palette, ResizeHandle, SetClipboardMode,
AppConfig, ApplyHeightBounds, ChipButtonStyle, DragGroup, ExportSettings, FolderIconBand, FormatBatchName,
GetClipboardMode, ImportSettings, MoveIntoDirectory, NotificationStyle, Palette, ResizeHandle, SetClipboardMode,
)
from Editor import EditorView
from FolderIcons import FolderIconChoices, FolderIconLabelOverrides, FolderIconOverride, LoadFolderIconOverrides, SetFolderIconOverride
from ImageView import ImageView
from Preview import HumanSize, Preview, TextExtensions
from SaraSortdInfo import GetSortRules
@@ -269,6 +270,7 @@ class Browser(ft.Row):
# entries make the cache meaningless.
self.ColumnCells: dict[str, list[ft.Container]] = {}
self.MetadataCache: dict[Path, tuple[float, dict[str, str]]] = {}
self.FolderIconOverrides: dict[Path, FolderIconOverride] = LoadFolderIconOverrides()
# Bumped by _RefreshEntries every time it rebuilds the list, so a
# background _PopulateEntriesAsync pass from a superseded build can
# tell it's stale (navigated away, refreshed again) and stop instead
@@ -302,6 +304,16 @@ class Browser(ft.Row):
style=ChipButtonStyle(),
on_click=lambda e: self._ShowNewDirectoryDialog(),
)
self.RefreshButton = ft.OutlinedButton(
content="Refresh",
icon=ft.Icons.REFRESH,
style=ChipButtonStyle(),
# _Refresh (not the public Refresh() Sidebar/Filetree use after
# moving a file) — a user asking to refresh wants everything
# re-synced, breadcrumbs and the SaraSortd banner included, not
# just the entry list.
on_click=lambda e: self._Refresh(),
)
self.ViewToggleButton = ft.OutlinedButton(
content="Grid" if self.Config.ViewMode == "list" else "List",
icon=ft.Icons.GRID_VIEW_OUTLINED if self.Config.ViewMode == "list" else ft.Icons.VIEW_LIST_OUTLINED,
@@ -656,6 +668,8 @@ class Browser(ft.Row):
Items.append(self.NewDirectoryButton)
if self.Config.ShowPasteButton:
Items.append(self.PasteButton)
if self.Config.ShowRefreshButton:
Items.append(self.RefreshButton)
if self.Config.ShowViewToggleButton:
Items.append(self.ViewToggleButton)
if self.Config.ShowOptionsButton:
@@ -678,6 +692,10 @@ class Browser(ft.Row):
Items.append(
ft.PopupMenuItem(content="Paste", icon=ft.Icons.CONTENT_PASTE_OUTLINED, on_click=lambda e: self._Paste())
)
if self.Config.ShowRefreshButton:
Items.append(
ft.PopupMenuItem(content="Refresh", icon=ft.Icons.REFRESH, on_click=lambda e: self._Refresh())
)
if self.Config.ShowViewToggleButton:
Items.append(
ft.PopupMenuItem(
@@ -741,6 +759,11 @@ class Browser(ft.Row):
self.SelectedPaths = set()
self.SelectionAnchor = None
self.MetadataCache = {} # a new directory's entries make the old cache meaningless
if self.Config.ShowSearch and self.Config.SearchResetOnNavigate and self.SearchText:
# No separate update() here — the trailing self._Refresh() below
# already does a full-tree update, same convention as Breadcrumbs.
self.SearchText = ""
self.SearchField.value = ""
if self.OpenFilePath is not None: # navigating away closes whatever's open in-app
self.OpenFilePath = None
self.ShortcutsPaused = False # its TextField won't be around to fire on_blur
@@ -919,22 +942,72 @@ class Browser(ft.Row):
self._RefreshEntries()
def _RefreshSortBanner(self) -> None:
Lines = GetSortRules(self.CurrentPath, self.Config)
if not Lines:
# Bugs/Major: "SaraSortd integration header should be able to be
# hidden" — separate from Config.SaraSortd.Enabled (which also
# gates GetSortRules itself), so the integration can stay wired up
# without the banner necessarily showing.
if not self.Config.SaraSortd.ShowBanner:
self.SortBanner.visible = False
return
Rules = GetSortRules(self.CurrentPath, self.Config)
if not Rules:
self.SortBanner.visible = False
return
self.SortBanner.visible = True
# Group by destination: Bugs/Major: "...is way too long for some
# stuff, needs to also use the width... condense outputs and inputs
# into > then v button things" — e.g. Downloads -> Pictures getting
# both *.png and *.jpg collapses into one row instead of two.
Groups: dict[tuple[str, str], list[str]] = {}
for Rule in Rules:
Groups.setdefault((Rule.Direction, Rule.Other), []).append(Rule.Pattern)
self.SortBanner.content = ft.Column(
[
ft.Row(
[ft.Icon(ft.Icons.SYNC_ALT, size=14, color=Palette.Mauve), ft.Text(Line, size=self.MetaFontSize, color=Palette.Text)],
spacing=6,
)
for Line in Lines
],
[self._BuildSortBannerRow(Direction, Other, Patterns) for (Direction, Other), Patterns in Groups.items()],
spacing=2,
)
def _BuildSortBannerRow(self, Direction: str, Other: str, Patterns: list[str]) -> ft.Control:
"""One SortBanner line. A single-pattern destination (or
CondenseBanner off) always shows its pattern directly — nothing to
condense. Multiple patterns to the same destination collapse to a
count by default ("on by default"), with a >/v toggle next to it
that expands to the comma-joined pattern list on click."""
JoinedPatterns = ", ".join(Patterns)
if len(Patterns) == 1 or not self.Config.SaraSortd.CondenseBanner:
return ft.Row(
[
ft.Icon(ft.Icons.SYNC_ALT, size=14, color=Palette.Mauve),
ft.Text(f"{JoinedPatterns} {Direction} {Other}", size=self.MetaFontSize, color=Palette.Text),
],
spacing=6,
)
IsExpanded = [False] # mutable cell the closures below can flip
Label = ft.Text(f"{len(Patterns)} types {Direction} {Other}", size=self.MetaFontSize, color=Palette.Text)
ToggleIcon = ft.Icon(ft.Icons.CHEVRON_RIGHT, size=16, color=Palette.Overlay0)
def OnToggle(Event: ft.TapEvent) -> None:
IsExpanded[0] = not IsExpanded[0]
if IsExpanded[0]:
Label.value = f"{JoinedPatterns} {Direction} {Other}"
ToggleIcon.icon = ft.Icons.ARROW_DROP_DOWN
else:
Label.value = f"{len(Patterns)} types {Direction} {Other}"
ToggleIcon.icon = ft.Icons.CHEVRON_RIGHT
Row.update()
Row = ft.Row(
[
ft.GestureDetector(content=ToggleIcon, on_tap=OnToggle),
ft.Icon(ft.Icons.SYNC_ALT, size=14, color=Palette.Mauve),
Label,
],
spacing=6,
)
return Row
def _RefreshEntries(self) -> None:
self.EntriesGeneration += 1
self.EntryList.controls = self._BuildEntries(self.CurrentPath)
@@ -1043,13 +1116,58 @@ class Browser(ft.Row):
return Cached[1]
return {}
def _RenderFolderIcon(self, IconName: str, ImagePath: str, Size: int) -> ft.Control:
if ImagePath:
try:
ImgPath = Path(ImagePath).expanduser()
except (OSError, RuntimeError):
ImgPath = None
if ImgPath is not None and ImgPath.is_file():
return ft.Image(src=str(ImgPath), width=Size, height=Size, fit=ft.BoxFit.CONTAIN)
return ft.Icon(getattr(ft.Icons, IconName, ft.Icons.FOLDER), size=Size, color=Palette.Mauve)
def _ResolveFolderIconFast(self, Entry: Path, Size: int) -> ft.Control:
"""No I/O beyond the already-loaded overrides dict — used for the
synchronous placeholder pass. A per-directory override (Properties)
is already the final answer, no I/O needed either way; otherwise
this shows band[0] (identical to today's plain FOLDER icon when
only one band is configured) until the async pass can afford an
item-count for real fullness-based selection."""
Override = self.FolderIconOverrides.get(Entry)
if Override is not None:
return self._RenderFolderIcon(Override.Icon, Override.ImagePath, Size)
Bands = self.Config.FolderIconBands or [FolderIconBand()]
return self._RenderFolderIcon(Bands[0].Icon, Bands[0].ImagePath, Size)
def _ResolveFolderIconFull(self, Entry: Path, Size: int) -> ft.Control:
"""The real answer, including an item-count-based band lookup when
more than one band is configured — run off the UI thread (see
_ComputeRowExtras), the same place thumbnails get resolved, so a
folder full of subdirectories doesn't reintroduce the per-row I/O
Bugs/Major's "Load times for list view are horrid" fix removed."""
Override = self.FolderIconOverrides.get(Entry)
if Override is not None:
return self._RenderFolderIcon(Override.Icon, Override.ImagePath, Size)
Bands = self.Config.FolderIconBands or [FolderIconBand()]
if len(Bands) == 1:
Band = Bands[0]
else:
try:
ItemCount = sum(1 for _ in Entry.iterdir())
except OSError:
ItemCount = 0
Fraction = min(ItemCount / max(self.Config.FolderFullnessThreshold, 1), 1.0)
Index = min(int(Fraction * len(Bands)), len(Bands) - 1)
Band = Bands[Index]
return self._RenderFolderIcon(Band.Icon, Band.ImagePath, Size)
def _PlaceholderIcon(self, Entry: Path, Size: int) -> ft.Control:
"""What _BuildListRow shows before _PopulateEntriesAsync's background
pass resolves the real icon. Identical to _LeadingFor's non-
thumbnail branches, so only images visibly change once their
thumbnail loads — everything else never flickers."""
if Entry.is_dir():
return ft.Icon(ft.Icons.FOLDER, size=Size, color=Palette.Mauve)
return self._ResolveFolderIconFast(Entry, Size)
return ft.Icon(ft.Icons.INSERT_DRIVE_FILE_OUTLINED, size=Size, color=Palette.Overlay0)
def _ComputeRowExtras(self, Entry: Path) -> tuple[ft.Control, dict[str, str]]:
@@ -1146,6 +1264,10 @@ class Browser(ft.Row):
def _BuildEntries(self, DirPath: Path) -> list[ft.Control]:
self.EntryTiles = {}
self.ColumnCells = {}
# Reloaded fresh each build (cheap — one small TOML file, not a
# per-row read) so a Properties change takes effect on the very
# next refresh, same as any other entry list change.
self.FolderIconOverrides = LoadFolderIconOverrides()
try:
Entries = list(DirPath.iterdir())
except (PermissionError, FileNotFoundError):
@@ -1238,6 +1360,13 @@ class Browser(ft.Row):
Cell, Wrapped = self._MetadataCell(CachedValues.get(ColumnName, ""), Width)
self.ColumnCells.setdefault(ColumnName, []).append(Cell)
HighlightCells.append(Cell)
# Matches the 6px ResizeHandle _BuildListHeader puts between
# every pair of header cells — without an equal-width spacer
# here, rows have no such gap, so each header cell past the
# first drifts further right of its column's actual content
# (Bugs/Minor: "Headers in list view get offset compared to
# content"), worse the more columns are enabled.
MetaCells.append(ft.Container(width=6))
MetaCells.append(Wrapped)
def SetSelected(IsSelected: bool) -> None:
@@ -1389,7 +1518,7 @@ class Browser(ft.Row):
def _LeadingFor(self, Entry: Path, Size: int = 32):
if Entry.is_dir():
return ft.Icon(ft.Icons.FOLDER, size=Size, color=Palette.Mauve)
return self._ResolveFolderIconFull(Entry, Size)
if IsThumbnailable(Entry):
ThumbnailPath = GetThumbnail(Entry, Size=(Size * 2, Size * 2))
if ThumbnailPath is not None:
@@ -1586,6 +1715,8 @@ class Browser(ft.Row):
self._SetClipboard(sorted(self.SelectedPaths, key=lambda p: p.name.lower()), "cut")
elif Event.key == "V" and Event.ctrl:
self._Paste()
elif Event.key == "R" and Event.ctrl:
self._Refresh()
elif Event.key == "N" and Event.ctrl and Event.shift:
self._ShowNewDirectoryDialog()
elif Event.key == "N" and Event.ctrl:
@@ -1671,6 +1802,21 @@ class Browser(ft.Row):
def _ShowDialog(self, Dialog: ft.AlertDialog) -> None:
self.DialogOpen = True
# Bugs/Major: "Stuff stops working after creating new directory,
# have to re-open SaraFem." ShiftHeld/CtrlHeld are only ever cleared
# by _OnListKeyUp, which only fires while the file list's own
# KeyboardListener has focus. A shortcut like Ctrl+Shift+N opens
# this dialog mid-keypress — its field autofocuses immediately, so
# the Ctrl/Shift key-up that follows never reaches the list, and
# both flags stay stuck True forever. Every later plain click then
# gets treated as a Ctrl/Shift-click (toggle/range-select) instead
# of a normal select, which looks exactly like "stuff stops
# working" with no error anywhere. Opening any dialog means the
# list's own modifier tracking is about to go stale regardless of
# which keys are technically still physically held, so reset both
# here rather than trying to catch the key-up that's never coming.
self.ShiftHeld = False
self.CtrlHeld = False
self.page.show_dialog(Dialog)
def _DismissDialog(self) -> None:
@@ -1971,12 +2117,42 @@ class Browser(ft.Row):
else:
Rows.append(ft.Text(f"Size: {HumanSize(Stat.st_size)}", size=12, color=Palette.Text))
Actions: list[ft.Control] = []
if Entry.is_dir():
# Features: "Add custom icons for directories. Should be able
# to be changed in properties." A blank selection here (icon
# AND image path both empty) means "no override" — falls back
# to Config.FolderIconBands' fullness-variant system.
Override = self.FolderIconOverrides.get(Entry)
IconDropdown = ft.Dropdown(
value=Override.Icon if Override else "",
label="Custom icon",
options=[ft.DropdownOption(key="", text="(Use default)")] + [
ft.DropdownOption(key=Name, text=FolderIconLabelOverrides.get(Name, Name.split("_")[0].title()))
for Name in FolderIconChoices
],
)
ImagePathField = ft.TextField(
value=Override.ImagePath if Override else "",
label="Or custom image path (overrides icon choice)",
)
def DoSaveIcon(e=None) -> None:
self._DismissDialog()
SetFolderIconOverride(Entry, IconDropdown.value or "", ImagePathField.value.strip())
self._RefreshEntries()
self._Notify("Folder icon updated.", Kind="success")
Rows += [ft.Divider(height=1, color=Palette.Surface1), IconDropdown, ImagePathField]
Actions.append(ft.OutlinedButton(content="Save Icon", style=ChipButtonStyle(), on_click=DoSaveIcon))
Actions.append(
ft.OutlinedButton(content="Close", style=ChipButtonStyle(), on_click=lambda e: self._DismissDialog())
)
Dialog = ft.AlertDialog(
title="Properties",
content=ft.Column(Rows, tight=True, width=360, spacing=8),
actions=[
ft.OutlinedButton(content="Close", style=ChipButtonStyle(), on_click=lambda e: self._DismissDialog()),
],
actions=Actions,
)
self._ShowDialog(Dialog)