"""Settings dialog: the shared SaraSuite settings (with export/import), SaraSort's own, and where SaraSortd lives. Laid out like SaraFem's and SaraSuite's Settings (a category list on the left, the chosen category on the right).""" import subprocess from pathlib import Path from typing import Callable import flet as ft import tomlkit from Core import ( AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButtonStyle, ConfigDir, DisplayPath, ExportSettings, HasSystemd, Home, ImportSettings, IsMac, IsWindows, LoadSharedSettings, Palette, SharedConfigPath, SharedSettings, ) from Sortd import MajorVersion, Setup, SortdError from SortdTransfer import ApplyBundle, Bundle, BundleDirectories, ExportSortd, MissingDirectories, ReadBundleSafely from Widgets import App, DirField, ExpandPath DialogWidth = 700 DialogHeight = 520 CategoryListWidth = 150 RestartNote = "Theme, font and title bar changes apply the next time SaraSort starts." StartPages = ["Flow", "Service", "Configs", "Templates", "Tools"] def _Note(Text: str) -> ft.Text: return ft.Text(Text, color=Palette.Overlay0, size=12) def _WindowsFonts() -> set[str]: """Font families from the system and per-user Fonts registry keys ("Arial Bold (TrueType)" -> "Arial").""" import re import winreg Families: set[str] = set() Key = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" for Root in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER): try: with winreg.OpenKey(Root, Key) as Fonts: Index = 0 while True: try: Name = winreg.EnumValue(Fonts, Index)[0] except OSError: break Index += 1 Name = re.sub(r"\s*\(.*\)$", "", Name) Name = re.sub(r"\s+(Bold|Italic|Light|Semibold|SemiBold|Black|Regular|Medium|Thin)\b.*$", "", Name) if Name and "&" not in Name: Families.add(Name.strip()) except OSError: continue return Families def _MacFonts() -> set[str]: """Font families on macOS (no fontconfig there), read from the font files' name tables in the system, shared and personal Fonts folders. The typographic family (name ID 16) where there is one, else ID 1.""" import struct Families: set[str] = set() for Folder in (Path("/System/Library/Fonts"), Path("/Library/Fonts"), Path.home() / "Library" / "Fonts"): if not Folder.is_dir(): continue for FilePath in Folder.rglob("*"): if FilePath.suffix.lower() not in (".ttf", ".otf", ".ttc"): continue try: Data = FilePath.read_bytes() except OSError: continue Offsets = [0] if Data[:4] == b"ttcf": # a collection: every font in it Count = struct.unpack_from(">I", Data, 8)[0] Offsets = list(struct.unpack_from(f">{Count}I", Data, 12)) for Offset in Offsets: try: for Index in range(struct.unpack_from(">H", Data, Offset + 4)[0]): Tag, _Sum, Start, _Length = struct.unpack_from(">4sIII", Data, Offset + 12 + Index * 16) if Tag != b"name": continue _Format, Count, Strings = struct.unpack_from(">HHH", Data, Start) Found: dict[int, str] = {} for Record in range(Count): PlatformId, _Encoding, Language, NameId, Length, At = struct.unpack_from( ">6H", Data, Start + 6 + Record * 12) if NameId not in (1, 16) or NameId in Found: continue Raw = Data[Start + Strings + At:Start + Strings + At + Length] if PlatformId in (0, 3): Found[NameId] = Raw.decode("utf-16-be", "replace") elif PlatformId == 1 and Language == 0: Found[NameId] = Raw.decode("mac-roman", "replace") Name = (Found.get(16) or Found.get(1) or "").strip() if Name and not Name.startswith("."): # ".SF NS" and co. are system-internal Families.add(Name) except struct.error: continue return Families def _Fonts() -> list[str]: """Installed font families (fontconfig, the registry on Windows, the font files themselves on macOS), with the bundled font first.""" if IsWindows or IsMac: Families = _WindowsFonts() if IsWindows else _MacFonts() Families.discard(BundledFontFamily) return [BundledFontFamily] + sorted(Families, key=str.lower) try: Result = subprocess.run( ["fc-list", ":", "family"], capture_output=True, text=True, timeout=5, env=ChildEnvironment(), ) Families = {Line.split(",")[0].strip() for Line in Result.stdout.splitlines() if Line.strip()} except (OSError, subprocess.SubprocessError): Families = set() Families.discard(BundledFontFamily) return [BundledFontFamily] + sorted(Families, key=str.lower) def _NumberField(Label: str, Value: int) -> ft.TextField: return ft.TextField(label=Label, value=str(Value), keyboard_type=ft.KeyboardType.NUMBER) def _ReadInt(Field: ft.TextField, Fallback: int, Minimum: int) -> int: try: return max(Minimum, int(Field.value)) except (TypeError, ValueError): return Fallback # keep the previous value rather than crash on bad input def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[], None]) -> None: Page = TheApp.Page Config: AppConfig = TheApp.Config Themes = AvailableThemes() Fonts = _Fonts() def ThemePicker(Value: str) -> ft.Dropdown: Names = Themes if Value in Themes else [Value] + Themes return ft.Dropdown(value=Value, label="Theme", options=[ft.DropdownOption(key=Name, text=Name) for Name in Names]) def FontPicker(Value: str) -> ft.Dropdown: # Type to filter the long list. Names = Fonts if Value in Fonts else [Value] + Fonts return ft.Dropdown( value=Value, label="Font", editable=True, enable_filter=True, menu_height=320, options=[ft.DropdownOption(key=Name, text=Name) for Name in Names], ) # SaraSuite: ~/.config/SaraSuite/Config.toml, read by every SaraSuite program. Shared = LoadSharedSettings(Config) UseSharedSwitch = ft.Switch(label="SaraSort uses these shared settings", value=Config.UseSharedSettings) SharedTheme = ThemePicker(Shared.Theme) SharedFont = FontPicker(Shared.FontFamily) SharedTitleBar = ft.Switch(label="Hide window title bar", value=Shared.HideWindowTitleBar) SettingsFileField = ft.TextField(value=str(Home / "SaraSort-Settings.toml"), label="Settings file path") # SaraSort's own appearance, only used with shared settings off. OwnTheme = ThemePicker(Config.Theme) OwnFont = FontPicker(Config.FontFamily) OwnTitleBar = ft.Switch(label="Hide window title bar", value=Config.HideWindowTitleBar) OwnAppearance = ft.Column( [_Note("SaraSort's own appearance, used when shared settings are off"), OwnTheme, OwnFont, OwnTitleBar], spacing=12, disabled=Config.UseSharedSettings, opacity=0.5 if Config.UseSharedSettings else 1, ) def OnUseSharedChanged(e) -> None: OwnAppearance.disabled = UseSharedSwitch.value OwnAppearance.opacity = 0.5 if UseSharedSwitch.value else 1 OwnAppearance.update() UseSharedSwitch.on_change = OnUseSharedChanged StartPage = ft.Dropdown( label="Start page", value=Config.StartPage if Config.StartPage in StartPages else "Flow", options=[ft.DropdownOption(key=Name, text=Name) for Name in StartPages], ) LabelLength = _NumberField("Arrow label length before it's shortened", Config.LabelLength) ConfigDirField = ft.TextField(label="SaraSortd config folder", value=Config.SortdConfigDir) AppDirField = ft.TextField(label="SaraSortd program folder", value=Config.SortdAppDir) UnitField = ft.TextField(label="systemd user unit" if HasSystemd else "Service name", value=Config.ServiceName) AutoRestartSwitch = ft.Switch(label="Restart SaraSortd after changes to GlobalConf.toml", value=Config.AutoRestart) RefreshField = _NumberField("Service page refresh (seconds)", Config.RefreshSeconds) LogLinesField = _NumberField("Log lines to show", Config.LogLines) SortdFileField = ft.TextField(value=str(Home / "SaraSortd-Configs.toml"), label="SaraSortd export file path") # Where directory configs live (SaraSortd 7.2's DirConfDir), like SaraTag's Lexicons folder. try: CurrentSetup = Setup(Config) CurrentFolder = str(CurrentSetup.Dirs.get("DirConfDir", "") or "") except SortdError: CurrentSetup, CurrentFolder = None, "" LocationPicker = ft.RadioGroup( ft.Row([ ft.Radio(value="Inside", label="Inside each directory"), ft.Radio(value="Folder", label="In one folder"), ]), value="Folder" if CurrentFolder else "Inside", disabled=CurrentSetup is None, ) FolderRow, FolderField = DirField(TheApp, "Config folder (relative paths count from GlobalConf.toml's folder)", CurrentFolder or "Dirs") FolderRow.visible = bool(CurrentFolder) def OnLocationChanged(e) -> None: FolderRow.visible = LocationPicker.value == "Folder" FolderRow.update() LocationPicker.on_change = OnLocationChanged def OnMoveConfs(e) -> None: NewFolder = (FolderField.value or "").strip() if LocationPicker.value == "Folder" else "" if LocationPicker.value == "Folder" and not NewFolder: TheApp.Notify("Pick a folder for the configs.", "warning") return if NewFolder == CurrentFolder: TheApp.Notify("The configs are already there.", "info") return try: Moved = Setup(Config).MoveDirConfs(NewFolder) except (OSError, SortdError) as Error: TheApp.Notify(f"Couldn't move the configs: {Error}", "error") return Page.pop_dialog() OnSaved() TheApp.AfterGlobalChange() TheApp.Notify(f"Moved {Moved} file{'s' if Moved != 1 else ''}; directory configs now live " f"{'in ' + NewFolder if NewFolder else 'inside each directory'}.", "success") def OnSortdExport(e) -> None: PathText = (SortdFileField.value or "").strip() if not PathText: return try: Count = ExportSortd(Config, Path(PathText).expanduser()) except (OSError, SortdError) as Error: TheApp.Notify(f"Couldn't export SaraSortd's configs: {Error}", "error") return TheApp.Notify(f"Exported {Count} SaraSortd configs.", "success") def OnSortdImport(e) -> None: PathText = (SortdFileField.value or "").strip() if not PathText: return TheBundle, Error = ReadBundleSafely(Path(PathText).expanduser()) if TheBundle is None: TheApp.Notify(Error, "error") return # Same as importing SaraSort's own settings: the Settings dialog # closes, so a stale Save can't undo what the import wrote. Page.pop_dialog() ShowSortdImportDialog(TheApp, TheBundle, OnSaved) def OnExport(e) -> None: PathText = (SettingsFileField.value or "").strip() if not PathText: return try: ExportSettings(Config, Path(PathText).expanduser()) except OSError as Error: TheApp.Notify(f"Couldn't export settings: {Error}", "error") return TheApp.Notify("Settings exported.", "success") def OnImport(e) -> None: PathText = (SettingsFileField.value or "").strip() if not PathText: return try: ImportSettings(Config, Path(PathText).expanduser()) except (OSError, tomlkit.exceptions.TOMLKitError) as Error: TheApp.Notify(f"Couldn't import settings: {Error}", "error") return # The open dialog still shows the pre-import values; closing it # rather than leaving a stale Save that would overwrite the import. Page.pop_dialog() OnSaved() TheApp.Notify("Settings imported. Reopen Settings to see them; restart for Theme/Font/Title bar.", "success") Categories: dict[str, ft.Control] = { "SaraSuite": ft.Column( [ _Note(f"Shared by every SaraSuite program ({DisplayPath(str(SharedConfigPath))}). " "Other programs pick changes up the next time they start."), UseSharedSwitch, ft.Divider(height=1, color=Palette.Surface1), SharedTheme, SharedFont, SharedTitleBar, ft.Divider(height=1, color=Palette.Surface1), _Note("Export/import settings as one file: this tab's shared settings plus everything else " "in Settings. Same format as SaraFem's."), SettingsFileField, ft.Row([ ft.OutlinedButton(content="Export", style=ChipButtonStyle(), on_click=OnExport), ft.OutlinedButton(content="Import", style=ChipButtonStyle(), on_click=OnImport), ]), ], spacing=12, scroll=ft.ScrollMode.AUTO, ), "General": ft.Column( [ StartPage, LabelLength, ft.Divider(height=1, color=Palette.Surface1), OwnAppearance, ], spacing=12, scroll=ft.ScrollMode.AUTO, ), "SaraSortd": ft.Column( [ _Note("Where SaraSortd lives. The defaults are where SaraSuite installs it."), ConfigDirField, AppDirField, UnitField, AutoRestartSwitch, _Note("SaraSortd only reads GlobalConf.toml when it starts."), ft.Divider(height=1, color=Palette.Surface1), RefreshField, LogLinesField, ft.Divider(height=1, color=Palette.Surface1), _Note("Where SaraSortd keeps each directory's config (and history file). In one folder, the " "directories stay clean; each config is named after its directory, e.g. " "Pictures_CatsConfig.toml. Needs SaraSortd 7.2. Moving takes the existing configs along."), LocationPicker, FolderRow, ft.OutlinedButton(content="Move configs", style=ChipButtonStyle(), on_click=OnMoveConfs, disabled=CurrentSetup is None), ft.Divider(height=1, color=Palette.Surface1), _Note("Export/import all of SaraSortd's configs as one file: GlobalConf.toml, both templates " "and every input/output directory's own config. Importing replaces the current ones " "(they're backed up first)."), SortdFileField, ft.Row([ ft.OutlinedButton(content="Export", style=ChipButtonStyle(), on_click=OnSortdExport), ft.OutlinedButton(content="Import", style=ChipButtonStyle(), on_click=OnSortdImport), ]), ], spacing=12, scroll=ft.ScrollMode.AUTO, ), } DetailArea = ft.Container(Categories["General"], expand=True, padding=ft.Padding.only(left=12)) CategoryTiles: dict[str, ft.ListTile] = {} def SelectCategory(Name: str) -> None: for CategoryName, Tile in CategoryTiles.items(): Tile.selected = CategoryName == Name DetailArea.content = Categories[Name] DetailArea.update() CategoryList.update() for Name in Categories: CategoryTiles[Name] = ft.ListTile( title=Name, selected=Name == "General", selected_tile_color=Palette.Surface1, selected_color=Palette.Mauve, on_click=lambda e, n=Name: SelectCategory(n), ) CategoryList = ft.Column(list(CategoryTiles.values()), spacing=0, scroll=ft.ScrollMode.AUTO) def Save(e) -> None: Config.UseSharedSettings = UseSharedSwitch.value Config.Theme = OwnTheme.value or Config.Theme Config.FontFamily = OwnFont.value or Config.FontFamily Config.HideWindowTitleBar = OwnTitleBar.value Config.StartPage = StartPage.value or Config.StartPage Config.LabelLength = _ReadInt(LabelLength, Config.LabelLength, 6) Config.SortdConfigDir = (ConfigDirField.value or "").strip() or Config.SortdConfigDir Config.SortdAppDir = (AppDirField.value or "").strip() or Config.SortdAppDir Config.ServiceName = (UnitField.value or "").strip() or Config.ServiceName Config.AutoRestart = AutoRestartSwitch.value Config.RefreshSeconds = _ReadInt(RefreshField, Config.RefreshSeconds, 1) Config.LogLines = _ReadInt(LogLinesField, Config.LogLines, 10) Config.Save() SharedSettings( Theme=SharedTheme.value, FontFamily=SharedFont.value or Shared.FontFamily, HideWindowTitleBar=SharedTitleBar.value, ).Save() Page.pop_dialog() OnSaved() TheApp.Notify("Settings saved.", "success") Page.show_dialog(ft.AlertDialog( title="Settings", content=ft.Column( [ ft.Row( [ ft.Container( CategoryList, width=CategoryListWidth, bgcolor=Palette.Surface0, border_radius=4, padding=4, ), DetailArea, ], expand=True, vertical_alignment=ft.CrossAxisAlignment.STRETCH, ), ft.Divider(height=1, color=Palette.Surface1), _Note(RestartNote), ], width=DialogWidth, height=DialogHeight, ), actions=[ ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: Page.pop_dialog()), ft.OutlinedButton(content="Save", style=ChipButtonStyle(), on_click=Save), ], )) def ShowSortdImportDialog(TheApp: App, TheBundle: Bundle, OnDone: Callable[[], None]) -> None: """Confirms a SaraSortd import. Directories it uses that don't exist are listed with a path field each: keep the path to have it made, or change it to use another directory instead.""" Page = TheApp.Page Config = TheApp.Config try: Missing = MissingDirectories(Config, TheBundle) Total = len(BundleDirectories(Config, TheBundle)) except Exception as Error: # e.g. a GlobalConf in the export without the keys SaraSortd needs TheApp.Notify(f"Can't use that export: {Error}", "error") return Fields: dict[str, ft.TextField] = {} Rows: list[ft.Control] = [] for DirPath in Missing: Row, Field = DirField(TheApp, f"{Path(DirPath).name or DirPath} (doesn't exist)", DirPath) Fields[DirPath] = Field Rows.append(Row) Info = [ _Note(f"From an export of SaraSortd {TheBundle.SortdVersion or '?'}" + (f", made {TheBundle.Exported.replace('T', ' ')}" if TheBundle.Exported else "") + "."), ft.Text(f"This replaces SaraSortd's configs in {Config.SortdConfigDir} and in {Total} " f"director{'y' if Total == 1 else 'ies'}. The current ones are backed up to " f"{DisplayPath(str(ConfigDir / 'Backups'))} first.", size=13), ] try: Daemon = Setup(Config).DaemonVersion() except SortdError: Daemon = None if Daemon and TheBundle.SortdVersion and MajorVersion(Daemon) != MajorVersion(TheBundle.SortdVersion): Info.append(ft.Text(f"These configs are from SaraSortd {TheBundle.SortdVersion}, but {Daemon} is installed: " "the first number differs, so they may not work.", color=Palette.Red, size=13)) if Missing: Info += [ ft.Divider(height=1, color=Palette.Surface1), ft.Row([ft.Icon(ft.Icons.WARNING_AMBER, color=Palette.Yellow), ft.Text(f"{len(Missing)} director{'y' if len(Missing) == 1 else 'ies'} " f"{'doesn' if len(Missing) == 1 else 'don'}'t exist:", color=Palette.Yellow)]), _Note("Keep a path to have it made, or change it to use another directory instead " "(the configs and arrows follow it)."), # Only the list scrolls, so the dialog stays as small as its content. ft.Column(Rows, spacing=8, scroll=ft.ScrollMode.AUTO, height=min(300, 58 * len(Rows))), ] def Continue(e) -> None: Remap = {Old: ExpandPath(Field.value or "") or Old for Old, Field in Fields.items()} Page.pop_dialog() try: ApplyBundle(Config, TheBundle, Remap) except (OSError, SortdError) as Error: TheApp.Notify(f"Import failed: {Error}", "error") return OnDone() TheApp.AfterGlobalChange() # its toast says whether SaraSortd restarted Page.show_dialog(ft.AlertDialog( title="Import SaraSortd configs", content=ft.Column(Info, width=620, tight=True, spacing=10), actions=[ ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: Page.pop_dialog()), ft.OutlinedButton( content="Make directories and import" if Missing else "Import", style=ChipButtonStyle(Palette.Yellow if Missing else ""), on_click=Continue, ), ], ))