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)
|
||||
|
||||
|
||||
@@ -177,6 +177,18 @@ class SaraTagIntegrationConfig:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FolderIconBand:
|
||||
"""One slice of the folder-fullness icon system (Features: "turning on
|
||||
different variants depending on how full a directory is"). N configured
|
||||
bands split 0-100% (by item count, against FolderFullnessThreshold)
|
||||
into N equal slices — band 0 covers the emptiest. ImagePath wins over
|
||||
Icon when set (a custom image instead of a built-in Material icon)."""
|
||||
|
||||
Icon: str = "FOLDER_OUTLINED"
|
||||
ImagePath: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SaraSortdIntegrationConfig:
|
||||
"""Points at a SaraSortd checkout's GlobalConf.toml (read-only — SaraFem
|
||||
@@ -185,6 +197,15 @@ class SaraSortdIntegrationConfig:
|
||||
|
||||
Enabled: bool = False
|
||||
GlobalConfPath: str = ""
|
||||
# Separate from Enabled (which is "is this configured at all") — lets
|
||||
# the integration stay wired up without the banner itself showing
|
||||
# (Bugs/Major: "SaraSortd integration header should be able to be
|
||||
# hidden"). CondenseBanner collapses every destination with more than
|
||||
# one matching pattern into one line with a >/v expand toggle, on by
|
||||
# default (Bugs/Major: "...needs to also use the width... condense
|
||||
# outputs and inputs into > then v button things").
|
||||
ShowBanner: bool = True
|
||||
CondenseBanner: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -225,6 +246,15 @@ class AppConfig:
|
||||
GridIconSize: int = 64
|
||||
GridNameBelowIcon: bool = True
|
||||
ListIconSize: int = 32
|
||||
# Features: "Add custom icons for directories... in settings, there
|
||||
# should be settings for default, and turning on different variants
|
||||
# depending on how full a directory is." A single band (the default)
|
||||
# is exactly today's static FOLDER icon everywhere — nothing changes
|
||||
# until more bands get added in Settings. Per-directory overrides (set
|
||||
# via Properties) live separately, see FolderIcons.py — they always win
|
||||
# over this and never vary by fullness.
|
||||
FolderIconBands: list[FolderIconBand] = field(default_factory=lambda: [FolderIconBand()])
|
||||
FolderFullnessThreshold: int = 200 # item count counted as "100% full"
|
||||
FontSize: int = 14 # entry name text, list and grid alike; metadata/header text scales with it
|
||||
ToolbarHamburger: bool = False # collapse the toolbar's buttons into one overflow menu
|
||||
# Double-click on a text/config/markup file: True opens it in SaraFem's own
|
||||
@@ -241,6 +271,10 @@ class AppConfig:
|
||||
# a durable preference, same idea as a browser's find bar.
|
||||
ShowSearch: bool = False
|
||||
SearchAlwaysVisible: bool = False
|
||||
# Opt-in: clears the search filter on every NavigateTo instead of
|
||||
# carrying it over into the next directory (Features/Minor: "Option to
|
||||
# reset search to be blank upon changing directory").
|
||||
SearchResetOnNavigate: bool = False
|
||||
|
||||
# Sidebar sort control ("[Sort: Name] v" + a separate ascending/
|
||||
# descending arrow button next to it — see Sidebar._BuildSortControl).
|
||||
@@ -267,6 +301,7 @@ class AppConfig:
|
||||
BreadcrumbsPosition: str = "top" # "top" or "bottom"
|
||||
ShowNewDirectoryButton: bool = True
|
||||
ShowPasteButton: bool = True
|
||||
ShowRefreshButton: bool = True
|
||||
ShowViewToggleButton: bool = True
|
||||
ShowOptionsButton: bool = True
|
||||
|
||||
@@ -281,6 +316,17 @@ class AppConfig:
|
||||
ShowDevices: bool = True
|
||||
AutoMountDevices: bool = True # mount USB sticks etc. as soon as they're plugged in
|
||||
ShowInternalDrives: bool = False # also list internal disks that aren't system mounts
|
||||
# Requires ShowInternalDrives too — additionally lists the main drive
|
||||
# itself (mounted at "/" specifically, not /boot/efi or other system
|
||||
# partitions udisks2 reports as their own separate block devices). The
|
||||
# Devices panel never offers to eject/unmount this one regardless (see
|
||||
# Device.IsSystemMount) — doing that to your own running root
|
||||
# filesystem would crash the system.
|
||||
ShowSystemDrive: bool = False
|
||||
# Opt-in: collapses the Devices section (box + its own divider, see
|
||||
# Sidebar._OnDevicesEmptyChanged) when nothing's plugged in, instead of
|
||||
# always showing it with a "No devices." placeholder.
|
||||
AutoHideEmptyDevices: bool = False
|
||||
ShowFiletree: bool = True
|
||||
FiletreeRoot: str = "/"
|
||||
BookmarksMinHeight: int = 0 # 0 = no constraint
|
||||
@@ -349,6 +395,18 @@ def LoadConfig() -> AppConfig:
|
||||
SaraSortdFiltered = {Key: Value for Key, Value in SaraSortdData.items() if Key in KnownSaraSortdFields}
|
||||
Filtered["SaraSortd"] = SaraSortdIntegrationConfig(**SaraSortdFiltered)
|
||||
|
||||
# A list of nested dataclasses doesn't round-trip through toml.loads()
|
||||
# on its own — each entry lands as a plain dict, not a FolderIconBand,
|
||||
# which would break attribute access (.Icon) everywhere this gets read.
|
||||
BandsData = Filtered.pop("FolderIconBands", None)
|
||||
if isinstance(BandsData, list):
|
||||
KnownBandFields = {Field for Field in FolderIconBand.__dataclass_fields__}
|
||||
Bands = [
|
||||
FolderIconBand(**{K: V for K, V in Entry.items() if K in KnownBandFields})
|
||||
for Entry in BandsData if isinstance(Entry, dict)
|
||||
]
|
||||
Filtered["FolderIconBands"] = Bands or [FolderIconBand()]
|
||||
|
||||
return AppConfig(**Filtered)
|
||||
|
||||
|
||||
@@ -445,6 +503,16 @@ def ImportSettings(Config: AppConfig, SrcPath: Path) -> None:
|
||||
for Key, Value in SaraSortdFiltered.items():
|
||||
setattr(Config.SaraSortd, Key, Value)
|
||||
|
||||
BandsData = Filtered.pop("FolderIconBands", None)
|
||||
if isinstance(BandsData, list):
|
||||
KnownBandFields = {F for F in FolderIconBand.__dataclass_fields__}
|
||||
Bands = [
|
||||
FolderIconBand(**{K: V for K, V in Entry.items() if K in KnownBandFields})
|
||||
for Entry in BandsData if isinstance(Entry, dict)
|
||||
]
|
||||
if Bands:
|
||||
Filtered["FolderIconBands"] = Bands
|
||||
|
||||
for Key, Value in Filtered.items():
|
||||
setattr(Config, Key, Value)
|
||||
Config.Save()
|
||||
|
||||
+47
-9
@@ -46,6 +46,11 @@ class Device:
|
||||
MountPoint: Optional[Path]
|
||||
DrivePath: str = ""
|
||||
CanPowerOff: bool = False
|
||||
# True only for an internal drive mounted at a system path ("/", "/home",
|
||||
# ...) shown because of Config.ShowSystemDrive — _BuildTiles never shows
|
||||
# an eject button for one of these, no matter what CanPowerOff says.
|
||||
# Unmounting your own running root filesystem would crash the system.
|
||||
IsSystemMount: bool = False
|
||||
|
||||
|
||||
def _HumanSize(Bytes: int) -> str:
|
||||
@@ -95,7 +100,7 @@ class UDisksClient:
|
||||
raise OSError(Reply.body[0] if Reply.body else Reply.error_name)
|
||||
return Reply.body
|
||||
|
||||
async def ListDevices(self, ShowInternal: bool) -> list[Device]:
|
||||
async def ListDevices(self, ShowInternal: bool, ShowSystemDrive: bool = False) -> list[Device]:
|
||||
Objects = (await self._Call(UDisksPath, "org.freedesktop.DBus.ObjectManager", "GetManagedObjects"))[0]
|
||||
Devices = []
|
||||
for ObjectPath, Interfaces in Objects.items():
|
||||
@@ -110,11 +115,21 @@ class UDisksClient:
|
||||
for Raw in _Value(Interfaces[FilesystemInterface], "MountPoints", [])
|
||||
]
|
||||
MountPoint = MountPoints[0] if MountPoints else None
|
||||
IsSystemMount = False
|
||||
if _Value(Block, "HintSystem", False):
|
||||
# Internal disks: only unmounted ones or ones mounted like
|
||||
# removable media, never "/", "/home", "/boot" and friends.
|
||||
if not ShowInternal or (MountPoint is not None and not _UserMount(MountPoint)):
|
||||
# Internal disks: unmounted ones or ones mounted like
|
||||
# removable media always count as "internal". One mounted at
|
||||
# a system path only counts when ShowSystemDrive is also on,
|
||||
# and even then only the root "/" itself — udisks2 reports
|
||||
# /boot, /boot/efi, etc. as their own separate block devices,
|
||||
# and showing every one of those as its own non-ejectable
|
||||
# entry was more than "the main drive" (singular) implied.
|
||||
if not ShowInternal:
|
||||
continue
|
||||
if MountPoint is not None and not _UserMount(MountPoint):
|
||||
if not ShowSystemDrive or MountPoint != Path("/"):
|
||||
continue
|
||||
IsSystemMount = True
|
||||
Drive = Objects.get(DrivePath, {}).get(DriveInterface, {})
|
||||
Label = _Value(Block, "IdLabel", "") or _Value(Block, "HintName", "")
|
||||
if not Label:
|
||||
@@ -126,6 +141,7 @@ class UDisksClient:
|
||||
MountPoint=MountPoint,
|
||||
DrivePath=DrivePath,
|
||||
CanPowerOff=bool(_Value(Drive, "CanPowerOff", False)),
|
||||
IsSystemMount=IsSystemMount,
|
||||
))
|
||||
return sorted(Devices, key=lambda D: D.Label.lower())
|
||||
|
||||
@@ -156,9 +172,19 @@ class UDisksClient:
|
||||
|
||||
|
||||
class DevicesPanel(ft.Container):
|
||||
def __init__(self, OnNavigate: Callable[[Path], None], Config: AppConfig):
|
||||
def __init__(
|
||||
self,
|
||||
OnNavigate: Callable[[Path], None],
|
||||
Config: AppConfig,
|
||||
OnEmptyChanged: Optional[Callable[[bool], None]] = None,
|
||||
):
|
||||
self.OnNavigate = OnNavigate
|
||||
self.Config = Config
|
||||
# Features/Minor: "Option to hide devices section if there are none,
|
||||
# but show if there are" — fired from _Show only on an empty/non-
|
||||
# empty transition (not every device-list change), so Sidebar isn't
|
||||
# re-toggling visibility on every plug/unplug of an already-nonempty list.
|
||||
self.OnEmptyChanged = OnEmptyChanged
|
||||
self.Client: Optional[UDisksClient] = None
|
||||
self.CurrentDevices: list[Device] = []
|
||||
self.CurrentPath: Optional[Path] = None
|
||||
@@ -212,7 +238,7 @@ class DevicesPanel(ft.Container):
|
||||
|
||||
async def _Refresh(self, IsStartup: bool = False) -> None:
|
||||
try:
|
||||
Devices = await self.Client.ListDevices(self.Config.ShowInternalDrives)
|
||||
Devices = await self.Client.ListDevices(self.Config.ShowInternalDrives, self.Config.ShowSystemDrive)
|
||||
except Exception:
|
||||
return
|
||||
Known = {D.Key for D in self.CurrentDevices}
|
||||
@@ -224,6 +250,7 @@ class DevicesPanel(ft.Container):
|
||||
await self._MountDevice(NewDevice, Open=False)
|
||||
|
||||
def _Show(self, Devices: list[Device], Force: bool = False) -> None:
|
||||
WasEmpty = not self.CurrentDevices
|
||||
if Devices == self.CurrentDevices and not Force:
|
||||
return
|
||||
self.CurrentDevices = Devices
|
||||
@@ -231,6 +258,14 @@ class DevicesPanel(ft.Container):
|
||||
if self.CurrentPath is not None:
|
||||
self._ApplySelection()
|
||||
self.update()
|
||||
IsEmpty = not Devices
|
||||
# Force (startup) always fires too, not just real transitions: an
|
||||
# empty list right from the start is still "WasEmpty == IsEmpty",
|
||||
# which looks like a no-op transition but is actually the one time
|
||||
# Sidebar's listener most needs the initial state explicitly, since
|
||||
# DevicesBox otherwise defaults to visible with nothing to show.
|
||||
if self.OnEmptyChanged is not None and (IsEmpty != WasEmpty or Force):
|
||||
self.OnEmptyChanged(IsEmpty)
|
||||
|
||||
def _BuildTiles(self) -> list[ft.Control]:
|
||||
self.DeviceTiles = {}
|
||||
@@ -250,7 +285,10 @@ class DevicesPanel(ft.Container):
|
||||
icon=ft.Icons.EJECT,
|
||||
icon_color=Palette.Overlay0,
|
||||
tooltip="Eject",
|
||||
visible=self.Client is not None and (Mounted or Current.CanPowerOff),
|
||||
# Never offer to eject the system/boot drive — unmounting
|
||||
# your own running root filesystem would crash it.
|
||||
visible=self.Client is not None and not Current.IsSystemMount
|
||||
and (Mounted or Current.CanPowerOff),
|
||||
on_click=lambda e, D=Current: self.page.run_task(self._EjectDevice, D),
|
||||
),
|
||||
tooltip=str(Current.MountPoint) if Mounted else "Not mounted: click to mount",
|
||||
@@ -284,8 +322,8 @@ class DevicesPanel(ft.Container):
|
||||
self.OnNavigate(MountPoint)
|
||||
|
||||
async def _EjectDevice(self, Target: Device) -> None:
|
||||
if self.Client is None or Target.Key in self.Busy:
|
||||
return
|
||||
if self.Client is None or Target.Key in self.Busy or Target.IsSystemMount:
|
||||
return # never unmount the system drive, regardless of how this got called
|
||||
self.Busy.add(Target.Key)
|
||||
try:
|
||||
if Target.MountPoint is not None:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Per-directory custom folder icons (Features: "Add custom icons for
|
||||
directories... should be able to be changed in properties"). Independent
|
||||
of the global fullness-variant system in Core.py's FolderIconBands/
|
||||
AppConfig.FolderFullnessThreshold — a directory listed here always shows
|
||||
its own fixed icon/image regardless of how full it is; see Browser.py's
|
||||
_ResolveFolderIcon for how the two combine."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import toml
|
||||
|
||||
from Core import ConfigDir
|
||||
|
||||
FolderIconsPath = ConfigDir / "FolderIcons.toml"
|
||||
|
||||
# Pre-made choices for the icon picker (Features: "Use the pre-made ones"):
|
||||
# Material's own folder-shape variants first, then a few of Bookmarks'
|
||||
# thematic set so a folder can be marked by purpose too, not just shape.
|
||||
FolderIconChoices = [
|
||||
"FOLDER_OUTLINED", "FOLDER_OPEN", "FOLDER_SPECIAL", "FOLDER_SHARED",
|
||||
"FOLDER_ZIP", "FOLDER_COPY", "SNIPPET_FOLDER", "RULE_FOLDER",
|
||||
"STAR_OUTLINE", "FAVORITE_OUTLINE", "WORK_OUTLINE", "HOME_OUTLINED",
|
||||
"DOWNLOAD_OUTLINED", "IMAGE_OUTLINED", "MUSIC_NOTE_OUTLINED",
|
||||
"VIDEOCAM_OUTLINED", "CODE_OUTLINED",
|
||||
]
|
||||
|
||||
FolderIconLabelOverrides = {
|
||||
"FOLDER_OUTLINED": "Default", "FOLDER_OPEN": "Open", "FOLDER_SPECIAL": "Special",
|
||||
"FOLDER_SHARED": "Shared", "FOLDER_ZIP": "Archive", "FOLDER_COPY": "Copy",
|
||||
"SNIPPET_FOLDER": "Snippet", "RULE_FOLDER": "Rules",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FolderIconOverride:
|
||||
Icon: str = ""
|
||||
ImagePath: str = ""
|
||||
|
||||
|
||||
def LoadFolderIconOverrides() -> dict[Path, FolderIconOverride]:
|
||||
if not FolderIconsPath.exists():
|
||||
return {}
|
||||
try:
|
||||
Data = toml.loads(FolderIconsPath.read_text())
|
||||
except toml.TomlDecodeError:
|
||||
return {}
|
||||
Overrides: dict[Path, FolderIconOverride] = {}
|
||||
for PathText, Entry in Data.items():
|
||||
if isinstance(Entry, dict):
|
||||
Overrides[Path(PathText)] = FolderIconOverride(
|
||||
Icon=Entry.get("Icon", ""), ImagePath=Entry.get("ImagePath", "")
|
||||
)
|
||||
return Overrides
|
||||
|
||||
|
||||
def SetFolderIconOverride(TargetPath: Path, Icon: str, ImagePath: str) -> None:
|
||||
"""Icon and ImagePath both blank clears the override, back to whatever
|
||||
the global fullness-variant system would otherwise show."""
|
||||
Overrides = LoadFolderIconOverrides()
|
||||
if Icon or ImagePath:
|
||||
Overrides[TargetPath] = FolderIconOverride(Icon, ImagePath)
|
||||
else:
|
||||
Overrides.pop(TargetPath, None)
|
||||
ConfigDir.mkdir(parents=True, exist_ok=True)
|
||||
Data = {str(P): {"Icon": O.Icon, "ImagePath": O.ImagePath} for P, O in Overrides.items()}
|
||||
FolderIconsPath.write_text(toml.dumps(Data))
|
||||
+32
-17
@@ -13,11 +13,26 @@ only matter for file *renaming*, not this read-only "what goes where" view."""
|
||||
import fnmatch
|
||||
import re
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from Core import AppConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SortRule:
|
||||
"""One line of what GetSortRules found: Pattern goes Direction ("->" for
|
||||
outgoing, "<-" for incoming) Other (the other directory, display-
|
||||
formatted). Structured rather than a pre-formatted string so Browser.py
|
||||
can group rules sharing the same (Direction, Other) into one row —
|
||||
Bugs/Major: "SaraSortd integration header is way too long... condense
|
||||
outputs and inputs into > then v button things"."""
|
||||
|
||||
Pattern: str
|
||||
Direction: str
|
||||
Other: str
|
||||
|
||||
DefaultParentChar = "@"
|
||||
DefaultDirConfName = ".@Config"
|
||||
|
||||
@@ -54,14 +69,14 @@ def _DisplayPath(DirPath: Path) -> str:
|
||||
return str(DirPath)
|
||||
|
||||
|
||||
def GetSortRules(CurrentPath: Path, Config: AppConfig) -> list[str]:
|
||||
"""Returns display lines such as "*.png -> ~/Pictures" (CurrentPath is a
|
||||
configured SaraSortd input directory) and/or "*.png <- ~/Downloads"
|
||||
(CurrentPath is a configured output directory). Empty list if the
|
||||
integration is off, unconfigured (including SaraSortd's own un-set-up
|
||||
RootDir = "Unset"), or nothing matches this directory. Never raises —
|
||||
a malformed or foreign-format SaraSortd config degrades to no info
|
||||
rather than breaking the browser."""
|
||||
def GetSortRules(CurrentPath: Path, Config: AppConfig) -> list[SortRule]:
|
||||
"""Returns SortRules such as Pattern="*.png", Direction="->",
|
||||
Other="~/Pictures" (CurrentPath is a configured SaraSortd input
|
||||
directory) and/or Direction="<-" ones (CurrentPath is a configured
|
||||
output directory). Empty list if the integration is off, unconfigured
|
||||
(including SaraSortd's own un-set-up RootDir = "Unset"), or nothing
|
||||
matches this directory. Never raises — a malformed or foreign-format
|
||||
SaraSortd config degrades to no info rather than breaking the browser."""
|
||||
if not Config.SaraSortd.Enabled or not Config.SaraSortd.GlobalConfPath:
|
||||
return []
|
||||
|
||||
@@ -71,7 +86,7 @@ def GetSortRules(CurrentPath: Path, Config: AppConfig) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def _GetSortRulesUnsafe(CurrentPath: Path, Config: AppConfig) -> list[str]:
|
||||
def _GetSortRulesUnsafe(CurrentPath: Path, Config: AppConfig) -> list[SortRule]:
|
||||
GlobalConfPath = Path(Config.SaraSortd.GlobalConfPath).expanduser()
|
||||
GlobalConf = _LoadToml(GlobalConfPath)
|
||||
if not GlobalConf:
|
||||
@@ -106,7 +121,7 @@ def _GetSortRulesUnsafe(CurrentPath: Path, Config: AppConfig) -> list[str]:
|
||||
IsInputDir = any(D.exists() and D.resolve() == CurrentResolved for D in InputDirs)
|
||||
IsOutputDir = any(D.exists() and D.resolve() == CurrentResolved for D in OutputDirs)
|
||||
|
||||
Results: list[str] = []
|
||||
Results: list[SortRule] = []
|
||||
|
||||
if IsInputDir:
|
||||
for OutputDir in OutputDirs:
|
||||
@@ -126,7 +141,7 @@ def _GetSortRulesUnsafe(CurrentPath: Path, Config: AppConfig) -> list[str]:
|
||||
continue
|
||||
for FileRule in OutConf.get("Files") or []:
|
||||
if isinstance(FileRule, dict):
|
||||
Results.append(f"{FileRule.get('Pattern', '*')} -> {_DisplayPath(OutputDir)}")
|
||||
Results.append(SortRule(FileRule.get("Pattern", "*"), "->", _DisplayPath(OutputDir)))
|
||||
|
||||
if IsOutputDir:
|
||||
OutputDirName = CurrentResolved.name
|
||||
@@ -148,12 +163,12 @@ def _GetSortRulesUnsafe(CurrentPath: Path, Config: AppConfig) -> list[str]:
|
||||
):
|
||||
continue
|
||||
for Pattern in Patterns:
|
||||
Results.append(f"{Pattern} <- {_DisplayPath(InputDir)}")
|
||||
Results.append(SortRule(Pattern, "<-", _DisplayPath(InputDir)))
|
||||
|
||||
Seen: set[str] = set()
|
||||
Seen: set[SortRule] = set()
|
||||
Deduped = []
|
||||
for Line in Results:
|
||||
if Line not in Seen:
|
||||
Seen.add(Line)
|
||||
Deduped.append(Line)
|
||||
for Rule in Results:
|
||||
if Rule not in Seen:
|
||||
Seen.add(Rule)
|
||||
Deduped.append(Rule)
|
||||
return Deduped
|
||||
+145
-4
@@ -11,9 +11,10 @@ from typing import Callable
|
||||
import flet as ft
|
||||
|
||||
from Core import (
|
||||
AppConfig, AvailableThemes, ChipButtonStyle, InstalledFontFamilies, LoadSharedSettings, Palette,
|
||||
AppConfig, AvailableThemes, ChipButtonStyle, FolderIconBand, InstalledFontFamilies, LoadSharedSettings, Palette,
|
||||
SharedSettings,
|
||||
)
|
||||
from FolderIcons import FolderIconChoices, FolderIconLabelOverrides
|
||||
|
||||
# Settings Browser can already hot-apply live (dotfiles, breadcrumbs, preview, tags).
|
||||
# The window title bar, sidebar (bookmarks/filetree) layout, theme, and SaraTag's
|
||||
@@ -132,6 +133,81 @@ def BuildSettingsDialog(
|
||||
label="Collapse toolbar buttons into one menu", value=Config.ToolbarHamburger
|
||||
)
|
||||
|
||||
# Folder Icons (Features: "turning on different variants depending on
|
||||
# how full a directory is... add more... choose icons (or custom
|
||||
# images) for each"). WorkingFolderBands is edited live in the dialog;
|
||||
# only committed to Config.FolderIconBands in DoSave, same as every
|
||||
# other field here. One band (the default) behaves exactly like
|
||||
# today's plain folder icon everywhere — nothing changes until a
|
||||
# second variant gets added.
|
||||
FolderThresholdField = ft.TextField(
|
||||
value=str(Config.FolderFullnessThreshold),
|
||||
label="Items counted as 100% full",
|
||||
keyboard_type=ft.KeyboardType.NUMBER,
|
||||
)
|
||||
WorkingFolderBands: list[dict] = [
|
||||
{"Icon": B.Icon, "ImagePath": B.ImagePath} for B in (Config.FolderIconBands or [FolderIconBand()])
|
||||
]
|
||||
# Live widget refs, one (IconDropdown, ImageField) pair per currently-
|
||||
# displayed row — read from directly at save time (and before any
|
||||
# Add/Remove rebuild) rather than syncing via on_change, since Dropdown
|
||||
# only exposes on_select (a different event) and TextField already
|
||||
# keeps its own .value current without any handler needed.
|
||||
FolderBandWidgets: list[tuple[ft.Dropdown, ft.TextField]] = []
|
||||
FolderBandsColumn = ft.Column(spacing=8)
|
||||
|
||||
def _FolderIconLabel(Name: str) -> str:
|
||||
return FolderIconLabelOverrides.get(Name, Name.split("_")[0].title())
|
||||
|
||||
def _SyncWorkingFolderBands() -> None:
|
||||
for Index, (IconDropdown, ImageField) in enumerate(FolderBandWidgets):
|
||||
WorkingFolderBands[Index] = {
|
||||
"Icon": IconDropdown.value or "FOLDER_OUTLINED", "ImagePath": (ImageField.value or "").strip(),
|
||||
}
|
||||
|
||||
def RebuildFolderBandsColumn(DoUpdate: bool = True) -> None:
|
||||
FolderBandWidgets.clear()
|
||||
Count = len(WorkingFolderBands)
|
||||
Rows: list[ft.Control] = []
|
||||
for Index, BandData in enumerate(WorkingFolderBands):
|
||||
Low = round(Index / Count * 100)
|
||||
High = round((Index + 1) / Count * 100)
|
||||
|
||||
def OnRemove(e, I=Index) -> None:
|
||||
if len(WorkingFolderBands) > 1:
|
||||
_SyncWorkingFolderBands()
|
||||
WorkingFolderBands.pop(I)
|
||||
RebuildFolderBandsColumn()
|
||||
|
||||
IconDropdown = ft.Dropdown(
|
||||
value=BandData["Icon"],
|
||||
options=[ft.DropdownOption(key=Name, text=_FolderIconLabel(Name)) for Name in FolderIconChoices],
|
||||
width=150,
|
||||
)
|
||||
ImageField = ft.TextField(value=BandData["ImagePath"], label="Or custom image path", expand=True)
|
||||
FolderBandWidgets.append((IconDropdown, ImageField))
|
||||
RowItems: list[ft.Control] = [
|
||||
ft.Text(f"{Low}-{High}%", width=55, color=Palette.Overlay0), IconDropdown, ImageField,
|
||||
]
|
||||
if Count > 1:
|
||||
RowItems.append(
|
||||
ft.IconButton(icon=ft.Icons.DELETE_OUTLINE, tooltip="Remove this variant", on_click=OnRemove)
|
||||
)
|
||||
Rows.append(ft.Row(RowItems, spacing=8))
|
||||
FolderBandsColumn.controls = Rows
|
||||
if DoUpdate:
|
||||
FolderBandsColumn.update()
|
||||
|
||||
def AddFolderBand(e=None) -> None:
|
||||
_SyncWorkingFolderBands()
|
||||
WorkingFolderBands.append({"Icon": "FOLDER_OUTLINED", "ImagePath": ""})
|
||||
RebuildFolderBandsColumn()
|
||||
|
||||
RebuildFolderBandsColumn(DoUpdate=False) # dialog isn't shown yet
|
||||
AddFolderBandButton = ft.OutlinedButton(
|
||||
content="Add Variant", icon=ft.Icons.ADD, style=ChipButtonStyle(), on_click=AddFolderBand,
|
||||
)
|
||||
|
||||
# SaraFem's own values ("General" tab), only used with shared settings off.
|
||||
ThemeDropdown = ThemePicker(Config.Theme)
|
||||
FontDropdown = FontPicker(Config.FontFamily)
|
||||
@@ -164,6 +240,9 @@ def BuildSettingsDialog(
|
||||
label="Always show the search bar (off = only on Ctrl+F, Escape to dismiss)",
|
||||
value=Config.SearchAlwaysVisible,
|
||||
)
|
||||
SearchResetOnNavigateSwitch = ft.Switch(
|
||||
label="Clear search when changing directory", value=Config.SearchResetOnNavigate
|
||||
)
|
||||
ShowSortSwitch = ft.Switch(label="Enable sort control in the sidebar", value=Config.ShowSort)
|
||||
BatchRenamePatternField = ft.TextField(
|
||||
value=Config.BatchRenamePattern,
|
||||
@@ -179,6 +258,7 @@ def BuildSettingsDialog(
|
||||
|
||||
NewDirectoryButtonSwitch = ft.Switch(label="Show New Directory button", value=Config.ShowNewDirectoryButton)
|
||||
PasteButtonSwitch = ft.Switch(label="Show Paste button", value=Config.ShowPasteButton)
|
||||
RefreshButtonSwitch = ft.Switch(label="Show Refresh button", value=Config.ShowRefreshButton)
|
||||
ViewToggleButtonSwitch = ft.Switch(label="Show Grid/List button", value=Config.ShowViewToggleButton)
|
||||
OptionsButtonSwitch = ft.Switch(label="Show Options button", value=Config.ShowOptionsButton)
|
||||
ToolbarSwitch = ft.Switch(label="Show toolbar (breadcrumbs + buttons bar)", value=Config.ShowToolbar)
|
||||
@@ -201,6 +281,12 @@ def BuildSettingsDialog(
|
||||
DevicesSwitch = ft.Switch(label="Show devices section", value=Config.ShowDevices)
|
||||
AutoMountSwitch = ft.Switch(label="Mount devices when plugged in", value=Config.AutoMountDevices)
|
||||
InternalDrivesSwitch = ft.Switch(label="Also show internal drives", value=Config.ShowInternalDrives)
|
||||
SystemDriveSwitch = ft.Switch(
|
||||
label="Include the main drive (/) too (never ejectable)", value=Config.ShowSystemDrive
|
||||
)
|
||||
AutoHideEmptyDevicesSwitch = ft.Switch(
|
||||
label="Hide the devices section when there's nothing plugged in", value=Config.AutoHideEmptyDevices
|
||||
)
|
||||
DevicesHeightRow, DevicesMinHeightField, DevicesMaxHeightField = _MinMaxRow(
|
||||
"Devices height", Config.DevicesMinHeight, Config.DevicesMaxHeight
|
||||
)
|
||||
@@ -265,6 +351,12 @@ def BuildSettingsDialog(
|
||||
label="SaraSortd GlobalConf.toml path",
|
||||
hint_text="/home/sarah/Applications/SaraSortd/GlobalConf.toml",
|
||||
)
|
||||
SaraSortdShowBannerSwitch = ft.Switch(
|
||||
label="Show the SaraSortd info banner above the file list", value=Config.SaraSortd.ShowBanner
|
||||
)
|
||||
SaraSortdCondenseBannerSwitch = ft.Switch(
|
||||
label="Condense multiple patterns per destination (collapsible)", value=Config.SaraSortd.CondenseBanner
|
||||
)
|
||||
|
||||
Categories: dict[str, ft.Control] = {
|
||||
"SaraSuite": ft.Column(
|
||||
@@ -294,6 +386,7 @@ def BuildSettingsDialog(
|
||||
),
|
||||
],
|
||||
spacing=12,
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
),
|
||||
"General": ft.Column(
|
||||
[
|
||||
@@ -335,6 +428,25 @@ def BuildSettingsDialog(
|
||||
spacing=12,
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
),
|
||||
"Folder Icons": ft.Column(
|
||||
[
|
||||
_SectionLabel(
|
||||
"One variant always shows the same icon (today's plain folder, unless changed "
|
||||
"below). Add more to vary by how full a directory is, like Windows' folder fill "
|
||||
"levels — item count only, never a slow recursive size walk."
|
||||
),
|
||||
FolderThresholdField,
|
||||
FolderBandsColumn,
|
||||
AddFolderBandButton,
|
||||
ft.Divider(height=1, color=Palette.Surface1),
|
||||
_SectionLabel(
|
||||
'Per-directory overrides (right-click a folder → Properties → "Save Icon") '
|
||||
"always win over these."
|
||||
),
|
||||
],
|
||||
spacing=12,
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
),
|
||||
"Toolbar": ft.Column(
|
||||
[
|
||||
ToolbarSwitch,
|
||||
@@ -344,12 +456,14 @@ def BuildSettingsDialog(
|
||||
ft.Divider(height=1, color=Palette.Surface1),
|
||||
NewDirectoryButtonSwitch,
|
||||
PasteButtonSwitch,
|
||||
RefreshButtonSwitch,
|
||||
ViewToggleButtonSwitch,
|
||||
OptionsButtonSwitch,
|
||||
ft.Divider(height=1, color=Palette.Surface1),
|
||||
ToolbarHamburgerSwitch,
|
||||
],
|
||||
spacing=12,
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
),
|
||||
"Sidebar": ft.Column(
|
||||
[
|
||||
@@ -364,6 +478,8 @@ def BuildSettingsDialog(
|
||||
DevicesSwitch,
|
||||
AutoMountSwitch,
|
||||
InternalDrivesSwitch,
|
||||
SystemDriveSwitch,
|
||||
AutoHideEmptyDevicesSwitch,
|
||||
DevicesHeightRow,
|
||||
ft.Divider(height=1, color=Palette.Surface1),
|
||||
FiletreeSwitch,
|
||||
@@ -384,6 +500,7 @@ def BuildSettingsDialog(
|
||||
PreviewHeightRow,
|
||||
],
|
||||
spacing=12,
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
),
|
||||
"Tags": ft.Column(
|
||||
[
|
||||
@@ -404,13 +521,15 @@ def BuildSettingsDialog(
|
||||
[
|
||||
ShowSearchSwitch,
|
||||
SearchAlwaysVisibleSwitch,
|
||||
SearchResetOnNavigateSwitch,
|
||||
ft.Divider(height=1, color=Palette.Surface1),
|
||||
ShowSortSwitch,
|
||||
_SectionLabel('The sort control ("[Sort: Name] v") sits above Bookmarks in the sidebar.'),
|
||||
],
|
||||
spacing=12,
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
),
|
||||
"SaraTag": ft.Column([SaraTagSwitch, SaraTagPathField], spacing=12),
|
||||
"SaraTag": ft.Column([SaraTagSwitch, SaraTagPathField], spacing=12, scroll=ft.ScrollMode.AUTO),
|
||||
"SaraSortd": ft.Column(
|
||||
[
|
||||
SaraSortdSwitch,
|
||||
@@ -420,8 +539,12 @@ def BuildSettingsDialog(
|
||||
'"*.png -> ~/Pictures" for a watched input directory, or '
|
||||
'"*.png <- ~/Downloads" for one of its output directories.'
|
||||
),
|
||||
ft.Divider(height=1, color=Palette.Surface1),
|
||||
SaraSortdShowBannerSwitch,
|
||||
SaraSortdCondenseBannerSwitch,
|
||||
],
|
||||
spacing=12,
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -445,7 +568,7 @@ def BuildSettingsDialog(
|
||||
on_click=lambda e, n=Name: SelectCategory(n),
|
||||
)
|
||||
|
||||
CategoryListView = ft.Column(list(CategoryTiles.values()), spacing=0)
|
||||
CategoryListView = ft.Column(list(CategoryTiles.values()), spacing=0, scroll=ft.ScrollMode.AUTO)
|
||||
CategorySidebar = ft.Container(
|
||||
CategoryListView, width=CategoryListWidth, bgcolor=Palette.Surface0, border_radius=4, padding=4
|
||||
)
|
||||
@@ -474,13 +597,20 @@ def BuildSettingsDialog(
|
||||
Config.OpenFilesInApp = OpenFilesInAppSwitch.value
|
||||
Config.ShowSearch = ShowSearchSwitch.value
|
||||
Config.SearchAlwaysVisible = SearchAlwaysVisibleSwitch.value
|
||||
Config.SearchResetOnNavigate = SearchResetOnNavigateSwitch.value
|
||||
Config.ShowSort = ShowSortSwitch.value
|
||||
Config.BatchRenamePattern = BatchRenamePatternField.value.strip() or "~!^"
|
||||
Config.DeleteZipAfterExtract = DeleteZipSwitch.value
|
||||
Config.DefaultArchiveFormat = GetArchiveFormat()
|
||||
_SyncWorkingFolderBands()
|
||||
Config.FolderIconBands = [
|
||||
FolderIconBand(Icon=B["Icon"] or "FOLDER_OUTLINED", ImagePath=B["ImagePath"])
|
||||
for B in WorkingFolderBands
|
||||
]
|
||||
|
||||
Config.ShowNewDirectoryButton = NewDirectoryButtonSwitch.value
|
||||
Config.ShowPasteButton = PasteButtonSwitch.value
|
||||
Config.ShowRefreshButton = RefreshButtonSwitch.value
|
||||
Config.ShowViewToggleButton = ViewToggleButtonSwitch.value
|
||||
Config.ShowOptionsButton = OptionsButtonSwitch.value
|
||||
Config.ShowToolbar = ToolbarSwitch.value
|
||||
@@ -493,6 +623,8 @@ def BuildSettingsDialog(
|
||||
Config.ShowDevices = DevicesSwitch.value
|
||||
Config.AutoMountDevices = AutoMountSwitch.value
|
||||
Config.ShowInternalDrives = InternalDrivesSwitch.value
|
||||
Config.ShowSystemDrive = SystemDriveSwitch.value
|
||||
Config.AutoHideEmptyDevices = AutoHideEmptyDevicesSwitch.value
|
||||
Config.ShowFiletree = FiletreeSwitch.value
|
||||
Config.SplitBookmarksFiletreeEvenly = SplitSidebarEvenlySwitch.value
|
||||
Config.FiletreeRoot = FiletreeRootField.value.strip() or "/"
|
||||
@@ -510,6 +642,8 @@ def BuildSettingsDialog(
|
||||
|
||||
Config.SaraSortd.Enabled = SaraSortdSwitch.value
|
||||
Config.SaraSortd.GlobalConfPath = SaraSortdPathField.value.strip()
|
||||
Config.SaraSortd.ShowBanner = SaraSortdShowBannerSwitch.value
|
||||
Config.SaraSortd.CondenseBanner = SaraSortdCondenseBannerSwitch.value
|
||||
|
||||
for Field, TargetAttr in (
|
||||
(SidebarWidthField, "SidebarWidth"),
|
||||
@@ -529,6 +663,7 @@ def BuildSettingsDialog(
|
||||
(GridIconSizeField, "GridIconSize"),
|
||||
(ListIconSizeField, "ListIconSize"),
|
||||
(FontSizeField, "FontSize"),
|
||||
(FolderThresholdField, "FolderFullnessThreshold"),
|
||||
):
|
||||
try:
|
||||
setattr(Config, TargetAttr, int(Field.value))
|
||||
@@ -542,7 +677,13 @@ def BuildSettingsDialog(
|
||||
title="Options",
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Row([CategorySidebar, DetailArea], expand=True),
|
||||
# STRETCH confirmed necessary, not assumed: without it, Row
|
||||
# doesn't bound CategorySidebar/DetailArea's height to the
|
||||
# dialog's own fixed height, so their scroll=AUTO had nothing
|
||||
# to actually scroll within — content just overflowed instead
|
||||
# (Bugs/Minor: "Settings should be scrollable... too many for
|
||||
# one window").
|
||||
ft.Row([CategorySidebar, DetailArea], expand=True, vertical_alignment=ft.CrossAxisAlignment.STRETCH),
|
||||
ft.Divider(height=1, color=Palette.Surface1),
|
||||
ft.Text(RestartRequiredNote, color=Palette.Overlay0, size=12),
|
||||
],
|
||||
|
||||
+30
-2
@@ -39,6 +39,11 @@ class Sidebar(ft.Container):
|
||||
self.BookmarksListView: Optional[ft.ReorderableListView] = None
|
||||
self.DevicesView: Optional[DevicesPanel] = None
|
||||
self.FiletreeView: Optional[Filetree] = None
|
||||
# Both set below only if ShowDevices and AutoHideEmptyDevices are on
|
||||
# — see _OnDevicesEmptyChanged (Features/Minor: "Option to hide
|
||||
# devices section if there are none, but show if there are").
|
||||
self.DevicesBox: Optional[ft.Container] = None
|
||||
self.DevicesFiletreeDivider: Optional[ft.Control] = None
|
||||
|
||||
Sections: list[ft.Control] = []
|
||||
SplitEvenly = Config.SplitBookmarksFiletreeEvenly and Config.ShowBookmarks and Config.ShowFiletree
|
||||
@@ -77,15 +82,21 @@ class Sidebar(ft.Container):
|
||||
Sections.append(ft.Divider(height=1, color=Palette.Surface1))
|
||||
|
||||
if Config.ShowDevices:
|
||||
self.DevicesView = DevicesPanel(OnNavigate=OnNavigate, Config=Config)
|
||||
self.DevicesView = DevicesPanel(
|
||||
OnNavigate=OnNavigate,
|
||||
Config=Config,
|
||||
OnEmptyChanged=self._OnDevicesEmptyChanged if Config.AutoHideEmptyDevices else None,
|
||||
)
|
||||
DevicesBox = ft.Container(
|
||||
content=self.DevicesView,
|
||||
height=Config.DevicesMaxHeight or Config.DevicesMinHeight or None,
|
||||
)
|
||||
self.DevicesBox = DevicesBox
|
||||
Sections.append(DevicesBox)
|
||||
|
||||
if Config.ShowDevices and Config.ShowFiletree:
|
||||
Sections.append(self._BuildSectionDivider(DevicesBox, "DevicesMaxHeight"))
|
||||
self.DevicesFiletreeDivider = self._BuildSectionDivider(DevicesBox, "DevicesMaxHeight")
|
||||
Sections.append(self.DevicesFiletreeDivider)
|
||||
|
||||
if Config.ShowFiletree:
|
||||
self.FiletreeView = Filetree(
|
||||
@@ -161,6 +172,23 @@ class Sidebar(ft.Container):
|
||||
if self.OnSortChanged:
|
||||
self.OnSortChanged()
|
||||
|
||||
def _OnDevicesEmptyChanged(self, IsEmpty: bool) -> None:
|
||||
"""Only wired up when Config.AutoHideEmptyDevices is on. Hides the
|
||||
Devices box AND its own dedicated divider (the one between it and
|
||||
Filetree, which exists only because Devices does) together, so an
|
||||
empty Devices section disappears cleanly rather than leaving a
|
||||
double divider or dead space. The divider ABOVE Devices (shared
|
||||
with Bookmarks) is deliberately left alone — it still correctly
|
||||
separates Bookmarks from whatever's next once Devices itself is
|
||||
gone, and removing it too would need to depend on Filetree's own
|
||||
visibility, which this panel has no reason to know about."""
|
||||
if self.DevicesBox is not None:
|
||||
self.DevicesBox.visible = not IsEmpty
|
||||
self.DevicesBox.update()
|
||||
if self.DevicesFiletreeDivider is not None:
|
||||
self.DevicesFiletreeDivider.visible = not IsEmpty
|
||||
self.DevicesFiletreeDivider.update()
|
||||
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user