konoma 0.28.5

Terminal file browser built for AI pair-programming — full-screen previews (Markdown, images, PDF, CSV), a git suite (jj/Jujutsu in preview), and an agent-watch mode that follows your AI's edits (macOS and Linux)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
//! Drawing a parsed state diagram — stage 2 of konoma's own mermaid renderer.
//!
//! ```text
//! StateDiagram ──▶ measure ──▶ size the glyphs ──▶ GraphSpec ──▶ (stage 1's layout) ──▶ SVG
//! ```
//!
//! # What this module is, and what it is not
//!
//! It is **not** a layout. `docs/FEATURE-MERMAID-RENDERER.md` §7 puts stage 2 at a cost of
//! "cheap" precisely because a state diagram is the same kind of picture as a flowchart — boxes,
//! arrows and frames — so the whole of [`super::lay_out_spec`] is reused unchanged: ranking,
//! ordering, coordinates, the compound frames, the clipping, the label placement, the viewBox.
//! Everything this file adds is the translation from one language's model to the
//! language-neutral [`GraphSpec`], plus the six glyphs a state diagram needs that a flowchart has
//! no word for (`shapes::Glyph`).
//!
//! That is deliberate and it is checkable: [`super::state_tests`] runs the same geometric
//! invariants over state sources that [`super::tests`] runs over flowchart sources, and they are
//! literally the same properties because they are properties of the same [`Diagram`].
//!
//! # The four decisions this file does make
//!
//! 1. **A composite state is a frame, not a box.** It is the only thing in the model that can be
//!    both an edge endpoint *and* a container, and stage 1d already solved that pair: an edge
//!    naming a block is laid out against a member and cut back to the frame.
//! 2. **A `--` region is an untitled dashed frame.** mermaid gives each region a group with no
//!    label; without a title there is nothing but the outline to say the regions are concurrent
//!    rather than nested, so konoma dashes it (§0-1 allows the difference).
//! 3. **A fork bar turns with the diagram.** `forkJoin.ts` sizes the bar from the layout
//!    direction, and it has to: a bar drawn along the flow is a line, not a bar.
//! 4. **A note is placed by hand, after the layout, on the side the author asked for.** This is
//!    the one place stage 2 does *not* follow mermaid. Upstream makes the note a node and hangs
//!    it off a link, which leaves dagre to decide where it goes: `left of` and `right of` only
//!    reverse the link's direction, so on a top-to-bottom diagram a `right of` note lands
//!    *below* its state and a `left of` note lands *above* it — and konoma's first attempt,
//!    which copied that, drew a note reading "this is the note to the left" on the right.
//!    §0-1's acceptance criterion is "the same source read correctly", and a picture that
//!    contradicts its own words fails it.
//!
//!    So notes never reach dagre at all (a note orders nothing, so it has no ranking meaning to
//!    give up), and [`place_notes`] puts each one beside its state afterwards. That is
//!    deterministic — no search, no bias, the side is always the one that was written — and it
//!    keeps notes from perturbing the state layout. It also stays checkable: the note boxes and
//!    their connectors are ordinary [`Diagram`] geometry by the time [`lay_out`] returns, so the
//!    same invariants run over them, plus one that is about the side specifically.
//!
//! `note over …` is **not** part of this grammar — `stateDiagram.jison` has `left of`,
//! `right of` and the floating `note "text" as id`, and nothing else — so a `note over A` is
//! dropped by the parser rather than guessed at.

use crate::preview::mermaid::flowchart::{Shape, Stroke};
use crate::preview::mermaid::layout::Point;
use crate::preview::mermaid::state::{self, Direction, Kind, NotePosition, StateDiagram};
use crate::preview::mermaid::text_metrics;

use super::edges::Tip;
use super::orthogonal;
use super::shapes::{self, Glyph, Size};
use super::svg;
use super::{
    lay_out_spec, Curve, Diagram, GraphSpec, Label, PlacedEdge, PlacedNode, RenderError, Routing,
    SpecBlock, SpecEdge, SpecNode, Theme,
};

/// Blank space between a note and the state it belongs to, in px. Also the step a note is pushed
/// by when the place it wanted is taken.
pub const NOTE_GAP: f64 = 24.0;

/// §10-5 S4: a choice draws as a chamfered square this wide/tall under orthogonal routing —
/// smaller than [`shapes::CHOICE_SIZE`] (splines' own diamond, 40px), and drawn as
/// [`Glyph::ChamferedRect`] rather than [`Glyph::Choice`], for the same reason the flowchart's
/// own `spec_of` swaps a decision `Shape::Diamond` for one: a diamond has no flat run for
/// [`super::orthogonal::evict`] to spread more than one port along, only a point at each vertex.
pub(super) const STATE_CHOICE_ORTHO_SIZE: f64 = 28.0;

/// §10-5 S4: a fork/join bar's thickness under orthogonal routing — thinner than splines' own
/// [`shapes::BAR_THICKNESS`] (10px), which stays exactly as `forkJoin.ts` draws it. The bar's
/// *length* (the dimension along the cross axis) is not a fixed constant at all — see
/// `spec_of`'s own use of this alongside [`orthogonal::BAR_PORT_PAD`].
const BAR_THICKNESS_ORTHO: f64 = 6.0;

/// How many times a note may be pushed further out before konoma gives up and leaves it where it
/// is. Every push clears at least one box, so a diagram would have to hold this many boxes in one
/// row to reach the bound.
const NOTE_PUSH_LIMIT: usize = 200;

/// Reads a mermaid state-diagram source and draws it.
///
/// **This is the entry point the golden tests go through**, so that what they pin is what a
/// caller gets — `docs/FEATURE-MERMAID-RENDERER.md` §6, after konoma was once caught pinning a
/// function that was not the one in production.
///
/// `theme` is `ui.mermaid_theme`'s raw string; an unknown value silently means `dark`. Routes
/// every edge under [`Routing::Splines`] — the same, byte-stable path every caller of this
/// function has always gone through. [`render_flow`] is the `[ui] mermaid_routing`-aware sibling
/// (`docs/FEATURE-MERMAID-RENDERER.md` §10-5), kept apart the same way
/// [`super::render`]/[`super::render_flow`] are for a flowchart.
pub fn render(code: &str, theme: &str) -> Result<String, RenderError> {
    render_flow(code, theme, "splines")
}

/// [`render`], with a state diagram's edges routed by `routing` — `[ui] mermaid_routing`'s raw
/// string. `"splines"` reproduces [`render`] exactly, byte for byte; `"konoma-orthogonal"` is
/// §10-5's extension of the flowchart's own right-angle wiring mode to `stateDiagram-v2`. This is
/// what `App::media_load`/`md_media` actually call, since a media loader knows `[ui]
/// mermaid_routing` for every diagram kind, not just the flowchart.
pub fn render_flow(code: &str, theme: &str, routing: &str) -> Result<String, RenderError> {
    let diagram = state::parse(code)?;
    let routing = Routing::parse(routing);
    let laid = lay_out(&diagram, routing)?;
    Ok(svg::emit(&laid, &Theme::for_routing(theme, routing)))
}

/// Measures, sizes, lays out and routes a parsed state diagram.
pub fn lay_out(diagram: &StateDiagram, routing: Routing) -> Result<Diagram, RenderError> {
    // The gate. usvg does not fail on a missing font, it just drops the glyphs, so the only place
    // this can be caught is before anything is built (PRD design principle #3).
    if !text_metrics::fonts_available() {
        return Err(RenderError::NoFonts);
    }
    if diagram.states.is_empty() {
        return Err(RenderError::NothingToDraw);
    }
    let mut out = lay_out_spec(&spec_of(diagram, routing))?;
    place_notes(&mut out, diagram);
    // §10-5 S2: every composite-state frame draws its title as a left-aligned strip under
    // `konoma-orthogonal`, never the plain centred word `read_clusters`'s own generic construction
    // (shared with a flowchart subgraph) leaves it with — set here, once, over every cluster this
    // diagram produced, rather than threading a "which language is this" flag down into `mod.rs`'s
    // shared cluster machinery. Splines is untouched: `PlacedCluster::title_strip` starts `false`
    // and nothing before this line ever sets it.
    if routing == Routing::Orthogonal {
        for c in &mut out.clusters {
            c.title_strip = true;
        }
    }
    Ok(out)
}

/// The whole of what is specific to the state-diagram language.
///
/// `routing` is `[ui] mermaid_routing`, resolved — `Routing::Splines` (every caller before
/// `docs/FEATURE-MERMAID-RENDERER.md` §10-5, and every caller of [`render`]/[`lay_out`] with
/// `"splines"` today) reproduces the drawing exactly as before this parameter existed;
/// `Routing::Orthogonal` is §10-5's own extension.
pub fn spec_of(diagram: &StateDiagram, routing: Routing) -> GraphSpec {
    let horizontal_bars = matches!(
        diagram.direction,
        Direction::TopToBottom | Direction::BottomToTop
    );

    let mut nodes: Vec<SpecNode> = Vec::new();
    let mut blocks: Vec<SpecBlock> = Vec::new();
    for s in &diagram.states {
        // A note is not laid out. See the module docs: it carries no ordering meaning, and
        // leaving it to dagre is what put it on the wrong side.
        if s.kind == Kind::Note {
            continue;
        }
        if s.kind.is_block() {
            blocks.push(SpecBlock {
                id: s.id.clone(),
                title: s.label.clone(),
                members: s.members.clone(),
                // A `--` region has no title, so the outline is the only thing that can say what
                // it is; a composite state has one and does not need the hint.
                dashed: s.kind == Kind::Concurrent,
            });
            continue;
        }
        let glyph = glyph_of(s.kind, s.titled, horizontal_bars);
        // §10-5 S4: under orthogonal routing a choice is a fixed 28x28 chamfered square, not the
        // 40px diamond splines draws — see `STATE_CHOICE_ORTHO_SIZE`'s own doc. A choice draws no
        // label either way (`glyph_of`'s own doc), so the override does not need the label size.
        let (glyph, label, size) = if routing == Routing::Orthogonal && s.kind == Kind::Choice {
            (
                Glyph::ChamferedRect,
                Label::measure(&s.label),
                Size::new(STATE_CHOICE_ORTHO_SIZE, STATE_CHOICE_ORTHO_SIZE),
            )
        } else if routing == Routing::Orthogonal && matches!(s.kind, Kind::Fork | Kind::Join) {
            // §10-5 S4 ("長さ=接続先トランクspan+両端各16px…厚み6px"): the bar's own true length
            // is not knowable here — it depends on where dagre eventually places the trunks this
            // bar connects to, which has not run yet — so this starts at the *smallest* length the
            // rule can ever produce (a zero-wide span still keeps its two 16px end pads) and lets
            // `lay_out_spec`'s own growth retry (`mod.rs`'s `bar_required_sizes`, the same
            // "lay out, measure, grow, lay out again" loop §10-1 item 1's port eviction already
            // uses) widen it to the real span once the first pass has real trunk positions to
            // measure. Starting small, rather than at splines' own `shapes::BAR_LENGTH` (70px), is
            // what lets that monotonic "only grows" loop converge on the *true* minimum instead of
            // being stuck wherever an oversized guess happened to start.
            let min_length = 2.0 * orthogonal::BAR_PORT_PAD;
            let horizontal = matches!(glyph, Glyph::Bar { horizontal: true });
            (
                glyph,
                Label::measure(&s.label),
                if horizontal {
                    Size::new(min_length, BAR_THICKNESS_ORTHO)
                } else {
                    Size::new(BAR_THICKNESS_ORTHO, min_length)
                },
            )
        } else if let Some((label, size)) = (routing == Routing::Orthogonal)
            .then(|| shapes::orthogonal_node(glyph, &s.label))
            .flatten()
        {
            // §10-8 N1–N5: an ordinary state box — nested in a composite or not, N5 draws no
            // distinction — is 36px tall on an 8px width grid, exactly as a flowchart node is.
            // The three glyphs above have their own sizes (N3's own exclusions) and never reach
            // here; `orthogonal_node` returns `None` for a note, which is not laid out at all.
            (glyph, label, size)
        } else {
            let label = Label::measure(&s.label);
            let size = shapes::size(glyph, shapes::Size::new(label.width, label.height));
            (glyph, label, size)
        };
        nodes.push(SpecNode {
            id: s.id.clone(),
            glyph,
            label,
            size,
            panel: None,
            style: None,
            has_class: true,
        });
    }

    // dagre lays a compound graph out over its *leaves*, and a frame's rectangle is read back
    // from the border nodes hung off them. A block listed before its members would still work —
    // `Tree::from_blocks` resolves the nesting by id — but keeping the parser's order means the
    // frames come out innermost-first, which is the order `read_clusters` sorts by depth anyway.
    //
    // §10-5 S4's own clearance fix (2026-09-02, doubled `minlen` for every block-to-bar
    // transition) is **removed** (2026-09-03): it was papering over `clusters::Tree::anchor`
    // picking the wrong member, not a real rank-clearance shortfall. The bug it worked around —
    // `処理 -> join_state`'s join-bar port landing at the same rank depth as `処理`'s own member
    // `整形`, so the only perpendicular approach ran straight through `整形`'s box — happened
    // because the anchor `anchor` chose for a block-named edge's *source* end was whichever member
    // was declared first (`整形`, a shallow, non-terminal one), not whichever member the diagram's
    // own flow actually exits from. §10-5's own directional anchor fix (`AnchorRole::Exit` picks a
    // sink — no outgoing edge to another descendant, checked transitively through nested blocks)
    // now anchors `処理 -> join_state` at `集計`, the diagram's own true last member, which sits at
    // its own natural rank depth with nothing of `処理`'s to overlap — the doubled `minlen` is no
    // longer needed to buy clearance the anchor fix already gives for free, and removing it lets
    // dagre's own ranking place `監査`'s free-standing branch beside `処理` again instead of
    // stretched an extra rank down to fill the gap the doubling asked for (found by diffing
    // `zz-design-4c-ours.png` against the design reference with and without this block — every
    // orthogonal invariant test stays green either way, confirming this was never a structural
    // rank requirement, only a stand-in for the anchor bug).
    let mut edges: Vec<SpecEdge> = diagram
        .transitions
        .iter()
        .filter(|t| !t.is_note_link)
        .map(|t| SpecEdge {
            id: t.id.clone(),
            from: t.from.clone(),
            to: t.to.clone(),
            label: t
                .label
                .as_deref()
                .map(Label::measure)
                .filter(|l| !l.is_blank()),
            tip_start: Tip::None,
            tip_end: Tip::Arrow,
            stroke: Stroke::Normal,
            minlen: 1,
            start_label: None,
            end_label: None,
            style: None,
            curve: Curve::Basis,
        })
        .collect();

    // §10-5 S1 ("複数辺は認めない…終了に n 本入るならマーカーを n 個複製"): only under
    // orthogonal routing — splines keeps mermaid's own shared single dot/ring unchanged.
    if routing == Routing::Orthogonal {
        duplicate_multi_edge_markers(diagram, &mut nodes, &mut edges);
    }

    GraphSpec {
        direction: diagram.direction,
        nodes,
        edges,
        blocks,
        routing,
        // §10-5 S3: only under orthogonal routing — splines keeps the pre-existing dagre-derived
        // self-loop shape unchanged, the same gate S1's marker duplication just above uses.
        fixed_self_loops: routing == Routing::Orthogonal,
    }
}

/// §10-5 S1: a start/end marker more than one transition shares is drawn as one marker **per**
/// transition under orthogonal routing, each with its own single pole port.
///
/// mermaid's own `docTranslator` (`crate::preview::mermaid::state::parser`'s module doc) merges
/// every `[*] -->` written in one scope into a single start dot and every `--> [*]` into a single
/// end dot — correct for splines, where dagre is free to fan several waypoints out of (or into)
/// one node. Under `Routing::Orthogonal` that would mean [`super::orthogonal::evict`] spreading
/// more than one port along the marker's flow-axis face, which S1 forbids outright ("複数辺は
/// 認めない") — a marker's only valid port is the pole itself. So instead, a marker with more than
/// one attached transition here is replaced by that many separate marker nodes, one per
/// transition, each keeping the original's glyph/size/style and taking exactly the one edge — the
/// ordinary single-claim path through `evict` then lands every one of them dead on its own pole
/// (`evict`'s own "n == 1" case never has anything to offset from face-centre).
///
/// A marker with at most one transition (the overwhelmingly common case — most `[*]`s in a
/// diagram open or close exactly one path) is left exactly as `spec_of` already built it: this
/// only ever changes node/edge *counts*, so `every_state_and_transition_survives`'s box-count
/// assertion has to run against `model.boxes()` re-counted the same way this function counts,
/// which is why that test (and every other structural one) always renders `Routing::Splines`.
fn duplicate_multi_edge_markers(
    diagram: &StateDiagram,
    nodes: &mut Vec<SpecNode>,
    edges: &mut [SpecEdge],
) {
    for s in &diagram.states {
        let is_start = s.kind == Kind::Start;
        let is_end = s.kind == Kind::End;
        if !is_start && !is_end {
            continue;
        }
        // A start marker's `[*]` is only ever the *source* of a transition, an end marker's only
        // ever the *target* — `state::parser`'s own `rename_edge_state` translates the two
        // directions to two different ids, so there is no case where the same marker id needs
        // both counted at once.
        let matching: Vec<usize> = edges
            .iter()
            .enumerate()
            .filter(|(_, e)| {
                if is_start {
                    e.from == s.id
                } else {
                    e.to == s.id
                }
            })
            .map(|(i, _)| i)
            .collect();
        if matching.len() <= 1 {
            continue;
        }
        let Some(base_pos) = nodes.iter().position(|n| n.id == s.id) else {
            continue;
        };
        let base = nodes.remove(base_pos);
        for (i, &edge_idx) in matching.iter().enumerate() {
            let dup_id = format!("{}__ortho{i}", s.id);
            nodes.push(SpecNode {
                id: dup_id.clone(),
                ..base.clone()
            });
            if is_start {
                edges[edge_idx].from = dup_id;
            } else {
                edges[edge_idx].to = dup_id;
            }
        }
    }
}

/// Puts every note beside the state it belongs to, on the side the source asked for.
///
/// Runs on the finished geometry, so it can be stated as a rule rather than as a hope:
///
/// 1. the note's inner edge sits one [`NOTE_GAP`] clear of its state's box, on the requested
///    side, and its centre starts on the state's own centre line;
/// 2. if that place is taken — by a state, or by a note already placed — the note **slides down**
///    until it is clear. It never moves sideways, so the side the reader was told about is always
///    the side they see, and it stays next to its state instead of being pushed past the whole
///    row. (Sliding *out* was the first attempt: on a left-to-right diagram it sent a note the
///    width of the picture away and dragged its connector across every box in between.)
///    What this does *not* promise is that the connector stays clear of every other box; on a
///    left-to-right diagram "to the right of" is the next rank's column, and a line reaching it
///    can pass a state on the way. That is the price of honouring the word the author wrote, and
///    it is recorded rather than hidden — see
///    [`super::state_tests::a_notes_connector_can_cross_a_state_in_a_crowded_diagram`].
/// 3. the connector is drawn from the note's boundary to the state's, dotted and headless, so it
///    cannot be read as a transition.
///
/// Notes are placed in the order they were written, which is what makes two notes on one state
/// come out in a stable order.
fn place_notes(out: &mut Diagram, model: &StateDiagram) {
    for note in model.states.iter().filter(|s| s.kind == Kind::Note) {
        let Some(position) = note.note_position else {
            continue;
        };
        // Which state it belongs to, and which way its connector was written: the parser makes
        // the tie-line point at the state for `left of` and away from it for `right of`, and the
        // drawn line keeps that so the model and the picture say the same thing.
        let Some(link) = model
            .transitions
            .iter()
            .find(|t| t.is_note_link && (t.from == note.id || t.to == note.id))
        else {
            continue;
        };
        let anchor_id = if link.from == note.id {
            link.to.clone()
        } else {
            link.from.clone()
        };
        // A note may name a composite state, which is a frame rather than a box.
        let Some((anchor_center, anchor_size, anchor_glyph)) = out
            .node(&anchor_id)
            .map(|n| (n.center.clone(), n.size, n.shape))
            .or_else(|| {
                out.cluster(&anchor_id)
                    .map(|c| (c.center.clone(), c.size, Glyph::Flow(Shape::Rect)))
            })
        else {
            continue;
        };

        let label = Label::measure(&note.label);
        let size = shapes::size(Glyph::Note, Size::new(label.width, label.height));
        let sign = match position {
            NotePosition::Right => 1.0,
            NotePosition::Left => -1.0,
        };
        let mut center = Point::new(
            anchor_center.x + sign * (anchor_size.w / 2.0 + NOTE_GAP + size.w / 2.0),
            anchor_center.y,
        );
        center = Point::new(center.x, free_row(out, &center, size));
        let note_point = shapes::intersect(Glyph::Note, center.clone(), size, &anchor_center);
        let anchor_point = shapes::intersect(anchor_glyph, anchor_center, anchor_size, &center);
        let points = if link.from == note.id {
            vec![note_point, anchor_point]
        } else {
            vec![anchor_point, note_point]
        };

        out.nodes.push(PlacedNode {
            id: note.id.clone(),
            shape: Glyph::Note,
            center,
            size,
            label,
            panel: None,
            series: None,
            mark: None,
            style: None,
        });
        out.edges.push(PlacedEdge {
            from: link.from.clone(),
            to: link.to.clone(),
            points,
            gaps: Vec::new(),
            tip_start: Tip::None,
            tip_end: Tip::None,
            stroke: Stroke::Dotted,
            label: None,
            start_label: None,
            end_label: None,
            badge: None,
            series: None,
            straight: false,
            overlay: false,
            style: None,
            curve: Curve::Basis,
            tip_matches_line: false,
        });
    }
    // The drawing grew sideways, so the extent has to be recomputed over what is now on it.
    super::normalise(out);
}

/// How many rows the slide may look at. Every step passes at least one box, so a diagram would
/// need this many boxes stacked in one column to reach the bound.
const SLIDE_STEPS: usize = 200;

/// The `y` to put a note at: its state's own centre line, or — if a box is already there — the
/// first row below it that is free.
///
/// This is a guarantee, not a preference. Sliding downward past one box at a time is monotone and
/// eventually leaves the drawing behind, where nothing is in the way, so a note's box **never**
/// lands on a state's. What it does not guarantee is the *connector*: see
/// [`super::state_tests::a_notes_connector_can_cross_a_state_in_a_crowded_diagram`].
///
/// Two things were tried here and taken back out, both because they never once changed an answer
/// and a check that decides nothing is worse than no check — it reads as a guarantee:
///
/// * scanning **upward** as well and taking whichever row is nearer. The nearer row was always
///   the one below, over every source in [`super::state_tests::CASES`] and a dozen more.
/// * preferring a row whose **connector** crosses nothing. Measured the same way: the first free
///   row already satisfied it wherever any row did, so the preference never chose differently.
fn free_row(out: &Diagram, center: &Point, size: Size) -> f64 {
    let mut y = center.y;
    for _ in 0..SLIDE_STEPS {
        let Some(clear_by) = box_overlap(out, &Point::new(center.x, y), size) else {
            break;
        };
        y += clear_by.max(0.0) + NOTE_GAP;
    }
    y
}

/// How far a note at `center` would have to move down for the first thing it lands on to be clear
/// of it, or `None` when it lands on nothing.
///
/// Two things count. A **state's box**, obviously. And a **transition's line**: a note is opaque
/// and is drawn over the edges, so one dropped on a line hides part of it — and an arrow whose
/// shaft disappears halfway is worse than a note further from its state.
///
/// A *frame* does not count. A note drawn over one is still legible, and treating a frame as an
/// obstruction would eject a note from the block its state lives in, which puts it further from
/// what it is about than a frame's fill ever puts it out of reach.
fn box_overlap(out: &Diagram, center: &Point, size: Size) -> Option<f64> {
    let (l, t, r, b) = (
        center.x - size.w / 2.0,
        center.y - size.h / 2.0,
        center.x + size.w / 2.0,
        center.y + size.h / 2.0,
    );
    let on_a_box = out.nodes.iter().find_map(|n| {
        let (nl, nt, nr, nb) = n.bounds();
        let dx = nr.min(r) - nl.max(l);
        let dy = nb.min(b) - nt.max(t);
        (dx > 0.01 && dy > 0.01).then_some(dy)
    });
    if on_a_box.is_some() {
        return on_a_box;
    }
    // The lowest point at which a line passes through the box is how far down the note has to go
    // to be under it — the same shape of answer a box gives, so the caller's slide stays monotone.
    let mut lowest: Option<f64> = None;
    for e in &out.edges {
        for w in e.drawn_points().windows(2) {
            let len = (w[1].x - w[0].x).hypot(w[1].y - w[0].y);
            let steps = ((len * 2.0).ceil() as usize).clamp(1, 4000);
            for i in 0..=steps {
                let t_i = i as f64 / steps as f64;
                let (x, y) = (
                    w[0].x + t_i * (w[1].x - w[0].x),
                    w[0].y + t_i * (w[1].y - w[0].y),
                );
                if x > l + 0.01 && x < r - 0.01 && y > t + 0.01 && y < b - 0.01 {
                    lowest = Some(lowest.map_or(y, |cur: f64| cur.max(y)));
                }
            }
        }
    }
    lowest.map(|y| y - t)
}

/// What each kind of state is drawn as.
fn glyph_of(kind: Kind, titled: bool, horizontal_bars: bool) -> Glyph {
    match kind {
        Kind::Start => Glyph::StateStart,
        Kind::End => Glyph::StateEnd,
        Kind::Choice => Glyph::Choice,
        // `forkJoin.ts` reads the layout direction: the bar lies across the flow, so a
        // top-to-bottom diagram wants a wide one and a left-to-right diagram a tall one.
        Kind::Fork | Kind::Join => Glyph::Bar {
            horizontal: horizontal_bars,
        },
        Kind::Note => Glyph::Note,
        // A state written with two or more descriptions keeps the first apart from the rest with
        // a rule; one description is an ordinary box (mermaid's own fix-up at the end of
        // `extract` turns a single-entry `rectWithTitle` back into a `rect`).
        Kind::Simple if titled => Glyph::TitledBox,
        // The rounded rectangle every state diagram is mostly made of. konoma draws it with the
        // radius its flowchart uses for `A(text)` rather than mermaid's own 10, so the two
        // diagram kinds look like they came from one hand.
        Kind::Simple => Glyph::Flow(Shape::RoundedRect),
        // Unreachable: a block never reaches here (`spec_of` takes it out first). Drawing it as
        // an ordinary box rather than panicking is the honest answer if that ever changes.
        Kind::Composite | Kind::Concurrent => Glyph::Flow(Shape::RoundedRect),
    }
}