diff --git a/changes/unreleased/diagram-pilot-style.added.md b/changes/unreleased/diagram-pilot-style.added.md new file mode 100644 index 0000000000..083af83f38 --- /dev/null +++ b/changes/unreleased/diagram-pilot-style.added.md @@ -0,0 +1 @@ +- **The VS Code diagram panel draws in the pilot visualizer's style, and in colour, on request.** A **Style** list in the panel's toolbar, backed by the `opensysml.diagram.style` setting, picks the look of every diagram: `theme` (the default) follows the VS Code colour theme as before; `pilot` is the pilot's Standard B&W that the DOT and PlantUML forms already follow — white canvas, black sans-serif text, thin dark borders, square definitions and rounded usages, heavier packages, dashed regions, bold names over an italic keyword, thick arrowless connections, filled pseudo-states; and each of the eight colourblind-safe palettes (`okabe-ito`, `tol-bright`, `tol-muted`, `tol-light`, `brewer-set2`, `brewer-dark2`, `viridis`, `cividis`) is that look filled by keyword family, a usage a lighter tint of its definition's colour, text black. Changing the list keeps the choice in the settings and redraws every open diagram. The colours come from the server: `opensysml/render` with a `palette` now gives each node its `fill` and `border`, the same hex the DOT and PlantUML forms of that view take, advertised as `openSysmlRenderPalette`; against an older `sysml-lsp` a palette draws as `pilot` and the panel says why. diff --git a/docs/internals/design/vscode-visual-modeling.md b/docs/internals/design/vscode-visual-modeling.md index 8b149a7ea1..91a4e80244 100644 --- a/docs/internals/design/vscode-visual-modeling.md +++ b/docs/internals/design/vscode-visual-modeling.md @@ -530,6 +530,24 @@ pipeline's diagram output — and nothing there changed. The panel is a is edited as text with the diagram in step, and the editor's dirty state, undo and save are the text document's. +The canvas has two looks, chosen by `opensysml.diagram.style` and the panel's +**Style** list (`src/style.ts`): `theme`, which takes its colours from the VS Code +theme, and the pilot visualizer's Standard B&W that the DOT and PlantUML forms +follow (`docs/project/view-rendering-forms.md#style`), as CSS on the `pilot` class +— white canvas, black text, 0.5 px `#181818` borders, square definitions and +rounded usages by a class the node's kind gives its box, heavier packages, dashed +regions, bold names over an italic keyword, 3 px arrowless connections, dashed +flows, filled pseudo-states. The class changes no geometry the theme look draws (a +definition alone is square there); the pilot rules square its packages and regions. +A rendering answered after the setting moved is dropped, since the change queued a +render in the new style. A palette is that look plus the `fill` and `border` +the server puts on each node when the render request names one; the canvas sets +each it is given as a custom property on the node's shape (a sequence participant +comes with the fill alone) and computes no colour itself, so +the panel, DOT and PlantUML of one view agree hex for hex and the contrast rule +lives in one place. The server advertises `openSysmlRenderPalette`; without it the +panel asks for no palette, draws `pilot`, and says why under the diagram. + ### Test contract - `edit`: goldens for a new annotation in a view body and inline, an update in diff --git a/docs/project/view-rendering-forms.md b/docs/project/view-rendering-forms.md index aef041488e..338a1fffc5 100644 --- a/docs/project/view-rendering-forms.md +++ b/docs/project/view-rendering-forms.md @@ -456,7 +456,8 @@ every palette, and text stays black. | --- | --- | --- | | CLI | `-render -render-form dot\|plantuml`; `-render-all -render-form dot` writes `.dot` files and `-render-form plantuml` writes `.puml` files; `-render-palette ` fills either | [`docs/reference/cli.md`](../reference/cli.md#rendering-a-view) | | REPL | `%render dot\|plantuml [palette]`; `%help` names them; the form and, after a form that takes one, the palette complete | [`docs/reference/repl-commands.md`](../reference/repl-commands.md#rendering-a-view) | -| LSP | `"form": "dot"` or `"plantuml"` and `"palette": ""` on `opensysml/render` | [`docs/reference/lsp.md`](../reference/lsp.md) | +| LSP | `"form": "dot"` or `"plantuml"` and `"palette": ""` on `opensysml/render`; a palette also gives each node of the result its `fill` and `border`, so a client drawing its own SVG colours a node as these forms do (`Rendering.Fills`) | [`docs/reference/lsp.md`](../reference/lsp.md) | +| VS Code | The diagram panel's **Style** list and `opensysml.diagram.style`: `pilot` draws the panel's SVG under this section's B&W rules, a palette name fills its nodes from the `fill` and `border` the server returns | [`editors/vscode/README.md`](../../editors/vscode/README.md#the-diagram-panel) | | Documents | `-render-document`/`-render-documents … -diagram-form dot\|plantuml`, `%render-document dot\|plantuml`, `"diagramForm"` on `opensysml/renderDocument`: every graph-shaped diagram block as a ` ```dot ` or ` ```plantuml ` fence in Markdown, `
` or `
` in HTML, the source under a notice in PDF. The form is chosen at render time, not stated in the model: a `Diagram` block says what is drawn, not the notation — though it may state a `palette`, as it states a `direction`, which the DOT or PlantUML figure is filled with and the HTML figure carries as `data-palette` | [`docs/manual/authoring.md`](../manual/authoring.md#diagrams), [`docs/manual/outputs.md`](../manual/outputs.md) |
 
 The gRPC service (`api/proto/sysml.proto`, `internal/frontend/grpc`) has no view-render RPC and no
diff --git a/docs/reference/lsp.md b/docs/reference/lsp.md
index 4b92ace88e..cee1e89b21 100644
--- a/docs/reference/lsp.md
+++ b/docs/reference/lsp.md
@@ -9,15 +9,17 @@ result:
 { "capabilities": { "experimental": {
     "openSysmlRender": true, "openSysmlRenderDocument": true, "openSysmlStdlibContent": true,
     "openSysmlApplyModelEdit": true, "openSysmlDebug": true,
-    "openSysmlCrossDocumentLayout": true } } }
+    "openSysmlCrossDocumentLayout": true, "openSysmlRenderPalette": true } } }
 ```
 
 `openSysmlRender` covers the view-rendering methods, `openSysmlRenderDocument`
 the document-rendering ones, `openSysmlStdlibContent` the request that serves
 the bundled standard library's text, `openSysmlApplyModelEdit` the request
-that turns model operations into text edits, and `openSysmlDebug` the
+that turns model operations into text edits, `openSysmlDebug` the
 [`opensysml/debug/*`](#opensysmldebug-requests) requests that run a drawn
-behavior and report where it stands.
+behavior and report where it stands, and `openSysmlRenderPalette` that a
+`palette` named in an `opensysml/render` request colours the result's nodes
+(`fill`, `border`) as well as its DOT or PlantUML artifact.
 
 A client that does not see that capability must not send these methods. That is
 how a new client and an older server stay compatible.
@@ -79,7 +81,7 @@ Renders one view of a document.
 | `textDocument.uri` | The document to render. It must be one the session holds — an open document, or a workspace file the server read. |
 | `view` | The qualified name of a view the document declares, a pseudo-view (below), or omitted. |
 | `form` | `mermaid`, `text`, `markdown`, `dot` or `plantuml`. Omitted writes the machine form of the rendering's kind: `markdown` for a table, `mermaid` for every other kind. `dot` writes Graphviz DOT for a `tree`, `interconnection`, `state` or `action` rendering, without needing Graphviz installed; `plantuml` writes PlantUML in the Pilot visualizer's B&W style for those kinds and a `sequence`, without needing a PlantUML jar. |
-| `palette` | Optional. A palette the `dot` and `plantuml` forms fill nodes with by keyword family: `okabe-ito`, `tol-bright`, `tol-muted`, `tol-light`, `brewer-set2`, `brewer-dark2`, `viridis` or `cividis` ([the palettes](../project/view-rendering-forms.md#palettes)). Omitted or empty draws black and white. A `mermaid` artifact notes the palette as not represented; `text` and `markdown` ignore it. |
+| `palette` | Optional. A palette the `dot` and `plantuml` forms fill nodes with by keyword family: `okabe-ito`, `tol-bright`, `tol-muted`, `tol-light`, `brewer-set2`, `brewer-dark2`, `viridis` or `cividis` ([the palettes](../project/view-rendering-forms.md#palettes)). Omitted or empty draws black and white. A `mermaid` artifact notes the palette as not represented; `text` and `markdown` ignore it. Whatever the form, a server advertising `openSysmlRenderPalette` gives each node the palette colours as `fill` and `border`, so a client drawing the nodes itself draws them the colours the DOT and PlantUML forms take. |
 
 Omitting `view` renders the view the document declares. If the document declares
 several, the request is ambiguous and fails, naming them
@@ -166,6 +168,7 @@ The result, for `{"view": "KitViews::widgetTree"}` over a document declaring
 | `declaration` | On a node or edge a workspace document declares but no qualified name reaches — an unnamed transition, a connection inside an unnamed part — the `range` of that declaration, in place of `fqn`; the range is one of the document `origin.uri` names, this one or, for a client advertising `openSysmlCrossDocumentLayout`, another of the workspace. `opensysml/applyModelEdit`'s `setLayout` and `setRoute` take it, with `declaredIn` when it is another document's, as the target of an inline annotation, since a view body has no name to state one about; no other operation reaches such an element. |
 | `owners` | On a node with an `fqn`: the namespaces declaring it, nearest first, each as its `fqn` and whether it is a `feature`, whether or not the view draws them. A client writes a connection into the nearest owner two nodes share, or into the document when they share none, and spells each end from there — through a feature by `.`, into any other namespace by `::` (`tank.fuelOut`, `Car::tank.fuelOut`). Absent for a top-level declaration. |
 | `palette` | What a diagram of this kind offers to add, in the document's language: `members` are member kinds for `applyModelEdit`'s `addMember`, `connections` are connection kinds for `addConnection`, `typed` are the `members` that may be given a `type`, and `owners` lists, for each member only some bodies declare (`subject`, `actor`, `stakeholder` in a requirement or case, `objective` in a case), the ids of the nodes whose declaration opens such a body — a client offers such a member on those nodes alone, and a member absent from `owners` on every declared node. The same admission decides where a node may be moved: `owners` also lists, under the `notation` of each drawn node that only some bodies declare (`entry action`, `subject`), the nodes that admit it, so a client offers as the new owner of a node the declared nodes, and the document, that its `notation` finds in `owners` — or every one when it is absent. An `interconnection` offers parts, ports, items, attributes and the connection kinds; a `state` diagram states and transitions; an `action` or `sequence` diagram actions, control nodes and successions; a `tree` every kind the language has. Absent for a kind that is not edited from a diagram — a `table`, whose rows name no owner or endpoint to act on, included. |
+| `fill`, `border` | On a node, when the request named a `palette`: the `#RRGGBB` colours the palette gives the node's box and its outline, the same the DOT and PlantUML forms draw it with — a definition its keyword family's colour, a usage a lighter tint of it, both lightened until black text reads on them. A `sequence` participant carries `fill` alone, PlantUML taking no border colour on one. Absent for a node the palette leaves black and white — a control node, a container drawn around its children (a `tree` fills those too, drawing containment as edges) — and for every node when no palette was named. A server that does not advertise `openSysmlRenderPalette` never sends them. |
 | `x`, `y`, `width`, `height`, `collapsed` | On a node: where the model places it, from a `DiagramLayout::Layout` annotation, in pixels from the canvas's top-left corner with y increasing downward. Absent for a node the model does not place; `width` and `height` only when the annotation sizes it; `collapsed` only when it says so. |
 | `route` | On an edge: the waypoints a `DiagramLayout::Route` annotation gives it, as an array of `{"x", "y"}` in the same coordinates. Absent for an edge with none. |
 | `canvas` | The drawing surface the view states with a `DiagramLayout::Canvas` annotation: its `unit` when given, and `width` and `height` together when the annotation sizes it (an explicit `0` is a size). Absent for a view stating none and for every pseudo-view. |
diff --git a/editors/vscode/README.md b/editors/vscode/README.md
index b92611cf92..d499175097 100644
--- a/editors/vscode/README.md
+++ b/editors/vscode/README.md
@@ -93,6 +93,7 @@ own just waits for the server.
 | **What it draws** | The view the document declares. A document declaring several drawable views opens on the one whose declaration holds the editor's cursor, else the one last chosen for that document in this workspace, else the one picked from a list — the drawable views by name and kind, **All views** to open each in its own panel, and the pseudo-views last; views the server cannot draw are left out of that list (the panel's own picker still shows them, disabled, with the reason), and cancelling opens nothing. A document declaring none is drawn directly, as a model tree, interconnection diagram, state diagram, action flow, sequence diagram or element table — a table is written as Markdown rather than drawn, and is shown as that. A view whose rendering is not supported (`geometry`, `textual`) is listed but not drawable, and the reason is written under the diagram. |
 | **Several panels** | A document may have one panel per view open at once; they are titled `Diagram: ` while there are several, each redraws when the model changes, and each highlights the cursor's node. Open Diagram reveals the panel already showing the chosen view, or opens another beside the source for a different one. Picking a view in a panel's picker retargets that panel — unless another panel already draws it, which is revealed instead. Panels come back with their views when the window reloads. |
 | **Where things go** | A node the model places — a `DiagramLayout::Layout` annotation in the view's body or the element's own — is drawn exactly there, at the size it states; every other node takes a slot in a grid under its owner, in the order rendered, so the same model draws the same way every time. An edge follows the waypoints its `DiagramLayout::Route` gives it, else runs straight. |
+| **Style** | The panel's **Style** list, or the `opensysml.diagram.style` setting, picks the look of every diagram. `theme` (the default) follows the VS Code colour theme. `pilot` is the pilot visualizer's Standard B&W, the look the DOT and PlantUML forms are written in: white canvas, black sans-serif text, thin dark borders, square definitions and rounded usages, a heavier border on a package and a dashed one on a region, bold names over a small italic `«kind»`, thick arrowless connections and dashed flows, filled black pseudo-states. The eight palettes (`okabe-ito`, `tol-bright`, `tol-muted`, `tol-light`, `brewer-set2`, `brewer-dark2`, `viridis`, `cividis`, [described here](../../docs/project/view-rendering-forms.md#palettes)) are that look filled by keyword family — parts one colour, ports another, a usage a lighter tint of its definition's — in the very colours a DOT or PlantUML export of the view takes, since the server names them; text stays black. Changing the list keeps the choice in your settings and redraws every open diagram. A `sysml-lsp` too old to name colours draws a palette as `pilot` and says so under the diagram. |
 | **Navigation** | Click a node to open the declaration it was built from; moving the cursor in the editor highlights the node whose declaration contains it. A node built from a standard library declaration opens the bundled library file, read-only. |
 | **While typing** | A rendering that fails mid-keystroke leaves the last good diagram on screen, dimmed, with the error in the status line: the panel never blanks. What a rendering could not represent is listed under it. |
 | **Cost** | The panel asks for a diagram only while visible, and only once an editing burst settles. The panel draws its own SVG, and its CSP allows the bundled script alone — nothing is fetched from the network. |
@@ -171,6 +172,7 @@ The command exists only when the server advertises
 | `opensysml.server.enabled` | `true` | Set to `false` for highlighting without a server. |
 | `opensysml.trace.server` | `"off"` | Trace LSP traffic in the "SysML v2" output channel. |
 | `opensysml.diagram.autoOpen` | `true` | Open a model file's diagram beside it when the file is shown. Set to `false` to open diagrams only with `SysML: Open Diagram`. |
+| `opensysml.diagram.style` | `"theme"` | The look every diagram is drawn in: `theme` for the VS Code colour theme, `pilot` for the pilot visualizer's black and white, or one of the eight palettes for that look filled by keyword family. The panel's **Style** list sets the same value. |
 
 ## Grammar generation
 
diff --git a/editors/vscode/package.json b/editors/vscode/package.json
index 34c916ee58..3ff1056f37 100644
--- a/editors/vscode/package.json
+++ b/editors/vscode/package.json
@@ -94,6 +94,35 @@
           "type": "boolean",
           "default": true,
           "markdownDescription": "Open the diagram of a SysML or KerML file beside its editor as soon as the file is shown. A diagram you close stays closed for that file until you run **SysML: Open Diagram** on it again."
+        },
+        "opensysml.diagram.style": {
+          "type": "string",
+          "default": "theme",
+          "enum": [
+            "theme",
+            "pilot",
+            "okabe-ito",
+            "tol-bright",
+            "tol-muted",
+            "tol-light",
+            "brewer-set2",
+            "brewer-dark2",
+            "viridis",
+            "cividis"
+          ],
+          "enumDescriptions": [
+            "Follow the VS Code colour theme.",
+            "The pilot visualizer's Standard B&W: white canvas, black text, thin dark borders, square definitions and rounded usages.",
+            "The pilot look, filled by keyword family from the Okabe–Ito palette.",
+            "The pilot look, filled by keyword family from Paul Tol's bright palette.",
+            "The pilot look, filled by keyword family from Paul Tol's muted palette.",
+            "The pilot look, filled by keyword family from Paul Tol's light palette.",
+            "The pilot look, filled by keyword family from ColorBrewer Set2.",
+            "The pilot look, filled by keyword family from ColorBrewer Dark2.",
+            "The pilot look, filled by keyword family from the viridis colour map.",
+            "The pilot look, filled by keyword family from the cividis colour map."
+          ],
+          "markdownDescription": "The look every diagram is drawn in, also chosen from the **Style** list in the diagram panel. A palette colours definitions with their keyword family's colour and usages with a lighter tint of it, the same colours the DOT and PlantUML exports take; text stays black."
         }
       }
     },
diff --git a/editors/vscode/src/diagram.ts b/editors/vscode/src/diagram.ts
index 3d20072824..935d256f7b 100644
--- a/editors/vscode/src/diagram.ts
+++ b/editors/vscode/src/diagram.ts
@@ -41,8 +41,10 @@ import {
   RENDER_CAPABILITY,
   RENDER_CHANGED_METHOD,
   RENDER_METHOD,
+  RENDER_PALETTE_CAPABILITY,
   RenderChangedParams,
   RenderNode,
+  RenderParams,
   RenderResult,
   ToWebview,
   VIEWS_METHOD,
@@ -50,6 +52,7 @@ import {
   WorkspaceEdit,
 } from "./protocol";
 import { ActiveEditor, AUTO_OPEN_SETTING, Dismissals, Lifecycle, renamedUri, shouldAutoOpen, TabKind } from "./autoopen";
+import { DiagramStyle, paletteOf, STYLE_SETTING, styleOf } from "./style";
 import { CommandContext, PANEL_TYPE, resolveTarget } from "./target";
 import {
   chooseView,
@@ -67,6 +70,8 @@ import {
 /** Why the diagram commands cannot serve, when they cannot. */
 const NOT_RUNNING = "The SysML v2 language server is not running; run \"SysML: Restart Language Server\" to start it.";
 const NOT_SERVED = "The SysML v2 language server does not serve diagrams; update sysml-lsp to draw one.";
+/** Shown under a palette the attached server cannot fill: it predates coloured renderings. */
+export const NO_PALETTE_HINT = "This language server draws no palette; update sysml-lsp to colour the diagram.";
 
 // The file an exported rendering is saved as, by the form the server wrote.
 const EXPORT_FORMS: Record = {
@@ -132,6 +137,11 @@ export class DiagramPanels implements vscode.Disposable {
         if (event.affectsConfiguration(AUTO_OPEN_SETTING)) {
           this.autoOpen(vscode.window.activeTextEditor);
         }
+        if (event.affectsConfiguration(STYLE_SETTING)) {
+          for (const panel of this.panels.values()) {
+            panel.restyle();
+          }
+        }
       }),
       // A dismissal, a chosen view and an open panel follow the file through a rename; the records die with the file.
       vscode.workspace.onDidRenameFiles((event) => {
@@ -544,6 +554,24 @@ function autoOpenEnabled(): boolean {
   return vscode.workspace.getConfiguration("opensysml.diagram").get("autoOpen", true);
 }
 
+/** diagramStyle is the look the setting asks for, as it applies to a document; a value it does not name is the default. */
+function diagramStyle(scope: vscode.Uri): DiagramStyle {
+  return styleOf(vscode.workspace.getConfiguration("opensysml.diagram", scope).get("style"));
+}
+
+// setDiagramStyle keeps a style chosen in a panel as the setting, at the level that set the one in force.
+async function setDiagramStyle(scope: vscode.Uri, style: DiagramStyle): Promise {
+  const configuration = vscode.workspace.getConfiguration("opensysml.diagram", scope);
+  const levels = configuration.inspect("style");
+  let target = vscode.ConfigurationTarget.Global;
+  if (levels?.workspaceFolderValue !== undefined) {
+    target = vscode.ConfigurationTarget.WorkspaceFolder;
+  } else if (levels?.workspaceValue !== undefined) {
+    target = vscode.ConfigurationTarget.Workspace;
+  }
+  await configuration.update("style", style, target);
+}
+
 // activeEditorInfo reads the active editor and its tab into what the auto-open
 // decision looks at: a diff tab and a hover's peek editor draw no diagram.
 function activeEditorInfo(editor: vscode.TextEditor): ActiveEditor {
@@ -720,14 +748,21 @@ class DiagramPanel {
       selected: this.selected,
     });
     try {
-      // No form is asked for: the server writes the machine form of the kind it
-      // rendered, which is Mermaid for a diagram and Markdown for a table.
-      const result = normalizeRender(
-        await client.sendRequest(RENDER_METHOD, {
-          textDocument,
-          view: this.selected === "" ? undefined : this.selected,
-        }),
-      );
+      // No form is asked for: the server writes the machine form of the kind it rendered.
+      // A palette is asked only of a server that colours nodes by it; the hint says why otherwise.
+      const style = diagramStyle(this.docURI);
+      const palette = paletteOf(style);
+      const colours = palette !== undefined && supportsPalette(client);
+      const params: RenderParams = {
+        textDocument,
+        view: this.selected === "" ? undefined : this.selected,
+        palette: colours ? palette : undefined,
+      };
+      const result = normalizeRender(await client.sendRequest(RENDER_METHOD, params));
+      // A style chosen meanwhile has its own render queued; a drawing in the old one is dropped.
+      if (diagramStyle(this.docURI) !== style) {
+        return;
+      }
       // No palette unless the server also computes the edits it would lead to.
       if (!supportsEdit(client)) {
         delete result.palette;
@@ -744,13 +779,26 @@ class DiagramPanel {
         palette: result.palette,
       };
       this.drawn += 1;
-      this.post({ type: "render", result, selected: this.selected, drawn: this.drawn });
+      this.post({
+        type: "render",
+        result,
+        selected: this.selected,
+        drawn: this.drawn,
+        style,
+        hint: palette !== undefined && !colours ? NO_PALETTE_HINT : undefined,
+      });
       this.highlightActive();
     } catch (err) {
       this.fail(errorMessage(err));
     }
   }
 
+  /** restyle redraws the panel in the look the setting now asks for, then renders for its palette. */
+  restyle(): void {
+    this.post({ type: "style", style: diagramStyle(this.docURI) });
+    this.refresh();
+  }
+
   /** highlightAt marks the node whose declaration contains the cursor. */
   highlightAt(at: vscode.Position): void {
     this.post({ type: "highlight", id: this.nodeAt(this.rendering, at)?.id });
@@ -794,6 +842,10 @@ class DiagramPanel {
         this.select(message.view, true);
         this.refresh();
         return;
+      case "style":
+        // The setting's change notification restyles this panel and every other.
+        setDiagramStyle(this.docURI, message.style).catch((err: unknown) => this.fail(errorMessage(err)));
+        return;
       case "reveal":
         void this.revealSource(message.id, message.drawn);
         return;
@@ -1205,6 +1257,11 @@ function supportsEdit(client: LanguageClient): boolean {
   return experimental(client)?.[APPLY_MODEL_EDIT_CAPABILITY] === true;
 }
 
+/** supportsPalette reports whether the server colours a rendering's nodes by the palette asked for. */
+function supportsPalette(client: LanguageClient): boolean {
+  return experimental(client)?.[RENDER_PALETTE_CAPABILITY] === true;
+}
+
 /** supportsCrossDocument reports whether the server advertised the cross-document diagram contract. */
 function supportsCrossDocument(client: LanguageClient): boolean {
   return experimental(client)?.[CROSS_DOCUMENT_CAPABILITY] === true;
@@ -1285,6 +1342,7 @@ function html(
       body { margin: 0; padding: 0.5rem; font-family: var(--vscode-font-family); color: var(--vscode-foreground); }
       #bar { display: flex; align-items: center; gap: 0.5rem; padding-bottom: 0.5rem; }
       #view { flex: 1 1 auto; max-width: 30rem; }
+      #style { max-width: 14rem; }
       #kind { opacity: 0.8; font-size: 0.9em; }
       #add { max-width: 14rem; }
       #status { color: var(--vscode-errorForeground); min-height: 1.2em; font-size: 0.9em; white-space: pre-wrap; }
@@ -1332,6 +1390,33 @@ function html(
       #diagram .opensysml-selected > .shape, #diagram .opensysml-selected > g.shape > circle {
         stroke: var(--vscode-focusBorder); stroke-width: 3px;
       }
+      /* The pilot visualizer's Standard B&W, as the DOT and PlantUML forms draw it; a palette's fills ride on each shape. */
+      #diagram.pilot { background: white; color: black; }
+      #diagram.pilot svg { font-family: Arial, Helvetica, "Liberation Sans", sans-serif; }
+      #diagram.pilot .shape { fill: var(--node-fill, white); stroke: var(--node-border, #181818); stroke-width: 0.5px; }
+      #diagram.pilot .shape.container { fill: var(--node-fill, white); }
+      #diagram.pilot .shape.usage { rx: 10px; }
+      #diagram.pilot .shape.package { rx: 0; stroke-width: 1.5px; }
+      #diagram.pilot .shape.region { rx: 0; stroke-dasharray: 4 4; }
+      #diagram.pilot .shape.filled { fill: black; stroke: black; }
+      #diagram.pilot .label { fill: black; }
+      #diagram.pilot .label .head { font-weight: bold; }
+      #diagram.pilot .label .keyword { font-style: italic; font-size: 0.72em; opacity: 1; }
+      #diagram.pilot .label .detail { opacity: 1; }
+      #diagram.pilot .collapsed { fill: black; }
+      #diagram.pilot .line { stroke: #181818; stroke-width: 1px; }
+      #diagram.pilot .connection .line { stroke-width: 3px; }
+      #diagram.pilot .lifeline { stroke: #181818; }
+      #diagram.pilot .arrow-fill { fill: #181818; }
+      #diagram.pilot .arrow-line { stroke: #181818; stroke-width: 1px; }
+      #diagram.pilot .edge-label { fill: black; stroke: white; }
+      #diagram.pilot .waypoint { fill: white; }
+      #diagram.pilot .opensysml-drop-target > .shape, #diagram.pilot .opensysml-drop-target > g.shape > circle {
+        stroke: var(--vscode-focusBorder); stroke-width: 3px; stroke-dasharray: 6 3; fill: #dbe9ff;
+      }
+      #diagram.pilot .opensysml-selected > .shape, #diagram.pilot .opensysml-selected > g.shape > circle {
+        stroke: var(--vscode-focusBorder); stroke-width: 3px;
+      }
       details { margin-top: 0.75rem; font-size: 0.9em; }
       pre { white-space: pre-wrap; }
     
@@ -1340,6 +1425,8 @@ function html(
     
+ +
diff --git a/editors/vscode/src/manifest.test.ts b/editors/vscode/src/manifest.test.ts index 1826172643..3350e68ffe 100644 --- a/editors/vscode/src/manifest.test.ts +++ b/editors/vscode/src/manifest.test.ts @@ -5,6 +5,7 @@ import { test } from "node:test"; import * as manifest from "../package.json"; import { AUTO_OPEN_SETTING } from "./autoopen"; +import { DEFAULT_STYLE, STYLE_SETTING, STYLES } from "./style"; import { MODEL_LANGUAGES, PANEL_TYPE } from "./target"; interface Keybinding { @@ -129,3 +130,16 @@ test("the diagram opens on its own by default, and the setting that turns it off assert.equal(setting.default, true); assert.ok(setting.description || setting.markdownDescription, "the setting is described"); }); + +test("the style setting offers every look the panel does, each described, and follows the theme by default", () => { + const { properties } = manifest.contributes.configuration as { + properties: Record; + }; + const setting = properties[STYLE_SETTING]; + assert.ok(setting, `${STYLE_SETTING} is declared`); + assert.equal(setting.type, "string"); + assert.equal(setting.default, DEFAULT_STYLE); + assert.deepEqual(setting.enum, [...STYLES]); + assert.equal(setting.enumDescriptions?.length, STYLES.length); + assert.ok(setting.markdownDescription, "the setting is described"); +}); diff --git a/editors/vscode/src/protocol.test.ts b/editors/vscode/src/protocol.test.ts index 6c5d393fc6..6289ad315c 100644 --- a/editors/vscode/src/protocol.test.ts +++ b/editors/vscode/src/protocol.test.ts @@ -54,3 +54,22 @@ test("normalizeRender leaves a current server's rendering as it is", () => { }; assert.deepEqual(normalizeRender(current), current); }); + +test("a palette's colours pass through, and a server naming none leaves them absent", () => { + const coloured: RenderResult = { + ...olderServer, + nodes: [ + { id: "n0", kind: "part def", name: "Demo::Vehicle", type: "", detail: "", fill: "#E69F00", border: "#E69F00" }, + { id: "n1", kind: "initial", name: "", type: "", detail: "" }, + ], + edges: [], + notices: [], + }; + const result = normalizeRender(coloured); + assert.deepEqual([result.nodes[0].fill, result.nodes[0].border], ["#E69F00", "#E69F00"]); + assert.ok(!("fill" in result.nodes[1]) && !("border" in result.nodes[1])); + for (const node of normalizeRender(olderServer).nodes) { + assert.equal(node.fill, undefined); + assert.equal(node.border, undefined); + } +}); diff --git a/editors/vscode/src/protocol.ts b/editors/vscode/src/protocol.ts index cf127d3ace..04cc90bee9 100644 --- a/editors/vscode/src/protocol.ts +++ b/editors/vscode/src/protocol.ts @@ -1,6 +1,8 @@ // The custom methods the OpenSysML language server adds for diagrams, and the // payloads they carry. They mirror internal/frontend/lsp/render.go. +import type { DiagramStyle } from "./style"; + export const RENDER_METHOD = "opensysml/render"; export const VIEWS_METHOD = "opensysml/views"; export const RENDER_CHANGED_METHOD = "opensysml/renderChanged"; @@ -22,6 +24,9 @@ export const APPLY_MODEL_EDIT_CAPABILITY = "openSysmlApplyModelEdit"; /** The capability each side advertises when it speaks the cross-document diagram contract: renderings naming other documents' declarations, `declaredHere`, and layouts pinned with `declaredIn`. */ export const CROSS_DOCUMENT_CAPABILITY = "openSysmlCrossDocumentLayout"; +/** The capability the server advertises when a render request's `palette` colours each node with `fill` and `border`. */ +export const RENDER_PALETTE_CAPABILITY = "openSysmlRenderPalette"; + /** The URI scheme the server locates standard-library declarations in. */ export const STDLIB_SCHEME = "sysml-stdlib"; @@ -89,6 +94,9 @@ export interface RenderNode { height?: number; /** The node is drawn closed, its children hidden. */ collapsed?: boolean; + /** The colours the requested palette gives the node, `#RRGGBB`, the same its DOT and PlantUML forms take; each absent where the palette leaves it black and white (a sequence participant's border), both under no palette. */ + fill?: string; + border?: string; } /** One waypoint or corner, in the canvas's pixels, y down. */ @@ -134,6 +142,8 @@ export interface RenderParams { textDocument: { uri: string }; view?: string; form?: string; + /** The palette the nodes are coloured from, by keyword family; absent draws in black and white. */ + palette?: string; } export interface RenderResult { @@ -330,8 +340,10 @@ export interface PickerEntry { /** A message the extension sends the webview; `drawn` counts the panel's drawings and names this one. */ export type ToWebview = - | { type: "render"; result: RenderResult; selected: string; drawn: number } + | { type: "render"; result: RenderResult; selected: string; drawn: number; style: DiagramStyle; hint?: string } | { type: "views"; views: PickerEntry[]; selected: string } + /** The look to redraw the diagram on screen in, ahead of the rendering coloured for it. */ + | { type: "style"; style: DiagramStyle } | { type: "error"; message: string } | { type: "highlight"; id: string | undefined } /** A drop the model did not take: the canvas goes back to the model's layout, the status line says why. */ @@ -362,6 +374,8 @@ export type FromWebview = | { type: "ready" } | { type: "reveal"; id: string; drawn: number } | { type: "pick"; view: string } + /** The user chose a look in the panel's toolbar; the extension keeps it as the setting. */ + | { type: "style"; style: DiagramStyle } | { type: "edit"; action: EditAction; drawn: number } /** One completed gesture: everything it moved, applied as one edit. */ | { type: "place"; nodes: NodePlacement[]; edges: EdgePlacement[]; drawn: number } diff --git a/editors/vscode/src/style.test.ts b/editors/vscode/src/style.test.ts new file mode 100644 index 0000000000..63b76e3d6b --- /dev/null +++ b/editors/vscode/src/style.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { DEFAULT_STYLE, isStyle, paletteOf, PALETTES, pilotLook, STYLE_LABELS, STYLES, styleOf } from "./style"; + +test("the styles are the theme, the pilot's black and white, and one per server palette, each labelled", () => { + assert.deepEqual(STYLES.slice(0, 2), ["theme", "pilot"]); + assert.deepEqual(STYLES.slice(2), [...PALETTES]); + assert.deepEqual(Object.keys(STYLE_LABELS).sort(), [...STYLES].sort()); + assert.equal(DEFAULT_STYLE, "theme"); +}); + +test("a setting or saved state names a style or falls back to the default", () => { + assert.equal(isStyle("okabe-ito"), true); + assert.equal(isStyle("sysmlbw"), false); + assert.equal(isStyle(undefined), false); + assert.equal(styleOf("pilot"), "pilot"); + assert.equal(styleOf("no-such-look"), DEFAULT_STYLE); + assert.equal(styleOf(3), DEFAULT_STYLE); +}); + +test("only a palette style asks the server for a palette; every style but the theme draws the pilot look", () => { + assert.equal(paletteOf("theme"), undefined); + assert.equal(paletteOf("pilot"), undefined); + assert.equal(paletteOf("tol-bright"), "tol-bright"); + assert.deepEqual(STYLES.map(pilotLook), [false, true, ...PALETTES.map(() => true)]); +}); diff --git a/editors/vscode/src/style.ts b/editors/vscode/src/style.ts new file mode 100644 index 0000000000..deadeae7f1 --- /dev/null +++ b/editors/vscode/src/style.ts @@ -0,0 +1,60 @@ +// The look a diagram is drawn in: the editor's theme, the pilot visualizer's black and +// white, or that look filled from a server palette. Free of the vscode module for the webview. + +/** The setting that picks the look every diagram panel is drawn in. */ +export const STYLE_SETTING = "opensysml.diagram.style"; + +/** The palettes the server fills a rendering from, by keyword family, as `internal/ir/view/palette.go` names them. */ +export const PALETTES = [ + "okabe-ito", + "tol-bright", + "tol-muted", + "tol-light", + "brewer-set2", + "brewer-dark2", + "viridis", + "cividis", +] as const; + +export type Palette = (typeof PALETTES)[number]; + +/** The looks: `theme` follows VS Code, `pilot` is the pilot's Standard B&W, a palette is that look filled by family. */ +export const STYLES = ["theme", "pilot", ...PALETTES] as const; + +export type DiagramStyle = (typeof STYLES)[number]; + +export const DEFAULT_STYLE: DiagramStyle = "theme"; + +/** How each style is offered to the user. */ +export const STYLE_LABELS: Record = { + theme: "VS Code theme", + pilot: "Pilot (black and white)", + "okabe-ito": "Pilot, Okabe–Ito", + "tol-bright": "Pilot, Tol bright", + "tol-muted": "Pilot, Tol muted", + "tol-light": "Pilot, Tol light", + "brewer-set2": "Pilot, Brewer Set2", + "brewer-dark2": "Pilot, Brewer Dark2", + viridis: "Pilot, viridis", + cividis: "Pilot, cividis", +}; + +/** isStyle reports whether a value names a style; a setting or saved state may hold anything. */ +export function isStyle(value: unknown): value is DiagramStyle { + return typeof value === "string" && (STYLES as readonly string[]).includes(value); +} + +/** styleOf is the style a value names, or the default when it names none. */ +export function styleOf(value: unknown): DiagramStyle { + return isStyle(value) ? value : DEFAULT_STYLE; +} + +/** paletteOf is the palette a render request asks for under a style; none under the theme or black and white. */ +export function paletteOf(style: DiagramStyle): Palette | undefined { + return (PALETTES as readonly string[]).includes(style) ? (style as Palette) : undefined; +} + +/** pilotLook reports whether a style draws the pilot's look rather than the editor's theme. */ +export function pilotLook(style: DiagramStyle): boolean { + return style !== "theme"; +} diff --git a/editors/vscode/src/webview/canvas.test.ts b/editors/vscode/src/webview/canvas.test.ts index f22dc067f3..90abd7f738 100644 --- a/editors/vscode/src/webview/canvas.test.ts +++ b/editors/vscode/src/webview/canvas.test.ts @@ -189,3 +189,50 @@ test("drawCanvas draws a sequence's lifelines", () => { assert.equal(svg.querySelectorAll("g.edge-handles").length, 0); assert.equal(svg.querySelectorAll("g.opensysml-node.movable").length, 0); }); + +test("drawCanvas classes each box as the PlantUML form stereotypes it, squaring only a definition", () => { + const boxed: RenderResult = { + ...result, + nodes: [ + node("p", "Plant", { kind: "package" }), + node("q", "Lib", { kind: "library package" }), + node("def", "Tank", { kind: "part def" }), + node("cls", "Thing", { kind: "class" }), + node("reg", "r", { kind: "region" }), + node("use", "tank", { kind: "part" }), + ], + edges: [], + }; + const svg = drawCanvas(layoutCanvas(boxed)); + const rect = (id: string) => svg.querySelector(`g[data-opensysml-id="${id}"] > rect`)!; + assert.deepEqual( + ["p", "q", "def", "cls", "reg", "use"].map((id) => [rect(id).classList[1], rect(id).getAttribute("rx")]), + [["package", "6"], ["package", "6"], ["definition", null], ["definition", null], ["region", "6"], ["usage", "6"]], + ); +}); + +test("drawCanvas carries each palette colour on the shape as its own custom property, and nothing on a node given none", () => { + const coloured: RenderResult = { + ...result, + nodes: [ + node("def", "Tank", { kind: "part def", fill: "#E69F00", border: "#E69F00" }), + node("use", "tank", { kind: "part", parent: "def", fill: "#F5D999", border: "#E69F00" }), + node("lifeline", "producer", { kind: "part", fill: "#F5D999" }), + node("plain", "pump"), + node("d", "", { kind: "fork" }), + ], + }; + const svg = drawCanvas(layoutCanvas(coloured)); + const shape = (id: string) => svg.querySelector(`g[data-opensysml-id="${id}"] > .shape`)!; + assert.deepEqual([shape("def").style.getPropertyValue("--node-fill"), shape("def").style.getPropertyValue("--node-border")], ["#E69F00", "#E69F00"]); + assert.deepEqual([shape("use").style.getPropertyValue("--node-fill"), shape("use").style.getPropertyValue("--node-border")], ["#F5D999", "#E69F00"]); + // A sequence participant is filled alone; its border stays the look's. + assert.deepEqual([shape("lifeline").style.getPropertyValue("--node-fill"), shape("lifeline").style.getPropertyValue("--node-border")], ["#F5D999", ""]); + assert.equal(shape("plain").getAttribute("style"), null); + assert.equal(shape("d").getAttribute("style"), null); + // Colour changes nothing the editing gestures read. + const groups = [...svg.querySelectorAll("g.opensysml-node")]; + assert.deepEqual(groups.map((group) => [group.dataset.opensysmlId, group.dataset.kind]), [["def", "part def"], ["use", "part"], ["lifeline", "part"], ["plain", "part"], ["d", "fork"]]); + assert.equal(shape("def").classList.contains("container"), true); + assert.equal(shape("d").classList.contains("filled"), true); +}); diff --git a/editors/vscode/src/webview/canvas.ts b/editors/vscode/src/webview/canvas.ts index 928398c27f..a6f6ee7e0c 100644 --- a/editors/vscode/src/webview/canvas.ts +++ b/editors/vscode/src/webview/canvas.ts @@ -172,8 +172,21 @@ function labelLineClass(i: number, named: boolean): string { } // shape is the outline a node is drawn with: a box for an element, square-cornered -// for a definition; a symbol for a control node. +// for a definition; a symbol for a control node. Each colour a palette gave the node +// rides along as a custom property, for the looks that draw them. function shape(entry: PlacedNode): SVGElement { + const outline = outlineOf(entry); + const { fill, border } = entry.node; + if (fill !== undefined) { + outline.style.setProperty("--node-fill", fill); + } + if (border !== undefined) { + outline.style.setProperty("--node-border", border); + } + return outline; +} + +function outlineOf(entry: PlacedNode): SVGElement { const { x, y, width, height } = entry.box; const cx = x + width / 2; const cy = y + height / 2; @@ -205,12 +218,15 @@ function shape(entry: PlacedNode): SVGElement { return group; } case "box": { - const definition = entry.node.kind.endsWith(" def") || CLASSIFIER_KINDS.has(entry.node.kind); + const classes = ["shape", boxClass(entry.node.kind)]; + if (entry.children.length > 0 && !entry.collapsed) { + classes.push("container"); + } const attrs: Record = { x: String(x), y: String(y), width: String(width), height: String(height), - class: `shape${entry.children.length > 0 && !entry.collapsed ? " container" : ""}`, + class: classes.join(" "), }; - if (!definition) { + if (classes[1] !== "definition") { attrs.rx = String(CORNER); } return element("rect", attrs); @@ -218,6 +234,21 @@ function shape(entry: PlacedNode): SVGElement { } } +// boxClass is what a box is drawn as, the way the PlantUML form stereotypes it; the +// looks pick corner and border by it, a definition alone square in every look. +function boxClass(kind: string): "package" | "definition" | "region" | "usage" { + if (kind.split(" ").includes("package")) { + return "package"; + } + if (kind === "region") { + return "region"; + } + if (kind.endsWith(" def") || CLASSIFIER_KINDS.has(kind)) { + return "definition"; + } + return "usage"; +} + // drawEdge is an edge's polyline with the arrowhead its kind takes and its label // at the midpoint. function drawEdge(edge: PlacedEdge): SVGGElement { diff --git a/editors/vscode/src/webview/diagram.ts b/editors/vscode/src/webview/diagram.ts index f22d9d80c1..6bd22e6439 100644 --- a/editors/vscode/src/webview/diagram.ts +++ b/editors/vscode/src/webview/diagram.ts @@ -10,6 +10,7 @@ import { type RenderResult, type ToWebview, } from "../protocol"; +import { type DiagramStyle, pilotLook, STYLE_LABELS, STYLES, styleOf } from "../style"; import { MenuCommand, MenuItem, nodeMenu, paletteItems } from "./actions"; import { cssEscape, drawCanvas, liftNode } from "./canvas"; import { dragHint, Drop, dropOn } from "./drop"; @@ -37,6 +38,7 @@ declare function acquireVsCodeApi(): WebviewApi; const vscode = acquireVsCodeApi(); const body = document.body; const picker = document.getElementById("view") as HTMLSelectElement; +const styler = document.getElementById("style") as HTMLSelectElement; const kindLabel = document.getElementById("kind") as HTMLElement; const status = document.getElementById("status") as HTMLElement; const diagram = document.getElementById("diagram") as HTMLElement; @@ -48,10 +50,12 @@ const adder = document.getElementById("add") as HTMLSelectElement; const menu = document.getElementById("menu") as HTMLUListElement; const documentURI = (JSON.parse(body.dataset.state ?? "{}") as { uri?: string }).uri ?? ""; -const saved = (vscode.getState() ?? {}) as { view?: string; last?: RenderResult }; +const saved = (vscode.getState() ?? {}) as { view?: string; last?: RenderResult; style?: string }; let selected = saved.view ?? ""; -// A rendering saved by an older extension is normalized like a fresh one. +// A rendering saved by an older extension is normalized like a fresh one, and +// drawn in the look it was, or the default when it saved none. let last: RenderResult | undefined = saved.last === undefined ? undefined : normalizeRender(saved.last); +let style: DiagramStyle = styleOf(saved.style); let selectedNode: string | undefined; /** The layout on screen, which gestures act on; undefined while a table or nothing is shown. */ let layout: CanvasLayout | undefined; @@ -67,6 +71,9 @@ let paletteEntries: MenuItem[] = []; // The extension's number for the drawing shown; an action names the drawing its ids came from. let drawn = 0; +fillStyles(); +applyStyle(style); + // The panel is torn down while it is hidden, so the rendering it last drew is // put back — dimmed until the server answers — rather than showing nothing. if (last) { @@ -74,6 +81,12 @@ if (last) { diagram.classList.add("stale"); } +// The look changes at once; the extension keeps the choice and renders for its palette. +styler.addEventListener("change", () => { + applyStyle(styleOf(styler.value)); + vscode.postMessage({ type: "style", style }); +}); + picker.addEventListener("change", () => { selected = picker.value; remember(); @@ -120,10 +133,17 @@ window.addEventListener("message", (event: MessageEvent) => { return; case "render": // The number names what is on screen; a drawing that failed left the last one up. + applyStyle(message.style); if (draw(message.result)) { drawn = message.drawn; + if (message.hint !== undefined) { + showHint(message.hint); + } } return; + case "style": + applyStyle(message.style); + return; case "error": showError(message.message); return; @@ -158,6 +178,26 @@ function fillPicker(views: PickerEntry[], pick: string): void { showUndrawable(views); } +// fillStyles lists the looks the diagram can be drawn in. +function fillStyles(): void { + styler.replaceChildren(); + for (const entry of STYLES) { + const option = document.createElement("option"); + option.value = entry; + option.textContent = STYLE_LABELS[entry]; + styler.append(option); + } +} + +// applyStyle draws what is on screen in a look: the pilot's rules take over from the +// editor's theme under every look but `theme`, and a palette's fills ride on each shape. +function applyStyle(chosen: DiagramStyle): void { + style = chosen; + styler.value = chosen; + diagram.classList.toggle("pilot", pilotLook(chosen)); + remember(); +} + // showUndrawable says why a listed view is not drawable, since the picker only // says that it is not and holds the reason in a tooltip. function showUndrawable(views: PickerEntry[]): void { @@ -622,5 +662,5 @@ function highlight(id: string | undefined): void { // remember keeps what the panel is showing, so a window reload restores it. function remember(): void { - vscode.setState({ uri: documentURI, view: selected, last }); + vscode.setState({ uri: documentURI, view: selected, last, style }); } diff --git a/editors/vscode/src/webview/tsconfig.json b/editors/vscode/src/webview/tsconfig.json index f131d8cdae..f521932842 100644 --- a/editors/vscode/src/webview/tsconfig.json +++ b/editors/vscode/src/webview/tsconfig.json @@ -9,5 +9,5 @@ // The extension project excludes this directory; that exclusion is inherited // and would leave this project with nothing of the webview in it. "exclude": [], - "include": ["../protocol.ts", "."] + "include": ["../protocol.ts", "../style.ts", "."] } diff --git a/internal/frontend/lsp/lifecycle.go b/internal/frontend/lsp/lifecycle.go index 368b774623..c13bb680d1 100644 --- a/internal/frontend/lsp/lifecycle.go +++ b/internal/frontend/lsp/lifecycle.go @@ -54,6 +54,7 @@ func (s *Server) Initialize(ctx context.Context, params *protocol.InitializePara "openSysmlStdlibContent": true, "openSysmlDebug": true, CrossDocumentCapability: true, + RenderPaletteCapability: true, }, // Folders added mid-session are only indexed if the client reports them. Workspace: &protocol.ServerCapabilitiesWorkspace{ diff --git a/internal/frontend/lsp/render.go b/internal/frontend/lsp/render.go index 96cb28c25e..85701ba329 100644 --- a/internal/frontend/lsp/render.go +++ b/internal/frontend/lsp/render.go @@ -34,11 +34,15 @@ const ( // naming other documents' declarations and layouts pinned with declaredIn. const CrossDocumentCapability = "openSysmlCrossDocumentLayout" +// RenderPaletteCapability is the experimental capability the server advertises +// when a render request's palette colours the result's nodes with fill and border. +const RenderPaletteCapability = "openSysmlRenderPalette" + // renderParams asks for one rendering. View names a view the document declares, // or a supported pseudo-view (`#` or `#:`); empty renders the // document's own view. Form is the artifact written, defaulting to the machine -// form of the rendering's kind. Palette names the palette the DOT and PlantUML -// forms fill nodes from, by keyword family; empty draws in black and white. +// form of the rendering's kind. Palette names the palette that fills the nodes by +// keyword family, in the artifact and as each node's Fill and Border; empty is black and white. type renderParams struct { TextDocument protocol.TextDocumentIdentifier `json:"textDocument"` View string `json:"view,omitempty"` @@ -72,7 +76,9 @@ type renderResult struct { // targets the declaration at that range of the document Origin names. Both are // given for a declaration of a workspace document alone, a library's being // beyond every operation; DeclaredHere marks the requested document's own, the -// only ones the operations besides a layout reach. +// only ones the operations besides a layout reach. Fill and Border are the +// `#RRGGBB` colours the palette gives the node, as the DOT and PlantUML forms draw it; absent +// for a node left black and white, and for every node when no palette is asked for. type renderNode struct { ID string `json:"id"` Kind string `json:"kind"` @@ -80,6 +86,8 @@ type renderNode struct { Type string `json:"type"` Detail string `json:"detail"` Parent string `json:"parent,omitempty"` + Fill string `json:"fill,omitempty"` + Border string `json:"border,omitempty"` FQN string `json:"fqn,omitempty"` DeclaredHere bool `json:"declaredHere,omitempty"` Notation string `json:"notation,omitempty"` @@ -259,6 +267,10 @@ func (s *Server) Render(params *renderParams) (*renderResult, error) { if err != nil { return nil, err } + fills, err := rendering.Fills(colors) + if err != nil { + return nil, err + } data := rendering.Data() out := &renderResult{ View: data.View, @@ -283,7 +295,7 @@ func (s *Server) Render(params *renderParams) (*renderResult, error) { out.Canvas.Width, out.Canvas.Height = &w, &h } } - s.renderNodes(out, snapshot, data.Nodes) + s.renderNodes(out, snapshot, data.Nodes, fills) s.renderEdges(out, snapshot, data.Edges) for _, row := range data.Rows { out.Rows = append(out.Rows, renderRow{Cells: row.Cells, Origin: origin(row.Origin)}) @@ -291,9 +303,10 @@ func (s *Server) Render(params *renderParams) (*renderResult, error) { return out, nil } -// renderNodes converts the rendering's nodes into out; a node the document -// declares confines the palette to its notation and is admitted once all are known. -func (s *Server) renderNodes(out *renderResult, snapshot *model.Snapshot, nodes []view.NodeData) { +// renderNodes converts the rendering's nodes into out, each coloured as fills +// says; a node the document declares confines the palette to its notation and +// is admitted once all are known. +func (s *Server) renderNodes(out *renderResult, snapshot *model.Snapshot, nodes []view.NodeData, fills map[string]view.Fill) { name := snapshot.Rendered.Name var declared []declaredNode for _, node := range nodes { @@ -304,6 +317,8 @@ func (s *Server) renderNodes(out *renderResult, snapshot *model.Snapshot, nodes Type: node.Type, Detail: node.Detail, Parent: node.Parent, + Fill: fills[node.ID].Fill, + Border: fills[node.ID].Border, } declaring := s.declaring(snapshot, node.Origin) n.Origin = s.originOf(declaring, node.Origin) diff --git a/internal/frontend/lsp/render_test.go b/internal/frontend/lsp/render_test.go index 6ab7bd9ddb..7b310ee36c 100644 --- a/internal/frontend/lsp/render_test.go +++ b/internal/frontend/lsp/render_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "regexp" "slices" "strings" "sync" @@ -687,6 +688,99 @@ func TestRenderFillsFromThePaletteAsked(t *testing.T) { } } +// A palette colours the result's nodes as the DOT artifact of the same request +// does, hex for hex, whatever form is asked for; a control node stays uncoloured, +// and no node is coloured when no palette is asked for. +func TestRenderNodesCarryThePaletteFills(t *testing.T) { + s, docURI := renderServer(t, "kit.sysml", renderModel) + fillLine := regexp.MustCompile(`^\s*"([^"]+)" \[.*fillcolor="(#[0-9A-F]{6})", color="(#[0-9A-F]{6})"`) + for _, form := range []view.Form{view.FormDot, view.FormMermaid} { + raw, err := call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "KitViews::widgetStates", + Form: string(form), + Palette: string(view.PaletteTolBright), + }) + if err != nil { + t.Fatalf("render %s with a palette: %v", form, err) + } + var out renderResult + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("decode render result: %v", err) + } + got := map[string][2]string{} + for _, n := range out.Nodes { + if n.Fill != "" || n.Border != "" { + got[n.ID] = [2]string{n.Fill, n.Border} + } + if n.Kind == "initial" && (n.Fill != "" || n.Border != "") { + t.Errorf("%s: the initial pseudostate %s is coloured %s/%s", form, n.ID, n.Fill, n.Border) + } + } + if len(got) < 2 { + t.Errorf("%s: only %d nodes coloured: %+v", form, len(got), out.Nodes) + } + if form == view.FormDot { + want := map[string][2]string{} + for _, line := range strings.Split(out.Artifact, "\n") { + if m := fillLine.FindStringSubmatch(line); m != nil { + want[m[1]] = [2]string{m[2], m[3]} + } + } + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("nodes coloured %v, the DOT artifact %v", got, want) + } + } + } + raw, err := call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "KitViews::widgetStates", + }) + if err != nil { + t.Fatalf("render without a palette: %v", err) + } + if strings.Contains(string(raw), `"fill"`) || strings.Contains(string(raw), `"border"`) { + t.Errorf("a rendering without a palette carries colours:\n%s", raw) + } +} + +// A sequence participant carries the fill alone, as PlantUML colours no +// participant border; the border key is absent from the wire, not empty. +func TestRenderSequenceParticipantsFillWithoutBorder(t *testing.T) { + s, docURI := renderServer(t, "kit.sysml", renderModel) + raw, err := call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "KitViews::widgetSequence", + Form: string(view.FormPlantUML), + Palette: string(view.PaletteOkabeIto), + }) + if err != nil { + t.Fatalf("render the sequence with a palette: %v", err) + } + var out renderResult + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("decode render result: %v", err) + } + filled := 0 + for _, n := range out.Nodes { + if n.Fill != "" { + filled++ + } + if n.Border != "" { + t.Errorf("participant %s has border %s, want none", n.ID, n.Border) + } + } + if filled == 0 { + t.Errorf("no participant filled: %+v", out.Nodes) + } + if strings.Contains(string(raw), `"border"`) { + t.Errorf("a sequence rendering carries a border key:\n%s", raw) + } + if strings.Contains(out.Artifact, ";line:") { + t.Errorf("the PlantUML artifact colours a participant border:\n%s", out.Artifact) + } +} + // A view's layout annotations reach the client as geometry on nodes and edges // and a canvas on the result; a rendering without any carries none of the fields. func TestRenderCarriesLayoutGeometry(t *testing.T) { @@ -870,6 +964,9 @@ func TestInitializeAdvertisesTheRenderCapability(t *testing.T) { if experimental[CrossDocumentCapability] != true { t.Errorf("%s = %#v, want true", CrossDocumentCapability, experimental[CrossDocumentCapability]) } + if experimental[RenderPaletteCapability] != true { + t.Errorf("%s = %#v, want true", RenderPaletteCapability, experimental[RenderPaletteCapability]) + } } // A rendering's version, node names, FQNs and ranges all describe one document diff --git a/internal/ir/view/palette.go b/internal/ir/view/palette.go index 2dd58ca815..94d6db8e0a 100644 --- a/internal/ir/view/palette.go +++ b/internal/ir/view/palette.go @@ -108,6 +108,10 @@ func (k Kind) SupportsPalette() bool { // TakesPalette reports whether the form fills nodes from a palette. func (f Form) TakesPalette() bool { return slices.Contains(paletteForms, f) } +// paletteBorders reports whether a filled node's border takes the family colour +// in a rendering of the kind; PlantUML accepts no border colour on a participant. +func (k Kind) paletteBorders() bool { return k != KindSequence } + // controlKinds are the kinds drawn as control and pseudo-state nodes: they // keep the black-and-white rules and a square shape under every palette. var controlKinds = map[string]bool{startKind: true, "initial": true, "final": true, "fork": true, "join": true, @@ -158,6 +162,48 @@ func (f *familyFills) fill(node *Node) string { return paletteFill(f.color(node), !isDefinitionKind(node.Kind)) } +// Fill is the colours a palette gives one node, `#RRGGBB`: the fill of its box, +// and the border, the family colour the fill is tinted from — empty for a +// sequence participant, whose outline keeps the black-and-white rules. +type Fill struct { + Fill string + Border string +} + +// Fills is the fill each node takes under the palette, by node ID, as the DOT and PlantUML +// forms fill it; a node left black and white, and every node under no palette, is absent. +func (r *Rendering) Fills(palette Palette) (map[string]Fill, error) { + if err := palette.check(); err != nil { + return nil, err + } + fills := map[string]Fill{} + if palette == "" || !r.Kind.SupportsPalette() { + return fills, nil + } + f := familyFills{palette: palette, tree: r.Kind == KindTree} + for _, root := range r.Roots { + f.collect(root) + } + borders := r.Kind.paletteBorders() + var walk func(node *Node) + walk = func(node *Node) { + if f.filled(node) { + fill := Fill{Fill: f.fill(node)} + if borders { + fill.Border = f.color(node) + } + fills[node.ID] = fill + } + for _, child := range node.Children { + walk(child) + } + } + for _, root := range r.Roots { + walk(root) + } + return fills, nil +} + // The palettes' colours as their authors publish them, `#RRGGBB`. The // qualitative sets are in their published order; the sequential ramps are // matplotlib's viridis and cividis sampled at i/15 for i in 0..15. diff --git a/internal/ir/view/palette_test.go b/internal/ir/view/palette_test.go index 6a9fefafc3..bab73c16cc 100644 --- a/internal/ir/view/palette_test.go +++ b/internal/ir/view/palette_test.go @@ -3,6 +3,7 @@ package view import ( "errors" "fmt" + "regexp" "slices" "strings" "testing" @@ -230,3 +231,121 @@ func TestPaletteNotice(t *testing.T) { } } } + +// Fills gives every node the fill and border the DOT form of the same rendering +// draws it with, and no more: the nodes DOT leaves black and white are absent. +func TestFillsMatchDOT(t *testing.T) { + dotBorder := regexp.MustCompile(`^\s*"([^"]+)" \[.*fillcolor="(#[0-9A-F]{6})", color="(#[0-9A-F]{6})"`) + for _, tc := range plantumlGoldenCases { + rendering := render(t, tc.file, tc.view) + for _, palette := range Palettes() { + fills, err := rendering.Fills(palette) + if err != nil { + t.Fatalf("%s %s Fills: %v", tc.name, palette, err) + } + if !tc.kind.SupportsForm(FormDot) { + continue + } + dot, err := rendering.DOTWith(Options{Palette: palette}) + if err != nil { + t.Fatalf("%s %s DOT: %v", tc.name, palette, err) + } + want := map[string]Fill{} + for _, line := range strings.Split(dot, "\n") { + if m := dotBorder.FindStringSubmatch(line); m != nil { + want[m[1]] = Fill{Fill: m[2], Border: m[3]} + } + } + if len(want) == 0 { + t.Errorf("%s %s: DOT fills no node", tc.name, palette) + } + if fmt.Sprint(fills) != fmt.Sprint(want) { + t.Errorf("%s %s: Fills %v, DOT %v", tc.name, palette, fills, want) + } + } + } +} + +// Fills gives every node the fill and border the PlantUML form of the same +// rendering draws it with: a participant the fill alone, its border uncoloured. +func TestFillsMatchPlantUML(t *testing.T) { + decorated := regexp.MustCompile(`^\s*\w+ ".*" as ([^ ]+)(?: <<[^>]*>>)* (#[0-9A-F]{6})(?:;line:([0-9A-F]{6}))?$`) + sequences := 0 + for _, tc := range plantumlGoldenCases { + if tc.name == "sequence-empty" { + continue + } + rendering := render(t, tc.file, tc.view) + for _, palette := range Palettes() { + fills, err := rendering.Fills(palette) + if err != nil { + t.Fatalf("%s %s Fills: %v", tc.name, palette, err) + } + puml, err := rendering.PlantUMLWith(Options{Palette: palette}) + if err != nil { + t.Fatalf("%s %s PlantUML: %v", tc.name, palette, err) + } + want := map[string]Fill{} + for _, line := range strings.Split(puml, "\n") { + if m := decorated.FindStringSubmatch(line); m != nil { + fill := Fill{Fill: m[2]} + if m[3] != "" { + fill.Border = "#" + m[3] + } + want[m[1]] = fill + } + } + if len(want) == 0 { + t.Errorf("%s %s: PlantUML fills no node", tc.name, palette) + } + if fmt.Sprint(fills) != fmt.Sprint(want) { + t.Errorf("%s %s: Fills %v, PlantUML %v", tc.name, palette, fills, want) + } + if tc.kind == KindSequence { + sequences++ + for id, fill := range fills { + if fill.Border != "" { + t.Errorf("%s %s: participant %s has border %s, want none", tc.name, palette, id, fill.Border) + } + } + } + } + } + if sequences == 0 { + t.Error("no sequence rendering checked") + } +} + +// No palette fills nothing, an unknown one is refused, and a kind no form +// fills, a control node and an interconnection's container take no fill. +func TestFillsOutsideThePalette(t *testing.T) { + rendering := &Rendering{View: "V", Kind: KindInterconnection, Roots: []*Node{ + {ID: "n0", Kind: "part def", Name: "Def", Children: []*Node{ + {ID: "n1", Kind: "part", Name: "p"}, + {ID: "n2", Kind: "initial"}, + }}, + }} + if fills, err := rendering.Fills(""); err != nil || len(fills) != 0 { + t.Errorf("Fills(\"\") = %v, %v; want none", fills, err) + } + if _, err := rendering.Fills(Palette("rainbow")); !errors.Is(err, ErrUnknownPalette) { + t.Errorf("Fills(rainbow) error = %v, want ErrUnknownPalette", err) + } + fills, err := rendering.Fills(PaletteOkabeIto) + if err != nil { + t.Fatalf("Fills: %v", err) + } + part := paletteColors[PaletteOkabeIto][0] + want := map[string]Fill{"n1": {Fill: paletteFill(part, true), Border: part}} + if fmt.Sprint(fills) != fmt.Sprint(want) { + t.Errorf("Fills = %v, want %v", fills, want) + } + tree := &Rendering{View: "V", Kind: KindTree, Roots: rendering.Roots} + if fills, err := tree.Fills(PaletteOkabeIto); err != nil || fills["n0"] != (Fill{Fill: part, Border: part}) { + t.Errorf("tree Fills(n0) = %v, %v; want the container filled %s", fills["n0"], err, part) + } + table := &Rendering{View: "V", Kind: KindTable, Roots: rendering.Roots} + if fills, err := table.Fills(PaletteOkabeIto); err != nil || len(fills) != 0 { + t.Errorf("table Fills = %v, %v; want none", fills, err) + } +} diff --git a/internal/ir/view/plantuml.go b/internal/ir/view/plantuml.go index 56c9244143..fda035b636 100644 --- a/internal/ir/view/plantuml.go +++ b/internal/ir/view/plantuml.go @@ -28,7 +28,7 @@ func (r *Rendering) PlantUMLWith(options Options) (string, error) { if err := options.Palette.check(); err != nil { return "", err } - w := &plantumlWriter{borders: r.Kind != KindSequence, fills: familyFills{palette: options.Palette, tree: r.Kind == KindTree}} + w := &plantumlWriter{borders: r.Kind.paletteBorders(), fills: familyFills{palette: options.Palette, tree: r.Kind == KindTree}} for _, root := range r.Roots { w.fills.collect(root) }