"""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) TreeFilesSwitch = ft.Switch(label="Show files in the tree (off: directories only)", value=Config.ShowFilesInTree) 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) 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, FontSizeField, MenuButton, ft.Divider(height=1, color=Palette.Surface1), ft.Text("Markdown editing"), MarkdownEditingPicker, 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.MarkdownEditing = MarkdownEditingPicker.value or Config.MarkdownEditing 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") 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), ))