"""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, ) from ServerSettings import ServerSections from Sources import SourceError 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) DirectoryNotesSwitch = ft.Switch(label="Clicking a directory also opens its .sv_ note", value=Config.NavigateIntoDirectories) 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, DirectoryNotesSwitch, 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), } # Logged in to a server: your account and server settings too, like the # web view's Settings (ServerSettings.py). Server = TheApp.Server Extra = None if Server is not None and Server.User is not None and not TheApp.ConnectError: def LogOut() -> None: TheApp.Close() TheApp.Logout() try: Extra = ServerSections(TheApp, LogOut) Categories.update(Extra.Categories) except SourceError as Error: TheApp.Notify(f"Couldn't load your server settings: {Error}", "error") if StartCategory not in Categories: StartCategory = "General" 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, dense=True, # room for the server sections too 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: ServerFilesBefore = Extra and (Extra.Values.get("ShowHiddenFolders"), Extra.Values.get("ShowSvFiles")) if Extra is not None and not Extra.Save(): return # stays open, so nothing's lost ServerFilesAfter = Extra and (Extra.Switches["ShowHiddenFolders"].value, Extra.Switches["ShowSvFiles"].value) 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.NavigateIntoDirectories = DirectoryNotesSwitch.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() # A server's hidden-file settings change what it lists: reload like a new source. OnSaved(SourceAfter != SourceBefore or ServerFilesAfter != ServerFilesBefore) 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), ))