Client: Mermaid diagrams, drawn by the server

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
sarahandClaude Opus 5.5 committed 2026-10-13 12:22:49 +02:00
1 parent 292614c696
commit f99b227cc3
9 files changed
+446 -16

No files matched your search

+141
View File
@@ -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
+7 -3
View File
@@ -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),
+9
View File
@@ -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
View File
@@ -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