mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-13 18:18:12 +02:00
Settings dialog, per-program install defaults, Options after install; Update only when needed
This commit is contained in:
1 parent
7018d57dbc
commit
08c9071b48
8 files changed
+360
-30
No files matched your search
@@ -3,14 +3,15 @@
|
||||
SaraSuite list
|
||||
SaraSuite install <Name> [--set Id=Value ...] [--option Id=on|off ...]
|
||||
SaraSuite update <Name>
|
||||
SaraSuite options <Name> [--option Id=on|off ...] (no --option: list them)
|
||||
SaraSuite remove <Name> [--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
|
||||
@@ -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)
|
||||
|
||||
+27
-3
@@ -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],
|
||||
|
||||
+39
-10
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+13
-3
@@ -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
|
||||
@@ -42,10 +42,24 @@ HideWindowTitleBar = false
|
||||
|
||||
Each program's own `~/.config/SaraSuite/<Program>/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.<Program>]` and `[AnswerDefaults.<Program>]`, 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]
|
||||
```
|
||||
|
||||
|
||||
+199
@@ -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 <Git host>/<Name>."),
|
||||
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),
|
||||
],
|
||||
))
|
||||
Reference in new issue
Block a user