diff --git a/Cli.py b/Cli.py index 2d3c2c3..ac85b5c 100644 --- a/Cli.py +++ b/Cli.py @@ -3,14 +3,15 @@ SaraSuite list SaraSuite install [--set Id=Value ...] [--option Id=on|off ...] SaraSuite update + SaraSuite options [--option Id=on|off ...] (no --option: list them) SaraSuite remove [--configs] [--keep-runtime] -Install uses each question's default and each option's default unless set. +Install starts from your defaults (Settings, or the manifest's) unless set. """ import argparse -from Installer import Apply, GetStatus, Remove +from Installer import Apply, DefaultAnswers, DefaultOptions, FetchCommit, GetStatus, Remove from Manifests import LoadPrograms @@ -24,6 +25,10 @@ def _Pairs(Items: list[str]) -> dict[str, str]: return Pairs +def _Switches(Items: list[str]) -> dict[str, bool]: + return {Key: Value.lower() in ("on", "yes", "true", "1") for Key, Value in _Pairs(Items).items()} + + def Run(Arguments: list[str]) -> int: Parser = argparse.ArgumentParser(prog="SaraSuite") Commands = Parser.add_subparsers(dest="Command", required=True) @@ -33,6 +38,9 @@ def Run(Arguments: list[str]) -> int: Install.add_argument("--set", action="append", default=[], dest="Answers") Install.add_argument("--option", action="append", default=[], dest="Options") Commands.add_parser("update").add_argument("Name") + OptionsCommand = Commands.add_parser("options") + OptionsCommand.add_argument("Name") + OptionsCommand.add_argument("--option", action="append", default=[], dest="Options") RemoveCommand = Commands.add_parser("remove") RemoveCommand.add_argument("Name") RemoveCommand.add_argument("--configs", action="store_true") @@ -51,20 +59,35 @@ def Run(Arguments: list[str]) -> int: if Program is None: print(f"Unknown program {Args.Name!r}. Known: {', '.join(P.Name for P in Programs.values())}") return 1 - Installed = GetStatus(Program).Installed + Current = GetStatus(Program) + Installed = Current.Installed if Args.Command == "install": if Installed: print(f"{Program.Name} is already installed; use update.") return 1 - Options = {Option.Id: Option.Default for Option in Program.Options} - Options.update({Key: Value.lower() in ("on", "yes", "true", "1") for Key, Value in _Pairs(Args.Options).items()}) - Apply(Program, print, Options, _Pairs(Args.Answers)) + Options = DefaultOptions(Program) + Options.update(_Switches(Args.Options)) + Answers = DefaultAnswers(Program) + Answers.update(_Pairs(Args.Answers)) + Apply(Program, print, Options, Answers) elif Args.Command == "update": if not Installed: print(f"{Program.Name} isn't installed.") return 1 Apply(Program, print) + elif Args.Command == "options": + if not Installed: + print(f"{Program.Name} isn't installed.") + return 1 + if not Args.Options: + for Option in Program.Options: + State = "on" if Current.Receipt.Options.get(Option.Id) else "off" + print(f"{Option.Id:20} {State:4} {Option.Label}") + return 0 + Options = dict(Current.Receipt.Options) + Options.update(_Switches(Args.Options)) + Apply(Program, print, Options, Source=FetchCommit(Program, Current.Receipt.Commit, print)) elif Args.Command == "remove": Remove(Program, Args.configs, not Args.keep_runtime, print) return 0 diff --git a/Core.py b/Core.py index 4755a1a..0250a9e 100644 --- a/Core.py +++ b/Core.py @@ -7,7 +7,7 @@ picked for one Sara program looks the same in all of them. import os import sys -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, field from pathlib import Path import flet as ft @@ -106,6 +106,11 @@ class AppConfig: # Where the Sara repos live (Gitea on SpineS); manifests # use it as {GitHost}. GitHost: str = "https://git.srhtnin.garden/sarah" + # What the install dialog starts with, per program, overriding the + # manifest's defaults: {Program: {OptionId: ticked}} and + # {Program: {QuestionId: answer}}. Set in Settings. + OptionDefaults: dict[str, dict[str, bool]] = field(default_factory=dict) + AnswerDefaults: dict[str, dict[str, str]] = field(default_factory=dict) def Save(self) -> None: ConfigDir.mkdir(parents=True, exist_ok=True) diff --git a/Dialogs.py b/Dialogs.py index 1502344..e2dda94 100644 --- a/Dialogs.py +++ b/Dialogs.py @@ -6,7 +6,7 @@ from typing import Callable import flet as ft from Core import ChipButtonStyle, Home, Palette -from Installer import ConfigPlan, RemovalItem, RemovalPlan, Status +from Installer import ConfigPlan, DefaultAnswers, DefaultOptions, RemovalItem, RemovalPlan, Status from Manifests import Program _MaxListedItems = 12 @@ -57,8 +57,12 @@ def _Dialog(Page: ft.Page, Title: str, Body: list[ft.Control], ConfirmText: str, def ShowInstallDialog(Page: ft.Page, Program: Program, OnConfirm: Callable[[dict[str, bool], dict[str, str]], None]) -> None: - Fields = {Question.Id: ft.TextField(label=Question.Prompt, value=Question.Default) for Question in Program.Questions} - Boxes = {Option.Id: ft.Checkbox(label=Option.Label, value=Option.Default) for Option in Program.Options} + # Start from the user's defaults (Settings → this program's tab), which + # fall back to the manifest's. + Answers = DefaultAnswers(Program) + Ticked = DefaultOptions(Program) + Fields = {Question.Id: ft.TextField(label=Question.Prompt, value=Answers[Question.Id]) for Question in Program.Questions} + Boxes = {Option.Id: ft.Checkbox(label=Option.Label, value=Ticked[Option.Id]) for Option in Program.Options} Body: list[ft.Control] = [ft.Text(Program.Description, size=13, color=Palette.Overlay0)] Body += list(Fields.values()) @@ -75,6 +79,26 @@ def ShowInstallDialog(Page: ft.Page, Program: Program, OnConfirm: Callable[[dict )) +def ShowOptionsDialog(Page: ft.Page, Program: Program, Current: Status, OnConfirm: Callable[[dict[str, bool]], None]) -> None: + """Change an installed program's options. Turning one off takes out what + it placed (and puts back any file it had replaced); turning one on places it.""" + Recorded = Current.Receipt.Options if Current.Receipt else {} + Ticked = DefaultOptions(Program) + Boxes = { + Option.Id: ft.Checkbox(label=Option.Label, value=bool(Recorded.get(Option.Id, Ticked[Option.Id]))) + for Option in Program.Options + } + Body: list[ft.Control] = list(Boxes.values()) + Body.append(ft.Text( + "Only what these options control changes; the program stays at the version it's on.", + size=12, color=Palette.Overlay0, + )) + Page.show_dialog(_Dialog( + Page, f"{Program.Name} options", Body, "Apply", + lambda: OnConfirm({Id: bool(Box.value) for Id, Box in Boxes.items()}), + )) + + def ShowUpdateDialog( Page: ft.Page, Program: Program, Current: Status, NewVersion: str, NewCommit: str, Plans: list[ConfigPlan], OnConfirm: Callable[[], None], diff --git a/Installer.py b/Installer.py index 1c2ddbd..b9964c4 100644 --- a/Installer.py +++ b/Installer.py @@ -19,7 +19,7 @@ from pathlib import Path, PurePosixPath from typing import Callable, Optional from ConfigMerge import ApplySet, MergeConfig, MergeResult -from Core import BackupsDir, ChildEnvironment, DownloadsDir, PristineDir +from Core import BackupsDir, ChildEnvironment, DownloadsDir, LoadConfig, PristineDir from Manifests import Leftover, Program, Render from Receipts import IniRecord, LoadReceipt, PlacedFile, Receipt from Sources import ClearDownloads, Download, LatestCommit, SourceError @@ -112,6 +112,23 @@ def Fetch(Program: Program, Log: LogFn) -> Fetched: return Fetched(Commit, Download(Program.Name, Program.GitUrl, Commit, Log)) +def FetchCommit(Program: Program, Commit: str, Log: LogFn) -> Fetched: + """A specific commit, e.g. the installed one when only options change.""" + return Fetched(Commit, Download(Program.Name, Program.GitUrl, Commit, Log)) + + +def DefaultOptions(Program: Program) -> dict[str, bool]: + """The manifest's option defaults, overridden by the user's own + (Settings → the program's tab).""" + Overrides = LoadConfig().OptionDefaults.get(Program.Name, {}) + return {Option.Id: bool(Overrides.get(Option.Id, Option.Default)) for Option in Program.Options} + + +def DefaultAnswers(Program: Program) -> dict[str, str]: + Overrides = LoadConfig().AnswerDefaults.get(Program.Name, {}) + return {Question.Id: str(Overrides.get(Question.Id, Question.Default)) for Question in Program.Questions} + + def SourceFiles(Program: Program, SourceDir: Path) -> list[PurePosixPath]: """Relative paths of the files to copy, per the manifest's Include/Exclude.""" Found = [] @@ -448,10 +465,18 @@ def Apply( ) -> Receipt: """Install (no receipt yet) or update (receipt exists) a program to the latest commit, or to Source if it was already fetched (the Update dialog - fetches first to preview the config merges). - Options/Answers are only used on install; updates keep the recorded ones.""" + fetches first to preview the config merges; changing options passes the + installed commit, so nothing else changes). + Answers are only used on install. Options are used on install, and on an + installed program they replace the recorded ones (e.g. turning the file + picker off); otherwise the recorded options are kept.""" OldReceipt = LoadReceipt(Program.Name) - Action = "Updating" if OldReceipt else "Installing" + if OldReceipt is None: + Action = "Installing" + elif Options is not None and Source is not None and Source.Commit == OldReceipt.Commit: + Action = "Changing options for" + else: + Action = "Updating" Log(f"== {Action} {Program.Name}") Source = Source or Fetch(Program, Log) @@ -463,14 +488,17 @@ def Apply( ManifestHash=ManifestHash(Program), InstalledAt=OldReceipt.InstalledAt if OldReceipt else _Now(), UpdatedAt=_Now(), - Options=dict(OldReceipt.Options) if OldReceipt else dict(Options or {}), + Options=dict(OldReceipt.Options) if OldReceipt else {}, Answers=dict(OldReceipt.Answers) if OldReceipt else dict(Answers or {}), ) - # Options added to the manifest since this program was installed. - for Option in Program.Options: - NewReceipt.Options.setdefault(Option.Id, Option.Default) + NewReceipt.Options.update(Options or {}) + # Anything not given or recorded yet (e.g. an option added to the manifest + # since this program was installed) starts at the user's/manifest default. + for OptionId, Default in DefaultOptions(Program).items(): + NewReceipt.Options.setdefault(OptionId, Default) + for QuestionId, Default in DefaultAnswers(Program).items(): + NewReceipt.Answers.setdefault(QuestionId, Default) for Question in Program.Questions: - NewReceipt.Answers.setdefault(Question.Id, Question.Default) if Question.Kind == "Path": NewReceipt.Answers[Question.Id] = str(Path(NewReceipt.Answers[Question.Id]).expanduser().absolute()) Context = Program.Context(NewReceipt.Answers) @@ -506,7 +534,8 @@ def Apply( NewReceipt.Save() Label = f"{Program.Name} {NewReceipt.Version} ({NewReceipt.Commit[:7]})".replace(" ", " ") - Log(f"== {Label} {'updated' if OldReceipt else 'installed'}") + Done = {"Installing": "installed", "Updating": "updated", "Changing options for": "options changed"}[Action] + Log(f"== {Label} {Done}") return NewReceipt diff --git a/Main.py b/Main.py index 026d722..16b6298 100644 --- a/Main.py +++ b/Main.py @@ -13,10 +13,11 @@ from Core import ( AppName, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataDir, DataHome, EffectiveSettings, LoadConfig, Palette, ) -from Dialogs import ShowInstallDialog, ShowRemoveDialog, ShowUpdateDialog -from Installer import Apply, Fetch, GetStatus, PlanConfigs, PlanRemoval, Remove, SourceVersion +from Dialogs import ShowInstallDialog, ShowOptionsDialog, ShowRemoveDialog, ShowUpdateDialog +from Installer import Apply, Fetch, FetchCommit, GetStatus, PlanConfigs, PlanRemoval, Remove, SourceVersion from Manifests import LoadPrograms from ProgramCard import ProgramCard +from Settings import ShowSettingsDialog _MaxLogLines = 2000 @@ -154,8 +155,23 @@ def Main(Page: ft.Page) -> None: ), ) + def OnOptions(Card: ProgramCard) -> None: + # Reapplies the installed commit with the new options, so turning + # e.g. the file picker on or off doesn't also update the program. + Program = Card.Program + Current = Card.CurrentStatus + if Current is None or Current.Receipt is None: + return + Commit = Current.Receipt.Commit + ShowOptionsDialog( + Page, Program, Current, + lambda Options: RunOperation( + Card, lambda: Apply(Program, Log, Options=Options, Source=FetchCommit(Program, Commit, Log)), + ), + ) + for Program in Programs: - Cards.append(ProgramCard(Program, OnInstall, OnUpdate, OnRemove)) + Cards.append(ProgramCard(Program, OnInstall, OnUpdate, OnRemove, OnOptions)) def ToggleMenuEntry(e) -> None: _SetMenuEntry(not _MenuEntry.exists()) @@ -175,6 +191,13 @@ def Main(Page: ft.Page) -> None: tooltip="Check for updates", on_click=lambda e: RunOperation(None, RefreshAll), ) + SettingsButton = ft.IconButton( + icon=ft.Icons.SETTINGS_OUTLINED, + icon_color=Palette.Mauve, + tooltip="Settings", + # The git host may have changed, so re-check every program afterwards. + on_click=lambda e: ShowSettingsDialog(Page, Config, Programs, lambda: RunOperation(None, RefreshAll)), + ) Page.add( ft.Column( @@ -187,6 +210,7 @@ def Main(Page: ft.Page) -> None: ft.Container(expand=True), MenuButton, RefreshButton, + SettingsButton, ]), padding=ft.Padding.symmetric(horizontal=16, vertical=8), bgcolor=Palette.Mantle, diff --git a/ProgramCard.py b/ProgramCard.py index 7937da2..0d0aa6d 100644 --- a/ProgramCard.py +++ b/ProgramCard.py @@ -16,6 +16,7 @@ class ProgramCard(ft.Container): OnInstall: Callable[["ProgramCard"], None], OnUpdate: Callable[["ProgramCard"], None], OnRemove: Callable[["ProgramCard"], None], + OnOptions: Callable[["ProgramCard"], None], ) -> None: self.Program = Program self.CurrentStatus: Optional[Status] = None @@ -37,6 +38,9 @@ class ProgramCard(ft.Container): self.UpdateButton = ft.OutlinedButton( content="Update", style=ChipButtonStyle(), visible=False, on_click=lambda e: OnUpdate(self), ) + self.OptionsButton = ft.OutlinedButton( + content="Options", style=ChipButtonStyle(), visible=False, on_click=lambda e: OnOptions(self), + ) self.RemoveButton = ft.OutlinedButton( content="Remove", style=ChipButtonStyle(Palette.Red), visible=False, on_click=lambda e: OnRemove(self), ) @@ -54,6 +58,7 @@ class ProgramCard(ft.Container): self.Spinner, self.InstallButton, self.UpdateButton, + self.OptionsButton, self.RemoveButton, ], ), @@ -102,9 +107,14 @@ class ProgramCard(ft.Container): self.InstallButton.visible = not Current.Installed self.InstallButton.disabled = Busy or not Current.LatestCommit - self.UpdateButton.visible = Current.Installed - self.UpdateButton.disabled = Busy or not Current.UpdateAvailable or not Current.LatestCommit + # Only there when there's something to update (or repair). + self.UpdateButton.visible = Current.UpdateAvailable + self.UpdateButton.disabled = Busy or not Current.LatestCommit self.UpdateButton.content = "Repair" if Current.Broken else "Update" + # Change options (e.g. the file picker) after installing. Needs the + # git host, since it reapplies the installed commit. + self.OptionsButton.visible = Current.Installed and bool(self.Program.Options) + self.OptionsButton.disabled = Busy or not Current.LatestCommit # Not installed but configs/runtime files were kept: still removable. self.RemoveButton.visible = Current.Installed or bool(Leftovers.Items) self.RemoveButton.content = "Remove" if Current.Installed else "Clean up" @@ -114,6 +124,6 @@ class ProgramCard(ft.Container): def SetBusy(self, Busy: bool, Working: bool = False) -> None: """Busy greys out the buttons (only one operation runs at a time); Working also shows this card's spinner.""" - for Button in (self.InstallButton, self.UpdateButton, self.RemoveButton): + for Button in (self.InstallButton, self.UpdateButton, self.OptionsButton, self.RemoveButton): Button.disabled = Busy self.Spinner.visible = Working diff --git a/README.md b/README.md index 2d5700b..d624794 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,24 @@ HideWindowTitleBar = false Each program's own `~/.config/SaraSuite//Config.toml` has the same three keys plus `UseSharedSettings` (default `true`). With it on, the program reads the shared file; with it -off, it uses its own values instead. SaraFem edits both in its Settings (the SaraSuite tab -for the shared file, the General tab for its own); for SaraSuite itself, edit its -`Config.toml` directly. Whichever program starts first creates the shared file from its own -values. +off, it uses its own values instead. Both SaraSuite (the gear button, Shared and SaraSuite +tabs) and SaraFem (its Settings, SaraSuite and General tabs) can edit them. Whichever program +starts first creates the shared file from its own values. + +## SaraSuite's own settings + +The gear button also holds the git host and, in one tab per program, what that program's +install dialog starts with: which option boxes are ticked and the default answers (e.g. +SaraSortd's root folder). They're stored in `~/.config/SaraSuite/SaraSuite/Config.toml` as +`[OptionDefaults.]` and `[AnswerDefaults.]`, and override the manifest's +defaults. + +## Changing options after installing + +Installed programs with options (e.g. SaraFem's file picker) get an **Options** button. Turning +an option off takes out what it placed and puts back any file it had replaced; turning it on +places it. It reapplies the installed commit, so the program itself isn't updated. The +**Update** button only shows up when there's an update (or a broken install to repair). ## Running and building @@ -60,6 +74,8 @@ values. SaraSuite-x86_64.AppImage list SaraSuite-x86_64.AppImage install SaraSortd --set RootDir=~/Sorting --option Autostart=on SaraSuite-x86_64.AppImage update SaraSortd + SaraSuite-x86_64.AppImage options SaraFem # list them + SaraSuite-x86_64.AppImage options SaraFem --option FilePicker=off SaraSuite-x86_64.AppImage remove SaraSortd [--configs] [--keep-runtime] ``` diff --git a/Settings.py b/Settings.py new file mode 100644 index 0000000..a1e3ac4 --- /dev/null +++ b/Settings.py @@ -0,0 +1,199 @@ +"""Settings dialog: the shared SaraSuite settings, SaraSuite's own, and the +install defaults for each program. Laid out like SaraFem's Settings (a +category list on the left, the chosen category on the right).""" + +import subprocess +from typing import Callable + +import flet as ft + +from Core import ( + AppConfig, BundledFontFamily, ChildEnvironment, ChipButtonStyle, LoadSharedSettings, Palette, + SharedSettings, ThemesDir, +) +from Installer import DefaultAnswers, DefaultOptions +from Manifests import Program + +DialogWidth = 700 +DialogHeight = 480 +CategoryListWidth = 150 +RestartNote = "Theme, font and title bar changes apply the next time a program starts." + + +def _Note(Text: str) -> ft.Text: + return ft.Text(Text, color=Palette.Overlay0, size=12) + + +def _Themes() -> list[str]: + Names = {Path.stem for Path in ThemesDir.glob("*.toml")} if ThemesDir.is_dir() else set() + Names.add("CatppuccinMocha") # built in, even before SaraFem seeds the folder + return sorted(Names) + + +def _Fonts() -> list[str]: + """Installed font families (fontconfig), with the bundled font first.""" + 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 ShowSettingsDialog(Page: ft.Page, Config: AppConfig, Programs: list[Program], OnSaved: Callable[[], None]) -> None: + Themes = _Themes() + 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], + ) + + # Shared: ~/.config/SaraSuite/Config.toml, read by every SaraSuite program. + Shared = LoadSharedSettings(Config) + UseSharedSwitch = ft.Switch(label="SaraSuite 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) + + # SaraSuite'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("SaraSuite'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 + GitHostField = ft.TextField( + value=Config.GitHost, label="Git host", hint_text="https://git.srhtnin.garden/sarah", + ) + + Categories: dict[str, ft.Control] = { + "Shared": ft.Column( + [ + _Note("Shared by every SaraSuite program (~/.config/SaraSuite/Config.toml)."), + UseSharedSwitch, + ft.Divider(height=1, color=Palette.Surface1), + SharedTheme, + SharedFont, + SharedTitleBar, + ], + spacing=12, + ), + "SaraSuite": ft.Column( + [ + GitHostField, + _Note("Where programs are installed and updated from; each program's repo is /."), + ft.Divider(height=1, color=Palette.Surface1), + OwnAppearance, + ], + spacing=12, + scroll=ft.ScrollMode.AUTO, + ), + } + + # One tab per program: what its install dialog starts with. + DefaultBoxes: dict[str, dict[str, ft.Checkbox]] = {} + DefaultFields: dict[str, dict[str, ft.TextField]] = {} + for Current in Programs: + if not Current.Options and not Current.Questions: + continue + Answers = DefaultAnswers(Current) + Ticked = DefaultOptions(Current) + DefaultFields[Current.Name] = { + Question.Id: ft.TextField(label=Question.Prompt, value=Answers[Question.Id]) for Question in Current.Questions + } + DefaultBoxes[Current.Name] = { + Option.Id: ft.Checkbox(label=Option.Label, value=Ticked[Option.Id]) for Option in Current.Options + } + Categories[Current.Name] = ft.Column( + [_Note(f"What installing {Current.Name} starts with.")] + + list(DefaultFields[Current.Name].values()) + + list(DefaultBoxes[Current.Name].values()), + spacing=12, + scroll=ft.ScrollMode.AUTO, + ) + + DetailArea = ft.Container(Categories["Shared"], 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 == "Shared", + 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) + + def Save(e) -> None: + Config.UseSharedSettings = UseSharedSwitch.value + Config.Theme = OwnTheme.value + Config.FontFamily = OwnFont.value or Config.FontFamily + Config.HideWindowTitleBar = OwnTitleBar.value + Config.GitHost = (GitHostField.value or "").strip().rstrip("/") or Config.GitHost + for Name, Boxes in DefaultBoxes.items(): + Config.OptionDefaults[Name] = {Id: bool(Box.value) for Id, Box in Boxes.items()} + for Name, Fields in DefaultFields.items(): + if Fields: + Config.AnswerDefaults[Name] = {Id: (Field.value or "").strip() for Id, Field in Fields.items()} + Config.Save() + SharedSettings( + Theme=SharedTheme.value, + FontFamily=SharedFont.value or Shared.FontFamily, + HideWindowTitleBar=SharedTitleBar.value, + ).Save() + Page.pop_dialog() + OnSaved() + + 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, + ), + 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), + ], + ))