From d588d445b4781ee0affcb448d85f2698a05f5c32 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Sat, 26 Sep 2026 18:11:18 +0200 Subject: [PATCH] Add SaraSortd integration: show incoming/outgoing sort rules for the browsed directory --- Browser.py | 144 +++++++++++++++++++++++++++++++++++++++--- Core.py | 75 ++++++++++++++++++++++ SaraSortdInfo.py | 159 +++++++++++++++++++++++++++++++++++++++++++++++ Settings.py | 67 ++++++++++++++++++++ 4 files changed, 436 insertions(+), 9 deletions(-) create mode 100644 SaraSortdInfo.py diff --git a/Browser.py b/Browser.py index e17303a..4856525 100644 --- a/Browser.py +++ b/Browser.py @@ -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: diff --git a/Core.py b/Core.py index bd176c3..5ebbad5 100644 --- a/Core.py +++ b/Core.py @@ -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.""" diff --git a/SaraSortdInfo.py b/SaraSortdInfo.py new file mode 100644 index 0000000..30b8ea8 --- /dev/null +++ b/SaraSortdInfo.py @@ -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 .@Config.toml 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 diff --git a/Settings.py b/Settings.py index 6bbe3fe..3c14ed4 100644 --- a/Settings.py +++ b/Settings.py @@ -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))