Add SaraSortd integration: show incoming/outgoing sort rules for the browsed directory

This commit is contained in:
sarah committed 2026-09-26 18:11:18 +02:00
1 parent 31b6c554e6
commit d588d445b4
4 files changed
+436 -9

No files matched your search

+135 -9
View File
@@ -16,6 +16,7 @@ from typing import Callable, Optional
import flet as ft
import send2trash
import toml
from PIL import Image
from Bookmarks import AddBookmark
@@ -24,10 +25,11 @@ from Bookmarks import IconChoices as BookmarkIconChoices
from Bookmarks import IconLabelOverrides
from Books import ClearSaraTagCache, IsInternalSaraTagFile
from Core import (
AppConfig, ApplyHeightBounds, ChipButtonStyle, DragGroup, FormatBatchName, NotificationStyle, Palette,
ResizeHandle,
AppConfig, ApplyHeightBounds, ChipButtonStyle, DragGroup, ExportSettings, FormatBatchName, ImportSettings,
NotificationStyle, Palette, ResizeHandle,
)
from Preview import HumanSize, Preview, TextExtensions
from SaraSortdInfo import GetSortRules
from Settings import BuildSettingsDialog
from Tags import TagPanel
from Thumbnails import GetThumbnail, IsThumbnailable
@@ -299,6 +301,18 @@ class Browser(ft.Row):
expand=True,
)
self.EntryList = self._MakeEntryListContainer()
# SaraSortd integration (Features/Major): "*.png -> ~/Pictures" style
# lines for the currently-browsed directory. Mutated in place by
# _RefreshSortBanner on every NavigateTo, rather than rebuilt as part
# of _BuildChrome, since a plain directory change doesn't otherwise
# touch FileListColumn.controls.
self.SortBanner = ft.Container(
visible=False,
padding=ft.Padding.symmetric(horizontal=12, vertical=6),
bgcolor=Palette.Surface0,
border=ft.Border.all(1, Palette.Surface1),
content=ft.Column([], spacing=2),
)
# One shared popup host, opened programmatically (see _RequestMenu) so
# a right-click resolves to exactly one menu. Its built-in triggers
# can't be switched off: Flet 1.0.1's client reads a None trigger as
@@ -483,6 +497,7 @@ class Browser(ft.Row):
# bottom toolbar sat right under the last entry. Inside that fixed
# height, the inner Container's bgcolor makes it paint, and so
# hit-test, everywhere, including below the last entry.
Items.append(self.SortBanner)
Items.append(
ft.Container(
expand=True,
@@ -543,6 +558,8 @@ class Browser(ft.Row):
# unbounded/scrollable axis and an expanding child on that same axis
# don't mix. Right-alignment happens one level up instead, via the
# Container ActionsToolbar sits in.
if self.Config.ToolbarHamburger:
return self._BuildHamburgerToolbar()
Items: list[ft.Control] = []
if self.Config.ShowNewDirectoryButton:
Items.append(self.NewDirectoryButton)
@@ -554,6 +571,40 @@ class Browser(ft.Row):
Items.append(self.OptionsButton)
return Items
def _BuildHamburgerToolbar(self) -> list[ft.Control]:
"""Same buttons, collapsed into one overflow menu (Features/Minor:
"Hamburger menu option instead of buttons for toolbar")."""
Items: list[ft.PopupMenuItem] = []
if self.Config.ShowNewDirectoryButton:
Items.append(
ft.PopupMenuItem(
content="New Directory",
icon=ft.Icons.CREATE_NEW_FOLDER_OUTLINED,
on_click=lambda e: self._ShowNewDirectoryDialog(),
)
)
if self.Config.ShowPasteButton:
Items.append(
ft.PopupMenuItem(content="Paste", icon=ft.Icons.CONTENT_PASTE_OUTLINED, on_click=lambda e: self._Paste())
)
if self.Config.ShowViewToggleButton:
Items.append(
ft.PopupMenuItem(
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,
on_click=lambda e: self._ToggleViewMode(),
)
)
if self.Config.ShowOptionsButton:
Items.append(
ft.PopupMenuItem(
content="Options", icon=ft.Icons.SETTINGS_OUTLINED, on_click=lambda e: self._ShowSettingsDialog()
)
)
if not Items:
return []
return [ft.PopupMenuButton(icon=ft.Icons.MENU, items=Items)]
def RefreshToolbarVisibility(self) -> None:
self.ActionsToolbar.controls = self._BuildActionsToolbar()
self.ActionsToolbar.update()
@@ -580,6 +631,7 @@ class Browser(ft.Row):
self.ViewToggleButton.icon = (
ft.Icons.VIEW_LIST_OUTLINED if self.Config.ViewMode == "grid" else ft.Icons.GRID_VIEW_OUTLINED
)
self.RefreshToolbarVisibility() # the hamburger menu's "Grid"/"List" label needs the same refresh
self.EntryList = self._MakeEntryListContainer()
self.EntryContextMenu.content = self._BuildEntryListContent()
@@ -748,8 +800,26 @@ class Browser(ft.Row):
def _Refresh(self) -> None:
self.Breadcrumbs.controls = self._BuildBreadcrumbs(self.CurrentPath)
self._RefreshSortBanner()
self._RefreshEntries()
def _RefreshSortBanner(self) -> None:
Lines = GetSortRules(self.CurrentPath, self.Config)
if not Lines:
self.SortBanner.visible = False
return
self.SortBanner.visible = True
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
],
spacing=2,
)
def _RefreshEntries(self) -> None:
self.EntryList.controls = self._BuildEntries(self.CurrentPath)
self.update()
@@ -820,7 +890,20 @@ class Browser(ft.Row):
return [self._BuildListRow(Entry) for Entry in Entries]
MinColumnWidth = 40 # floor for header-divider drags, so a column can't vanish
ListHeaderHeight = 28 # 12px text + 2*6px vertical padding — see _BuildListHeader
@property
def MetaFontSize(self) -> int:
"""Secondary text (metadata columns, the header) scales with the
configurable name font size but stays a couple sizes smaller, same
visual hierarchy as the fixed 14/12 before it was configurable."""
return max(9, self.Config.FontSize - 2)
@property
def ListHeaderHeight(self) -> int:
"""Text line height + 2*6px vertical padding — see _BuildListHeader.
Derived from MetaFontSize (not fixed) so the header's own resize
divider still matches its actual height at any FontSize."""
return self.MetaFontSize + 16
def _MetadataCell(self, Text: str, Width: int) -> tuple[ft.Container, ft.GestureDetector]:
"""A fixed-width metadata column (Size/Modified/...): left-click
@@ -829,7 +912,7 @@ class Browser(ft.Row):
things than name shouldn't be right-clickable... it should behave
like you're right-clicking the background")."""
Cell = ft.Container(
content=ft.Text(Text, size=12, color=Palette.Overlay0, overflow=ft.TextOverflow.ELLIPSIS),
content=ft.Text(Text, size=self.MetaFontSize, color=Palette.Overlay0, overflow=ft.TextOverflow.ELLIPSIS),
width=Width,
padding=ft.Padding.symmetric(horizontal=8, vertical=8),
)
@@ -849,10 +932,11 @@ class Browser(ft.Row):
# after the last shown column, before the triple-dot (still pinned
# right, as before), is the background gap.
NameLabel = ft.Text(
Entry.name, size=14, color=Palette.Text, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1
Entry.name, size=self.Config.FontSize, color=Palette.Text,
overflow=ft.TextOverflow.ELLIPSIS, max_lines=1,
)
NameCell = ft.Container(
content=ft.Row([self._LeadingFor(Entry, Size=32), NameLabel], spacing=12),
content=ft.Row([self._LeadingFor(Entry, Size=self.Config.ListIconSize), NameLabel], spacing=12),
padding=ft.Padding.symmetric(horizontal=12, vertical=8),
bgcolor=Palette.Surface0 if Entry in self.SelectedPaths else None,
width=self.Config.ListWidthName,
@@ -943,7 +1027,7 @@ class Browser(ft.Row):
PreviousField: Optional[str] = None
for Label, FieldName, LeftPad in Fields:
Cell = ft.Container(
ft.Text(Label, size=12, color=Palette.Overlay0),
ft.Text(Label, size=self.MetaFontSize, color=Palette.Overlay0),
width=getattr(self.Config, f"ListWidth{FieldName}"),
padding=ft.Padding.only(left=LeftPad, right=8),
)
@@ -962,7 +1046,7 @@ class Browser(ft.Row):
def _BuildGridCell(self, Entry: Path) -> ft.Control:
IconControl = self._LeadingFor(Entry, Size=self.Config.GridIconSize)
NameControl = ft.Text(
Entry.name, size=11, text_align=ft.TextAlign.CENTER,
Entry.name, size=self.Config.FontSize, text_align=ft.TextAlign.CENTER,
max_lines=2, overflow=ft.TextOverflow.ELLIPSIS,
)
CellItems = [IconControl, NameControl] if self.Config.GridNameBelowIcon else [NameControl, IconControl]
@@ -1017,6 +1101,7 @@ class Browser(ft.Row):
ft.PopupMenuItem(
content="Cut", on_click=lambda e, p=Entry: self._SetClipboard(self._TargetSelection(p), "cut")
),
ft.PopupMenuItem(content="Copy Path", on_click=lambda e, p=Entry: self._HandleCopyPath(p)),
ft.PopupMenuItem(content="Properties", on_click=lambda e, p=Entry: self._HandleProperties(p)),
ft.PopupMenuItem(content="Delete", on_click=lambda e, p=Entry: self._HandleDelete(p)),
ft.PopupMenuItem(content="Compress", on_click=lambda e, p=Entry: self._HandleCompress(p)),
@@ -1146,6 +1231,8 @@ class Browser(ft.Row):
self._ToggleDotfiles()
elif Event.key == "T" and Event.ctrl:
self._ToggleTags()
elif Event.key == "C" and Event.ctrl and Event.shift and self.SelectedPaths:
self._HandleCopyPath(self.SelectedPath)
elif Event.key == "C" and Event.ctrl and self.SelectedPaths:
self._SetClipboard(sorted(self.SelectedPaths, key=lambda p: p.name.lower()), "copy")
elif Event.key == "X" and Event.ctrl and self.SelectedPaths:
@@ -1165,6 +1252,17 @@ class Browser(ft.Row):
self.PasteButton.disabled = False
self.update()
def _HandleCopyPath(self, Entry: Optional[Path]) -> None:
if Entry is None:
return
Targets = self._TargetSelection(Entry)
Text = "\n".join(str(P) for P in Targets)
self.page.run_task(self._DoCopyPath, Text, len(Targets))
async def _DoCopyPath(self, Text: str, Count: int) -> None:
await ft.Clipboard().set(Text)
self._Notify(f"Copied {Count} path(s)." if Count > 1 else "Copied path.", Kind="success")
def _Paste(self) -> None:
if not self.ClipboardPaths:
return
@@ -1418,6 +1516,7 @@ class Browser(ft.Row):
self.Config.ViewMode,
*(getattr(self.Config, ConfigFlag) for ConfigFlag, _Name, _Fn in ListColumns),
self.Config.GridIconSize, self.Config.GridNameBelowIcon,
self.Config.ListIconSize, self.Config.FontSize,
)
def _ShowSettingsDialog(self) -> None:
@@ -1439,6 +1538,7 @@ class Browser(ft.Row):
)
self.EntryList = self._MakeEntryListContainer()
self.EntryContextMenu.content = self._BuildEntryListContent()
self._RefreshSortBanner() # a just-toggled Enabled/GlobalConfPath should take effect immediately
self.FileListColumn.controls = self._BuildChrome()
self.FileListColumn.update()
self.controls = self._BuildLayout()
@@ -1450,7 +1550,33 @@ class Browser(ft.Row):
self.OnSidebarSettingsChanged()
self._Notify("Options saved.", Kind="success")
Dialog = BuildSettingsDialog(self.Config, OnSave=OnSave, OnDismiss=self._DismissDialog)
def OnExport(PathText: str) -> None:
if not PathText:
return
try:
ExportSettings(self.Config, Path(PathText))
except OSError as Error:
self._Notify(f"Couldn't export settings: {Error}")
return
self._Notify("Settings exported.", Kind="success")
def OnImport(PathText: str) -> None:
if not PathText:
return
try:
ImportSettings(self.Config, Path(PathText))
except (OSError, toml.TomlDecodeError) as Error:
self._Notify(f"Couldn't import settings: {Error}")
return
# The open dialog's widgets still show the pre-import values —
# closing it rather than leaving it open to a stale Save that
# would immediately overwrite what was just imported.
self._DismissDialog()
self._Notify("Settings imported — reopen Options to see them, restart for Theme/Font/Title bar.", Kind="success")
Dialog = BuildSettingsDialog(
self.Config, OnSave=OnSave, OnDismiss=self._DismissDialog, OnExport=OnExport, OnImport=OnImport
)
self._ShowDialog(Dialog)
def _HandleProperties(self, Entry: Path) -> None: