Client: diagrams in local folders (setting)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
sarahandClaude Opus 5.5 committed 2026-10-13 13:12:28 +02:00
1 parent f99b227cc3
commit db81507b7c
4 files changed
+376 -9

No files matched your search

+3
View File
@@ -190,6 +190,9 @@ class AppConfig:
# Double-click on a file: True opens it in SaraView's own viewer, False
# hands it to the system's default program. The menu always offers the other.
OpenFilesInApp: bool = True
# Draw ```mermaid diagrams in local folders (LocalDiagrams.py); off removes
# what it downloaded. On a server, the server draws them either way.
LocalDiagrams: bool = True
def Save(self) -> None:
ConfigDir.mkdir(parents=True, exist_ok=True)
+33 -7
View File
@@ -1,7 +1,8 @@
"""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.
shown as pictures in this client's theme. In a local folder, LocalDiagrams.py
draws them on this computer instead (Settings > General, on by default). If
neither can, 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
@@ -14,6 +15,7 @@ from typing import Callable, Optional
import flet as ft
import LocalDiagrams
from Core import CodeStyleSheet, MarkdownCodeTheme, MarkdownSheet, Palette
from Sources import SourceError
@@ -51,8 +53,14 @@ def ThemePalette() -> dict[str, str]:
}
def IsLocal(TheApp) -> bool:
return TheApp.Source.Kind != "Server"
def CanDraw(TheApp) -> bool:
return TheApp.Source.Kind == "Server" and not ServerCantDraw
if IsLocal(TheApp):
return TheApp.Config.LocalDiagrams
return not ServerCantDraw
class DiagramBlock(ft.Container):
@@ -83,14 +91,21 @@ class DiagramBlock(ft.Container):
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._WithNote(f"Diagram error: {Problem}", Palette.Red)
if self.Fetching and IsLocal(self.TheApp) and not LocalDiagrams.IsSetUp():
return self._WithNote("Setting up diagrams (first time only, can take a few minutes)…", Palette.Overlay0)
return self._Code()
def _WithNote(self, Note: str, Colour: str) -> ft.Control:
return ft.Column([self._Code(), ft.Text(Note, color=Colour, size=self.FontSize - 2, selectable=True)], spacing=4)
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
if IsLocal(self.TheApp) and not LocalDiagrams.IsSetUp():
self.content = self._Content()
self.update()
self.page.run_thread(self._Fetch)
def will_unmount(self) -> None:
@@ -99,14 +114,25 @@ class DiagramBlock(ft.Container):
def _Fetch(self) -> None:
global ServerCantDraw
try:
Pictures[self.Key] = self.TheApp.Source.Diagram(self.Source, ThemePalette(), self.FontSize)
if IsLocal(self.TheApp):
Pictures[self.Key] = LocalDiagrams.Render(self.Source, ThemePalette(), self.FontSize)
else:
Pictures[self.Key] = self.TheApp.Source.Diagram(self.Source, ThemePalette(), self.FontSize)
except LocalDiagrams.DiagramError as Error:
Problems[self.Key] = str(Error)
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
except Exception as Error:
# Can't draw on this computer (setup failed, or it timed out): say why.
Problems[self.Key] = f"Can't draw diagrams here: {Error or type(Error).__name__}"
finally:
self.Fetching = False
self._Finish()
def _Finish(self) -> None:
self.Fetching = False
if self.Mounted:
self.content = self._Content()
self.update()
+315
View File
@@ -0,0 +1,315 @@
"""Drawing Mermaid diagrams in a local folder, where there's no server to do
it (Diagrams.py): the same way the server's Backend/Diagrams.py does, with
Mermaid in a headless Chromium driven by Playwright.
Settings > General > "Draw diagrams in local folders" (Config.LocalDiagrams,
on by default). Nothing for this ships with SaraView: the first diagram sets
up what's needed, all of it in ToolsDir (~/.local/share/SaraView/DiagramTools):
- Playwright, installed with pip into ToolsDir/Python;
- Mermaid, the version the web view uses, downloaded from npm and checked;
- a headless Chromium (ToolsDir/Browsers), only when no Chromium-based
browser (Chrome, Chromium, Brave, Edge) is installed or it won't start.
An installed one is used as it is, never changed.
Turning the setting off deletes ToolsDir: all of that, and nothing else.
Playwright's sync API has to stay on the thread that started it, so one
worker thread does all the drawing; Render() hands it a job and waits."""
import base64
import hashlib
import io
import os
import queue
import shutil
import subprocess
import sys
import tarfile
import threading
from concurrent.futures import Future
from pathlib import Path
from typing import Optional
import httpx
from Core import AssetsDir, ChildEnvironment, DataHome, AppName, IsWindows
ToolsDir = DataHome / AppName / "DiagramTools"
PythonDir = ToolsDir / "Python"
BrowsersDir = ToolsDir / "Browsers"
MermaidPath = ToolsDir / "mermaid.min.js"
PagePath = ToolsDir / "Render.html"
PlaywrightVersion = "1.62.0" # same as the server's (Backend/requirements.txt)
MermaidVersion = "11.17.2"
MermaidIntegrity = "sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg=="
FontPath = AssetsDir / "fonts" / "Fixedsys_Excelsior_Regular.ttf"
RenderTimeout = 20 # seconds, per diagram
SetupTimeout = 900 # seconds, for the downloads
BrowserNames = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser",
"brave-browser", "brave", "microsoft-edge", "microsoft-edge-stable"]
WindowsBrowsers = [
r"Microsoft\Edge\Application\msedge.exe",
r"Google\Chrome\Application\chrome.exe",
r"BraveSoftware\Brave-Browser\Application\brave.exe",
]
class DiagramError(Exception):
"""The diagram itself is wrong (Mermaid's message)."""
class Unavailable(Exception):
"""Diagrams can't be drawn here (setup failed, or a frozen build)."""
def ThemeVariables(Palette: dict[str, str], FontSize: int) -> dict:
"""Mermaid's colours from the theme's roles: the same mapping as
Backend/Diagrams.py and the web view's Frontend/src/Mermaid.js."""
Role = Palette.get
Background = Role("Background")
Red, Green, Blue = (int(Background[Index:Index + 2], 16) for Index in (1, 3, 5))
return {
"darkMode": (0.299 * Red + 0.587 * Green + 0.114 * Blue) / 255 < 0.5,
"background": Background,
"fontFamily": '"Fixedsys Excelsior", "Cascadia Mono", monospace',
"fontSize": f"{FontSize}px",
"textColor": Role("Text"), "lineColor": Role("TextMuted"),
"primaryColor": Role("SurfaceAlt"), "primaryTextColor": Role("Text"), "primaryBorderColor": Role("Purple"),
"secondaryColor": Role("Surface"), "secondaryTextColor": Role("Text"), "secondaryBorderColor": Role("Blue"),
"tertiaryColor": Role("Background"), "tertiaryTextColor": Role("Text"), "tertiaryBorderColor": Role("Border"),
"mainBkg": Role("SurfaceAlt"), "nodeBorder": Role("Purple"),
"clusterBkg": Role("Surface"), "clusterBorder": Role("Border"),
"edgeLabelBackground": Role("Surface"), "titleColor": Role("Text"),
"noteBkgColor": Role("Surface"), "noteTextColor": Role("Text"), "noteBorderColor": Role("Yellow"),
"actorBkg": Role("SurfaceAlt"), "actorBorder": Role("Purple"), "actorTextColor": Role("Text"),
"actorLineColor": Role("TextMuted"), "signalColor": Role("Text"), "signalTextColor": Role("Text"),
"labelBoxBkgColor": Role("SurfaceAlt"), "labelBoxBorderColor": Role("Purple"), "labelTextColor": Role("Text"),
"loopTextColor": Role("Text"), "activationBkgColor": Role("Surface"), "activationBorderColor": Role("Blue"),
"pie1": Role("Blue"), "pie2": Role("Purple"), "pie3": Role("Green"),
"pie4": Role("Yellow"), "pie5": Role("Orange"), "pie6": Role("Red"),
"pieTitleTextColor": Role("Text"), "pieSectionTextColor": Role("Black"), "pieLegendTextColor": Role("Text"),
"pieStrokeColor": Background, "pieOuterStrokeColor": Role("Border"),
}
# The same drawing as Backend/Diagrams.py's RenderScript.
RenderScript = """async ({ Source, Variables, Id }) => {
await document.fonts.ready;
mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: "base", themeVariables: Variables });
try {
await mermaid.parse(Source);
} catch (Error) {
return { Error: String(Error.message || Error) };
}
const { svg } = await mermaid.render(Id, Source);
const Out = document.getElementById("Out");
Out.innerHTML = svg;
const Picture = Out.querySelector("svg");
const Box = Picture.viewBox.baseVal;
Picture.style.maxWidth = "none";
Picture.setAttribute("width", Box.width);
Picture.setAttribute("height", Box.height);
return {};
}"""
# --- Setup ----------------------------------------------------------------
def _Run(Command: list[str], Environment: Optional[dict] = None) -> None:
Result = subprocess.run(Command, env=Environment or ChildEnvironment(), capture_output=True, text=True,
timeout=SetupTimeout)
if Result.returncode != 0:
Detail = (Result.stderr or Result.stdout).strip().splitlines()
raise Unavailable(Detail[-1] if Detail else f"{Command[0]} failed")
def _PlaywrightEnvironment() -> dict:
Environment = ChildEnvironment()
Environment["PYTHONPATH"] = str(PythonDir)
Environment["PLAYWRIGHT_BROWSERS_PATH"] = str(BrowsersDir)
return Environment
def _InstallPlaywright() -> None:
if (PythonDir / "playwright").is_dir():
return
if getattr(sys, "frozen", False):
raise Unavailable("This build of SaraView can't install what diagrams need; "
"the SaraSuite install can.")
_Run([sys.executable, "-m", "pip", "install", "--quiet", "--disable-pip-version-check",
"--target", str(PythonDir), f"playwright=={PlaywrightVersion}"])
def _InstallMermaid() -> None:
if MermaidPath.is_file():
return
Url = f"https://registry.npmjs.org/mermaid/-/mermaid-{MermaidVersion}.tgz"
try:
Archive = httpx.get(Url, timeout=120, follow_redirects=True).raise_for_status().content
except httpx.HTTPError as Error:
raise Unavailable(f"Couldn't download Mermaid: {Error}")
Expected = base64.b64decode(MermaidIntegrity.removeprefix("sha512-"))
if hashlib.sha512(Archive).digest() != Expected:
raise Unavailable("The Mermaid download didn't match its checksum.")
with tarfile.open(fileobj=io.BytesIO(Archive), mode="r:gz") as Tar:
Script = Tar.extractfile("package/dist/mermaid.min.js")
if Script is None:
raise Unavailable("The Mermaid download didn't have mermaid.min.js.")
Partial = MermaidPath.with_suffix(".part")
Partial.write_bytes(Script.read())
Partial.replace(MermaidPath)
def _InstalledBrowser() -> Optional[str]:
if IsWindows:
for Base in (os.environ.get("ProgramFiles(x86)"), os.environ.get("ProgramFiles"),
os.environ.get("LOCALAPPDATA")):
for Relative in WindowsBrowsers:
if Base and (Path(Base) / Relative).is_file():
return str(Path(Base) / Relative)
return None
return next((Found for Name in BrowserNames if (Found := shutil.which(Name))), None)
def _InstallBrowser() -> None:
_Run([sys.executable, "-m", "playwright", "install", "--only-shell", "chromium"], _PlaywrightEnvironment())
def _WritePage() -> None:
FontFace = f'@font-face {{ font-family: "Fixedsys Excelsior"; src: url("{FontPath.as_uri()}"); }}' if FontPath.is_file() else ""
PagePath.write_text(
"<!doctype html><html><head><meta charset='utf-8'>"
f"<style>{FontFace} body {{ margin: 0; }} #Out {{ display: inline-block; padding: 8px; }}</style>"
f"<script src='{MermaidPath.as_uri()}'></script></head><body><div id='Out'></div></body></html>",
encoding="utf-8",
)
# --- The worker -----------------------------------------------------------
class _Worker:
def __init__(self) -> None:
self.Jobs: "queue.Queue[Optional[tuple]]" = queue.Queue()
self.Thread: Optional[threading.Thread] = None
self.Lock = threading.Lock()
self.Failure: Optional[str] = None # why it can't draw, once setup failed
self.SettingUp = False
def Start(self) -> None:
with self.Lock:
if self.Thread is None or not self.Thread.is_alive():
self.Thread = threading.Thread(target=self._Run, name="LocalDiagrams", daemon=True)
self.Thread.start()
def Stop(self) -> None:
with self.Lock:
Thread = self.Thread
self.Thread = None
if Thread is not None and Thread.is_alive():
self.Jobs.put(None)
Thread.join(timeout=30)
def _Setup(self):
"""Everything ToolsDir needs, then a page ready to draw on."""
self.SettingUp = True
try:
ToolsDir.mkdir(parents=True, exist_ok=True)
_InstallPlaywright()
_InstallMermaid()
_WritePage()
if str(PythonDir) not in sys.path:
sys.path.insert(0, str(PythonDir))
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(BrowsersDir)
from playwright.sync_api import sync_playwright
Playwright = sync_playwright().start()
Browser = None
Installed = _InstalledBrowser()
if Installed:
try:
Browser = Playwright.chromium.launch(executable_path=Installed)
except Exception:
Browser = None # e.g. a Snap Chromium that can't be driven: use our own
if Browser is None:
if not BrowsersDir.is_dir():
_InstallBrowser()
Browser = Playwright.chromium.launch()
Page = Browser.new_context(device_scale_factor=2, viewport={"width": 1600, "height": 1200}).new_page()
Page.route("**/*", lambda Route: Route.continue_() if Route.request.url.startswith("file:") else Route.abort())
Page.goto(PagePath.as_uri())
return Playwright, Browser, Page
finally:
self.SettingUp = False
def _Run(self) -> None:
Playwright = Browser = Page = None
Count = 0
try:
while True:
Job = self.Jobs.get()
if Job is None:
return
Source, Palette, FontSize, Result = Job
try:
if Page is None:
Playwright, Browser, Page = self._Setup()
Count += 1
Outcome = Page.evaluate(RenderScript, {"Source": Source, "Variables": ThemeVariables(Palette, FontSize),
"Id": f"Diagram{Count}"})
if Outcome.get("Error"):
raise DiagramError(Outcome["Error"])
Result.set_result(Page.locator("#Out").screenshot(omit_background=True, timeout=RenderTimeout * 1000))
except DiagramError as Error:
Result.set_exception(Error)
except Unavailable as Error:
self.Failure = str(Error)
Result.set_exception(Error)
except Exception as Error:
if Page is None: # setup itself broke
self.Failure = str(Error)
Result.set_exception(Unavailable(str(Error)))
else:
Result.set_exception(DiagramError(f"Couldn't draw the diagram ({Error})."))
finally:
for Closing in (Browser, Playwright):
try:
if Closing is not None:
Closing.close() if Closing is Browser else Closing.stop()
except Exception:
pass
_SharedWorker = _Worker()
def IsSetUp() -> bool:
"""Whether the first diagram will draw without downloading anything."""
return (PythonDir / "playwright").is_dir() and MermaidPath.is_file()
def IsSettingUp() -> bool:
return _SharedWorker.SettingUp
def Render(Source: str, Palette: dict[str, str], FontSize: int) -> bytes:
"""A diagram as a PNG at twice the size. Blocks: call it off the UI thread.
Raises DiagramError for a wrong diagram, Unavailable if it can't draw."""
if _SharedWorker.Failure is not None:
raise Unavailable(_SharedWorker.Failure)
_SharedWorker.Start()
Result: Future = Future()
_SharedWorker.Jobs.put((Source, Palette, FontSize, Result))
return Result.result(timeout=SetupTimeout + RenderTimeout)
def Remove() -> None:
"""Stops drawing and deletes ToolsDir: everything set up for local diagrams."""
_SharedWorker.Stop()
_SharedWorker.Failure = None
shutil.rmtree(ToolsDir, ignore_errors=True)
def ToolsSize() -> int:
"""Bytes in ToolsDir, for the setting's note."""
return sum(File.stat().st_size for File in ToolsDir.rglob("*") if File.is_file()) if ToolsDir.is_dir() else 0
+25 -2
View File
@@ -9,10 +9,11 @@ from typing import Callable
import flet as ft
import tomlkit
import LocalDiagrams
from App import App
from Core import (
AppConfig, AssetsDir, AvailableThemes, ChipButton, DataHome, ExportSettings, Home, ImportSettings,
InstalledFontFamilies, LoadSharedSettings, Palette, SharedSettings,
AppConfig, AssetsDir, AvailableThemes, ChipButton, DataHome, ExportSettings, Home, HumanSize,
ImportSettings, InstalledFontFamilies, LoadSharedSettings, Palette, SharedSettings,
)
DialogWidth = 700
@@ -109,6 +110,16 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego
)
FontSizeField = ft.TextField(label="Text size", value=str(Config.FontSize), keyboard_type=ft.KeyboardType.NUMBER, width=160)
LocalDiagramsSwitch = ft.Switch(label="Draw diagrams in local folders", value=Config.LocalDiagrams)
ToolsBytes = LocalDiagrams.ToolsSize()
LocalDiagramsNote = _Note(
"```mermaid blocks (flowcharts and the like) as pictures, drawn on this computer. The first one "
"downloads what that needs: ~150 MB with Chrome, Chromium, Brave or Edge installed, ~430 MB without. "
"Off: deletes those downloads"
+ (f" ({HumanSize(ToolsBytes)} now)." if ToolsBytes else ".")
+ " On a server, the server draws them either way."
)
def ToggleMenuEntry(e) -> None:
SetMenuEntry(not MenuEntryPath.exists())
MenuButton.content = "Remove from app menu" if MenuEntryPath.exists() else "Add to app menu"
@@ -217,6 +228,8 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego
ft.Divider(height=1, color=Palette.Surface1),
ft.Text("Markdown editing"),
MarkdownEditingPicker,
LocalDiagramsSwitch,
LocalDiagramsNote,
ft.Divider(height=1, color=Palette.Surface1),
OwnAppearance,
], spacing=12, scroll=ft.ScrollMode.AUTO),
@@ -266,6 +279,8 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego
Config.ShowSidebar = SidebarSwitch.value
Config.ShowFilesInTree = TreeFilesSwitch.value
Config.MarkdownEditing = MarkdownEditingPicker.value or Config.MarkdownEditing
RemoveDiagramTools = Config.LocalDiagrams and not LocalDiagramsSwitch.value
Config.LocalDiagrams = LocalDiagramsSwitch.value
Config.GraphShowDirectories = GraphDirectoriesSwitch.value
Config.GraphColourByDepth = ColourByDepthSwitch.value
Config.GraphShrinkByDepth = ShrinkByDepthSwitch.value
@@ -289,6 +304,14 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego
TheApp.Close()
OnSaved(SourceAfter != SourceBefore)
TheApp.Notify("Settings saved.", "success")
if RemoveDiagramTools:
def RemoveTools() -> None:
Freed = LocalDiagrams.ToolsSize()
LocalDiagrams.Remove()
if Freed:
TheApp.Notify(f"Removed the diagram downloads ({HumanSize(Freed)}).", "success")
Page.run_thread(RemoveTools)
TheApp.DialogOpen = True
Page.show_dialog(ft.AlertDialog(