mirror of
https://git.srhtnin.garden/sarah/SaraFem.git
synced 2026-10-13 18:18:11 +02:00
Add Refresh button/Ctrl+R; add custom folder icons (per-directory override + global fullness-variant system)
This commit is contained in:
1 parent
1c2acf3f16
commit
c542134f62
7 files changed
+581
-48
No files matched your search
+192
-16
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user