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 list
|
||||||
SaraSuite install <Name> [--set Id=Value ...] [--option Id=on|off ...]
|
SaraSuite install <Name> [--set Id=Value ...] [--option Id=on|off ...]
|
||||||
SaraSuite update <Name>
|
SaraSuite update <Name>
|
||||||
|
SaraSuite options <Name> [--option Id=on|off ...] (no --option: list them)
|
||||||
SaraSuite remove <Name> [--configs] [--keep-runtime]
|
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
|
import argparse
|
||||||
|
|
||||||
from Installer import Apply, GetStatus, Remove
|
from Installer import Apply, DefaultAnswers, DefaultOptions, FetchCommit, GetStatus, Remove
|
||||||
from Manifests import LoadPrograms
|
from Manifests import LoadPrograms
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +25,10 @@ def _Pairs(Items: list[str]) -> dict[str, str]:
|
|||||||
return Pairs
|
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:
|
def Run(Arguments: list[str]) -> int:
|
||||||
Parser = argparse.ArgumentParser(prog="SaraSuite")
|
Parser = argparse.ArgumentParser(prog="SaraSuite")
|
||||||
Commands = Parser.add_subparsers(dest="Command", required=True)
|
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("--set", action="append", default=[], dest="Answers")
|
||||||
Install.add_argument("--option", action="append", default=[], dest="Options")
|
Install.add_argument("--option", action="append", default=[], dest="Options")
|
||||||
Commands.add_parser("update").add_argument("Name")
|
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 = Commands.add_parser("remove")
|
||||||
RemoveCommand.add_argument("Name")
|
RemoveCommand.add_argument("Name")
|
||||||
RemoveCommand.add_argument("--configs", action="store_true")
|
RemoveCommand.add_argument("--configs", action="store_true")
|
||||||
@@ -51,20 +59,35 @@ def Run(Arguments: list[str]) -> int:
|
|||||||
if Program is None:
|
if Program is None:
|
||||||
print(f"Unknown program {Args.Name!r}. Known: {', '.join(P.Name for P in Programs.values())}")
|
print(f"Unknown program {Args.Name!r}. Known: {', '.join(P.Name for P in Programs.values())}")
|
||||||
return 1
|
return 1
|
||||||
Installed = GetStatus(Program).Installed
|
Current = GetStatus(Program)
|
||||||
|
Installed = Current.Installed
|
||||||
|
|
||||||
if Args.Command == "install":
|
if Args.Command == "install":
|
||||||
if Installed:
|
if Installed:
|
||||||
print(f"{Program.Name} is already installed; use update.")
|
print(f"{Program.Name} is already installed; use update.")
|
||||||
return 1
|
return 1
|
||||||
Options = {Option.Id: Option.Default for Option in Program.Options}
|
Options = DefaultOptions(Program)
|
||||||
Options.update({Key: Value.lower() in ("on", "yes", "true", "1") for Key, Value in _Pairs(Args.Options).items()})
|
Options.update(_Switches(Args.Options))
|
||||||
Apply(Program, print, Options, _Pairs(Args.Answers))
|
Answers = DefaultAnswers(Program)
|
||||||
|
Answers.update(_Pairs(Args.Answers))
|
||||||
|
Apply(Program, print, Options, Answers)
|
||||||
elif Args.Command == "update":
|
elif Args.Command == "update":
|
||||||
if not Installed:
|
if not Installed:
|
||||||
print(f"{Program.Name} isn't installed.")
|
print(f"{Program.Name} isn't installed.")
|
||||||
return 1
|
return 1
|
||||||
Apply(Program, print)
|
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":
|
elif Args.Command == "remove":
|
||||||
Remove(Program, Args.configs, not Args.keep_runtime, print)
|
Remove(Program, Args.configs, not Args.keep_runtime, print)
|
||||||
return 0
|
return 0
|
||||||
@@ -7,7 +7,7 @@ picked for one Sara program looks the same in all of them.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import flet as ft
|
import flet as ft
|
||||||
@@ -106,6 +106,11 @@ class AppConfig:
|
|||||||
# Where the Sara repos live (Gitea on SpineS); manifests
|
# Where the Sara repos live (Gitea on SpineS); manifests
|
||||||
# use it as {GitHost}.
|
# use it as {GitHost}.
|
||||||
GitHost: str = "https://git.srhtnin.garden/sarah"
|
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:
|
def Save(self) -> None:
|
||||||
ConfigDir.mkdir(parents=True, exist_ok=True)
|
ConfigDir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
+27
-3
@@ -6,7 +6,7 @@ from typing import Callable
|
|||||||
import flet as ft
|
import flet as ft
|
||||||
|
|
||||||
from Core import ChipButtonStyle, Home, Palette
|
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
|
from Manifests import Program
|
||||||
|
|
||||||
_MaxListedItems = 12
|
_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:
|
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}
|
# Start from the user's defaults (Settings → this program's tab), which
|
||||||
Boxes = {Option.Id: ft.Checkbox(label=Option.Label, value=Option.Default) for Option in Program.Options}
|
# 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[ft.Control] = [ft.Text(Program.Description, size=13, color=Palette.Overlay0)]
|
||||||
Body += list(Fields.values())
|
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(
|
def ShowUpdateDialog(
|
||||||
Page: ft.Page, Program: Program, Current: Status, NewVersion: str, NewCommit: str,
|
Page: ft.Page, Program: Program, Current: Status, NewVersion: str, NewCommit: str,
|
||||||
Plans: list[ConfigPlan], OnConfirm: Callable[[], None],
|
Plans: list[ConfigPlan], OnConfirm: Callable[[], None],
|
||||||
|
|||||||
+39
-10
@@ -19,7 +19,7 @@ from pathlib import Path, PurePosixPath
|
|||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from ConfigMerge import ApplySet, MergeConfig, MergeResult
|
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 Manifests import Leftover, Program, Render
|
||||||
from Receipts import IniRecord, LoadReceipt, PlacedFile, Receipt
|
from Receipts import IniRecord, LoadReceipt, PlacedFile, Receipt
|
||||||
from Sources import ClearDownloads, Download, LatestCommit, SourceError
|
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))
|
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]:
|
def SourceFiles(Program: Program, SourceDir: Path) -> list[PurePosixPath]:
|
||||||
"""Relative paths of the files to copy, per the manifest's Include/Exclude."""
|
"""Relative paths of the files to copy, per the manifest's Include/Exclude."""
|
||||||
Found = []
|
Found = []
|
||||||
@@ -448,10 +465,18 @@ def Apply(
|
|||||||
) -> Receipt:
|
) -> Receipt:
|
||||||
"""Install (no receipt yet) or update (receipt exists) a program to the
|
"""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
|
latest commit, or to Source if it was already fetched (the Update dialog
|
||||||
fetches first to preview the config merges).
|
fetches first to preview the config merges; changing options passes the
|
||||||
Options/Answers are only used on install; updates keep the recorded ones."""
|
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)
|
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}")
|
Log(f"== {Action} {Program.Name}")
|
||||||
|
|
||||||
Source = Source or Fetch(Program, Log)
|
Source = Source or Fetch(Program, Log)
|
||||||
@@ -463,14 +488,17 @@ def Apply(
|
|||||||
ManifestHash=ManifestHash(Program),
|
ManifestHash=ManifestHash(Program),
|
||||||
InstalledAt=OldReceipt.InstalledAt if OldReceipt else _Now(),
|
InstalledAt=OldReceipt.InstalledAt if OldReceipt else _Now(),
|
||||||
UpdatedAt=_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 {}),
|
Answers=dict(OldReceipt.Answers) if OldReceipt else dict(Answers or {}),
|
||||||
)
|
)
|
||||||
# Options added to the manifest since this program was installed.
|
NewReceipt.Options.update(Options or {})
|
||||||
for Option in Program.Options:
|
# Anything not given or recorded yet (e.g. an option added to the manifest
|
||||||
NewReceipt.Options.setdefault(Option.Id, Option.Default)
|
# 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:
|
for Question in Program.Questions:
|
||||||
NewReceipt.Answers.setdefault(Question.Id, Question.Default)
|
|
||||||
if Question.Kind == "Path":
|
if Question.Kind == "Path":
|
||||||
NewReceipt.Answers[Question.Id] = str(Path(NewReceipt.Answers[Question.Id]).expanduser().absolute())
|
NewReceipt.Answers[Question.Id] = str(Path(NewReceipt.Answers[Question.Id]).expanduser().absolute())
|
||||||
Context = Program.Context(NewReceipt.Answers)
|
Context = Program.Context(NewReceipt.Answers)
|
||||||
@@ -506,7 +534,8 @@ def Apply(
|
|||||||
NewReceipt.Save()
|
NewReceipt.Save()
|
||||||
|
|
||||||
Label = f"{Program.Name} {NewReceipt.Version} ({NewReceipt.Commit[:7]})".replace(" ", " ")
|
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
|
return NewReceipt
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ from Core import (
|
|||||||
AppName, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataDir,
|
AppName, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataDir,
|
||||||
DataHome, EffectiveSettings, LoadConfig, Palette,
|
DataHome, EffectiveSettings, LoadConfig, Palette,
|
||||||
)
|
)
|
||||||
from Dialogs import ShowInstallDialog, ShowRemoveDialog, ShowUpdateDialog
|
from Dialogs import ShowInstallDialog, ShowOptionsDialog, ShowRemoveDialog, ShowUpdateDialog
|
||||||
from Installer import Apply, Fetch, GetStatus, PlanConfigs, PlanRemoval, Remove, SourceVersion
|
from Installer import Apply, Fetch, FetchCommit, GetStatus, PlanConfigs, PlanRemoval, Remove, SourceVersion
|
||||||
from Manifests import LoadPrograms
|
from Manifests import LoadPrograms
|
||||||
from ProgramCard import ProgramCard
|
from ProgramCard import ProgramCard
|
||||||
|
from Settings import ShowSettingsDialog
|
||||||
|
|
||||||
_MaxLogLines = 2000
|
_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:
|
for Program in Programs:
|
||||||
Cards.append(ProgramCard(Program, OnInstall, OnUpdate, OnRemove))
|
Cards.append(ProgramCard(Program, OnInstall, OnUpdate, OnRemove, OnOptions))
|
||||||
|
|
||||||
def ToggleMenuEntry(e) -> None:
|
def ToggleMenuEntry(e) -> None:
|
||||||
_SetMenuEntry(not _MenuEntry.exists())
|
_SetMenuEntry(not _MenuEntry.exists())
|
||||||
@@ -175,6 +191,13 @@ def Main(Page: ft.Page) -> None:
|
|||||||
tooltip="Check for updates",
|
tooltip="Check for updates",
|
||||||
on_click=lambda e: RunOperation(None, RefreshAll),
|
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(
|
Page.add(
|
||||||
ft.Column(
|
ft.Column(
|
||||||
@@ -187,6 +210,7 @@ def Main(Page: ft.Page) -> None:
|
|||||||
ft.Container(expand=True),
|
ft.Container(expand=True),
|
||||||
MenuButton,
|
MenuButton,
|
||||||
RefreshButton,
|
RefreshButton,
|
||||||
|
SettingsButton,
|
||||||
]),
|
]),
|
||||||
padding=ft.Padding.symmetric(horizontal=16, vertical=8),
|
padding=ft.Padding.symmetric(horizontal=16, vertical=8),
|
||||||
bgcolor=Palette.Mantle,
|
bgcolor=Palette.Mantle,
|
||||||
|
|||||||
+13
-3
@@ -16,6 +16,7 @@ class ProgramCard(ft.Container):
|
|||||||
OnInstall: Callable[["ProgramCard"], None],
|
OnInstall: Callable[["ProgramCard"], None],
|
||||||
OnUpdate: Callable[["ProgramCard"], None],
|
OnUpdate: Callable[["ProgramCard"], None],
|
||||||
OnRemove: Callable[["ProgramCard"], None],
|
OnRemove: Callable[["ProgramCard"], None],
|
||||||
|
OnOptions: Callable[["ProgramCard"], None],
|
||||||
) -> None:
|
) -> None:
|
||||||
self.Program = Program
|
self.Program = Program
|
||||||
self.CurrentStatus: Optional[Status] = None
|
self.CurrentStatus: Optional[Status] = None
|
||||||
@@ -37,6 +38,9 @@ class ProgramCard(ft.Container):
|
|||||||
self.UpdateButton = ft.OutlinedButton(
|
self.UpdateButton = ft.OutlinedButton(
|
||||||
content="Update", style=ChipButtonStyle(), visible=False, on_click=lambda e: OnUpdate(self),
|
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(
|
self.RemoveButton = ft.OutlinedButton(
|
||||||
content="Remove", style=ChipButtonStyle(Palette.Red), visible=False, on_click=lambda e: OnRemove(self),
|
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.Spinner,
|
||||||
self.InstallButton,
|
self.InstallButton,
|
||||||
self.UpdateButton,
|
self.UpdateButton,
|
||||||
|
self.OptionsButton,
|
||||||
self.RemoveButton,
|
self.RemoveButton,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -102,9 +107,14 @@ class ProgramCard(ft.Container):
|
|||||||
|
|
||||||
self.InstallButton.visible = not Current.Installed
|
self.InstallButton.visible = not Current.Installed
|
||||||
self.InstallButton.disabled = Busy or not Current.LatestCommit
|
self.InstallButton.disabled = Busy or not Current.LatestCommit
|
||||||
self.UpdateButton.visible = Current.Installed
|
# Only there when there's something to update (or repair).
|
||||||
self.UpdateButton.disabled = Busy or not Current.UpdateAvailable or not Current.LatestCommit
|
self.UpdateButton.visible = Current.UpdateAvailable
|
||||||
|
self.UpdateButton.disabled = Busy or not Current.LatestCommit
|
||||||
self.UpdateButton.content = "Repair" if Current.Broken else "Update"
|
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.
|
# Not installed but configs/runtime files were kept: still removable.
|
||||||
self.RemoveButton.visible = Current.Installed or bool(Leftovers.Items)
|
self.RemoveButton.visible = Current.Installed or bool(Leftovers.Items)
|
||||||
self.RemoveButton.content = "Remove" if Current.Installed else "Clean up"
|
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:
|
def SetBusy(self, Busy: bool, Working: bool = False) -> None:
|
||||||
"""Busy greys out the buttons (only one operation runs at a time);
|
"""Busy greys out the buttons (only one operation runs at a time);
|
||||||
Working also shows this card's spinner."""
|
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
|
Button.disabled = Busy
|
||||||
self.Spinner.visible = Working
|
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
|
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
|
`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
|
off, it uses its own values instead. Both SaraSuite (the gear button, Shared and SaraSuite
|
||||||
for the shared file, the General tab for its own); for SaraSuite itself, edit its
|
tabs) and SaraFem (its Settings, SaraSuite and General tabs) can edit them. Whichever program
|
||||||
`Config.toml` directly. Whichever program starts first creates the shared file from its own
|
starts first creates the shared file from its own values.
|
||||||
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
|
## Running and building
|
||||||
|
|
||||||
@@ -60,6 +74,8 @@ values.
|
|||||||
SaraSuite-x86_64.AppImage list
|
SaraSuite-x86_64.AppImage list
|
||||||
SaraSuite-x86_64.AppImage install SaraSortd --set RootDir=~/Sorting --option Autostart=on
|
SaraSuite-x86_64.AppImage install SaraSortd --set RootDir=~/Sorting --option Autostart=on
|
||||||
SaraSuite-x86_64.AppImage update SaraSortd
|
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]
|
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