mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-14 18:38:11 +02:00
307 lines
13 KiB
Python
307 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. On a server, ```mermaid blocks show as diagrams (Diagrams.py). 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 Diagrams import MaybeDiagram, WithDiagrams
|
|
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,
|
|
MakeDiagram=lambda Text, Size: MaybeDiagram(self.TheApp, Text, Size))
|
|
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 ""
|
|
Views: Optional[list[ft.Control]] = None
|
|
if self.FileType == "markdown" and len(Text) <= HighlightCharLimit:
|
|
def MakeMarkdown(Part: str) -> ft.Control:
|
|
return ft.Markdown(
|
|
value=Part,
|
|
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,
|
|
)
|
|
|
|
Views = WithDiagrams(self.TheApp, Text, self.TheApp.Config.FontSize, MakeMarkdown)
|
|
View: ft.Control = Views[0]
|
|
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, Views = ft.Text("This file is empty.", color=Palette.Overlay0, italic=True), None
|
|
return ft.Column(Views or [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")
|