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)
+68
View File
@@ -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
View File
@@ -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:
+67
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,