From c542134f624915153865ed950ce7963a13912905 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Wed, 7 Oct 2026 17:28:58 +0200 Subject: [PATCH] Add Refresh button/Ctrl+R; add custom folder icons (per-directory override + global fullness-variant system) --- Browser.py | 208 +++++++++++++++++++++++++++++++++++++++++++---- Core.py | 68 ++++++++++++++++ Devices.py | 56 +++++++++++-- FolderIcons.py | 67 +++++++++++++++ SaraSortdInfo.py | 49 +++++++---- Settings.py | 149 ++++++++++++++++++++++++++++++++- Sidebar.py | 32 +++++++- 7 files changed, 581 insertions(+), 48 deletions(-) create mode 100644 FolderIcons.py diff --git a/Browser.py b/Browser.py index 1144e97..bca84ef 100644 --- a/Browser.py +++ b/Browser.py @@ -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) diff --git a/Core.py b/Core.py index 91eaa18..536777f 100644 --- a/Core.py +++ b/Core.py @@ -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() diff --git a/Devices.py b/Devices.py index 7bd5520..3d543f7 100644 --- a/Devices.py +++ b/Devices.py @@ -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: diff --git a/FolderIcons.py b/FolderIcons.py new file mode 100644 index 0000000..997ac6b --- /dev/null +++ b/FolderIcons.py @@ -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)) diff --git a/SaraSortdInfo.py b/SaraSortdInfo.py index 30b8ea8..bde7dd9 100644 --- a/SaraSortdInfo.py +++ b/SaraSortdInfo.py @@ -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 diff --git a/Settings.py b/Settings.py index 7dea024..05a53f0 100644 --- a/Settings.py +++ b/Settings.py @@ -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), ], diff --git a/Sidebar.py b/Sidebar.py index e4123d3..87ed636 100644 --- a/Sidebar.py +++ b/Sidebar.py @@ -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,