Files
SaraView/Client/Diagrams.py
T
2026-10-13 12:22:49 +02:00

142 lines
5.6 KiB
Python

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