From db81507b7c78075fa098475d51574643e9d8659e Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Tue, 13 Oct 2026 13:12:28 +0200 Subject: [PATCH] Client: diagrams in local folders (setting) Co-Authored-By: Claude Opus 5.5 --- Client/Core.py | 3 + Client/Diagrams.py | 40 ++++- Client/LocalDiagrams.py | 315 ++++++++++++++++++++++++++++++++++++++++ Client/Settings.py | 27 +++- 4 files changed, 376 insertions(+), 9 deletions(-) create mode 100644 Client/LocalDiagrams.py diff --git a/Client/Core.py b/Client/Core.py index 30cf714..01990fc 100644 --- a/Client/Core.py +++ b/Client/Core.py @@ -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) diff --git a/Client/Diagrams.py b/Client/Diagrams.py index c48c1d9..9a5d114 100644 --- a/Client/Diagrams.py +++ b/Client/Diagrams.py @@ -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() diff --git a/Client/LocalDiagrams.py b/Client/LocalDiagrams.py new file mode 100644 index 0000000..a91d377 --- /dev/null +++ b/Client/LocalDiagrams.py @@ -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( + "" + f"" + f"
", + 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 diff --git a/Client/Settings.py b/Client/Settings.py index b497bb9..c77f58b 100644 --- a/Client/Settings.py +++ b/Client/Settings.py @@ -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(