Files
SaraView/Client/Viewer.py
T
2026-10-08 23:25:41 +02:00

248 lines
11 KiB
Python

"""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")