Files
SaraView/Client/Settings.py
T
2026-10-13 17:10:21 +02:00

337 lines
16 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
import LocalDiagrams
from App import App
from Core import (
AppConfig, AssetsDir, AvailableThemes, ChipButton, DataHome, ExportSettings, Home, HumanSize,
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)
TreeFilesSwitch = ft.Switch(label="Show files in the tree (off: directories only)", value=Config.ShowFilesInTree)
ButtonsAtTopSwitch = ft.Switch(label="New File, New Directory and Upload above the tree (off: below)",
value=Config.NewEntryButtonsAtTop)
MarkdownEditingPicker = ft.RadioGroup(
ft.Column([
ft.Radio(value="Live", label="Live: click a part of the rendered file to edit its Markdown"),
ft.Radio(value="Separate", label="Separate: View and Edit are two modes"),
], spacing=0),
value=Config.MarkdownEditing,
)
FontSizeField = ft.TextField(label="Text size", value=str(Config.FontSize), keyboard_type=ft.KeyboardType.NUMBER, width=160)
LocalDiagramsSwitch = ft.Switch(label="Draw diagrams in local folders", value=Config.LocalDiagrams)
ToolsBytes = LocalDiagrams.ToolsSize()
LocalDiagramsNote = _Note(
"```mermaid blocks (flowcharts and the like) as pictures, drawn on this computer. The first one "
"downloads what that needs: ~150 MB with Chrome, Chromium, Brave or Edge installed, ~430 MB without. "
"Off: deletes those downloads"
+ (f" ({HumanSize(ToolsBytes)} now)." if ToolsBytes else ".")
+ " On a server, the server draws them either way."
)
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)
# Graph.
GraphDirectoriesSwitch = ft.Switch(label="Show directories (off: only files and the links between them)",
value=Config.GraphShowDirectories)
ColourByDepthSwitch = ft.Switch(label="Colour directories by depth (off: all purple)",
value=Config.GraphColourByDepth)
ShrinkByDepthSwitch = ft.Switch(label="Smaller directory dots the deeper they are",
value=Config.GraphShrinkByDepth)
PanButtonPicker = ft.RadioGroup(
ft.Column([
ft.Radio(value="Right", label="Right (left-click opens a dot)"),
ft.Radio(value="Left", label="Left (right-click opens a dot)"),
], spacing=0),
value="Left" if Config.GraphPanButton == "Left" else "Right",
)
# 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, TreeFilesSwitch, ButtonsAtTopSwitch, FontSizeField, MenuButton,
ft.Divider(height=1, color=Palette.Surface1),
ft.Text("Markdown editing"),
MarkdownEditingPicker,
LocalDiagramsSwitch,
LocalDiagramsNote,
ft.Divider(height=1, color=Palette.Surface1),
OwnAppearance,
], spacing=12, scroll=ft.ScrollMode.AUTO),
"Graph": ft.Column([
_Note("The graph view (Ctrl+G): directories purple or in the heading colours by depth, files white, "
"joined to their directory and to the files that reference them."),
GraphDirectoriesSwitch,
ColourByDepthSwitch,
ShrinkByDepthSwitch,
ft.Divider(height=1, color=Palette.Surface1),
ft.Text("Pan with mouse button"),
PanButtonPicker,
_Note("Scroll zooms. The other button opens the dot you click."),
], 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.ShowFilesInTree = TreeFilesSwitch.value
Config.NewEntryButtonsAtTop = ButtonsAtTopSwitch.value
Config.MarkdownEditing = MarkdownEditingPicker.value or Config.MarkdownEditing
RemoveDiagramTools = Config.LocalDiagrams and not LocalDiagramsSwitch.value
Config.LocalDiagrams = LocalDiagramsSwitch.value
Config.GraphShowDirectories = GraphDirectoriesSwitch.value
Config.GraphColourByDepth = ColourByDepthSwitch.value
Config.GraphShrinkByDepth = ShrinkByDepthSwitch.value
Config.GraphPanButton = PanButtonPicker.value or Config.GraphPanButton
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")
if RemoveDiagramTools:
def RemoveTools() -> None:
Freed = LocalDiagrams.ToolsSize()
LocalDiagrams.Remove()
if Freed:
TheApp.Notify(f"Removed the diagram downloads ({HumanSize(Freed)}).", "success")
Page.run_thread(RemoveTools)
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),
))