mirror of
https://git.srhtnin.garden/sarah/SaraFem.git
synced 2026-10-13 18:18:11 +02:00
184 lines
7.6 KiB
Python
184 lines
7.6 KiB
Python
"""In-app text/config/markup viewer and editor (Features/Major: "Different
|
|
display in middle" — the code-editor part). Fills Browser's file-area
|
|
Container in place of the entry list while a file is open.
|
|
|
|
Flet 1.0.1 has no CodeMirror-equivalent rich editor widget and no embeddable
|
|
WebView, so this mirrors SaraView's View/Edit split as closely as Flet
|
|
actually allows: View mode gets real syntax highlighting by wrapping the
|
|
content in a fenced code block and rendering it read-only via ft.Markdown
|
|
(which ships GitHub-style highlight.js theming); Edit mode is a plain
|
|
monospace TextField — no highlighting while typing — with an explicit Save."""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Callable, Optional
|
|
|
|
import flet as ft
|
|
|
|
from Core import ChipButtonStyle, Palette
|
|
|
|
LanguageByExtension = {
|
|
".py": "python", ".json": "json", ".toml": "toml", ".yaml": "yaml", ".yml": "yaml",
|
|
".js": "javascript", ".ts": "typescript", ".css": "css", ".html": "xml", ".htm": "xml",
|
|
".xml": "xml", ".sh": "bash", ".fish": "bash", ".md": "markdown", ".ipynb": "json",
|
|
".cfg": "ini", ".ini": "ini", ".csv": "", ".log": "", ".scad": "",
|
|
}
|
|
|
|
# Above this, re-parsing the whole file into a highlighted markdown block on
|
|
# every render gets slow — large logs stay readable (unhighlighted) instead.
|
|
HighlightCharLimit = 500_000
|
|
|
|
|
|
def LanguageFor(EntryPath: Path) -> str:
|
|
return LanguageByExtension.get(EntryPath.suffix.lower(), "")
|
|
|
|
|
|
def FencedBlock(Content: str, Language: str) -> str:
|
|
"""A fence long enough that no run of backticks already in Content can
|
|
prematurely close it (CommonMark: closing fence >= opening fence length)."""
|
|
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 EditorView(ft.Container):
|
|
def __init__(
|
|
self,
|
|
EntryPath: Path,
|
|
FontFamily: str,
|
|
OnClose: Callable[[], None],
|
|
OnNotify: Callable[[str, str], None],
|
|
OnFocusChange: Optional[Callable[[bool], None]] = None,
|
|
):
|
|
self.EntryPath = EntryPath
|
|
self.OnClose = OnClose
|
|
self.OnNotify = OnNotify
|
|
# Browser's Enter/Backspace/Delete/etc shortcuts are a page-level
|
|
# keydown listener (see Browser._OnKeyboardEvent), not scoped to
|
|
# whatever has focus — without this, typing in the TextField below
|
|
# was also navigating directories (Backspace) or re-opening the file
|
|
# (Enter) out from under you. Same ShortcutsPaused mechanism Picker.py
|
|
# already uses for its own inline (non-dialog) text field.
|
|
self.OnFocusChange = OnFocusChange
|
|
self.Language = LanguageFor(EntryPath)
|
|
|
|
try:
|
|
self.OriginalContent = EntryPath.read_text(errors="replace")
|
|
self.ReadError: Optional[str] = None
|
|
except OSError as Error:
|
|
self.OriginalContent = ""
|
|
self.ReadError = str(Error)
|
|
|
|
self.IsEditing = False
|
|
self.IsDirty = False
|
|
self.SkipHighlight = len(self.OriginalContent) > HighlightCharLimit
|
|
CodeFont = ft.TextStyle(font_family=FontFamily, size=13)
|
|
|
|
self.TextField = ft.TextField(
|
|
value=self.OriginalContent,
|
|
multiline=True,
|
|
expand=True,
|
|
border=ft.InputBorder.NONE,
|
|
text_style=CodeFont,
|
|
on_change=self._OnTextChanged,
|
|
on_focus=lambda e: self._SetFocused(True),
|
|
on_blur=lambda e: self._SetFocused(False),
|
|
)
|
|
self.MarkdownView = ft.Markdown(
|
|
value=FencedBlock(self.OriginalContent, self.Language),
|
|
selectable=True,
|
|
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
|
|
code_theme=ft.MarkdownCodeTheme.ATOM_ONE_DARK,
|
|
)
|
|
self.PlainView = ft.Text(self.OriginalContent, selectable=True, style=CodeFont)
|
|
|
|
self.SaveButton = ft.OutlinedButton(
|
|
content="Save", style=ChipButtonStyle(), on_click=self._Save, disabled=True
|
|
)
|
|
self.ToggleButton = ft.OutlinedButton(content="Edit", style=ChipButtonStyle(), on_click=self._ToggleEdit)
|
|
self.StatusText = ft.Text("", color=Palette.Overlay0, size=12)
|
|
|
|
Toolbar = ft.Row(
|
|
[
|
|
ft.IconButton(
|
|
icon=ft.Icons.ARROW_BACK, tooltip="Back to file list", on_click=lambda e: self.OnClose()
|
|
),
|
|
ft.Text(EntryPath.name, weight=ft.FontWeight.BOLD, expand=True),
|
|
self.StatusText,
|
|
self.ToggleButton,
|
|
self.SaveButton,
|
|
],
|
|
spacing=8,
|
|
)
|
|
|
|
if self.ReadError:
|
|
BodyContent: ft.Control = ft.Text(f"Couldn't read this file: {self.ReadError}", color=Palette.Red)
|
|
elif self.SkipHighlight:
|
|
BodyContent = self.PlainView
|
|
else:
|
|
BodyContent = self.MarkdownView
|
|
self.Body = ft.Container(content=BodyContent, expand=True, padding=12)
|
|
|
|
super().__init__(
|
|
content=ft.Column(
|
|
[Toolbar, ft.Divider(height=1, color=Palette.Surface1), self.Body], expand=True, spacing=8
|
|
),
|
|
expand=True,
|
|
bgcolor=Palette.Base,
|
|
padding=12,
|
|
)
|
|
|
|
def _SetFocused(self, Focused: bool) -> None:
|
|
if self.OnFocusChange is not None:
|
|
self.OnFocusChange(Focused)
|
|
|
|
def _OnTextChanged(self, Event: ft.ControlEvent) -> 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, Event: ft.ControlEvent) -> None:
|
|
if self.ReadError:
|
|
return
|
|
self.IsEditing = not self.IsEditing
|
|
if self.IsEditing:
|
|
self.Body.content = self.TextField
|
|
self.ToggleButton.content = "View"
|
|
# Don't wait on Flet's own focus event for this — swapping the
|
|
# TextField into Body.content isn't guaranteed to fire on_focus
|
|
# (or fire it before the next keypress), so shortcuts would stay
|
|
# live for a keystroke or two right after clicking Edit.
|
|
self._SetFocused(True)
|
|
else:
|
|
# Re-render the highlighted view from whatever's currently typed
|
|
# (even if unsaved), so Edit -> View -> Edit doesn't discard it.
|
|
if not self.SkipHighlight:
|
|
self.MarkdownView.value = FencedBlock(self.TextField.value, self.Language)
|
|
else:
|
|
self.PlainView.value = self.TextField.value
|
|
self.Body.content = self.PlainView if self.SkipHighlight else self.MarkdownView
|
|
self.ToggleButton.content = "Edit"
|
|
# Same reasoning in reverse: the TextField leaving the tree isn't
|
|
# guaranteed to fire on_blur, so explicitly un-pause here rather
|
|
# than risk shortcuts staying dead after leaving Edit mode.
|
|
self._SetFocused(False)
|
|
self.Body.update()
|
|
self.ToggleButton.update()
|
|
|
|
def _Save(self, Event: ft.ControlEvent) -> None:
|
|
try:
|
|
self.EntryPath.write_text(self.TextField.value)
|
|
except OSError as Error:
|
|
self.OnNotify(f"Couldn't save {self.EntryPath.name}: {Error}", "error")
|
|
return
|
|
self.OriginalContent = self.TextField.value
|
|
self.IsDirty = False
|
|
self.SaveButton.disabled = True
|
|
self.StatusText.value = "Saved."
|
|
self.SaveButton.update()
|
|
self.StatusText.update()
|
|
self.OnNotify(f"Saved {self.EntryPath.name}.", "success")
|