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