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