Files
SaraView/Client/Viewer.py
T

300 lines
13 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.
- With Markdown editing set to Live (Settings > General), Markdown opens in
LiveMarkdown instead: rendered, and click a block to edit it in place.
Edit/View becomes Source/Live there.
- 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, MarkdownCodeTheme, MarkdownSheet, Palette
from LiveMarkdown import LiveMarkdown
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.IsLive = (self.FileType == "markdown" and TheApp.Config.MarkdownEditing == "Live"
and TheApp.Source.CanWrite)
self.Live: Optional[LiveMarkdown] = None
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)
if self.IsLive:
self.ToggleButton.content = "Source"
self.ToggleButton.icon = ft.Icons.CODE
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
if self.IsLive:
self.Live = LiveMarkdown(self.Content, self.TheApp.Config.FontSize, self._OnTextChanged,
self.OnFocusChange, self._OnTapLink)
return self.Live
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,
md_style_sheet=MarkdownSheet(self.TheApp.Config.FontSize),
code_theme=MarkdownCodeTheme(),
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,
md_style_sheet=MarkdownSheet(self.TheApp.Config.FontSize),
code_theme=MarkdownCodeTheme(),
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) -> None:
if not self.IsDirty:
self.IsDirty = True
self.SaveButton.disabled = False
self.StatusText.value = "Unsaved changes"
self.SaveButton.update()
self.StatusText.update()
@property
def Text(self) -> str:
"""What's being edited, in whichever view has it."""
if self.Live is not None and not self.IsEditing:
return self.Live.Text
return self.TextField.value or ""
def _ToggleEdit(self, e=None) -> None:
if self.Live is not None:
self._ToggleSource()
return
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 _ToggleSource(self) -> None:
"""Live mode's toggle: the whole file as plain text, and back."""
self.IsEditing = not self.IsEditing
if self.IsEditing:
self.TextField.value = self.Live.Text
self.Live.EditingIndex = None # its open block's text is in the field now
self.Body.content = self.TextField
self.ToggleButton.content = "Live"
self.ToggleButton.icon = ft.Icons.VISIBILITY_OUTLINED
self.OnFocusChange(True)
else:
self.Live.SetText(self.TextField.value or "", Update=False) # not on the page yet
self.Body.content = self.Live
self.ToggleButton.content = "Source"
self.ToggleButton.icon = ft.Icons.CODE
self.OnFocusChange(False)
self.Body.update()
self.ToggleButton.update()
def HandleEscape(self) -> bool:
"""Esc first closes an open block in live mode; True if it did."""
if self.Live is not None and not self.IsEditing and self.Live.IsEditing:
self.Live.Commit()
return True
return False
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.Text)
except SourceError as Error:
self.TheApp.HandleError(Error, f"Couldn't save {self.Item.Name}")
return
self.Content = self.Text
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")