Files
2026-10-13 17:51:30 +02:00

444 lines
19 KiB
Python

"""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, ExportSettings, HasSystemd,
Home, ImportSettings, IsWindows, LoadSharedSettings, Palette, 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 _Fonts() -> list[str]:
"""Installed font families (fontconfig, or the registry on Windows), with
the bundled font first."""
if IsWindows:
Families = _WindowsFonts()
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("Shared by every SaraSuite program (~/.config/SaraSuite/Config.toml). "
"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 "
"~/.config/SaraSuite/SaraSort/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,
),
],
))