diff --git a/Client/.gitignore b/Client/.gitignore new file mode 100644 index 0000000..2b48d2e --- /dev/null +++ b/Client/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.pyc +build/ +.flet/ +dist/ +*.AppImage diff --git a/Client/App.py b/Client/App.py new file mode 100644 index 0000000..b03d7bc --- /dev/null +++ b/Client/App.py @@ -0,0 +1,167 @@ +"""What the window's parts share: the config, the current Source (local or +server), toasts, dialogs and the login flow. Changing the source (Settings, +logging in or out) tells every listener to reload.""" + +from typing import Callable, Optional + +import flet as ft + +from Core import AppConfig, ChipButton, LoadSessionToken, NotificationStyle, Palette, SaveSessionToken +from Sources import MakeSource, ServerSource, Source, SourceError + + +class App: + def __init__(self, Page: ft.Page, Config: AppConfig): + self.Page = Page + self.Config = Config + self.Picker = ft.FilePicker() # registers itself with the page + self.Source: Source = MakeSource(Config) + self.ConnectError = "" # why the server couldn't be reached, shown instead of the file list + self.DialogOpen = False # keyboard shortcuts pause while a dialog is up + self.Listeners: list[Callable[[], None]] = [] # run after the source changes + + # --- Source ----------------------------------------------------------- + + def Connect(self) -> None: + """(Re)builds the source from Config and, for a server, checks it's + there and whether the saved login still works. Listeners reload.""" + if isinstance(self.Source, ServerSource): + self.Source.Close() + Token = LoadSessionToken(self.Config.ServerUrl) if self.Config.Mode == "Server" and self.Config.RememberLogin else "" + self.Source = MakeSource(self.Config, Token) + self.ConnectError = "" + if isinstance(self.Source, ServerSource): + try: + self.Source.Connect() + except SourceError as Error: + self.ConnectError = str(Error) + if Token and self.Source.User is None and not self.ConnectError: + SaveSessionToken(self.Config.ServerUrl, "") + self.Notify("Your saved login ended (the server restarted?). Browsing as Guest.", "warning") + self.Changed() + + def Changed(self) -> None: + for Listener in self.Listeners: + Listener() + + @property + def Server(self) -> Optional[ServerSource]: + return self.Source if isinstance(self.Source, ServerSource) else None + + def HandleError(self, Error: SourceError, Doing: str = "") -> None: + """A toast, plus the login dialog when the server wants a login.""" + Text = f"{Doing}: {Error}" if Doing else str(Error) + if Error.Status == 401 and self.Server is not None: + if self.Server.User is not None: # the session ended under us + self.Server.Logout() + SaveSessionToken(self.Config.ServerUrl, "") + self.Changed() + self.ShowLoginDialog(Reason=Text) + return + self.Notify(Text, "error") + + # --- Login ------------------------------------------------------------ + + def ShowLoginDialog(self, Reason: str = "") -> None: + Server = self.Server + if Server is None: + return + UsernameField = ft.TextField(label="Username", value=self.Config.Username, autofocus=not self.Config.Username) + PasswordField = ft.TextField(label="Password", password=True, can_reveal_password=True, + autofocus=bool(self.Config.Username)) + RememberSwitch = ft.Switch(label="Stay logged in", value=self.Config.RememberLogin) + ErrorText = ft.Text("", color=Palette.Red, size=12, visible=False) + + def DoLogin(e=None) -> None: + Username = (UsernameField.value or "").strip() + if not Username or not PasswordField.value: + return + try: + Server.Login(Username, PasswordField.value) + except SourceError as Error: + ErrorText.value = str(Error) + ErrorText.visible = True + ErrorText.update() + return + self.Config.Username = Username + self.Config.RememberLogin = RememberSwitch.value + self.Config.Save() + SaveSessionToken(self.Config.ServerUrl, Server.Token if RememberSwitch.value else "") + self.Close() + self.Notify(f"Logged in as {Username}.", "success") + self.Changed() + + UsernameField.on_submit = lambda e: PasswordField.focus() + PasswordField.on_submit = DoLogin + Content = [ft.Text(Server.Url, color=Palette.Overlay0, size=12)] + if Reason: + Content.append(ft.Text(Reason, color=Palette.Yellow, size=13)) + Content += [UsernameField, PasswordField, RememberSwitch, ErrorText] + self.ShowDialog("Log in", ft.Column(Content, tight=True, spacing=12), [ + ChipButton("Cancel", lambda e: self.Close()), + ChipButton("Log in", DoLogin), + ], Width=380) + + def Logout(self) -> None: + if self.Server is None: + return + self.Server.Logout() + SaveSessionToken(self.Config.ServerUrl, "") + self.Notify("Logged out. Browsing as Guest.", "info") + self.Changed() + + # --- UI helpers ------------------------------------------------------- + + def Notify(self, Text: str, Kind: str = "info") -> None: + Background, Accent = NotificationStyle(Kind) + self.Page.show_dialog(ft.SnackBar( + content=ft.Text(Text, color=Accent), + bgcolor=Background, + shape=ft.RoundedRectangleBorder(radius=4, side=ft.BorderSide(1, Accent)), + open=True, + )) + + def ShowDialog(self, Title: str, Content: ft.Control, Actions: list[ft.Control], Width: int = 440) -> None: + self.DialogOpen = True + self.Page.show_dialog(ft.AlertDialog( + title=Title, + content=ft.Container(Content, width=Width), + actions=Actions, + on_dismiss=lambda e: setattr(self, "DialogOpen", False), + )) + + def Close(self) -> None: + self.DialogOpen = False + self.Page.pop_dialog() + + def Confirm(self, Title: str, Text: str, OnYes: Callable[[], None], YesText: str = "Delete") -> None: + def Yes(e) -> None: + self.Close() + OnYes() + + self.ShowDialog(Title, ft.Text(Text), [ + ChipButton("Cancel", lambda e: self.Close()), + ChipButton(YesText, Yes, Accent=Palette.Red), + ]) + + def AskName(self, Title: str, Value: str, SelectEnd: int, OnDone: Callable[[str], None], + ActionText: str = "Create", Note: str = "", RequireChange: bool = True) -> None: + """A one-field dialog with the name's stem preselected, like SaraFem's. + RequireChange: an unchanged name does nothing (Rename).""" + NameField = ft.TextField(value=Value, autofocus=True, + selection=ft.TextSelection(base_offset=0, extent_offset=SelectEnd)) + + def Done(e=None) -> None: + NewName = (NameField.value or "").strip() + self.Close() + if NewName and (NewName != Value or not RequireChange): + OnDone(NewName) + + NameField.on_submit = Done + Content: ft.Control = NameField + if Note: + Content = ft.Column([NameField, ft.Text(Note, color=Palette.Overlay0, size=12)], tight=True, spacing=8) + self.ShowDialog(Title, Content, [ + ChipButton("Cancel", lambda e: self.Close()), + ChipButton(ActionText, Done), + ], Width=380) diff --git a/Client/Browser.py b/Client/Browser.py new file mode 100644 index 0000000..2deadf5 --- /dev/null +++ b/Client/Browser.py @@ -0,0 +1,651 @@ +"""The file list and everything around it, laid out like SaraFem's Browser: +breadcrumbs and the toolbar share one bar on top, the file list fills the +rest, and an open file takes the list's place (Viewer.py). Right-click menus +use SaraFem's one shared ContextMenu (see _RequestMenu).""" + +import asyncio +import time +from pathlib import Path +from typing import Callable, Optional + +import flet as ft + +from App import App +from Core import ChipButtonStyle, HumanSize, OpenExternally, Palette +from Sources import BaseName, Entry, JoinPath, MoveDestination, ParentPath, SourceError +from Viewer import FileView + +DoubleClickWindow = 0.4 # seconds + +FileIcons = { + "markdown": ft.Icons.DESCRIPTION_OUTLINED, + "text": ft.Icons.ARTICLE_OUTLINED, + "config": ft.Icons.SETTINGS_OUTLINED, + "html": ft.Icons.CODE, + "image": ft.Icons.IMAGE_OUTLINED, + "video": ft.Icons.MOVIE_OUTLINED, +} +TypeLabels = {"markdown": "Markdown", "text": "Text", "config": "Config", "html": "HTML", "image": "Image", "video": "Video"} + + +class Browser(ft.Column): + def __init__(self, TheApp: App, OnPathChanged: Callable[[Optional[str], str, bool], None], + OnShowSettings: Callable[[], None], OnToggleSidebar: Callable[[], None]): + super().__init__(expand=True, spacing=0, horizontal_alignment=ft.CrossAxisAlignment.STRETCH) + self.TheApp = TheApp + self.Config = TheApp.Config + self.OnPathChanged = OnPathChanged + self.OnShowSettings = OnShowSettings + self.OnToggleSidebar = OnToggleSidebar + + # Root None: the list of roots (a server with more than one). Path is + # the directory inside Root, "" for the root itself. + self.Root: Optional[str] = None + self.Path = "" + self.Entries: list[Entry] = [] + self.ListError = "" # why the current directory couldn't be listed + self.SelectedName: Optional[str] = None + self.EntryRows: dict[str, ft.Container] = {} + self.BackStack: list[tuple[Optional[str], str]] = [] + self.ForwardStack: list[tuple[Optional[str], str]] = [] + self.OpenView: Optional[FileView] = None + self.ShortcutsPaused = False # a text field has focus + self.LastClickName: Optional[str] = None + self.LastClickTime = 0.0 + # Right-click menu state, see _RequestMenu. + self.MenuOpen = False + self.MenuOpenedAt = 0.0 + self.PendingMenu: Optional[tuple[Optional[str], ft.Offset]] = None + + self.Breadcrumbs = ft.Row(spacing=0, wrap=True) + self.NewFileButton = ft.OutlinedButton(content="New File", icon=ft.Icons.NOTE_ADD_OUTLINED, + style=ChipButtonStyle(), on_click=lambda e: self._ShowNewDialog(False)) + self.NewDirectoryButton = ft.OutlinedButton(content="New Directory", icon=ft.Icons.CREATE_NEW_FOLDER_OUTLINED, + style=ChipButtonStyle(), on_click=lambda e: self._ShowNewDialog(True)) + self.UploadButton = ft.OutlinedButton(content="Upload", icon=ft.Icons.UPLOAD_OUTLINED, + style=ChipButtonStyle(), on_click=self._Upload) + self.ConnectionButton = ft.PopupMenuButton( + content=ft.Container( + ft.Row([ft.Icon(ft.Icons.CLOUD_OUTLINED, size=16, color=Palette.Mauve), ft.Text("", size=13)], spacing=6, tight=True), + padding=ft.Padding.symmetric(horizontal=10, vertical=6), + border=ft.Border.all(1, Palette.Mauve), + border_radius=4, + bgcolor=Palette.Surface0, + ), + items=[], + ) + self.ActionsToolbar = ft.Row( + [ + self.NewFileButton, + self.NewDirectoryButton, + self.UploadButton, + ft.IconButton(icon=ft.Icons.REFRESH, icon_color=Palette.Mauve, tooltip="Refresh (Ctrl+R)", + on_click=lambda e: self.Refresh()), + self.ConnectionButton, + ft.IconButton(icon=ft.Icons.SETTINGS_OUTLINED, icon_color=Palette.Mauve, tooltip="Settings", + on_click=lambda e: self.OnShowSettings()), + ], + spacing=8, + tight=True, + ) + self.EntryList = ft.ListView(expand=True, spacing=2) + # One shared popup host, opened programmatically (see _RequestMenu). + # Its built-in triggers can't be switched off, so they're set to + # LONG_PRESS with no items, which leaves them inert (SaraFem's fix for + # the "right-click turns the whole app black" bug). + self.ContextMenu = ft.ContextMenu( + content=ft.Column([self._BuildListHeader(), self.EntryList], spacing=0, expand=True), + expand=True, + secondary_trigger=ft.ContextMenuTrigger.LONG_PRESS, + tertiary_trigger=ft.ContextMenuTrigger.LONG_PRESS, + on_select=self._OnMenuClosed, + on_dismiss=self._OnMenuClosed, + ) + self.FileArea = ft.Container(expand=True) + self.controls = [ + # Unlike SaraFem's 50/50 split, the buttons take what they need + # and the breadcrumbs get the rest (and wrap when it's tight). + ft.Row([ft.Container(self.Breadcrumbs, padding=8, expand=True), ft.Container(self.ActionsToolbar, padding=8)]), + ft.Divider(height=1, color=Palette.Surface1), + self.FileArea, + ] + self._ShowList(Update=False) + + def did_mount(self) -> None: + self.page.on_keyboard_event = self._OnKeyboardEvent + + # --- Source ----------------------------------------------------------- + + def SourceChanged(self) -> None: + """The source was (re)connected, or someone logged in or out: what's + visible may have changed, so start over from the first root.""" + self._RefreshConnectionButton() + self.BackStack.clear() + self.ForwardStack.clear() + self.OpenView = None + self.ShortcutsPaused = False + if self.TheApp.ConnectError: + self.Root, self.Path, self.Entries = None, "", [] + self._ShowList() + return + try: + Roots = self.TheApp.Source.Roots() + except SourceError as Error: + self.TheApp.HandleError(Error) + Roots = [] + self.NavigateTo(Roots[0] if len(Roots) == 1 else None, "", RecordHistory=False) + + def _RefreshConnectionButton(self) -> None: + TheApp = self.TheApp + Server = TheApp.Server + Label: ft.Text = self.ConnectionButton.content.content.controls[1] + Icon: ft.Icon = self.ConnectionButton.content.content.controls[0] + Items: list[ft.PopupMenuItem] = [] + if Server is None: + Label.value = "Local" + Icon.icon = ft.Icons.COMPUTER + Items.append(ft.PopupMenuItem(content=TheApp.Source.Describe(), disabled=True)) + else: + Label.value = (Server.User or {}).get("Username") or ("Offline" if TheApp.ConnectError else "Guest") + Icon.icon = ft.Icons.CLOUD_OFF_OUTLINED if TheApp.ConnectError else ft.Icons.CLOUD_OUTLINED + Items.append(ft.PopupMenuItem(content=Server.Url or "No server address", disabled=True)) + if TheApp.ConnectError: + Items.append(ft.PopupMenuItem(content="Reconnect", icon=ft.Icons.REFRESH, on_click=lambda e: TheApp.Connect())) + elif Server.User is None: + Items.append(ft.PopupMenuItem(content="Log in", icon=ft.Icons.LOGIN, on_click=lambda e: TheApp.ShowLoginDialog())) + else: + Items.append(ft.PopupMenuItem(content="Log out", icon=ft.Icons.LOGOUT, on_click=lambda e: TheApp.Logout())) + Items.append(ft.PopupMenuItem(content="Connection settings", icon=ft.Icons.SETTINGS_OUTLINED, + on_click=lambda e: self.OnShowSettings())) + self.ConnectionButton.items = Items + self.ConnectionButton.tooltip = TheApp.Source.Describe() + CanWrite = TheApp.Source.CanWrite and not TheApp.ConnectError + for Button in (self.NewFileButton, self.NewDirectoryButton, self.UploadButton): + Button.disabled = not CanWrite + Button.tooltip = None if CanWrite else "Log in to change files" + + # --- Navigation ------------------------------------------------------- + + def NavigateTo(self, Root: Optional[str], RelativePath: str, RecordHistory: bool = True) -> None: + if self.OpenView is not None and self.OpenView.IsDirty: + self.OpenView.RequestClose(Then=lambda: self.NavigateTo(Root, RelativePath, RecordHistory)) + return + if RecordHistory and (Root, RelativePath) != (self.Root, self.Path): + self.BackStack.append((self.Root, self.Path)) + self.ForwardStack.clear() + self.Root, self.Path = Root, RelativePath + self.SelectedName = None + self.OpenView = None + self.ShortcutsPaused = False + self._LoadEntries() + self._ShowList() + self.OnPathChanged(Root, RelativePath, False) + + def GoBack(self) -> None: + if self.BackStack: + self.ForwardStack.append((self.Root, self.Path)) + self.NavigateTo(*self.BackStack.pop(), RecordHistory=False) + + def GoForward(self) -> None: + if self.ForwardStack: + self.BackStack.append((self.Root, self.Path)) + self.NavigateTo(*self.ForwardStack.pop(), RecordHistory=False) + + def GoUp(self) -> None: + if self.Root is None: + return + if self.Path: + self.NavigateTo(self.Root, ParentPath(self.Path)) + elif len(self._Roots()) > 1: + self.NavigateTo(None, "") + + def Refresh(self) -> None: + if self.OpenView is not None: + return # reloading would throw away edits; close the file first + self._LoadEntries() + self._ShowList() + + def _Roots(self) -> list[str]: + try: + return self.TheApp.Source.Roots() + except SourceError: + return [] + + def _LoadEntries(self) -> None: + self.ListError = "" + if self.TheApp.ConnectError: + self.Entries = [] + return + try: + if self.Root is None: + self.Entries = [Entry(Name, True) for Name in self.TheApp.Source.Roots()] + else: + self.Entries = self.TheApp.Source.List(self.Root, self.Path) + except SourceError as Error: + self.Entries = [] + self.ListError = str(Error) + + def _EntryPath(self, Name: str) -> tuple[str, str]: + """(Root, Path) of an entry in the current directory.""" + if self.Root is None: + return Name, "" + return self.Root, JoinPath(self.Path, Name) + + def _Find(self, Name: Optional[str]) -> Optional[Entry]: + return next((Item for Item in self.Entries if Item.Name == Name), None) + + # --- Rendering -------------------------------------------------------- + + def _ShowList(self, Update: bool = True) -> None: + self.Breadcrumbs.controls = self._BuildBreadcrumbs() + if self.TheApp.ConnectError: + self.FileArea.content = self._EmptyState( + "Can't reach the server", self.TheApp.ConnectError, + [("Reconnect", lambda e: self.TheApp.Connect()), ("Settings", lambda e: self.OnShowSettings())], + ) + else: + self.EntryList.controls = self._BuildRows() + self.FileArea.content = ft.GestureDetector( + content=ft.Container(content=self.ContextMenu, bgcolor=Palette.Base, expand=True), + on_tap=lambda e: self.Select(None), + on_secondary_tap_down=lambda e: self._RequestMenu(None, e), + ) + if Update: + self.update() + + def _EmptyState(self, Title: str, Detail: str, Buttons: list[tuple[str, Callable]]) -> ft.Control: + return ft.Container( + ft.Column( + [ + ft.Text(Title, color=Palette.Overlay0, size=16), + ft.Text(Detail, color=Palette.Overlay0, size=12, text_align=ft.TextAlign.CENTER), + ft.Row([ft.OutlinedButton(content=Text, style=ChipButtonStyle(), on_click=Handler) for Text, Handler in Buttons], + alignment=ft.MainAxisAlignment.CENTER), + ], + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + tight=True, + ), + alignment=ft.Alignment.CENTER, + expand=True, + padding=24, + ) + + def _BuildBreadcrumbs(self) -> list[ft.Control]: + Segments: list[tuple[str, Optional[str], str]] = [] # (label, root, path) + if self.Root is None or len(self._Roots()) > 1: + Segments.append(("Roots", None, "")) + if self.Root is not None: + Segments.append((self.Root, self.Root, "")) + Cumulative = "" + for Part in [Part for Part in self.Path.split("/") if Part]: + Cumulative = JoinPath(Cumulative, Part) + Segments.append((Part, self.Root, Cumulative)) + Controls: list[ft.Control] = [] + for Index, (Label, Root, PathText) in enumerate(Segments): + Controls.append(ft.TextButton(content=Label, on_click=lambda e, R=Root, P=PathText: self.NavigateTo(R, P))) + if Index < len(Segments) - 1: + Controls.append(ft.Text("/", color=Palette.Overlay0)) + return Controls + + def _BuildListHeader(self) -> ft.Control: + def Cell(Text: str, Width: Optional[int] = None, Expand: bool = False) -> ft.Container: + return ft.Container(ft.Text(Text, size=12, color=Palette.Overlay0), width=Width, expand=Expand, + padding=ft.Padding.only(left=12, right=8)) + + return ft.Container( + ft.Row([Cell("Name", Expand=True), Cell("Type", 110), Cell("Size", 100), ft.Container(width=48)], spacing=0), + padding=ft.Padding.symmetric(vertical=6), + bgcolor=Palette.Mantle, + border=ft.Border(bottom=ft.BorderSide(1, Palette.Surface1)), + ) + + def _BuildRows(self) -> list[ft.Control]: + self.EntryRows = {} + if self.ListError: + return [ft.Container(ft.Text(self.ListError, color=Palette.Red), padding=16)] + if not self.Entries: + return [ft.Container(ft.Text("This directory is empty.", color=Palette.Overlay0, italic=True), padding=16)] + return [self._BuildRow(Item) for Item in self.Entries] + + def _BuildRow(self, Item: Entry) -> ft.Control: + FontSize = self.Config.FontSize + if self.Root is None: + Icon, IconColor, TypeText = ft.Icons.STORAGE_OUTLINED, Palette.Mauve, "Root" + elif Item.IsDirectory: + Icon, IconColor, TypeText = ft.Icons.FOLDER_OUTLINED, Palette.Mauve, "Directory" + else: + Icon = FileIcons.get(Item.FileType or "", ft.Icons.INSERT_DRIVE_FILE_OUTLINED) + IconColor = Palette.Text + TypeText = TypeLabels.get(Item.FileType or "", "File") + Row = ft.Container( + content=ft.Row( + [ + ft.Container( + ft.Row([ + ft.Icon(Icon, size=FontSize + 6, color=IconColor), + ft.Text(Item.Name, size=FontSize, color=Palette.Text, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1, expand=True), + ], spacing=12), + expand=True, + padding=ft.Padding.symmetric(horizontal=12, vertical=8), + ), + ft.Container(ft.Text(TypeText, size=FontSize - 2, color=Palette.Overlay0), width=110, padding=ft.Padding.only(left=12)), + ft.Container(ft.Text(HumanSize(Item.Size), size=FontSize - 2, color=Palette.Overlay0), width=100, + padding=ft.Padding.only(left=12)), + ft.PopupMenuButton(icon=ft.Icons.MORE_VERT, icon_color=Palette.Overlay0, items=self._MenuItems(Item.Name)), + ], + spacing=0, + ), + bgcolor=Palette.Surface0 if Item.Name == self.SelectedName else None, + border_radius=4, + ) + self.EntryRows[Item.Name] = Row + return ft.GestureDetector( + content=Row, + on_tap=lambda e, N=Item.Name: self._HandleTap(N), + on_secondary_tap_down=lambda e, N=Item.Name: self._RequestMenu(N, e), + ) + + # --- Selection and opening -------------------------------------------- + + def _HandleTap(self, Name: str) -> None: + Now = time.monotonic() + IsDoubleClick = Name == self.LastClickName and (Now - self.LastClickTime) < DoubleClickWindow + self.LastClickName = None if IsDoubleClick else Name + self.LastClickTime = Now + if IsDoubleClick: + self.OpenEntry(Name) + else: + self.Select(Name) + + def Select(self, Name: Optional[str]) -> None: + if Name == self.SelectedName: + return + for RowName in (self.SelectedName, Name): + if RowName in self.EntryRows: + self.EntryRows[RowName].bgcolor = Palette.Surface0 if RowName == Name else None + self.EntryRows[RowName].update() + self.SelectedName = Name + + def OpenEntry(self, Name: str, InApp: Optional[bool] = None) -> None: + Item = self._Find(Name) + if Item is None: + return + Root, RelativePath = self._EntryPath(Name) + if Item.IsDirectory: + self.NavigateTo(Root, RelativePath) + return + if InApp is None: + InApp = self.Config.OpenFilesInApp + if not InApp: + self._OpenExternally(Root, RelativePath) + return + self.OpenView = FileView( + self.TheApp, Root, RelativePath, Item, + OnClose=self._CloseFile, + OnOpenExternally=lambda: self._OpenExternally(Root, RelativePath), + OnDownload=(lambda: self._Download(Name)) if self.TheApp.Server else None, + OnFocusChange=self._SetShortcutsPaused, + ) + self.FileArea.content = self.OpenView + self.FileArea.update() + + def _CloseFile(self) -> None: + self.OpenView = None + self.ShortcutsPaused = False + self._ShowList() + + def _OpenExternally(self, Root: str, RelativePath: str) -> None: + try: + if self.TheApp.Server: + self.TheApp.Notify(f"Downloading {BaseName(RelativePath)}...", "info") + OpenExternally(self.TheApp.Source.LocalFile(Root, RelativePath)) + except SourceError as Error: + self.TheApp.HandleError(Error, f"Couldn't open {BaseName(RelativePath)}") + except OSError as Error: + self.TheApp.Notify(f"Couldn't open {BaseName(RelativePath)}: {Error}", "error") + + # --- Menus ------------------------------------------------------------ + + def _MenuItems(self, Name: str) -> list[ft.PopupMenuItem]: + """Shared by the triple-dot button and the right-click menu.""" + if self.Root is None: + return [ft.PopupMenuItem(content="Open", on_click=lambda e: self.OpenEntry(Name))] + Item = self._Find(Name) + CanWrite = self.TheApp.Source.CanWrite + Items: list[ft.PopupMenuItem] = [] + if Item is not None and not Item.IsDirectory: + OtherWay = "Open Externally" if self.Config.OpenFilesInApp else "View in SaraView" + Items += [ + ft.PopupMenuItem(content="View" if self.Config.OpenFilesInApp else "Open Externally", + on_click=lambda e: self.OpenEntry(Name)), + ft.PopupMenuItem(content=OtherWay, on_click=lambda e: self.OpenEntry(Name, InApp=not self.Config.OpenFilesInApp)), + ] + else: + Items.append(ft.PopupMenuItem(content="Open", on_click=lambda e: self.OpenEntry(Name))) + if self.TheApp.Server: + Items.append(ft.PopupMenuItem(content="Download", on_click=lambda e: self._Download(Name))) + Items += [ + ft.PopupMenuItem(content="Copy Path", on_click=lambda e: self._CopyPath(Name)), + ft.PopupMenuItem(content="Rename / Move", disabled=not CanWrite, on_click=lambda e: self._ShowRenameDialog(Name)), + ft.PopupMenuItem(content="Delete", disabled=not CanWrite, on_click=lambda e: self._ConfirmDelete(Name)), + ] + return Items + + def _EmptySpaceMenuItems(self) -> list[ft.PopupMenuItem]: + CanWrite = self.TheApp.Source.CanWrite and self.Root is not None + return [ + ft.PopupMenuItem(content="New File", disabled=not CanWrite, on_click=lambda e: self._ShowNewDialog(False)), + ft.PopupMenuItem(content="New Directory", disabled=not CanWrite, on_click=lambda e: self._ShowNewDialog(True)), + ft.PopupMenuItem(content="Upload", disabled=not CanWrite, on_click=self._Upload), + ft.PopupMenuItem(content="Refresh", on_click=lambda e: self.Refresh()), + ] + + def _RequestMenu(self, Name: Optional[str], Event: ft.TapEvent) -> None: + """Right-click on an entry (Name) or the background (None). An entry + sits inside the background's GestureDetector and both report the + click, so this waits a moment and opens one menu, the entry's + winning (SaraFem's _RequestMenu).""" + if self.MenuOpen: + if time.monotonic() - self.MenuOpenedAt < 0.5: + return + self.MenuOpen = False + First = self.PendingMenu is None + if First or Name is not None: + self.PendingMenu = (Name, Event.global_position) + if First: + self.page.run_task(self._OpenPendingMenu) + + async def _OpenPendingMenu(self) -> None: + await asyncio.sleep(0.08) + Name, Position = self.PendingMenu + self.PendingMenu = None + if Name is not None: + self.Select(Name) + self.ContextMenu.items = self._MenuItems(Name) if Name is not None else self._EmptySpaceMenuItems() + self.ContextMenu.update() + self.MenuOpen = True + self.MenuOpenedAt = time.monotonic() + try: + await self.ContextMenu.open(global_position=Position) + except Exception: + self.MenuOpen = False + raise + + def _OnMenuClosed(self, Event) -> None: + if Event.button is None: # only menus opened through open() + self.MenuOpen = False + + # --- File operations -------------------------------------------------- + + def _AfterChange(self) -> None: + """Re-list, and let the sidebar tree catch up with moved directories.""" + self.Refresh() + self.OnPathChanged(self.Root, self.Path, True) + + def _ShowNewDialog(self, IsDirectory: bool) -> None: + if self.Root is None: + self.TheApp.Notify("Open a root first.", "warning") + return + Default = "New Directory" if IsDirectory else "New File.md" + + def Create(NewName: str) -> None: + Root, RelativePath = self._EntryPath(NewName) + try: + self.TheApp.Source.Create(Root, RelativePath, IsDirectory) + except SourceError as Error: + self.TheApp.HandleError(Error, f"Couldn't create {NewName}") + return + self._AfterChange() + if not IsDirectory: + self.Select(NewName) + + # Unlike Rename, Create also takes the suggested name as it is. + self.TheApp.AskName("New Directory" if IsDirectory else "New File", Default, len(Default.rsplit(".", 1)[0]), + Create, RequireChange=False) + + def _ShowRenameDialog(self, Name: str) -> None: + Item = self._Find(Name) + Root, RelativePath = self._EntryPath(Name) + SelectEnd = len(Name) if (Item and Item.IsDirectory) or "." not in Name else len(Name.rsplit(".", 1)[0]) + + def Rename(NewName: str) -> None: + Destination = MoveDestination(RelativePath, NewName) + if not Destination: + self.TheApp.Notify("That would move it to the root itself.", "warning") + return + try: + self.TheApp.Source.Move(Root, RelativePath, Destination) + except SourceError as Error: + self.TheApp.HandleError(Error, f"Couldn't rename {Name}") + return + self._AfterChange() + + self.TheApp.AskName("Rename", Name, SelectEnd, Rename, ActionText="Rename", + Note='A "/" moves it, e.g. ../Archive/' + Name) + + def _ConfirmDelete(self, Name: str) -> None: + Item = self._Find(Name) + Root, RelativePath = self._EntryPath(Name) + + def Delete() -> None: + try: + self.TheApp.Source.Delete(Root, RelativePath) + except SourceError as Error: + self.TheApp.HandleError(Error, f"Couldn't delete {Name}") + return + self.SelectedName = None + self._AfterChange() + self.TheApp.Notify(f"Deleted {Name}.", "success") + + if not self.Config.ConfirmDelete: + Delete() + return + What = "directory and everything in it" if Item and Item.IsDirectory else "file" + Where = "moved to the trash" if self.TheApp.Server is None else "deleted from the server for good" + self.TheApp.Confirm(f"Delete {Name}?", f"This {What} will be {Where}.", Delete) + + def _CopyPath(self, Name: str) -> None: + Root, RelativePath = self._EntryPath(Name) + Server = self.TheApp.Server + Text = f"{Root}/{RelativePath}" if Server else str(self.TheApp.Source.LocalFile(Root, RelativePath)) + self.page.run_task(ft.Clipboard().set, Text) + self.TheApp.Notify(f"Copied {Text}", "success") + + async def _Upload(self, e=None) -> None: + if self.Root is None or not self.TheApp.Source.CanWrite: + return + Picked = await self.TheApp.Picker.pick_files(dialog_title="Upload files", allow_multiple=True) + Paths = [Path(File.path) for File in Picked or [] if File.path] + if not Paths: + return + Saved, Failed = 0, [] + Server = self.TheApp.Server + for LocalFile in Paths: + if Server: + self.TheApp.Notify(f"Uploading {LocalFile.name} ({HumanSize(LocalFile.stat().st_size)})...", "info") + try: + await asyncio.to_thread(self.TheApp.Source.Upload, self.Root, self.Path, LocalFile) + Saved += 1 + except SourceError as Error: + if Error.Status == 401: + self.TheApp.HandleError(Error) + return + Failed.append(f"{LocalFile.name}: {Error}") + except OSError as Error: + Failed.append(f"{LocalFile.name}: {Error}") + self.Refresh() + if Failed: + self.TheApp.Notify("Couldn't upload " + "; ".join(Failed), "error") + else: + self.TheApp.Notify(f"{'Uploaded' if Server else 'Copied in'} {Saved} file{'s' if Saved != 1 else ''}.", "success") + + def _Download(self, Name: str) -> None: + self.page.run_task(self._DoDownload, Name) + + async def _DoDownload(self, Name: str) -> None: + Server = self.TheApp.Server + Item = self._Find(Name) or (self.OpenView.Item if self.OpenView else None) + if Server is None: + return + Root, RelativePath = self._EntryPath(Name) + FileName = f"{Name}.zip" if (Item and Item.IsDirectory) or self.Root is None else Name + Chosen = await self.TheApp.Picker.save_file(dialog_title="Download to", file_name=FileName, + initial_directory=str(Path.home() / "Downloads")) + if not Chosen: + return + self.TheApp.Notify(f"Downloading {FileName}...", "info") + try: + await asyncio.to_thread(Server.Download, Root, RelativePath, Path(Chosen)) + except SourceError as Error: + self.TheApp.HandleError(Error, f"Couldn't download {Name}") + return + self.TheApp.Notify(f"Saved {Chosen}", "success") + + # --- Keyboard --------------------------------------------------------- + + def _SetShortcutsPaused(self, Paused: bool) -> None: + self.ShortcutsPaused = Paused + + def _OnKeyboardEvent(self, Event: ft.KeyboardEvent) -> None: + if self.TheApp.DialogOpen: + return + View = self.OpenView + # These work while typing too. + if Event.key == "S" and Event.ctrl and View is not None: + View.Save() + return + if Event.key == "E" and Event.ctrl and View is not None: + View.ToggleEdit() + return + if Event.key == "Escape" and View is not None: + View.RequestClose() + return + if self.ShortcutsPaused: + return + + if Event.key == "B" and Event.ctrl: + self.OnToggleSidebar() + elif Event.key == "R" and Event.ctrl: + self.Refresh() + elif Event.key == "Arrow Left" and Event.alt: + self.GoBack() + elif Event.key == "Arrow Right" and Event.alt: + self.GoForward() + elif View is not None: + return # the rest act on the file list + elif Event.key == "N" and Event.ctrl and Event.shift: + self._ShowNewDialog(True) + elif Event.key == "N" and Event.ctrl: + self._ShowNewDialog(False) + elif Event.key == "Backspace": + self.GoUp() + elif Event.key == "Escape": + self.Select(None) + elif self.SelectedName is None: + return + elif Event.key == "Enter": + self.OpenEntry(self.SelectedName) + elif Event.key == "F2" and self.TheApp.Source.CanWrite and self.Root is not None: + self._ShowRenameDialog(self.SelectedName) + elif Event.key == "Delete" and self.TheApp.Source.CanWrite and self.Root is not None: + self._ConfirmDelete(self.SelectedName) diff --git a/Client/Core.py b/Client/Core.py new file mode 100644 index 0000000..047b9f2 --- /dev/null +++ b/Client/Core.py @@ -0,0 +1,414 @@ +"""Shared app-wide constants: palette, theme, paths, SaraView client config. + +Palette, theming and the shared SaraSuite settings mirror SaraFem's Core.py +and read the same ~/.config/SaraSuite files, so a theme picked for one Sara +program looks the same in all of them. The role names are SaraView's own +(Backend/DefaultThemes/*.toml).""" + +import os +import shutil +import subprocess +import sys +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Callable, Optional + +import flet as ft +import tomlkit + + +class Palette: + """Live values for the active theme (see ApplyTheme). Starts as Catppuccin + Mocha so anything constructed before ApplyTheme() runs still looks right. + Same attribute names and role mapping as SaraFem's Palette.""" + + Mauve = "#cba6f7" # SaraView's "Purple" role: the one accent colour + Red = "#f38ba8" + Peach = "#fab387" # SaraView's "Orange" + Yellow = "#f9e2af" + Green = "#a6e3a1" + Blue = "#89b4fa" + Text = "#cdd6f4" + White = "#cdd6f4" + Overlay0 = "#6c7086" # SaraView's "TextMuted" + Surface1 = "#45475a" # SaraView's "Border" + Surface0 = "#313244" # SaraView's "SurfaceAlt" + Base = "#1e1e2e" # SaraView's "Background" + Mantle = "#181825" # SaraView's "Surface" + Crust = "#11111b" # SaraView's "Black" + + +AppName = "SaraView" +# Bundled in assets/fonts and always registered, so it works as FontFamily +# without being installed. +BundledFontFamily = "Fixedsys Excelsior" +ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme + +Home = Path.home() +SharedConfigDir = Home / ".config" / "SaraSuite" +ThemesDir = SharedConfigDir / "Themes" +SharedConfigPath = SharedConfigDir / "Config.toml" +ConfigDir = SharedConfigDir / AppName +ConfigPath = ConfigDir / "Config.toml" +# The server's session token, kept out of Config.toml so a settings export +# never carries it. +SessionPath = ConfigDir / "Session.toml" +# Server files opened in another program are downloaded here first. +CacheDir = Home / ".cache" / AppName +DataHome = Home / ".local" / "share" + +# Inside a PyInstaller build, bundled files are unpacked to sys._MEIPASS. +BundleDir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent)) +AssetsDir = BundleDir / "assets" + +_ThemeRoleMap = { + "Background": "Base", "Surface": "Mantle", "SurfaceAlt": "Surface0", "Border": "Surface1", + "Text": "Text", "TextMuted": "Overlay0", "Black": "Crust", "White": "White", + "Red": "Red", "Orange": "Peach", "Yellow": "Yellow", "Green": "Green", + "Blue": "Blue", "Purple": "Mauve", +} + +# Same as SaraFem's BuiltinThemes, copied from Backend/DefaultThemes/*.toml. +BuiltinThemes: dict[str, dict[str, str]] = { + "CatppuccinMocha": { + "Background": "#1e1e2e", "Surface": "#181825", "SurfaceAlt": "#313244", "Border": "#45475a", + "Text": "#cdd6f4", "TextMuted": "#6c7086", "Black": "#11111b", "White": "#cdd6f4", + "Red": "#f38ba8", "Orange": "#fab387", "Yellow": "#f9e2af", "Green": "#a6e3a1", + "Blue": "#89b4fa", "Purple": "#cba6f7", + }, + "Forest": { + "Background": "#1b2420", "Surface": "#141b17", "SurfaceAlt": "#243029", "Border": "#334539", + "Text": "#d6e5d0", "TextMuted": "#7c9184", "Black": "#0d120f", "White": "#eef5ea", + "Red": "#e0685a", "Orange": "#e0a458", "Yellow": "#e0c458", "Green": "#7fb069", + "Blue": "#5c9e8f", "Purple": "#9b7fb0", + }, + "Gruvbox": { + "Background": "#282828", "Surface": "#1d2021", "SurfaceAlt": "#3c3836", "Border": "#504945", + "Text": "#ebdbb2", "TextMuted": "#a89984", "Black": "#1d2021", "White": "#fbf1c7", + "Red": "#fb4934", "Orange": "#fe8019", "Yellow": "#fabd2f", "Green": "#b8bb26", + "Blue": "#83a598", "Purple": "#d3869b", + }, +} + + +def _ReadToml(FilePath: Path) -> dict: + """The file's data, or {} if it's missing or unreadable.""" + try: + return tomlkit.parse(FilePath.read_text()).unwrap() + except (OSError, tomlkit.exceptions.TOMLKitError): + return {} + + +def EnsureSharedThemes() -> None: + """Seed the shared Themes directory with the bundled defaults, like SaraFem.""" + ThemesDir.mkdir(parents=True, exist_ok=True) + for Name, RoleValues in BuiltinThemes.items(): + ThemeFile = ThemesDir / f"{Name}.toml" + if not ThemeFile.exists(): + ThemeFile.write_text(tomlkit.dumps({"Palette": RoleValues})) + + +def AvailableThemes() -> list[str]: + EnsureSharedThemes() + return sorted(ThemeFile.stem for ThemeFile in ThemesDir.glob("*.toml")) + + +def ApplyTheme(ThemeName: str) -> None: + """Mutate Palette in place to the named theme. Must run before any control + that reads Palette.X is built.""" + EnsureSharedThemes() + RoleValues = dict(BuiltinThemes.get(ThemeName, BuiltinThemes["CatppuccinMocha"])) + RoleValues.update(_ReadToml(ThemesDir / f"{ThemeName}.toml").get("Palette", {})) + for RoleName, AttrName in _ThemeRoleMap.items(): + if RoleName in RoleValues: + setattr(Palette, AttrName, RoleValues[RoleName]) + + +@dataclass +class AppConfig: + # True: Theme, FontFamily and HideWindowTitleBar come from the shared + # ~/.config/SaraSuite/Config.toml. False: SaraView's own values below. + UseSharedSettings: bool = True + Theme: str = "CatppuccinMocha" + FontFamily: str = BundledFontFamily + HideWindowTitleBar: bool = False + + # "Local" browses LocalRoot on this computer; "Server" talks to a + # SaraView server (the one on SpineS), with its users, locks and blame. + Mode: str = "Local" + LocalRoot: str = "~" + ServerUrl: str = "" # e.g. http://spines:8420 + Username: str = "" # last one logged in with, prefilled in the login dialog + RememberLogin: bool = True # keep the session in Session.toml between starts + + # Local mode only; on a server these come from your SaraView user settings. + ShowHiddenFolders: bool = False + ShowSvFiles: bool = False + # Same as the server's [SaraTag] HideInternalFiles/InternalPatterns. + HideInternalFiles: bool = True + InternalPatterns: list[str] = field(default_factory=lambda: [".saratag", ".SaraTag*", "*.book.toml"]) + + ConfirmDelete: bool = True + ShowSidebar: bool = True + SidebarWidth: int = 240 + FontSize: int = 14 # file list and viewer text + # Double-click on a file: True opens it in SaraView's own viewer, False + # hands it to the system's default program. The menu always offers the other. + OpenFilesInApp: bool = True + + def Save(self) -> None: + ConfigDir.mkdir(parents=True, exist_ok=True) + ConfigPath.write_text(tomlkit.dumps(asdict(self))) + + @property + def LocalRootPath(self) -> Path: + return Path(os.path.expanduser(self.LocalRoot.strip() or "~")).resolve() + + +def LoadConfig() -> AppConfig: + if not ConfigPath.exists(): + Config = AppConfig() + Config.Save() + return Config + Data = _ReadToml(ConfigPath) + return AppConfig(**{Key: Value for Key, Value in Data.items() if Key in AppConfig.__dataclass_fields__}) + + +def LoadSessionToken(ServerUrl: str) -> str: + """The saved session for ServerUrl, or "" (a session from another server is useless).""" + Data = _ReadToml(SessionPath) + return Data.get("Token", "") if Data.get("ServerUrl") == ServerUrl else "" + + +def SaveSessionToken(ServerUrl: str, Token: str) -> None: + if not Token: + SessionPath.unlink(missing_ok=True) + return + ConfigDir.mkdir(parents=True, exist_ok=True) + SessionPath.write_text(tomlkit.dumps({"ServerUrl": ServerUrl, "Token": Token})) + SessionPath.chmod(0o600) + + +@dataclass +class SharedSettings: + """~/.config/SaraSuite/Config.toml, shared by every SaraSuite program. + Same schema and fallback rules as SaraFem's.""" + + Theme: str = "CatppuccinMocha" + FontFamily: str = BundledFontFamily + HideWindowTitleBar: bool = False + + def Save(self) -> None: + SharedConfigDir.mkdir(parents=True, exist_ok=True) + SharedConfigPath.write_text(tomlkit.dumps(asdict(self))) + + +def _OwnSettings(Config: AppConfig) -> SharedSettings: + return SharedSettings(Config.Theme, Config.FontFamily, Config.HideWindowTitleBar) + + +def LoadSharedSettings(Config: AppConfig) -> SharedSettings: + """The shared file, created from SaraView's own values if missing.""" + if not SharedConfigPath.exists(): + Shared = _OwnSettings(Config) + Shared.Save() + return Shared + Result = _OwnSettings(Config) # fallback for keys the file doesn't have + Data = _ReadToml(SharedConfigPath) + for Key in SharedSettings.__dataclass_fields__: + if Key in Data: + setattr(Result, Key, Data[Key]) + return Result + + +def EffectiveSettings(Config: AppConfig) -> SharedSettings: + return LoadSharedSettings(Config) if Config.UseSharedSettings else _OwnSettings(Config) + + +def ExportSettings(Config: AppConfig, DestPath: Path) -> None: + """One portable file in SaraFem's format: the shared settings under + [SaraSuite] (with a custom theme's palette as ThemeData) and SaraView's + own under [SaraView]. Other programs ignore [SaraView] and vice versa.""" + Shared = LoadSharedSettings(Config) + SharedData = asdict(Shared) + if Shared.Theme not in BuiltinThemes: + ThemeData = _ReadToml(ThemesDir / f"{Shared.Theme}.toml").get("Palette") + if ThemeData: + SharedData["ThemeData"] = ThemeData + DestPath.write_text(tomlkit.dumps({"SaraSuite": SharedData, AppName: asdict(Config)})) + + +def ImportSettings(Config: AppConfig, SourcePath: Path) -> None: + """Applies [SaraSuite] to the shared file and [SaraView] onto Config, key + by key; unknown sections and keys are skipped. Raises TOMLKitError on an + unreadable file.""" + Data = tomlkit.parse(SourcePath.read_text()).unwrap() + + SharedData = Data.get("SaraSuite") + if isinstance(SharedData, dict): + ThemeData = SharedData.pop("ThemeData", None) + Merged = asdict(LoadSharedSettings(Config)) + Merged.update({Key: Value for Key, Value in SharedData.items() if Key in SharedSettings.__dataclass_fields__}) + ImportedTheme = Merged.get("Theme") + if isinstance(ThemeData, dict) and ImportedTheme and ImportedTheme not in BuiltinThemes: + ThemesDir.mkdir(parents=True, exist_ok=True) + (ThemesDir / f"{ImportedTheme}.toml").write_text(tomlkit.dumps({"Palette": ThemeData})) + SharedSettings(**Merged).Save() + + OwnData = Data.get(AppName) + if isinstance(OwnData, dict): + for Key, Value in OwnData.items(): + if Key in AppConfig.__dataclass_fields__: + setattr(Config, Key, Value) + Config.Save() + + +def ApplyAppearance(Settings: SharedSettings) -> None: + """Theme colours and font; must run before anything reads Palette.X.""" + global ActiveFontFamily + ApplyTheme(Settings.Theme) + ActiveFontFamily = Settings.FontFamily or BundledFontFamily + + +def ChildEnvironment() -> dict[str, str]: + """The environment for child processes (xdg-open, fc-list). A PyInstaller + build points LD_LIBRARY_PATH at its own bundled libraries; system tools + need the original back. Same as SaraSort's.""" + Environment = dict(os.environ) + if getattr(sys, "frozen", False): + Original = Environment.pop("LD_LIBRARY_PATH_ORIG", None) + if Original is not None: + Environment["LD_LIBRARY_PATH"] = Original + else: + Environment.pop("LD_LIBRARY_PATH", None) + for Key in ("PYTHONHOME", "PYTHONPATH"): + Environment.pop(Key, None) + return Environment + + +def OpenExternally(FilePath: Path) -> None: + """Hands a file to the system's default program. Raises OSError.""" + subprocess.Popen(["xdg-open", str(FilePath)], env=ChildEnvironment(), start_new_session=True) + + +def InstalledFontFamilies() -> list[str]: + """Font families fontconfig knows about, 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 BuildTheme() -> ft.Theme: + ColorScheme = ft.ColorScheme( + primary=Palette.Mauve, + on_primary=Palette.Crust, + primary_container=Palette.Surface0, + on_primary_container=Palette.Mauve, + secondary=Palette.Mauve, + on_secondary=Palette.Crust, + secondary_container=Palette.Surface0, + on_secondary_container=Palette.Mauve, + surface=Palette.Base, + on_surface=Palette.Text, + on_surface_variant=Palette.Overlay0, + surface_container=Palette.Mantle, + surface_container_low=Palette.Mantle, + surface_container_high=Palette.Surface0, + surface_container_highest=Palette.Surface1, + outline=Palette.Surface1, + outline_variant=Palette.Surface1, + error=Palette.Red, + on_error=Palette.Crust, + ) + return ft.Theme( + font_family=ActiveFontFamily, + color_scheme=ColorScheme, + use_material3=True, + scaffold_bgcolor=Palette.Base, + canvas_color=Palette.Base, + dialog_theme=ft.DialogTheme( + bgcolor=Palette.Mantle, + shape=ft.RoundedRectangleBorder(radius=8, side=ft.BorderSide(1, Palette.Mauve)), + title_text_style=ft.TextStyle(color=Palette.Mauve, size=18, font_family=ActiveFontFamily), + ), + ) + + +def ChipButtonStyle(Accent: str = "") -> ft.ButtonStyle: + """SaraView's button look: SurfaceAlt fill, 1px accent border, small radius. + Disabled, it fades to the muted colours (plain colours would look enabled).""" + return ft.ButtonStyle( + color={ft.ControlState.DISABLED: Palette.Overlay0, ft.ControlState.DEFAULT: Palette.Text}, + icon_color={ft.ControlState.DISABLED: Palette.Overlay0, ft.ControlState.DEFAULT: Palette.Text}, + bgcolor={ft.ControlState.DISABLED: Palette.Mantle, ft.ControlState.DEFAULT: Palette.Surface0}, + side={ + ft.ControlState.DISABLED: ft.BorderSide(1, Palette.Surface1), + ft.ControlState.DEFAULT: ft.BorderSide(1, Accent or Palette.Mauve), + }, + shape=ft.RoundedRectangleBorder(radius=4), + ) + + +def CodeStyleSheet() -> ft.MarkdownStyleSheet: + """Code in the app's own font rather than Flutter's default monospace.""" + return ft.MarkdownStyleSheet(code_text_style=ft.TextStyle(font_family=ActiveFontFamily)) + + +def ChipButton(Text: str, OnClick, Icon=None, Accent: str = "", Disabled: bool = False) -> ft.OutlinedButton: + return ft.OutlinedButton(content=Text, icon=Icon, style=ChipButtonStyle(Accent), on_click=OnClick, disabled=Disabled) + + +def BlendHex(BaseHex: str, AccentHex: str, Ratio: float) -> str: + """Solid colour mixing Base and Accent (0 = pure Base, 1 = pure Accent). + Same as SaraFem's.""" + BaseRGB = tuple(int(BaseHex.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4)) + AccentRGB = tuple(int(AccentHex.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4)) + Mixed = (round(B + (A - B) * Ratio) for B, A in zip(BaseRGB, AccentRGB)) + return "#" + "".join(f"{C:02x}" for C in Mixed) + + +def NotificationStyle(Kind: str) -> tuple[str, str]: + """(Background, AccentColor) for a toast, from the theme's own colours like SaraFem's.""" + Accent = {"success": Palette.Green, "error": Palette.Red, "warning": Palette.Yellow}.get(Kind, Palette.Mauve) + return BlendHex(Palette.Mantle, Accent, 0.12), Accent + + +def ResizeHandle(OnDrag: Callable[[float], None], OnDragEnd: Callable[[], None]) -> ft.GestureDetector: + """A thin draggable divider between side-by-side panels (SaraFem's + ResizeHandle, horizontal only). The parent Row must stretch its children + vertically, or the handle has no height to drag.""" + return ft.GestureDetector( + content=ft.Container(width=6, bgcolor=Palette.Surface1), + mouse_cursor=ft.MouseCursor.RESIZE_COLUMN, + on_horizontal_drag_update=lambda e: OnDrag(e.primary_delta or 0), + on_horizontal_drag_end=lambda e: OnDragEnd(), + ) + + +def HumanSize(NumBytes: Optional[int]) -> str: + if NumBytes is None: + return "" + Size = float(NumBytes) + for Unit in ("B", "KB", "MB", "GB"): + if Size < 1024 or Unit == "GB": + return f"{Size:.0f} {Unit}" if Unit == "B" else f"{Size:.1f} {Unit}" + Size /= 1024 + return f"{Size:.1f} TB" + + +def DisplayPath(PathText: str) -> str: + """/home/sarah/Notes -> ~/Notes, for labels.""" + HomeText = str(Home) + if PathText == HomeText or PathText.startswith(HomeText + "/"): + return "~" + PathText[len(HomeText):] + return PathText + + +def ClearCache() -> None: + shutil.rmtree(CacheDir, ignore_errors=True) diff --git a/Client/Filetree.py b/Client/Filetree.py new file mode 100644 index 0000000..f9e23bc --- /dev/null +++ b/Client/Filetree.py @@ -0,0 +1,128 @@ +"""The sidebar's directory tree, built like SaraFem's Filetree: one node per +root, children loaded lazily on expand (every expand on a server is a +request). Clicking a directory opens it in the browser.""" + +from typing import Callable, Optional + +import flet as ft + +from App import App +from Core import Palette +from Sources import JoinPath, SourceError + +Location = tuple[str, str] # (Root, Path) + + +class _FiletreeNode: + def __init__(self, TheApp: App, Root: str, RelativePath: str, OnNavigate: Callable[[str, str], None], Depth: int): + self.TheApp = TheApp + self.Root = Root + self.RelativePath = RelativePath + self.OnNavigate = OnNavigate + self.Depth = Depth + self.ChildrenLoaded = False + self.ChildNodes: dict[str, "_FiletreeNode"] = {} + + Name = RelativePath.rsplit("/", 1)[-1] if RelativePath else Root + self.NameLabel = ft.Text(Name, size=13, color=Palette.Text, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1) + self.NameContainer = ft.Container( + content=ft.GestureDetector(content=self.NameLabel, on_tap=lambda e: self.OnNavigate(self.Root, self.RelativePath)), + padding=4, + border_radius=4, + ) + self.Tile = ft.ExpansionTile( + title=self.NameContainer, + leading=ft.Icon(ft.Icons.STORAGE_OUTLINED if not RelativePath else ft.Icons.FOLDER_OUTLINED, + size=16, color=Palette.Mauve if not RelativePath else Palette.Overlay0), + controls=[], + tile_padding=ft.Padding.only(left=8 + Depth * 14, right=8), + icon_color=Palette.Overlay0, + collapsed_icon_color=Palette.Overlay0, + # No shape: the default draws divider lines above and below an open tile. + shape=ft.RoundedRectangleBorder(radius=4), + collapsed_shape=ft.RoundedRectangleBorder(radius=4), + on_change=self._OnExpansionChanged, + ) + + def _OnExpansionChanged(self, e) -> None: + if self.Tile.expanded and not self.ChildrenLoaded: + self.LoadChildren() + + def LoadChildren(self, Update: bool = True) -> None: + try: + Directories = [Item.Name for Item in self.TheApp.Source.List(self.Root, self.RelativePath) if Item.IsDirectory] + except SourceError: + Directories = [] # locked, gone or unreachable: just no children + self.ChildNodes = {} + for Name in Directories: + ChildPath = JoinPath(self.RelativePath, Name) + self.ChildNodes[Name] = _FiletreeNode(self.TheApp, self.Root, ChildPath, self.OnNavigate, self.Depth + 1) + self.Tile.controls = [Node.Tile for Node in self.ChildNodes.values()] + self.ChildrenLoaded = True + if Update: + self.Tile.update() + + def SetSelected(self, IsSelected: bool) -> None: + self.NameContainer.bgcolor = Palette.Surface1 if IsSelected else None + self.NameLabel.color = Palette.Mauve if IsSelected else Palette.Text + self.NameContainer.update() + + +class Filetree(ft.Container): + def __init__(self, TheApp: App, OnNavigate: Callable[[str, str], None]): + self.TheApp = TheApp + self.OnNavigate = OnNavigate + self.RootNodes: dict[str, _FiletreeNode] = {} + self.SelectedNode: Optional[_FiletreeNode] = None + self.TreeList = ft.ListView(expand=True) + self.SourceLabel = ft.Text("", size=12, color=Palette.Overlay0, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1) + super().__init__( + content=ft.Column([ + ft.Container(self.SourceLabel, padding=ft.Padding.only(left=12, right=12, top=10, bottom=4)), + self.TreeList, + ], spacing=0, expand=True), + width=TheApp.Config.SidebarWidth, + bgcolor=Palette.Mantle, + ) + + def Reload(self) -> None: + """Rebuilds the tree from the current source (after connecting, + logging in or out, or a change that moved directories around).""" + Source = self.TheApp.Source + self.SourceLabel.value = "Not connected" if self.TheApp.ConnectError else Source.Describe() + self.SelectedNode = None + Roots: list[str] = [] + if not self.TheApp.ConnectError: + try: + Roots = Source.Roots() + except SourceError: + Roots = [] + Expanded = {Root: Node.Tile.expanded for Root, Node in self.RootNodes.items()} + self.RootNodes = {Root: _FiletreeNode(self.TheApp, Root, "", self.OnNavigate, 0) for Root in Roots} + for Root, Node in self.RootNodes.items(): + # One root (always the case locally) starts open; otherwise keep what was open. + if Expanded.get(Root, len(Roots) == 1): + Node.LoadChildren(Update=False) + Node.Tile.expanded = True + self.TreeList.controls = [Node.Tile for Node in self.RootNodes.values()] + self.update() + + def RevealPath(self, Root: Optional[str], RelativePath: str) -> None: + """Expand and highlight the tree down to a directory, loading as needed.""" + if self.SelectedNode: + self.SelectedNode.SetSelected(False) + self.SelectedNode = None + Node = self.RootNodes.get(Root or "") + if Node is None: + return + for Part in [Part for Part in RelativePath.split("/") if Part]: + if not Node.ChildrenLoaded: + Node.LoadChildren() + NextNode = Node.ChildNodes.get(Part) + if NextNode is None: + break # hidden or not a directory: highlight the closest parent + Node.Tile.expanded = True + Node.Tile.update() + Node = NextNode + Node.SetSelected(True) + self.SelectedNode = Node diff --git a/Client/Main.py b/Client/Main.py new file mode 100644 index 0000000..52733d8 --- /dev/null +++ b/Client/Main.py @@ -0,0 +1,105 @@ +"""SaraView's desktop client: browse and edit files in a local root directory, +or on a SaraView server (the one on SpineS) through its API. Laid out like +SaraFem: a directory tree on the left, the file list (or an open file) on +the right.""" + +import os +import subprocess +from typing import Optional + +import flet as ft + +from App import App +from Browser import Browser +from Core import ( + AppName, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, EffectiveSettings, LoadConfig, Palette, + ResizeHandle, +) +from Filetree import Filetree +from Settings import ShowSettingsDialog + + +def _SetCursorThemeFallback() -> None: + """GTK reads XCURSOR_THEME directly and doesn't always inherit it (shows as + 'Unable to load ... from the cursor theme'). Same fix as SaraFem's.""" + if os.environ.get("XCURSOR_THEME"): + return + try: + Result = subprocess.run(["gsettings", "get", "org.gnome.desktop.interface", "cursor-theme"], + capture_output=True, text=True, timeout=2) + Theme = Result.stdout.strip().strip("'") + except (OSError, subprocess.SubprocessError): + Theme = "" + os.environ["XCURSOR_THEME"] = Theme or "Adwaita" + + +def Main(Page: ft.Page) -> None: + Config = LoadConfig() + Appearance = EffectiveSettings(Config) + ApplyAppearance(Appearance) # before anything reads Palette.X + + Page.title = AppName + Page.theme_mode = ft.ThemeMode.DARK + Page.theme = BuildTheme() + Page.dark_theme = BuildTheme() + Page.fonts = {BundledFontFamily: "fonts/Fixedsys_Excelsior_Regular.ttf"} + Page.window.title_bar_hidden = Appearance.HideWindowTitleBar + Page.padding = 0 + Page.bgcolor = Palette.Base + Page.window.width = 1180 + Page.window.height = 760 + Page.window.min_width = 720 + Page.window.min_height = 480 + + TheApp = App(Page, Config) + + def OnPathChanged(Root: Optional[str], RelativePath: str, Reload: bool) -> None: + if Reload: # directories may have moved, so the tree starts over + Tree.Reload() + Tree.RevealPath(Root, RelativePath) + + def ShowSettings(Category: str = "General") -> None: + ShowSettingsDialog(TheApp, OnSettingsSaved, Category) + + def OnSettingsSaved(Reconnect: bool) -> None: + Tree.visible = Divider.visible = Config.ShowSidebar + if Reconnect: + TheApp.Connect() # its listeners reload the tree and the browser + else: + Body.update() + TheBrowser.Refresh() + + def ToggleSidebar() -> None: + Config.ShowSidebar = not Config.ShowSidebar + Config.Save() + Tree.visible = Divider.visible = Config.ShowSidebar + Body.update() + + def OnDrag(Delta: float) -> None: + Tree.width = max(140, (Tree.width or Config.SidebarWidth) + Delta) + Tree.update() + + def OnDragEnd() -> None: + Config.SidebarWidth = int(Tree.width) + Config.Save() + + TheBrowser = Browser(TheApp, OnPathChanged, lambda: ShowSettings("Connection" if TheApp.ConnectError else "General"), + ToggleSidebar) + Tree = Filetree(TheApp, TheBrowser.NavigateTo) + Divider = ResizeHandle(OnDrag, OnDragEnd) + Tree.visible = Divider.visible = Config.ShowSidebar + # STRETCH so the divider gets the full height to be dragged along. + Body = ft.Row([Tree, Divider, TheBrowser], expand=True, spacing=0, vertical_alignment=ft.CrossAxisAlignment.STRETCH) + Page.add(Body) + + def OnSourceChanged() -> None: + Tree.Reload() + TheBrowser.SourceChanged() + + TheApp.Listeners.append(OnSourceChanged) + TheApp.Connect() + + +if __name__ == "__main__": + _SetCursorThemeFallback() + ft.run(Main, assets_dir=str(AssetsDir)) diff --git a/Client/Packaging/BuildAppImage.sh b/Client/Packaging/BuildAppImage.sh new file mode 100755 index 0000000..cf4cf89 --- /dev/null +++ b/Client/Packaging/BuildAppImage.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Builds dist/SaraView-x86_64.AppImage: a PyInstaller one-file build (with +# the font and icon bundled) wrapped by appimagetool, the same way SaraSort's, +# SaraSuite's and SaraFem's AppImages are made. Needs appimagetool on PATH. +set -euo pipefail +cd "$(dirname "$(readlink -f "$0")")/.." + +[ -d .venv ] || python3 -m venv .venv +.venv/bin/pip install --quiet --disable-pip-version-check -r requirements.txt pyinstaller + +# --collect-data flet: without it ft.Icons crashes when frozen. +.venv/bin/pyinstaller --noconfirm --clean --log-level WARN \ + --onefile --windowed --name SaraView --collect-data flet \ + --add-data "$PWD/assets:assets" \ + --distpath build/PyInstaller/dist --workpath build/PyInstaller/work --specpath build/PyInstaller \ + "$PWD/Main.py" + +AppDir=build/AppDir +rm -rf "$AppDir" +mkdir -p "$AppDir/usr/bin" +cp build/PyInstaller/dist/SaraView "$AppDir/usr/bin/SaraView" +ln -s usr/bin/SaraView "$AppDir/AppRun" +cp assets/Icon.png "$AppDir/SaraView.png" +ln -s SaraView.png "$AppDir/.DirIcon" +cat > "$AppDir/SaraView.desktop" << DESKTOP +[Desktop Entry] +Type=Application +Name=SaraView +Comment=View and edit files, locally or on a SaraView server +Exec=SaraView +Icon=SaraView +Terminal=false +Categories=Utility; +DESKTOP + +mkdir -p dist +ARCH=x86_64 appimagetool --no-appstream "$AppDir" dist/SaraView-x86_64.AppImage +echo "Built dist/SaraView-x86_64.AppImage" diff --git a/Client/Packaging/Launch.sh b/Client/Packaging/Launch.sh new file mode 100755 index 0000000..514065b --- /dev/null +++ b/Client/Packaging/Launch.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Desktop-entry launcher: resolves the venv relative to this script rather +# than the caller's cwd, since .desktop Exec= lines are invoked with no +# guaranteed working directory. +cd "$(dirname "$(readlink -f "$0")")/.." || exit 1 +exec .venv/bin/python Main.py "$@" diff --git a/Client/Packaging/SaraSuite/Manifest.toml b/Client/Packaging/SaraSuite/Manifest.toml new file mode 100644 index 0000000..79ebc80 --- /dev/null +++ b/Client/Packaging/SaraSuite/Manifest.toml @@ -0,0 +1,37 @@ +# Kept in sync between SaraSuite/Programs/SaraView/ and SaraView/Client/Packaging/SaraSuite/. +Name = "SaraView" +Description = "SaraView's desktop client: view and edit files in a local folder, or on a SaraView server." + +[Source] +Git = "{GitHost}/SaraView" +# Only the Flet client; the server (Backend/, Frontend/) runs in Docker on SpineS. +Include = ["Client/*.py", "Client/requirements.txt", "Client/assets/**"] +Exclude = ["Client/Packaging"] + +[Python] +RequirementsFile = "Client/requirements.txt" + +[[Templates]] +Source = "Launch.sh" +Target = "{AppDir}/Client/Packaging/Launch.sh" +Executable = true + +[[Templates]] +Source = "SaraView.desktop" +Target = "{ApplicationsDir}/SaraView.desktop" + +# Made by SaraView itself: Config.toml and Session.toml (your saved login). +[[Leftovers]] +Path = "{ConfigDir}" +Kind = "Config" +Note = "SaraView's settings and saved server login" + +[[Leftovers]] +Path = "{CacheHome}/SaraView" +Kind = "Runtime" +Note = "server files downloaded to open in other programs" + +# Shared with the other Flet programs; see Programs/FletIdentity.py. +[[Templates]] +Source = "../../FletIdentity.py" +Target = "{ProgramDir}/FletIdentity.py" diff --git a/Client/Packaging/SaraSuite/Templates/Launch.sh b/Client/Packaging/SaraSuite/Templates/Launch.sh new file mode 100755 index 0000000..b48bad9 --- /dev/null +++ b/Client/Packaging/SaraSuite/Templates/Launch.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# Written by SaraSuite. Runs from the client's folder so Flet finds assets/. +# A Flet client copy with the window app ID srhtnin.{Name}, so the desktop +# matches the window to {Name}.desktop (StartupWMClass) instead of showing it +# as "com.appveyor.flet". Empty if that fails: Flet then uses its own client. +export FLET_VIEW_PATH="$("{Python}" "{ProgramDir}/FletIdentity.py" "srhtnin.{Name}")" +cd "{AppDir}/Client" || exit 1 +exec "{Python}" Main.py "$@" diff --git a/Client/Packaging/SaraSuite/Templates/SaraView.desktop b/Client/Packaging/SaraSuite/Templates/SaraView.desktop new file mode 100644 index 0000000..3ef928b --- /dev/null +++ b/Client/Packaging/SaraSuite/Templates/SaraView.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=SaraView +Comment=View and edit files, locally or on a SaraView server +Exec={AppDir}/Client/Packaging/Launch.sh +Icon={AppDir}/Client/assets/Icon.png +Terminal=false +Categories=Utility; +StartupNotify=true +StartupWMClass=srhtnin.{Name} diff --git a/Client/Settings.py b/Client/Settings.py new file mode 100644 index 0000000..65047ef --- /dev/null +++ b/Client/Settings.py @@ -0,0 +1,267 @@ +"""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), + )) diff --git a/Client/Sources.py b/Client/Sources.py new file mode 100644 index 0000000..e6319b1 --- /dev/null +++ b/Client/Sources.py @@ -0,0 +1,458 @@ +"""Where files come from: the local disk (one root folder, set in Settings) or +a SaraView server's HTTP API. Both speak the same terms as the server: a +Root name plus a "/"-separated Path inside it ("" is the root itself), and +entries shaped like the server's /api/tree items, so the rest of the client +never needs to know which one it's talking to.""" + +import fnmatch +import shutil +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Optional + +import httpx +import send2trash + +from Core import AppConfig, CacheDir + +SessionCookieName = "SaraViewSession" # Backend/Auth.py + +# Backend/Explorer.py's FileTypeMap and Backend/Preview.py's TextFileTypes, +# so local files get the same types (and so the same viewer) as server ones. +FileTypeMap = { + ".md": "markdown", ".markdown": "markdown", ".txt": "text", + ".toml": "config", ".yaml": "config", ".yml": "config", ".json": "config", ".cfg": "config", ".ini": "config", + ".html": "html", ".htm": "html", + ".png": "image", ".jpg": "image", ".jpeg": "image", ".gif": "image", ".webp": "image", ".svg": "image", + ".mp4": "video", ".webm": "video", ".mov": "video", ".mkv": "video", +} +TextFileTypes = {"markdown", "text", "config", "html"} + +NoteFilePrefix = ".sv_" # a directory's own note: