Files
SaraView/Client/Settings.py
T
2026-10-08 23:25:41 +02:00

268 lines
12 KiB
Python

"""Settings dialog, laid out like SaraFem's and SaraSort's: a category list on
the left (SaraSuite / General / Connection), the chosen category on the right."""
import os
import shutil
from pathlib import Path
from typing import Callable
import flet as ft
import tomlkit
from App import App
from Core import (
AppConfig, AssetsDir, AvailableThemes, ChipButton, DataHome, ExportSettings, Home, ImportSettings,
InstalledFontFamilies, LoadSharedSettings, Palette, SharedSettings,
)
DialogWidth = 700
DialogHeight = 520
CategoryListWidth = 150
RestartNote = "Theme, font and title bar changes apply the next time SaraView starts."
# When running as an AppImage, SaraView can put itself in the app menu (like SaraSort).
AppImagePath = os.environ.get("APPIMAGE", "")
MenuEntryPath = DataHome / "applications" / "SaraView.desktop"
MenuIconPath = DataHome / "SaraView" / "SaraView.png"
def SetMenuEntry(Enabled: bool) -> None:
if not Enabled:
MenuEntryPath.unlink(missing_ok=True)
MenuIconPath.unlink(missing_ok=True)
return
MenuIconPath.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(AssetsDir / "Icon.png", MenuIconPath)
MenuEntryPath.parent.mkdir(parents=True, exist_ok=True)
MenuEntryPath.write_text(
"[Desktop Entry]\nType=Application\nName=SaraView\n"
"Comment=View and edit files, locally or on a SaraView server\n"
f"Exec={AppImagePath}\nIcon={MenuIconPath}\nTerminal=false\n"
"Categories=Utility;\nStartupNotify=true\n"
)
def _Note(Text: str) -> ft.Text:
return ft.Text(Text, color=Palette.Overlay0, size=12)
def _ReadInt(Field: ft.TextField, Fallback: int, Minimum: int, Maximum: int) -> int:
try:
return min(Maximum, 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[[bool], None], StartCategory: str = "General") -> None:
"""OnSaved(Reconnect): Reconnect is True when the source itself changed
(mode, root, server or local listing options)."""
Page = TheApp.Page
Config: AppConfig = TheApp.Config
Themes = AvailableThemes()
Fonts = InstalledFontFamilies()
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:
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="SaraView 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 / "SaraView-Settings.toml"), label="Settings file path")
# SaraView'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("SaraView'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
# General.
OpenInAppSwitch = ft.Switch(label="Double-click opens files in SaraView (off: in their default program)",
value=Config.OpenFilesInApp)
ConfirmDeleteSwitch = ft.Switch(label="Ask before deleting", value=Config.ConfirmDelete)
SidebarSwitch = ft.Switch(label="Show the directory tree (Ctrl+B)", value=Config.ShowSidebar)
FontSizeField = ft.TextField(label="Text size", value=str(Config.FontSize), keyboard_type=ft.KeyboardType.NUMBER, width=160)
def ToggleMenuEntry(e) -> None:
SetMenuEntry(not MenuEntryPath.exists())
MenuButton.content = "Remove from app menu" if MenuEntryPath.exists() else "Add to app menu"
MenuButton.update()
MenuButton = ChipButton("Remove from app menu" if MenuEntryPath.exists() else "Add to app menu", ToggleMenuEntry)
MenuButton.visible = bool(AppImagePath)
# Connection.
ModePicker = ft.RadioGroup(
ft.Row([ft.Radio(value="Local", label="Local files"), ft.Radio(value="Server", label="SaraView server")]),
value=Config.Mode,
)
LocalRootField = ft.TextField(label="Root directory", value=Config.LocalRoot, expand=True)
async def BrowseRoot(e) -> None:
Start = os.path.expanduser(LocalRootField.value or "~")
Chosen = await TheApp.Picker.get_directory_path(dialog_title="Root directory",
initial_directory=Start if os.path.isdir(Start) else None)
if Chosen:
LocalRootField.value = Chosen
LocalRootField.update()
ShowHiddenSwitch = ft.Switch(label="Show hidden directories and files", value=Config.ShowHiddenFolders)
ShowSvSwitch = ft.Switch(label="Show .sv_ directory notes", value=Config.ShowSvFiles)
HideInternalSwitch = ft.Switch(label="Hide SaraTag's own files (Books, .saratag)", value=Config.HideInternalFiles)
LocalOptions = ft.Column([
ft.Row([LocalRootField, ft.IconButton(ft.Icons.FOLDER_OPEN_OUTLINED, tooltip="Browse", on_click=BrowseRoot)]),
ShowHiddenSwitch, ShowSvSwitch, HideInternalSwitch,
], spacing=12, visible=Config.Mode == "Local")
ServerUrlField = ft.TextField(label="Server address", value=Config.ServerUrl, hint_text="http://spines:8420")
RememberSwitch = ft.Switch(label="Stay logged in between starts", value=Config.RememberLogin)
ServerOptions = ft.Column([
ServerUrlField,
_Note("Over Tailscale, like the web view. Logged out, you browse as Guest; log in from the "
"connection button in the toolbar to change files. Hidden files and the theme on the "
"server follow your SaraView user settings."),
RememberSwitch,
], spacing=12, visible=Config.Mode == "Server")
def OnModeChanged(e) -> None:
LocalOptions.visible = ModePicker.value == "Local"
ServerOptions.visible = ModePicker.value == "Server"
LocalOptions.update()
ServerOptions.update()
ModePicker.on_change = OnModeChanged
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 old values; close it rather than
# leave a stale Save that would overwrite the import.
TheApp.Close()
OnSaved(True)
TheApp.Notify("Settings imported. Restart for Theme/Font/Title bar.", "success")
Categories: dict[str, ft.Control] = {
"SaraSuite": ft.Column([
_Note("Shared by every SaraSuite program (~/.config/SaraSuite/Config.toml). "
"Other programs pick changes up the next time they start."),
UseSharedSwitch,
ft.Divider(height=1, color=Palette.Surface1),
SharedTheme, SharedFont, SharedTitleBar,
ft.Divider(height=1, color=Palette.Surface1),
_Note("Export/import settings as one file: this tab's shared settings plus everything else "
"in Settings (never your login). Same format as SaraFem's."),
SettingsFileField,
ft.Row([ChipButton("Export", OnExport), ChipButton("Import", OnImport)]),
], spacing=12, scroll=ft.ScrollMode.AUTO),
"General": ft.Column([
OpenInAppSwitch, ConfirmDeleteSwitch, SidebarSwitch, FontSizeField, MenuButton,
ft.Divider(height=1, color=Palette.Surface1),
OwnAppearance,
], spacing=12, scroll=ft.ScrollMode.AUTO),
"Connection": ft.Column([
_Note("Browse a directory on this computer, or connect to a SaraView server."),
ModePicker, LocalOptions, ServerOptions,
], spacing=12, scroll=ft.ScrollMode.AUTO),
}
DetailArea = ft.Container(Categories[StartCategory], 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 == StartCategory, 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:
SourceBefore = (Config.Mode, Config.LocalRoot, Config.ServerUrl, Config.ShowHiddenFolders,
Config.ShowSvFiles, Config.HideInternalFiles)
Config.UseSharedSettings = UseSharedSwitch.value
Config.Theme = OwnTheme.value or Config.Theme
Config.FontFamily = OwnFont.value or Config.FontFamily
Config.HideWindowTitleBar = OwnTitleBar.value
Config.OpenFilesInApp = OpenInAppSwitch.value
Config.ConfirmDelete = ConfirmDeleteSwitch.value
Config.ShowSidebar = SidebarSwitch.value
Config.FontSize = _ReadInt(FontSizeField, Config.FontSize, 8, 32)
Config.Mode = ModePicker.value or Config.Mode
Config.LocalRoot = (LocalRootField.value or "").strip() or "~"
Config.ServerUrl = (ServerUrlField.value or "").strip().rstrip("/")
Config.RememberLogin = RememberSwitch.value
Config.ShowHiddenFolders = ShowHiddenSwitch.value
Config.ShowSvFiles = ShowSvSwitch.value
Config.HideInternalFiles = HideInternalSwitch.value
Config.Save()
SharedSettings(
Theme=SharedTheme.value,
FontFamily=SharedFont.value or Shared.FontFamily,
HideWindowTitleBar=SharedTitleBar.value,
).Save()
SourceAfter = (Config.Mode, Config.LocalRoot, Config.ServerUrl, Config.ShowHiddenFolders,
Config.ShowSvFiles, Config.HideInternalFiles)
TheApp.Close()
OnSaved(SourceAfter != SourceBefore)
TheApp.Notify("Settings saved.", "success")
TheApp.DialogOpen = True
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=[ChipButton("Cancel", lambda e: TheApp.Close()), ChipButton("Save", Save)],
on_dismiss=lambda e: setattr(TheApp, "DialogOpen", False),
))