mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-15 02:43:17 +02:00
Client: Mermaid diagrams, drawn by the server
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
292614c696
commit
f99b227cc3
9 files changed
+446
-16
No files matched your search
@@ -0,0 +1,141 @@
|
||||
"""Mermaid diagrams (```mermaid blocks), like the web view's. Flet can't draw
|
||||
them, so the server does (POST /api/diagram, Backend/Diagrams.py) and they're
|
||||
shown as pictures in this client's theme. In a local folder (no server), or
|
||||
if the server can't draw them, they stay code blocks.
|
||||
|
||||
A DiagramBlock shows its code straight away and swaps in the picture once it
|
||||
arrives (fetched on a background thread), so opening a note never waits on
|
||||
the server. Pictures are kept for the session, so the live editor's
|
||||
re-renders don't ask again."""
|
||||
|
||||
import re
|
||||
import struct
|
||||
from typing import Callable, Optional
|
||||
|
||||
import flet as ft
|
||||
|
||||
from Core import CodeStyleSheet, MarkdownCodeTheme, MarkdownSheet, Palette
|
||||
from Sources import SourceError
|
||||
|
||||
DiagramFence = re.compile(r"^ {0,3}(`{3,}|~{3,})[ \t]*mermaid[ \t]*$", re.IGNORECASE)
|
||||
|
||||
# Per session: pictures and Mermaid's complaints by (source, font size), and
|
||||
# whether the server said it can't draw diagrams at all.
|
||||
Pictures: dict[tuple[str, int], bytes] = {}
|
||||
Problems: dict[tuple[str, int], str] = {}
|
||||
ServerCantDraw = False
|
||||
|
||||
|
||||
def DiagramSource(BlockText: str) -> Optional[str]:
|
||||
"""The diagram inside a ```mermaid code block, or None for other blocks."""
|
||||
Lines = BlockText.split("\n")
|
||||
Match = DiagramFence.match(Lines[0])
|
||||
if not Match:
|
||||
return None
|
||||
Fence = Match.group(1)
|
||||
Body = Lines[1:]
|
||||
if Body:
|
||||
Last = Body[-1].strip()
|
||||
if Last.startswith(Fence) and not Last.strip(Fence[0]):
|
||||
Body = Body[:-1]
|
||||
return "\n".join(Body)
|
||||
|
||||
|
||||
def ThemePalette() -> dict[str, str]:
|
||||
"""This client's theme by the server's role names (see Core.Palette)."""
|
||||
return {
|
||||
"Background": Palette.Base, "Surface": Palette.Mantle, "SurfaceAlt": Palette.Surface0,
|
||||
"Border": Palette.Surface1, "Text": Palette.Text, "TextMuted": Palette.Overlay0,
|
||||
"Black": Palette.Crust, "White": Palette.White, "Red": Palette.Red, "Orange": Palette.Peach,
|
||||
"Yellow": Palette.Yellow, "Green": Palette.Green, "Blue": Palette.Blue, "Purple": Palette.Mauve,
|
||||
}
|
||||
|
||||
|
||||
def CanDraw(TheApp) -> bool:
|
||||
return TheApp.Source.Kind == "Server" and not ServerCantDraw
|
||||
|
||||
|
||||
class DiagramBlock(ft.Container):
|
||||
def __init__(self, TheApp, Source: str, FontSize: int):
|
||||
self.TheApp = TheApp
|
||||
self.Source = Source
|
||||
self.FontSize = FontSize
|
||||
self.Key = (Source, FontSize)
|
||||
self.Mounted = False
|
||||
self.Fetching = False
|
||||
super().__init__(content=self._Content())
|
||||
|
||||
def _Code(self) -> ft.Control:
|
||||
return ft.Markdown(
|
||||
value=f"```\n{self.Source}\n```",
|
||||
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
|
||||
md_style_sheet=MarkdownSheet(self.FontSize),
|
||||
code_theme=MarkdownCodeTheme(),
|
||||
code_style_sheet=CodeStyleSheet(),
|
||||
)
|
||||
|
||||
def _Content(self) -> ft.Control:
|
||||
Picture = Pictures.get(self.Key)
|
||||
if Picture is not None:
|
||||
# The server draws at twice the size; shown at half, it's sharp on HiDPI.
|
||||
Width, Height = struct.unpack(">II", Picture[16:24])
|
||||
return ft.Row([ft.Image(src=Picture, width=Width / 2, height=Height / 2)],
|
||||
alignment=ft.MainAxisAlignment.CENTER, scroll=ft.ScrollMode.AUTO)
|
||||
Problem = Problems.get(self.Key)
|
||||
if Problem is not None:
|
||||
return ft.Column([self._Code(), ft.Text(f"Diagram error: {Problem}", color=Palette.Red,
|
||||
size=self.FontSize - 2, selectable=True)], spacing=4)
|
||||
return self._Code()
|
||||
|
||||
def did_mount(self) -> None:
|
||||
self.Mounted = True
|
||||
if self.Key not in Pictures and self.Key not in Problems and not self.Fetching and CanDraw(self.TheApp):
|
||||
self.Fetching = True
|
||||
self.page.run_thread(self._Fetch)
|
||||
|
||||
def will_unmount(self) -> None:
|
||||
self.Mounted = False
|
||||
|
||||
def _Fetch(self) -> None:
|
||||
global ServerCantDraw
|
||||
try:
|
||||
Pictures[self.Key] = self.TheApp.Source.Diagram(self.Source, ThemePalette(), self.FontSize)
|
||||
except SourceError as Error:
|
||||
if Error.Status == 422:
|
||||
Problems[self.Key] = str(Error)
|
||||
elif Error.Status in (404, 503):
|
||||
ServerCantDraw = True # an older server, or one without Chromium: stay code blocks
|
||||
finally:
|
||||
self.Fetching = False
|
||||
if self.Mounted:
|
||||
self.content = self._Content()
|
||||
self.update()
|
||||
|
||||
|
||||
def MaybeDiagram(TheApp, BlockText: str, FontSize: int) -> Optional[ft.Control]:
|
||||
"""A DiagramBlock for a ```mermaid code block (LiveMarkdown's blocks), else None."""
|
||||
Source = DiagramSource(BlockText)
|
||||
if Source is None or not CanDraw(TheApp):
|
||||
return None
|
||||
return DiagramBlock(TheApp, Source, FontSize)
|
||||
|
||||
|
||||
def WithDiagrams(TheApp, Document: str, FontSize: int, MakeMarkdown: Callable[[str], ft.Control]) -> list[ft.Control]:
|
||||
"""The rendered view's controls: the Markdown, split around its diagrams."""
|
||||
if not CanDraw(TheApp) or not any(DiagramFence.match(Line) for Line in Document.split("\n")):
|
||||
return [MakeMarkdown(Document)]
|
||||
from LiveMarkdown import SplitBlocks # LiveMarkdown imports this module
|
||||
|
||||
Controls: list[ft.Control] = []
|
||||
Position = 0
|
||||
for Block in SplitBlocks(Document):
|
||||
Source = DiagramSource(Document[Block.Start:Block.End]) if Block.Kind == "Code" else None
|
||||
if Source is None:
|
||||
continue
|
||||
if Document[Position:Block.Start].strip():
|
||||
Controls.append(MakeMarkdown(Document[Position:Block.Start]))
|
||||
Controls.append(DiagramBlock(TheApp, Source, FontSize))
|
||||
Position = Block.End
|
||||
if Document[Position:].strip():
|
||||
Controls.append(MakeMarkdown(Document[Position:]))
|
||||
return Controls
|
||||
@@ -6,7 +6,8 @@ another block, puts it back rendered.
|
||||
A block is what Markdown itself separates: a heading line, a fenced code
|
||||
block, a horizontal rule, or a run of lines up to a blank line (a paragraph,
|
||||
list, table or quote). The text between blocks (blank lines) is kept
|
||||
byte for byte, so editing one block never reformats the rest of the file."""
|
||||
byte for byte, so editing one block never reformats the rest of the file.
|
||||
A ```mermaid block shows as its diagram when MakeDiagram gives one (Diagrams.py)."""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
@@ -101,7 +102,8 @@ def SplitBlocks(Document: str) -> list[Block]:
|
||||
|
||||
class LiveMarkdown(ft.Column):
|
||||
def __init__(self, Document: str, FontSize: int, OnChange: Callable[[], None],
|
||||
OnFocusChange: Callable[[bool], None], OnTapLink: Callable):
|
||||
OnFocusChange: Callable[[bool], None], OnTapLink: Callable,
|
||||
MakeDiagram: Optional[Callable[[str, int], Optional[ft.Control]]] = None):
|
||||
super().__init__(scroll=ft.ScrollMode.AUTO, expand=True, spacing=0,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.STRETCH)
|
||||
self.Document = Document
|
||||
@@ -109,6 +111,7 @@ class LiveMarkdown(ft.Column):
|
||||
self.OnChange = OnChange
|
||||
self.OnFocusChange = OnFocusChange
|
||||
self.OnTapLink = OnTapLink
|
||||
self.MakeDiagram = MakeDiagram
|
||||
self.Blocks: list[Block] = []
|
||||
self.BlockControls: list[ft.Control] = []
|
||||
# Rendered blocks by their text, reused when the layout is rebuilt so
|
||||
@@ -217,8 +220,9 @@ class LiveMarkdown(ft.Column):
|
||||
# --- Rendering -------------------------------------------------------
|
||||
|
||||
def _Render(self, Text: str) -> ft.Control:
|
||||
Diagram = self.MakeDiagram(Text, self.FontSize) if self.MakeDiagram else None
|
||||
Rendered = ft.Container(
|
||||
content=ft.Markdown(
|
||||
content=Diagram or ft.Markdown(
|
||||
value=Text,
|
||||
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
|
||||
md_style_sheet=MarkdownSheet(self.FontSize),
|
||||
|
||||
@@ -114,6 +114,7 @@ class Source:
|
||||
def LocalFile(self, Root: str, RelativePath: str) -> Path: ...
|
||||
def Book(self, Root: str, RelativePath: str) -> dict: ...
|
||||
def Graph(self, Root: str, RelativePath: str) -> dict: ...
|
||||
def Diagram(self, Source: str, ThemePalette: dict[str, str], FontSize: int) -> bytes: ...
|
||||
|
||||
@property
|
||||
def CanWrite(self) -> bool:
|
||||
@@ -275,6 +276,9 @@ class LocalSource(Source):
|
||||
|
||||
return BuildLocalGraph(self, Root, RelativePath)
|
||||
|
||||
def Diagram(self, Source: str, ThemePalette: dict[str, str], FontSize: int) -> bytes:
|
||||
raise SourceError("Diagrams are drawn by a SaraView server.")
|
||||
|
||||
|
||||
class ServerSource(Source):
|
||||
"""A SaraView server, through the same /api endpoints the web frontend
|
||||
@@ -464,6 +468,11 @@ class ServerSource(Source):
|
||||
# A big root takes the server a while to read through.
|
||||
return self._Json("GET", "/graph", params=self._At(Root, RelativePath), timeout=120)
|
||||
|
||||
def Diagram(self, Source: str, ThemePalette: dict[str, str], FontSize: int) -> bytes:
|
||||
"""A ```mermaid block as a PNG, drawn by the server (Diagrams.py)."""
|
||||
Body = {"Source": Source, "Palette": ThemePalette, "FontSize": FontSize}
|
||||
return self._Request("POST", "/diagram", json=Body).content
|
||||
|
||||
|
||||
def MakeSource(Config: AppConfig, Token: str = "") -> Source:
|
||||
return ServerSource(Config, Token) if Config.Mode == "Server" else LocalSource(Config)
|
||||
+20
-13
@@ -2,7 +2,7 @@
|
||||
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
|
||||
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.
|
||||
@@ -20,6 +20,7 @@ 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
|
||||
|
||||
@@ -121,7 +122,8 @@ class FileView(ft.Container):
|
||||
self.TextField.value = self.Content
|
||||
if self.IsLive:
|
||||
self.Live = LiveMarkdown(self.Content, self.TheApp.Config.FontSize, self._OnTextChanged,
|
||||
self.OnFocusChange, self._OnTapLink)
|
||||
self.OnFocusChange, self._OnTapLink,
|
||||
MakeDiagram=lambda Text, Size: MaybeDiagram(self.TheApp, Text, Size))
|
||||
return self.Live
|
||||
return self._RenderedView()
|
||||
if self.FileType == "image":
|
||||
@@ -133,16 +135,21 @@ class FileView(ft.Container):
|
||||
|
||||
def _RenderedView(self) -> ft.Control:
|
||||
Text = self.TextField.value or ""
|
||||
Views: Optional[list[ft.Control]] = None
|
||||
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,
|
||||
)
|
||||
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(
|
||||
@@ -156,8 +163,8 @@ class FileView(ft.Container):
|
||||
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)
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user