mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-15 02:43:17 +02:00
Add Flet desktop client
This commit is contained in:
1 parent
16787aba14
commit
0eecfba224
18 files changed
+2588
No files matched your search
@@ -0,0 +1,247 @@
|
||||
"""The file area while a file is open, in place of the file list (SaraFem's
|
||||
EditorView/ImageView idea, with SaraView's View/Edit/Save toolbar).
|
||||
|
||||
- Markdown is rendered in View; the other text types are shown as
|
||||
highlighted code. Edit is a plain monospace field with an explicit Save
|
||||
(Ctrl+S). On a server, saving records line blame for you, like the web view.
|
||||
- Images are shown fitted to the window.
|
||||
- Video and other files get their details and Open Externally (a server
|
||||
file is downloaded to ~/.cache/SaraView first). Flet has no built-in
|
||||
video player, so videos play in the system's own player.
|
||||
- SaraTag tags (from the server's Books) show as chips under the toolbar."""
|
||||
|
||||
import re
|
||||
from typing import Callable, Optional
|
||||
|
||||
import flet as ft
|
||||
|
||||
from App import App
|
||||
from Core import ChipButton, CodeStyleSheet, HumanSize, Palette
|
||||
from Sources import Entry, SourceError, TextFileTypes
|
||||
|
||||
LanguageByExtension = {
|
||||
".json": "json", ".toml": "toml", ".yaml": "yaml", ".yml": "yaml", ".cfg": "ini", ".ini": "ini",
|
||||
".html": "xml", ".htm": "xml",
|
||||
}
|
||||
|
||||
# Above this, highlighting the whole file on every render gets slow, so
|
||||
# large files stay readable (unhighlighted) instead. Same as SaraFem's.
|
||||
HighlightCharLimit = 500_000
|
||||
|
||||
|
||||
def FencedBlock(Content: str, Language: str) -> str:
|
||||
"""A fence longer than any run of backticks already in Content."""
|
||||
LongestRun = max((len(Run) for Run in re.findall(r"`+", Content)), default=0)
|
||||
Fence = "`" * max(LongestRun + 1, 3)
|
||||
return f"{Fence}{Language}\n{Content}\n{Fence}"
|
||||
|
||||
|
||||
class FileView(ft.Container):
|
||||
def __init__(
|
||||
self,
|
||||
TheApp: App,
|
||||
Root: str,
|
||||
RelativePath: str,
|
||||
Item: Entry,
|
||||
OnClose: Callable[[], None],
|
||||
OnOpenExternally: Callable[[], None],
|
||||
OnDownload: Optional[Callable[[], None]],
|
||||
OnFocusChange: Callable[[bool], None],
|
||||
):
|
||||
self.TheApp = TheApp
|
||||
self.Root = Root
|
||||
self.RelativePath = RelativePath
|
||||
self.Item = Item
|
||||
self.FileType = Item.FileType or "unknown"
|
||||
self.OnClose = OnClose
|
||||
self.OnFocusChange = OnFocusChange
|
||||
self.IsText = self.FileType in TextFileTypes
|
||||
self.IsEditing = False
|
||||
self.IsDirty = False
|
||||
self.Content = ""
|
||||
FontSize = TheApp.Config.FontSize
|
||||
|
||||
self.StatusText = ft.Text("", color=Palette.Overlay0, size=12)
|
||||
self.ToggleButton = ChipButton("Edit", self._ToggleEdit, Icon=ft.Icons.EDIT_OUTLINED)
|
||||
self.SaveButton = ChipButton("Save", lambda e: self.Save(), Icon=ft.Icons.SAVE_OUTLINED, Disabled=True)
|
||||
CanWrite = TheApp.Source.CanWrite
|
||||
if not CanWrite:
|
||||
self.ToggleButton.disabled = True
|
||||
self.ToggleButton.tooltip = "Log in to edit"
|
||||
|
||||
ToolbarItems: list[ft.Control] = [
|
||||
ft.IconButton(icon=ft.Icons.ARROW_BACK, tooltip="Back to the file list (Esc)", on_click=lambda e: self.RequestClose()),
|
||||
ft.Text(Item.Name, weight=ft.FontWeight.BOLD, expand=True, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1),
|
||||
self.StatusText,
|
||||
]
|
||||
if self.IsText:
|
||||
ToolbarItems += [self.ToggleButton, self.SaveButton]
|
||||
ToolbarItems.append(ft.IconButton(icon=ft.Icons.OPEN_IN_NEW, tooltip="Open Externally",
|
||||
on_click=lambda e: OnOpenExternally()))
|
||||
if OnDownload is not None:
|
||||
ToolbarItems.append(ft.IconButton(icon=ft.Icons.DOWNLOAD_OUTLINED, tooltip="Download",
|
||||
on_click=lambda e: OnDownload()))
|
||||
|
||||
CodeStyle = ft.TextStyle(size=FontSize - 1)
|
||||
self.TextField = ft.TextField(
|
||||
multiline=True,
|
||||
expand=True,
|
||||
border=ft.InputBorder.NONE,
|
||||
text_style=CodeStyle,
|
||||
on_change=self._OnTextChanged,
|
||||
on_focus=lambda e: self.OnFocusChange(True),
|
||||
on_blur=lambda e: self.OnFocusChange(False),
|
||||
)
|
||||
self.Body = ft.Container(content=self._BuildBody(), expand=True, padding=12)
|
||||
|
||||
Column: list[ft.Control] = [ft.Row(ToolbarItems, spacing=8), ft.Divider(height=1, color=Palette.Surface1)]
|
||||
Tags = self._BuildTags()
|
||||
if Tags is not None:
|
||||
Column.append(Tags)
|
||||
Column.append(self.Body)
|
||||
super().__init__(content=ft.Column(Column, expand=True, spacing=8), expand=True, bgcolor=Palette.Base, padding=12)
|
||||
|
||||
# --- Content ----------------------------------------------------------
|
||||
|
||||
def _BuildBody(self) -> ft.Control:
|
||||
Source = self.TheApp.Source
|
||||
try:
|
||||
if self.IsText:
|
||||
self.Content = Source.ReadText(self.Root, self.RelativePath)
|
||||
self.TextField.value = self.Content
|
||||
return self._RenderedView()
|
||||
if self.FileType == "image":
|
||||
return ft.Image(src=Source.ReadBytes(self.Root, self.RelativePath), fit=ft.BoxFit.CONTAIN, expand=True)
|
||||
except SourceError as Error:
|
||||
self.ToggleButton.disabled = True
|
||||
return ft.Text(f"Couldn't open this file: {Error}", color=Palette.Red)
|
||||
return self._InfoView()
|
||||
|
||||
def _RenderedView(self) -> ft.Control:
|
||||
Text = self.TextField.value or ""
|
||||
if self.FileType == "markdown" and len(Text) <= HighlightCharLimit:
|
||||
View: ft.Control = ft.Markdown(
|
||||
value=Text,
|
||||
selectable=True,
|
||||
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
|
||||
code_theme=ft.MarkdownCodeTheme.ATOM_ONE_DARK,
|
||||
code_style_sheet=CodeStyleSheet(),
|
||||
on_tap_link=self._OnTapLink,
|
||||
)
|
||||
elif self.FileType in ("config", "html") and len(Text) <= HighlightCharLimit:
|
||||
Extension = "." + self.Item.Name.rsplit(".", 1)[-1].lower()
|
||||
View = ft.Markdown(
|
||||
value=FencedBlock(Text, LanguageByExtension.get(Extension, "")),
|
||||
selectable=True,
|
||||
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
|
||||
code_theme=ft.MarkdownCodeTheme.ATOM_ONE_DARK,
|
||||
code_style_sheet=CodeStyleSheet(),
|
||||
)
|
||||
else:
|
||||
View = ft.Text(Text, selectable=True, size=self.TheApp.Config.FontSize)
|
||||
if not Text:
|
||||
View = ft.Text("This file is empty.", color=Palette.Overlay0, italic=True)
|
||||
return ft.Column([View], scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
|
||||
def _InfoView(self) -> ft.Control:
|
||||
Icon = ft.Icons.MOVIE_OUTLINED if self.FileType == "video" else ft.Icons.INSERT_DRIVE_FILE_OUTLINED
|
||||
Note = ("Videos play in your system's video player: use Open Externally."
|
||||
if self.FileType == "video" else "SaraView can't show this type of file. Use Open Externally.")
|
||||
return ft.Column(
|
||||
[
|
||||
ft.Icon(Icon, size=64, color=Palette.Mauve),
|
||||
ft.Text(self.Item.Name, weight=ft.FontWeight.BOLD, text_align=ft.TextAlign.CENTER),
|
||||
ft.Text(HumanSize(self.Item.Size), color=Palette.Overlay0),
|
||||
ft.Text(Note, color=Palette.Overlay0, size=12, text_align=ft.TextAlign.CENTER),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
expand=True,
|
||||
)
|
||||
|
||||
def _BuildTags(self) -> Optional[ft.Control]:
|
||||
Book = self.TheApp.Source.Book(self.Root, self.RelativePath)
|
||||
Tags = Book.get("Tags") or {}
|
||||
if not Tags:
|
||||
return None
|
||||
Chips = []
|
||||
for Key, Value in Tags.items():
|
||||
Text = Key if Value is True else f"{Key}: {', '.join(map(str, Value)) if isinstance(Value, list) else Value}"
|
||||
Chips.append(ft.Container(
|
||||
ft.Text(Text, size=12, color=Palette.Mauve),
|
||||
padding=ft.Padding.symmetric(horizontal=6, vertical=2),
|
||||
border=ft.Border.all(1, Palette.Surface1),
|
||||
border_radius=4,
|
||||
))
|
||||
return ft.Row(Chips, wrap=True, spacing=4, run_spacing=4)
|
||||
|
||||
def _OnTapLink(self, e) -> None:
|
||||
Target = str(e.data or "")
|
||||
if Target.startswith(("http://", "https://", "mailto:")):
|
||||
self.TheApp.Page.run_task(ft.UrlLauncher().launch_url, Target)
|
||||
|
||||
# --- Editing ----------------------------------------------------------
|
||||
|
||||
@property
|
||||
def CanSave(self) -> bool:
|
||||
return self.IsText and self.IsDirty
|
||||
|
||||
def _OnTextChanged(self, e) -> None:
|
||||
if not self.IsDirty:
|
||||
self.IsDirty = True
|
||||
self.SaveButton.disabled = False
|
||||
self.StatusText.value = "Unsaved changes"
|
||||
self.SaveButton.update()
|
||||
self.StatusText.update()
|
||||
|
||||
def _ToggleEdit(self, e=None) -> None:
|
||||
self.IsEditing = not self.IsEditing
|
||||
if self.IsEditing:
|
||||
self.Body.content = self.TextField
|
||||
self.ToggleButton.content = "View"
|
||||
self.ToggleButton.icon = ft.Icons.VISIBILITY_OUTLINED
|
||||
self.OnFocusChange(True) # swapping the field in doesn't reliably fire on_focus
|
||||
else:
|
||||
# Re-render from whatever's typed (even unsaved), so Edit -> View -> Edit keeps it.
|
||||
self.Body.content = self._RenderedView()
|
||||
self.ToggleButton.content = "Edit"
|
||||
self.ToggleButton.icon = ft.Icons.EDIT_OUTLINED
|
||||
self.OnFocusChange(False)
|
||||
self.Body.update()
|
||||
self.ToggleButton.update()
|
||||
|
||||
def ToggleEdit(self) -> None:
|
||||
if self.IsText and not self.ToggleButton.disabled:
|
||||
self._ToggleEdit()
|
||||
|
||||
def Save(self) -> None:
|
||||
if not self.CanSave:
|
||||
return
|
||||
try:
|
||||
self.TheApp.Source.WriteText(self.Root, self.RelativePath, self.TextField.value or "")
|
||||
except SourceError as Error:
|
||||
self.TheApp.HandleError(Error, f"Couldn't save {self.Item.Name}")
|
||||
return
|
||||
self.Content = self.TextField.value or ""
|
||||
self.IsDirty = False
|
||||
self.SaveButton.disabled = True
|
||||
self.StatusText.value = "Saved."
|
||||
self.SaveButton.update()
|
||||
self.StatusText.update()
|
||||
self.TheApp.Notify(f"Saved {self.Item.Name}.", "success")
|
||||
|
||||
def RequestClose(self, Then: Optional[Callable[[], None]] = None) -> None:
|
||||
"""Closes, asking first if there are unsaved changes. Then runs after
|
||||
closing (e.g. to go to another directory)."""
|
||||
|
||||
def DoClose() -> None:
|
||||
self.OnFocusChange(False)
|
||||
self.OnClose()
|
||||
if Then:
|
||||
Then()
|
||||
|
||||
if not self.IsDirty:
|
||||
DoClose()
|
||||
return
|
||||
self.TheApp.Confirm("Unsaved changes", f"{self.Item.Name} has changes that aren't saved. Close it anyway?",
|
||||
DoClose, YesText="Discard")
|
||||
Reference in new issue
Block a user