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:
+75
View File
@@ -155,6 +155,16 @@ class SaraTagIntegrationConfig:
)
@dataclass
class SaraSortdIntegrationConfig:
"""Points at a SaraSortd checkout's GlobalConf.toml (read-only — SaraFem
never writes to it). Disabled until pointed at one, e.g.
GlobalConfPath = "/home/sarah/Applications/SaraSortd/GlobalConf.toml"."""
Enabled: bool = False
GlobalConfPath: str = ""
@dataclass
class AppConfig:
"""Persisted user settings. Defaults match SaraFem's existing behaviour
@@ -192,6 +202,9 @@ class AppConfig:
# Grid view.
GridIconSize: int = 64
GridNameBelowIcon: bool = True
ListIconSize: int = 32
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
# Batch-rename pattern for multi-select rename, syntax modeled on
# SaraSortd's own placeholder characters (see ~/Applications/SaraSortd's
@@ -257,6 +270,7 @@ class AppConfig:
# MaxHeight when on, since a fixed height and an even flex split conflict.
SplitPreviewTagsEvenly: bool = False
SaraTag: SaraTagIntegrationConfig = field(default_factory=SaraTagIntegrationConfig)
SaraSortd: SaraSortdIntegrationConfig = field(default_factory=SaraSortdIntegrationConfig)
def Save(self) -> None:
ConfigDir.mkdir(parents=True, exist_ok=True)
@@ -285,6 +299,11 @@ def LoadConfig() -> AppConfig:
SaraTagFiltered = {Key: Value for Key, Value in SaraTagData.items() if Key in KnownSaraTagFields}
Filtered["SaraTag"] = SaraTagIntegrationConfig(**SaraTagFiltered)
SaraSortdData = Filtered.pop("SaraSortd", {})
KnownSaraSortdFields = {Field for Field in SaraSortdIntegrationConfig.__dataclass_fields__}
SaraSortdFiltered = {Key: Value for Key, Value in SaraSortdData.items() if Key in KnownSaraSortdFields}
Filtered["SaraSortd"] = SaraSortdIntegrationConfig(**SaraSortdFiltered)
return AppConfig(**Filtered)
@@ -330,6 +349,62 @@ def EffectiveSettings(Config: AppConfig) -> SharedSettings:
return LoadSharedSettings(Config) if Config.UseSharedSettings else _OwnSettings(Config)
def ExportSettings(Config: AppConfig, DestPath: Path) -> None:
"""One portable file bundling both the cross-program shared settings
(Theme/Font/TitleBar) and SaraFem's own, under separate top-level
sections (Features/Major: "Exporting and importing settings as one
file... applies to every other SaraSuite program"). A future SaraView/
SaraTag/etc. import would read the same [SaraSuite] section plus its
own top-level one, ignoring [SaraFem] — the same convention
ImportSettings below uses to ignore sections that aren't its own."""
Data = {"SaraSuite": asdict(_OwnSettings(Config)), "SaraFem": asdict(Config)}
DestPath.write_text(toml.dumps(Data))
def ImportSettings(Config: AppConfig, SrcPath: Path) -> None:
"""Applies a [SaraSuite] section (if present) to the shared settings
file and a [SaraFem] section (if present) onto Config in place, field
by field — a field the import doesn't mention is left as it was, rather
than reset to default, and unknown sections/fields (another program's,
or a newer SaraFem version's) are silently skipped: the "should stack
fine with others if installed or not... one file works anywhere on the
same version no matter what you have installed" compatibility the
feature asked for. Raises toml.TomlDecodeError on an unreadable file —
callers show that to the user rather than silently doing nothing."""
Data = toml.loads(SrcPath.read_text())
SharedData = Data.get("SaraSuite")
if isinstance(SharedData, dict):
KnownSharedFields = {F for F in SharedSettings.__dataclass_fields__}
Filtered = {K: V for K, V in SharedData.items() if K in KnownSharedFields}
Merged = asdict(_OwnSettings(Config))
Merged.update(Filtered)
SharedSettings(**Merged).Save()
OwnData = Data.get("SaraFem")
if isinstance(OwnData, dict):
KnownFields = {F for F in AppConfig.__dataclass_fields__}
Filtered = {K: V for K, V in OwnData.items() if K in KnownFields}
SaraTagData = Filtered.pop("SaraTag", None)
if isinstance(SaraTagData, dict):
KnownSaraTagFields = {F for F in SaraTagIntegrationConfig.__dataclass_fields__}
SaraTagFiltered = {K: V for K, V in SaraTagData.items() if K in KnownSaraTagFields}
for Key, Value in SaraTagFiltered.items():
setattr(Config.SaraTag, Key, Value)
SaraSortdData = Filtered.pop("SaraSortd", None)
if isinstance(SaraSortdData, dict):
KnownSaraSortdFields = {F for F in SaraSortdIntegrationConfig.__dataclass_fields__}
SaraSortdFiltered = {K: V for K, V in SaraSortdData.items() if K in KnownSaraSortdFields}
for Key, Value in SaraSortdFiltered.items():
setattr(Config.SaraSortd, Key, Value)
for Key, Value in Filtered.items():
setattr(Config, Key, Value)
Config.Save()
def ApplyAppearance(Settings: SharedSettings) -> None:
"""Theme colours and font. Must run before anything reads Palette.X or
calls BuildTheme(). The window title bar is applied by Main."""
+159
View File
@@ -0,0 +1,159 @@
"""Read-only SaraSortd integration (Features/Major: "Shows what types of files
go to somewhere else, or where types of files come from"). Reads SaraSortd's
own GlobalConf.toml plus each relevant directory's [email protected] sidecar file
directly as TOML — never imports or runs SaraSortd itself, and never writes to
any of its files.
Only replicates the one substitution InputDir/OutputDir/ValidInputDirs entries
actually use in practice: %Name% tokens resolved against [DirectoryPaths], and
the Parent placeholder ("@" by default) swapped for a directory's own name.
SaraSortd's fuller Parse() also handles NextNum/Year/OrgFileName/etc, but those
only matter for file *renaming*, not this read-only "what goes where" view."""
import fnmatch
import re
import tomllib
from pathlib import Path
from typing import Optional
from Core import AppConfig
DefaultParentChar = "@"
DefaultDirConfName = ".@Config"
def _LoadToml(TomlPath: Path) -> Optional[dict]:
try:
return tomllib.loads(TomlPath.read_text())
except (OSError, tomllib.TOMLDecodeError):
return None
def _ResolveRootDirTokens(Value: str, DirectoryPaths: dict) -> str:
def Replace(Match: re.Match) -> str:
Name = Match.group(1)
Found = DirectoryPaths.get(Name) if Name else None
return Found if isinstance(Found, str) else Match.group(0)
return re.sub(r"%(.*?)%", Replace, Value)
def _DirConfFileName(NamePattern: str, ParentChar: str, DirName: str) -> str:
return f"{NamePattern.replace(ParentChar, DirName)}.toml"
def _ValidInputDirMatches(Pattern: str, ParentChar: str, OutputDirName: str, InputPath: Path) -> bool:
Resolved = Pattern.replace(ParentChar, OutputDirName)
return fnmatch.fnmatchcase(str(InputPath), Resolved)
def _DisplayPath(DirPath: Path) -> str:
try:
return f"~/{DirPath.relative_to(Path.home())}"
except ValueError:
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."""
if not Config.SaraSortd.Enabled or not Config.SaraSortd.GlobalConfPath:
return []
try:
return _GetSortRulesUnsafe(CurrentPath, Config)
except Exception:
return []
def _GetSortRulesUnsafe(CurrentPath: Path, Config: AppConfig) -> list[str]:
GlobalConfPath = Path(Config.SaraSortd.GlobalConfPath).expanduser()
GlobalConf = _LoadToml(GlobalConfPath)
if not GlobalConf:
return []
DirectoryPaths = GlobalConf.get("DirectoryPaths")
if not isinstance(DirectoryPaths, dict):
return []
RootDir = DirectoryPaths.get("RootDir")
if not isinstance(RootDir, str) or not RootDir or RootDir == "Unset":
return []
Names = GlobalConf.get("Names") or {}
ParentChar = (GlobalConf.get("Variables") or {}).get("Parent", DefaultParentChar)
OutDirConfName = Names.get("OutDirConfName", DefaultDirConfName)
def ResolvedDirs(Key: str) -> list[Path]:
Raw = DirectoryPaths.get(Key, [])
if not isinstance(Raw, list):
return []
return [
Path(_ResolveRootDirTokens(Entry, DirectoryPaths)).expanduser()
for Entry in Raw if isinstance(Entry, str)
]
InputDirs = ResolvedDirs("InputDir")
OutputDirs = ResolvedDirs("OutputDir")
CurrentResolved = CurrentPath.expanduser().resolve()
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] = []
if IsInputDir:
for OutputDir in OutputDirs:
if not OutputDir.is_dir():
continue
OutputDirName = OutputDir.name
OutConf = _LoadToml(OutputDir / _DirConfFileName(OutDirConfName, ParentChar, OutputDirName))
if not OutConf:
continue
ValidInputDirs = OutConf.get("ValidInputDirs", ["*"])
if not isinstance(ValidInputDirs, list):
continue
if not any(
_ValidInputDirMatches(V, ParentChar, OutputDirName, CurrentResolved)
for V in ValidInputDirs if isinstance(V, str)
):
continue
for FileRule in OutConf.get("Files") or []:
if isinstance(FileRule, dict):
Results.append(f"{FileRule.get('Pattern', '*')} -> {_DisplayPath(OutputDir)}")
if IsOutputDir:
OutputDirName = CurrentResolved.name
OutConf = _LoadToml(CurrentResolved / _DirConfFileName(OutDirConfName, ParentChar, OutputDirName))
if OutConf:
ValidInputDirs = OutConf.get("ValidInputDirs", ["*"])
Patterns = [
FileRule.get("Pattern", "*")
for FileRule in (OutConf.get("Files") or [])
if isinstance(FileRule, dict)
]
if isinstance(ValidInputDirs, list) and Patterns:
for InputDir in InputDirs:
if not InputDir.is_dir():
continue
if not any(
_ValidInputDirMatches(V, ParentChar, OutputDirName, InputDir.resolve())
for V in ValidInputDirs if isinstance(V, str)
):
continue
for Pattern in Patterns:
Results.append(f"{Pattern} <- {_DisplayPath(InputDir)}")
Seen: set[str] = set()
Deduped = []
for Line in Results:
if Line not in Seen:
Seen.add(Line)
Deduped.append(Line)
return Deduped
+67
View File
@@ -5,6 +5,7 @@ look good... sidebar with areas, then a much bigger main box"), with the
left/right/top/bottom-style choices as icon-button pickers instead of text
dropdowns ("some way to choose layout in a more graphical... way")."""
from pathlib import Path
from typing import Callable
import flet as ft
@@ -66,6 +67,8 @@ def BuildSettingsDialog(
Config: AppConfig,
OnSave: Callable[[], None],
OnDismiss: Callable[[], None],
OnExport: Callable[[str], None],
OnImport: Callable[[str], None],
) -> ft.AlertDialog:
Themes = AvailableThemes()
Fonts = InstalledFontFamilies()
@@ -91,6 +94,14 @@ def BuildSettingsDialog(
SharedFontDropdown = FontPicker(Shared.FontFamily)
SharedTitleBarSwitch = ft.Switch(label="Hide window title bar", value=Shared.HideWindowTitleBar)
# Export/import as one portable file (Features/Major) — separate from
# the shared settings above: that's the always-on, automatic sync every
# SaraSuite program reads on startup, this is a one-off snapshot you
# choose a path for, to back up or carry to another machine.
SettingsFileField = ft.TextField(
value=str(Path.home() / "SaraFem-Settings.toml"), label="Settings file path"
)
# "Middle" tab: the main file-list pane itself, not the panels around it.
ViewModePicker, GetViewMode = _OptionPicker(
[("list", "List", "VIEW_LIST_OUTLINED"), ("grid", "Grid", "GRID_VIEW_OUTLINED")], Config.ViewMode
@@ -111,6 +122,15 @@ def BuildSettingsDialog(
[("below", "Below", "SOUTH"), ("above", "Above", "NORTH")],
"below" if Config.GridNameBelowIcon else "above",
)
ListIconSizeField = ft.TextField(
value=str(Config.ListIconSize), label="List icon size", keyboard_type=ft.KeyboardType.NUMBER
)
FontSizeField = ft.TextField(
value=str(Config.FontSize), label="Name font size", keyboard_type=ft.KeyboardType.NUMBER
)
ToolbarHamburgerSwitch = ft.Switch(
label="Collapse toolbar buttons into one menu", value=Config.ToolbarHamburger
)
# SaraFem's own values ("General" tab), only used with shared settings off.
ThemeDropdown = ThemePicker(Config.Theme)
@@ -229,6 +249,13 @@ def BuildSettingsDialog(
hint_text="/home/sarah/Applications/SaraTag/.saratag",
)
SaraSortdSwitch = ft.Switch(label="Enable SaraSortd integration", value=Config.SaraSortd.Enabled)
SaraSortdPathField = ft.TextField(
value=Config.SaraSortd.GlobalConfPath,
label="SaraSortd GlobalConf.toml path",
hint_text="/home/sarah/Applications/SaraSortd/GlobalConf.toml",
)
Categories: dict[str, ft.Control] = {
"SaraSuite": ft.Column(
[
@@ -239,6 +266,22 @@ def BuildSettingsDialog(
SharedThemeDropdown,
SharedFontDropdown,
SharedTitleBarSwitch,
ft.Divider(height=1, color=Palette.Surface1),
_SectionLabel("Export/import settings as one file — this tab's shared "
"settings plus everything else in Options"),
SettingsFileField,
ft.Row(
[
ft.OutlinedButton(
content="Export", style=ChipButtonStyle(),
on_click=lambda e: OnExport(SettingsFileField.value.strip()),
),
ft.OutlinedButton(
content="Import", style=ChipButtonStyle(),
on_click=lambda e: OnImport(SettingsFileField.value.strip()),
),
]
),
],
spacing=12,
),
@@ -269,6 +312,10 @@ def BuildSettingsDialog(
ListContentSwitch,
ListAccessSwitch,
ft.Divider(height=1, color=Palette.Surface1),
_SectionLabel("Font and icon sizes"),
FontSizeField,
ListIconSizeField,
ft.Divider(height=1, color=Palette.Surface1),
_SectionLabel("Grid"),
GridIconSizeField,
_SectionLabel("Name position"),
@@ -288,6 +335,8 @@ def BuildSettingsDialog(
PasteButtonSwitch,
ViewToggleButtonSwitch,
OptionsButtonSwitch,
ft.Divider(height=1, color=Palette.Surface1),
ToolbarHamburgerSwitch,
],
spacing=12,
),
@@ -341,6 +390,18 @@ def BuildSettingsDialog(
scroll=ft.ScrollMode.AUTO,
),
"SaraTag": ft.Column([SaraTagSwitch, SaraTagPathField], spacing=12),
"SaraSortd": ft.Column(
[
SaraSortdSwitch,
SaraSortdPathField,
_SectionLabel(
'Shows what SaraSortd does with the directory you\'re browsing, e.g. '
'"*.png -> ~/Pictures" for a watched input directory, or '
'"*.png <- ~/Downloads" for one of its output directories.'
),
],
spacing=12,
),
}
DetailArea = ft.Container(Categories["General"], expand=True, padding=ft.Padding.only(left=12))
@@ -378,6 +439,7 @@ def BuildSettingsDialog(
Config.ListShowContent = ListContentSwitch.value
Config.ListShowAccess = ListAccessSwitch.value
Config.GridNameBelowIcon = GetGridNamePosition() == "below"
Config.ToolbarHamburger = ToolbarHamburgerSwitch.value
Config.UseSharedSettings = UseSharedSwitch.value
Config.Theme = ThemeDropdown.value
Config.FontFamily = FontDropdown.value or Config.FontFamily
@@ -421,6 +483,9 @@ def BuildSettingsDialog(
Config.SaraTag.Enabled = SaraTagSwitch.value
Config.SaraTag.CoreModulePath = SaraTagPathField.value.strip()
Config.SaraSortd.Enabled = SaraSortdSwitch.value
Config.SaraSortd.GlobalConfPath = SaraSortdPathField.value.strip()
for Field, TargetAttr in (
(SidebarWidthField, "SidebarWidth"),
(BookmarksMinHeightField, "BookmarksMinHeight"),
@@ -437,6 +502,8 @@ def BuildSettingsDialog(
(TagPanelMinHeightField, "TagPanelMinHeight"),
(TagPanelMaxHeightField, "TagPanelMaxHeight"),
(GridIconSizeField, "GridIconSize"),
(ListIconSizeField, "ListIconSize"),
(FontSizeField, "FontSize"),
):
try:
setattr(Config, TargetAttr, int(Field.value))