Files
2026-10-09 14:21:42 +02:00

319 lines
14 KiB
Python

"""Settings dialog: the shared SaraSuite settings (with export/import),
SaraTag's own GUI settings, and SaraTagConf.toml (Explorer, Lexicons,
History). Laid out like SaraFem's, SaraSuite's and SaraSort'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, Home,
ImportSettings, IsWindows, LoadSharedSettings, Palette, SharedSettings,
)
from Library import ConfPath, ConfValue, DataDir, DataError, SaveConfValues
from Widgets import App
DialogWidth = 720
DialogHeight = 540
CategoryListWidth = 150
RestartNote = "Theme, font and title bar changes apply the next time SaraTag starts."
StartPages = ["Browse", "Records", "Libraries", "Lexicons", "Update"]
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, dense=True)
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="SaraTag 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 / "SaraTag-Settings.toml"), label="Settings file path", dense=True)
# SaraTag'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("SaraTag'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 "Browse",
options=[ft.DropdownOption(key=Name, text=Name) for Name in StartPages],
)
PanelWidth = _NumberField("Browse side panel width", Config.PreviewWidth)
# SaraTagConf.toml, the portable config next to SaraTag's data.
def Switch(Section: str, Key: str, Label: str, Fallback: bool = True) -> ft.Switch:
return ft.Switch(label=Label, value=bool(ConfValue(Section, Key, Fallback)), data=(Section, Key))
def Number(Section: str, Key: str, Label: str, Fallback: int) -> ft.TextField:
Field = _NumberField(Label, int(ConfValue(Section, Key, Fallback)))
Field.data = (Section, Key, Fallback)
return Field
ConfSwitches = [
Switch("Explorer", "HideUntaggedDirectories", "Hide directories with no Lexicon in or below them"),
Switch("Explorer", "HideTagFiles", "Hide a directory's own Lexicon file from its listing"),
Switch("Explorer", "ShowInvalidEntries", "Show files whose Book is broken or has invalid values"),
Switch("Explorer", "ShowImagePreviews", "Show image previews"),
Switch("Lexicon", "AutoInheritance", "New subdirectory Lexicons inherit from the one above"),
Switch("Lexicon", "RelativeSourcePaths", "Write Source paths relative to the Lexicon"),
Switch("History", "Enabled", "Keep history (.SaraTagHistory.toml)"),
Switch("History", "RememberFilter", "Remember each collection's filter"),
Switch("History", "RememberSorting", "Remember each collection's sorting"),
Switch("History", "RememberLastDirectory", "Remember the last directory"),
Switch("History", "RememberLastSelectedFile", "Remember the last selected file"),
Switch("History", "RememberRecentFiles", "Remember recently opened files"),
Switch("History", "RememberUpdaterMode", "Remember the Update page's last choices"),
Switch("History", "RememberUpdates", "Remember which Books updates changed"),
]
ConfNumbers = [
Number("History", "MaxFileHistory", "Recent files kept", 30),
Number("History", "MaxDirectoryHistory", "Collections remembered", 20),
Number("History", "MaxUpdateHistory", "Updated Books remembered", 20),
]
VideoPlayer = ft.TextField(label="Video player (empty: the default app)", value=str(ConfValue("Media", "VideoPlayer", "")), dense=True)
PatternField = ft.TextField(label="Lexicon file name pattern", value=str(ConfValue("Lexicon", "LexiconFilePattern", "{Source}_{DirectoryName}.toml")),
dense=True)
def ConfControls(Section: str) -> list[ft.Control]:
return [Control for Control in ConfSwitches + ConfNumbers if Control.data[0] == Section]
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 SaraTag's own "
"(General). Same format as SaraFem's and SaraSort'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, PanelWidth, ft.Divider(height=1, color=Palette.Surface1), OwnAppearance],
spacing=12, scroll=ft.ScrollMode.AUTO,
),
"Browse": ft.Column(
[_Note(f"Stored in SaraTagConf.toml ({DataDir})."), *ConfControls("Explorer"), VideoPlayer],
spacing=8, scroll=ft.ScrollMode.AUTO,
),
"Lexicons": ft.Column(
[_Note("Stored in SaraTagConf.toml."), *ConfControls("Lexicon"), PatternField,
_Note("{Source} is the parent Lexicon's name (or the parent directory's), {DirectoryName} the directory's.")],
spacing=8, scroll=ft.ScrollMode.AUTO,
),
"History": ft.Column(
[_Note("Stored in SaraTagConf.toml; the history itself in .SaraTagHistory.toml."), *ConfControls("History")],
spacing=8, 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.PreviewWidth = _ReadInt(PanelWidth, Config.PreviewWidth, 240)
Config.Save()
SharedSettings(
Theme=SharedTheme.value,
FontFamily=SharedFont.value or Shared.FontFamily,
HideWindowTitleBar=SharedTitleBar.value,
).Save()
Values: dict[str, dict] = {}
for Control in ConfSwitches:
Values.setdefault(Control.data[0], {})[Control.data[1]] = bool(Control.value)
for Control in ConfNumbers:
Section, Key, Fallback = Control.data
Values.setdefault(Section, {})[Key] = _ReadInt(Control, int(ConfValue(Section, Key, Fallback)), 0)
Values.setdefault("Media", {})["VideoPlayer"] = (VideoPlayer.value or "").strip()
Pattern = (PatternField.value or "").strip()
if "{DirectoryName}" in Pattern and Pattern.endswith(".toml"):
Values["Lexicon"]["LexiconFilePattern"] = Pattern
elif Pattern:
TheApp.Notify("The Lexicon pattern needs {DirectoryName} and must end in .toml; kept the old one.", "warning")
try:
SaveConfValues(Values)
except (DataError, OSError) as Error:
TheApp.Notify(f"Couldn't save {ConfPath.name}: {Error}", "error")
return
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),
],
))