22 KiB
Notebook editor: Raw/Rendered toggle for Mermaid code blocks — Tech Spec
Context
See specs/GH549/PRODUCT.md for the full user-facing behavior.
Today the notebook editor unconditionally renders Mermaid-labeled code blocks as diagrams when FeatureFlag::MarkdownMermaid is enabled and the interaction state is Selectable or Editable (governed by NotebooksEditorModel::render_mermaid_diagrams_in_state). The new behavior requires every Mermaid block to default to Raw (code-block view) and let the user opt in to diagram rendering per block via an explicit toggle, matching the existing Raw/Rendered segmented control used in the markdown file viewer.
Relevant files:
crates/editor/src/render/element/mod.rs (880-895)—renderable_blocksmatch arm forBlockItem::RunnableCodeBlockfetches therunnable_commandviaparent.runnable_command_at(start_offset, ctx)and passes it toRenderableRunnableCommand::new(which callsrender_block_footer). TheBlockItem::MermaidDiagramarm does not fetch the command, so no footer is rendered — this is the root cause of the toggle disappearing in Rendered mode.crates/editor/src/render/element/runnable_command.rs—RenderableRunnableCommand::newacceptsOption<&dyn RunnableCommandModel>, creates the footer element, lays it out, and paints it atcontent_rect.lower_right()in a higher z-index layer. TheCOMMAND_SPACINGalready reservesBLOCK_FOOTER_HEIGHTpadding at the bottom, so footer space exists.crates/editor/src/content/edit.rs (689-798)—LayoutTask::from_styled_blockdecides whether aCodeBlockType::Mermaidblock becomesLayoutTask::MermaidDiagramorLayoutTask::MermaidCodeFallback, driven byRenderLayoutOptions::render_mermaid_diagramsandAssetCachestate.crates/editor/src/content/edit.rs (501-603)—EditDelta::layout_deltaiterates blocks with acurrent_offsetcounter that tracks each block's startCharOffset.crates/editor/src/content/mermaid_diagram.rs—mermaid_asset_sourceandmermaid_diagram_layout; constructs the asyncAssetSourcethat fetches and caches the SVG.crates/editor/src/render/model/mod.rs (201-204, 1649-1656)—RenderLayoutOptions(currentlyCopy) andRenderState::set_render_mermaid_diagrams.crates/editor/src/render/element/mermaid.rs—RenderableMermaidDiagramshows the loading placeholder, SVG, or error state and paints the footer in rendered mode.app/src/notebooks/editor/model.rs (148-161, 337-343)—render_mermaid_diagrams_in_stateand the interaction-state handler that sets the global flag.app/src/notebooks/editor/notebook_command.rs (582-686)—render_block_footerrenders the language dropdown, copy button, and run button; Mermaid blocks get no special UI today.app/src/notebooks/editor/view.rs—EditorViewActionenum,RichTextEditorView::handle_action, andwatch_layout_affecting_asset_loads, which eagerly watches layout-affecting Mermaid assets and rebuilds layout after they finish loading.app/src/view_components/markdown_toggle_view.rs—MarkdownToggleViewwrapsSegmentedControl<MarkdownDisplayMode>and emitsMarkdownToggleEvent::ModeSelected; already used byFileNotebookView.app/src/notebooks/file/mod.rs (78-81)—MarkdownDisplayModeenum (Rendered/Raw).
Current layout-time flow (Mermaid language tag, FeatureFlag::MarkdownMermaid enabled):
text.rsclassifies the block asCodeBlockType::Mermaid.edit.rsseesrender_mermaid_diagrams == trueand routes based onAssetCachestate:Loaded→MermaidDiagram,Loading→MermaidCodeFallbackwith pending asset,FailedToLoad→MermaidCodeFallback.RenderableMermaidDiagramshows "Rendering Mermaid diagram…" while loading, then SVG.mermaid_diagram_layoutpreviously usedwidth = min(available_width, intrinsic_svg_width)for loaded SVGs, and fell back to the raw code-block height when the SVG was not loaded. This caused rendered blocks to sometimes use the raw source text height and sometimes use SVG aspect-ratio height.
Key properties informing the design:
render_mermaid_diagramsis a globalboolinRenderLayoutOptions; there is no per-block rendering control today.EditDelta::layout_deltaalready trackscurrent_offsetper block — this can be threaded intofrom_styled_blockas ablock_start: CharOffsetparam to enable per-block lookup.MarkdownDisplayModeandMarkdownToggleViewalready exist and are reusable.
Proposed changes
1. Stop auto-rendering Mermaid in notebooks (default Raw)
Change NotebooksEditorModel::render_mermaid_diagrams_in_state to always return false. Remove (or no-op) the call to set_render_mermaid_diagrams in handle_interaction_state_model_event. This makes all Mermaid blocks default to code-block (Raw) view (Behavior invariants 3, 5, 8).
Non-notebook surfaces (plans, agent output) use RenderState instances that are separate from the notebook editor model. They are unaffected by this change to NotebooksEditorModel.
2. Add mermaid_render_offsets to RenderLayoutOptions
RenderLayoutOptions in crates/editor/src/render/model/mod.rs currently derives Copy. Add:
pub mermaid_render_offsets: std::collections::HashSet<string_offset::CharOffset>,
Remove Copy from the derive (a HashSet is not Copy). Change from_styled_block to take layout_options: &RenderLayoutOptions (reference) instead of by value. Update all call sites accordingly.
Add to RenderState:
pub fn set_mermaid_render_offsets(
&mut self,
offsets: std::collections::HashSet<string_offset::CharOffset>,
) -> bool { ... }
Returns true when the set changed (caller uses this to trigger relayout, same pattern as set_render_mermaid_diagrams).
3. Thread block_start into from_styled_block
Add block_start: CharOffset as a parameter to LayoutTask::from_styled_block. In EditDelta::layout_delta, pass the already-tracked current_offset as block_start when calling from_styled_block. Update the test helper layout_mermaid_block_for_test to pass CharOffset::zero() (or a suitable test offset).
Change the Mermaid routing condition from:
if layout_options.render_mermaid_diagrams && is_mermaid(...)
to:
if (layout_options.render_mermaid_diagrams
|| layout_options.mermaid_render_offsets.contains(&block_start))
&& is_mermaid(...)
4. Produce MermaidDiagram for all states when user opted in
For blocks in mermaid_render_offsets, always produce LayoutTask::MermaidDiagram regardless of AssetCache state (the render element will display the appropriate UI per state):
Loaded→ callmermaid_diagram_layout, which uses the full available code-block content width and derives height from the loaded SVG aspect ratio at that width.Loading→ callmermaid_diagram_layout, which uses the full available code-block content width and a stable placeholder height that is independent of raw source text height, then emitMermaidDiagram(render element shows loading placeholder).FailedToLoad→ callmermaid_diagram_layout, which uses the same full-width stable fallback dimensions, then emitMermaidDiagram(render element shows error message).
Empty source always falls through to MermaidCodeFallback regardless of mode (Behavior invariant 8 — cannot render empty).
For blocks NOT in mermaid_render_offsets, keep the existing logic (Loading → MermaidCodeFallback with pending asset, FailedToLoad → MermaidCodeFallback), which is the correct Raw behavior.
5. Show error message in RenderableMermaidDiagram
In crates/editor/src/render/element/mermaid.rs, extend RenderableMermaidDiagram::layout to check AssetCache for the block's asset_source:
AssetState::Loading→ existing "Rendering Mermaid diagram…"before_loadplaceholder.AssetState::Loaded→ existingImageelement (SVG render).AssetState::FailedToLoad→ create a text element with "Error rendering Mermaid diagram. Please check syntax." usingcode_textstyles andplaceholder_color, wrapped inAlign::finish(), stored inself.image_elementto reuse the same paint path. Draw the block cursor at the right edge of the rendered diagram when the selection head is on the Mermaid block, matching the existing horizontal rule and image affordance. Do not draw a text cursor over the diagram contents; the rendered frame itself is not text-editable.
6. Add mermaid_display_mode to NotebookCommand
Add to NotebookCommand:
mermaid_display_mode: MarkdownDisplayMode, // default: Raw
mermaid_toggle: ViewHandle<MermaidDisplayModeToggle>,
MermaidDisplayModeToggle is a thin new view (in notebook_command.rs) that wraps MarkdownToggleView. In its view-level subscription to MarkdownToggleEvent::ModeSelected(mode), it dispatches EditorViewAction::MermaidDisplayModeSelected { start_anchor, mode } (see §7).
In render_block_footer, when block_style == CodeBlockType::Mermaid, render two icon buttons directly (no separate view wrapper needed — see §10). This is always visible for Mermaid blocks regardless of editor focus (Behavior invariant 9).
7. Add EditorViewAction::MermaidDisplayModeSelected and handle it
Add to EditorViewAction in app/src/notebooks/editor/view.rs:
MermaidDisplayModeSelected {
start_anchor: Anchor,
mode: MarkdownDisplayMode,
},
In RichTextEditorView::handle_action for this variant:
- Resolve
start_anchorto aCharOffsetviaself.model.as_ref(ctx).buffer_selection_model().as_ref(ctx).resolve_anchor(&start_anchor). - Call
self.model.update(ctx, |model, ctx| model.set_mermaid_render_mode(offset, mode, ctx)).
NotebooksEditorModel::set_mermaid_render_mode(offset, mode, ctx):
- Find the
NotebookCommandatoffsetviaself.child_models.model_at::<NotebookCommand>(offset)and update itsmermaid_display_mode. - Recompute
mermaid_render_offsetsby iterating allNotebookCommandmodel handles and collecting offsets wheremermaid_display_mode == Rendered. - Let
changed = self.render_state.update(ctx, |rs, _| rs.set_mermaid_render_offsets(new_offsets)). - If
changed, callself.rebuild_layout(ctx).
8. Keep the Raw/Rendered toggle visible in Rendered mode (fix for toggle disappearing)
When a block switches to BlockItem::MermaidDiagram, renderable_blocks in crates/editor/src/render/element/mod.rs currently creates RenderableMermaidDiagram::new(item) with no footer. Fix:
-
In the
MermaidDiagramarm ofrenderable_blocks, fetchrunnable_commandthe same way theRunnableCodeBlockarm does:BlockItem::MermaidDiagram { .. } => { let start_offset = item.block_offset; let runnable_command = parent.runnable_command_at(start_offset, ctx); RenderableMermaidDiagram::new(item, runnable_command, self.display_options.focused, ctx).finish() } -
In
RenderableMermaidDiagram(crates/editor/src/render/element/mermaid.rs):- Add
footer: Box<dyn Element>field (same asRenderableRunnableCommand). - Accept
model: Option<&dyn RunnableCommandModel>,editor_is_focused: bool,ctx: &AppContextinnew(); create the footer viamodel.render_block_footer(editor_is_focused, ctx)orEmpty::new().finish()whenmodelisNone. - In
layout(), lay out the footer atSizeConstraint::strict(vec2f(content_width, BLOCK_FOOTER_HEIGHT)). - In
paint(), paint the footer atcontent_rect.lower_right() - vec2f(footer_width, 0)inside a higher z-index layer, matchingRenderableRunnableCommand. - In
after_layout()anddispatch_event(), delegate to the footer.
- Add
COMMAND_SPACING (used for Mermaid block layout) already reserves BLOCK_FOOTER_HEIGHT as padding.bottom, so the footer occupies space that is already accounted for in the block's total height — no layout dimension changes are needed.
This ensures the Raw/Rendered toggle (part of render_block_footer) remains visible regardless of whether the block renders as RunnableCodeBlock (Raw mode) or MermaidDiagram (Rendered mode), satisfying Behavior invariant 6.
9. Language icons in the code block dropdown (Behavior invariants 4–5)
Replace Dropdown::add_items (plain text labels) with Dropdown::set_rich_items (full MenuItem items) in NotebookCommand::new. Each item is built with MenuItemFields::new(code_block_type.to_string()).with_icon(icon_for_type(&code_block_type)).with_on_select_action(...).into_item().
Language → icon mapping (using existing bundled SVG assets via the Icon enum):
| Language | Icon |
|---|---|
| Shell | Icon::Terminal (terminal.svg) |
| Mermaid | Icon::Code1 (placeholder — no branded Mermaid SVG yet) |
| PowerShell | Icon::Powershell (powershell.svg) |
| Go | new Icon::GoLang (go.svg) |
| C++ | new Icon::CppLang (cpp.svg) |
| JavaScript | new Icon::JavaScriptLang (javascript.svg) |
| Python | new Icon::PythonLang (python.svg) |
| Rust | new Icon::RustLang (rust.svg) |
| SQL | new Icon::SqlLang (sql.svg) |
| JSON | new Icon::JsonLang (json.svg) |
| PHP | new Icon::PhpLang (php.svg) |
| Kotlin | new Icon::KotlinLang (kotlin.svg) |
| C#, Java, Ruby, YAML, Lua, Swift, Elixir, Scala, text | Icon::Code1 fallback |
Add the new variants (GoLang, CppLang, etc.) to the Icon enum in crates/warp_core/src/ui/icons.rs with the corresponding SVG paths.
Also increase the dropdown button width (set_top_bar_max_width) and menu width (set_menu_width) so language names and icons render without truncation.
10. Replace text toggle with icon buttons (Behavior invariants 6–10)
Remove MermaidDisplayModeToggle view and the mermaid_toggle field from NotebookCommand. Instead, render two icon buttons inline inside render_block_footer using block_footer_action_button-style construction:
// In render_block_footer, when block_style == CodeBlockType::Mermaid:
let is_raw = matches!(self.mermaid_display_mode, MarkdownDisplayMode::Raw);
let start_anchor_raw = self.start.clone();
let start_anchor_rendered = self.start.clone();
let raw_button = icon_button(appearance, Icon::Code1, is_raw, self.mouse_state_handles.mermaid_raw_button_state.clone())
.with_cursor(Cursor::Arrow).on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(&EditorViewAction::MermaidDisplayModeSelected {
start_anchor: start_anchor_raw.clone(),
mode: MarkdownDisplayMode::Raw,
});
}).finish();
let rendered_button = icon_button(appearance, Icon::Grid, !is_raw, self.mouse_state_handles.mermaid_rendered_button_state.clone())
.with_cursor(Cursor::Arrow).on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(&EditorViewAction::MermaidDisplayModeSelected {
start_anchor: start_anchor_rendered.clone(),
mode: MarkdownDisplayMode::Rendered,
});
}).finish();
Add mermaid_raw_button_state: MouseStateHandle and mermaid_rendered_button_state: MouseStateHandle to MouseStateHandles.
Using element-level click handlers that call ctx.dispatch_typed_action replaces the previous view-subscription mechanism, simplifying the implementation.
11. Mermaid block sizing and relayout (Behavior invariants 15–18)
In mermaid_diagram_layout (crates/editor/src/content/mermaid_diagram.rs):
- Remove the
default_heightparameter that was supplied from raw code-block layout. - Compute
max_width = layout.max_width() - spacing.x_axis_offset()and always use that value as the rendered frame width. - When the SVG is loaded, compute
height = max_width * intrinsic_svg_height / intrinsic_svg_width. - When the SVG is loading, failed, evicted, or lacks valid intrinsic dimensions, use a stable placeholder height equal to
base_line_height * 10.0. - Do not clamp loaded SVGs to their intrinsic width; a loaded rendered diagram should use the full available code-block content width.
The resulting shape is:
let width = max_width;
let height = loaded_svg_height_for_full_width
.unwrap_or((layout.rich_text_styles().base_line_height().as_f32() * 10.0).into_pixels());
In app/src/notebooks/editor/view.rs, keep eagerly watching layout-affecting BlockItem::MermaidDiagram asset loads and rebuild layout when they complete in Selectable, Editable, or EditableWithInvalidSelection. Rendered Mermaid blocks can now appear while the notebook is in editing mode, so restricting this rebuild to Selectable leaves the frame stuck at fallback dimensions until another unrelated layout event.
12. Keep markdown storage and classification unchanged
- Do not change
From<&CodeBlockText> for CodeBlockType. Block staysCodeBlockType::Mermaid. - Do not change
to_markdown_representation. Block serializes as```mermaid. - Do not change
CodeBlockType::all().Mermaidstays in the dropdown. mermaid_display_modeis transient (not saved to the notebook file); reopening always restores Raw (Behavior invariant 23).
Diagram
flowchart TD
A[User picks Mermaid from dropdown] --> B[Block in Raw mode by default]
B --> C[Footer shows Raw/Rendered toggle]
C --> D{User selects Rendered?}
D -- no --> E[LayoutTask::MermaidCodeFallback -> code-block view]
D -- yes --> F[offset added to mermaid_render_offsets, rebuild_layout]
F --> G[from_styled_block: offset in mermaid_render_offsets?]
G -- yes --> H[AssetCache state?]
H -- Loaded --> I[MermaidDiagram -> SVG]
H -- Loading --> J[MermaidDiagram -> loading placeholder]
H -- FailedToLoad --> K[MermaidDiagram -> error message]
G -- no --> E
Risks and mitigations
Risk: RenderLayoutOptions is no longer Copy — call sites break
Mitigation: Change from_styled_block to take &RenderLayoutOptions. The tasks are built sequentially before the parallel layout step, so a reference lifetime is safe. Update the test helper and any other call sites.
Risk: mermaid_render_offsets becomes stale when blocks are added, removed, or renumbered
Mitigation: NotebooksEditorModel::set_mermaid_render_mode recomputes the full set from current child models on every toggle. Child models' mermaid_display_mode defaults to Raw when a new NotebookCommand is created, so new blocks are always absent from the set. When a block is deleted, its NotebookCommand is dropped from child_models, and the next call to set_mermaid_render_mode (or a full recompute on rebuild_layout) will produce a set without the stale offset.
Risk: Error state blocks are not re-laid out when source changes
Mitigation: The MermaidCodeFallback path for Raw mode already watches pending assets. For Rendered-mode blocks with a failed asset, the block is in MermaidDiagram state. When the user edits the source, the buffer changes, rebuild_layout is called, and from_styled_block re-checks AssetCache for the new source hash. No special watcher is needed.
Risk: Loading rendered blocks keep fallback dimensions after the SVG finishes
Mitigation: Keep BlockItem::MermaidDiagram in layout_affecting_asset_load and rebuild layout when a watched asset load resolves in all notebook states where rendered Mermaid can be visible (Selectable, Editable, and EditableWithInvalidSelection).
Risk: Cost of calling mermaid_diagram_layout for Loading/Failed blocks in Rendered mode
Mitigation: mermaid_diagram_layout calls mermaid_diagram_size which only checks AssetCache state — it does not trigger a new render. If the asset is not loaded, it falls back to the stable placeholder height. There is no extra work.
Testing and validation
Layout unit tests in crates/editor/src/content/edit_tests.rs:
- Behavior invariants 5, 8: Mermaid block not in
mermaid_render_offsetslays out asBlockItem::RunnableCodeBlock, notBlockItem::MermaidDiagram. - Behavior invariants 11–13: Mermaid block in
mermaid_render_offsetswithAssetState::Loadedlays out asBlockItem::MermaidDiagram. - Behavior invariant 12: Mermaid block in
mermaid_render_offsetswithAssetState::Loadinglays out asBlockItem::MermaidDiagram(loading placeholder path). - Behavior invariant 14: Mermaid block in
mermaid_render_offsetswithAssetState::FailedToLoadlays out asBlockItem::MermaidDiagram(error path). - Behavior invariants 15–17: loaded Mermaid diagram layout uses full available width and derives height from the loaded SVG's aspect ratio at that width; unloaded Mermaid diagram layout uses full available width with stable placeholder height, not raw code-block height.
- Behavior invariant 22: markdown export for a Mermaid-labeled block emits
```mermaidregardless ofmermaid_render_offsets.
Notebook model tests in app/src/notebooks/editor/model_tests.rs:
- Behavior invariants 3, 5: setting a block's language to
Mermaidproduces aRunnableCodeBlockrender by default (notMermaidDiagram). - Behavior invariants 11–14: calling
set_mermaid_render_mode(offset, Rendered, ctx)updatesmermaid_render_offsetsand causes the block to lay out asMermaidDiagram. - Behavior invariants 5, 18: calling
set_mermaid_render_mode(offset, Raw, ctx)removes the offset and restores code-block layout. - Behavior invariant 23:
mermaid_display_modeisRawon a freshly createdNotebookCommand.
Manual verification per specs/GH549/PRODUCT.md:
- Add a code block, set language to Mermaid → block shows as code block with Raw/Rendered toggle defaulting to Raw.
- In Raw mode, place the cursor inside the Mermaid source and press Backspace/Delete → only the adjacent character is removed; the Mermaid block remains in place.
- Select Rendered → full-width diagram frame appears (loading, then SVG, or error if source is invalid). Toggle must remain visible inside the diagram frame. The loaded SVG should use the full available block width and derive height from that full-width render.
- While in Rendered mode, click inside the diagram frame → the cursor appears at the right edge of the rendered block, not over the diagram contents.
- Click Raw from the diagram view → code block view restored with toggle visible.
- Edit in Raw mode, then select Rendered again → new source is rendered.
- Save and reopen notebook → toggle resets to Raw.
Follow-ups
- Persist
mermaid_display_modeper block in the notebook file format so it survives reopen. - Consider unifying this toggle with plans/agent-output Mermaid rendering via
specs/mermaid-markdown-in-plans/. - Add a proper branded Mermaid SVG icon to the asset bundle and map it via
Icon::MermaidLang. - Add branded SVGs for languages currently falling back to
Icon::Code1(C#, Java, Ruby, YAML, Swift, Elixir, Scala).