Skip to main content

gpui_whiteboard/
lib.rs

1//! An infinite, pannable/zoomable whiteboard canvas for GPUI.
2//!
3//! Host-agnostic — depends only on `gpui`, `serde`, and `ttf-parser` (no
4//! `gpui-component`, no native libraries). Two layers: a serializable scene model
5//! ([`Scene`] / [`Element`]) the host persists as opaque JSON, and a
6//! [`WhiteboardView`] entity that renders the board *and* its editing UI (toolbar,
7//! color picker, flyouts, templates gallery, context menu) and drives all
8//! interaction. The host supplies a theme ([`WhiteboardStyle`]) and optional
9//! callbacks (persist on change, open a page, fetch an image bitmap, read/write the
10//! clipboard, store templates); with none installed it's still a working board.
11//!
12//! Elements: freehand pen, rect / ellipse / diamond / triangle / rounded-rect /
13//! hexagon / star, line, arrow, text, images, and page-cards — sharing one select /
14//! move / resize / rotate / fill / z-order machinery, plus copy-paste, templates,
15//! and undo/redo. Text renders as **vector outlines** (the `font` module, via
16//! `ttf-parser`) rather than gpui overlay glyphs, so it rotates + scales with the
17//! camera and a host can supply a custom face ([`Font`]). See `README.md` for the
18//! full API and usage; design notes in `docs/whiteboard-architecture.md`.
19//!
20//! Perf note: element geometry is re-tessellated when painted (as GPUI's own
21//! `painting`/`brush` examples do), but rendering is viewport-culled and text
22//! glyph layouts are cached. A built-`Path` cache remains a further optimization
23//! for extremely dense visible scenes.
24
25mod font;
26mod geometry;
27mod input;
28mod paint;
29mod render_perf;
30mod scene;
31
32use std::cell::Cell;
33use std::collections::{HashMap, HashSet};
34use std::hash::{DefaultHasher, Hash, Hasher};
35use std::ops::Range;
36use std::rc::Rc;
37use std::sync::Arc;
38
39pub use font::Font;
40use geometry::*;
41use input::WhiteboardInputElement;
42use paint::*;
43use render_perf::WorldViewport;
44pub use scene::*;
45
46use gpui::{
47    AnyElement, AnyView, App, AppContext, Bounds, Context, CursorStyle, Div, ElementId,
48    ElementInputHandler, Entity, EntityInputHandler, FocusHandle, GlobalElementId, Hsla,
49    InspectorElementId, InteractiveElement, IntoElement, KeyDownEvent, LayoutId, MouseButton,
50    MouseDownEvent, MouseMoveEvent, MouseUpEvent, ObjectFit, ParentElement, PathBuilder,
51    PinchEvent, Pixels, Point, Render, Rgba, ScrollDelta, ScrollWheelEvent, SharedString,
52    StatefulInteractiveElement, Style, Styled, StyledImage, TransformationMatrix, UTF16Selection,
53    Window, canvas, div, fill, hsla, linear_color_stop, linear_gradient, point, px, relative, rgba,
54    size,
55};
56use serde::{Deserialize, Serialize};
57
58/// Zoom is clamped to this range (also guards the world↔screen math against a
59/// zero/negative factor from hand-edited JSON).
60const MIN_ZOOM: f32 = 0.1;
61const MAX_ZOOM: f32 = 8.0;
62/// World-space distance between grid dots.
63const GRID: f32 = 24.0;
64/// Smallest on-screen dot spacing before the grid is coarsened (×4).
65const MIN_DOT_SPACING: f32 = 16.0;
66/// Dot size in screen px (constant — dots don't grow with zoom).
67const DOT: f32 = 2.0;
68/// Screen px per scroll "line" for inexact (`Lines`) scroll deltas.
69const LINE_PX: f32 = 16.0;
70const VIEWPORT_CULL_MARGIN_PX: f32 = 96.0;
71
72fn accepts_wheel_input(read_only: bool) -> bool {
73    !read_only
74}
75/// Pen nib in screen px. A stored width is world-space (`NIB / zoom` at draw
76/// time) so strokes/shapes feel like a constant nib yet scale with the content.
77/// Also the default of [`WhiteboardView::active_width`].
78const NIB: f32 = 2.5;
79/// Stroke-thickness presets (screen px) offered by the toolbar thickness flyout.
80/// `NIB` (the default) is one of them.
81const WIDTH_PRESETS: [f32; 5] = [1.0, 2.5, 4.0, 6.0, 9.0];
82/// Range (screen px) of the custom-width slider in the thickness flyout.
83const WIDTH_MIN: f32 = 1.0;
84const WIDTH_MAX: f32 = 20.0;
85/// Slider track width, px (matches the preset row: 5 × 30 + gaps).
86const WIDTH_SLIDER_W: f32 = 156.0;
87/// Minimum on-screen gap between recorded freehand points (input thinning).
88const MIN_POINT_PX: f32 = 2.0;
89/// Hit-test tolerance around an element's bounds, in screen px.
90const SELECT_PAD: f32 = 6.0;
91/// Most undo steps kept (bounds memory; each step is a scene snapshot).
92const UNDO_CAP: usize = 50;
93/// Half-size of a corner resize handle, screen px.
94const HANDLE_HALF: f32 = 4.0;
95/// Grab radius for a corner handle, screen px.
96const HANDLE_GRAB: f32 = 10.0;
97/// Distance in screen pixels from a selected shape edge to its connector button.
98const CONNECTOR_BUTTON_GAP: f32 = 24.0;
99const CONNECTOR_BUTTON_SIZE: f32 = 20.0;
100/// Color picker: saturation/brightness square + hue strip dimensions, px.
101const SV_W: f32 = 216.0;
102const SV_H: f32 = 140.0;
103const HUE_H: f32 = 14.0;
104/// Below this absolute rotation (radians), a box is treated as upright — it
105/// shows resize corners. Rotated past it, only the rotate handle is offered
106/// (rotated-frame resize is intentionally out of scope; rotate back to resize).
107const ROT_EPS: f32 = 0.05;
108/// While rotating, an orientation within this many radians (~6°) of horizontal
109/// or vertical snaps to it, so boxes square up to the grid easily.
110const ROT_SNAP: f32 = 0.105;
111/// Default text size at creation, screen px (stored world size is this / zoom).
112const TEXT_SIZE: f32 = 18.0;
113
114/// Inset (world units) kept between a shape's inscribed text rectangle and its
115/// border, so the auto-shrunk label never touches the edge.
116const LABEL_PAD: f32 = 8.0;
117
118/// Default highlighter color (packed `0xRRGGBBAA`) for the highlight toggle —
119/// translucent yellow so the text stays readable.
120const HIGHLIGHT_DEFAULT: u32 = 0xffe06680;
121/// Rough per-character advance and line height, as fractions of the font size,
122/// for an approximate text bounding box (hit-testing / selection).
123const TEXT_CHAR_W: f32 = 0.55;
124const TEXT_LINE_H: f32 = 1.3;
125/// Default page-card size at creation, screen px (stored world size is / zoom).
126const EMBED_W: f32 = 210.0;
127const EMBED_H: f32 = 76.0;
128const MINDMAP_ROOT_W: f32 = 196.0;
129const MINDMAP_ROOT_H: f32 = 60.0;
130const MINDMAP_NODE_W: f32 = 164.0;
131const MINDMAP_NODE_H: f32 = 48.0;
132const MINDMAP_BRANCH_GAP_X: f32 = 120.0;
133const MINDMAP_BRANCH_GAP_Y: f32 = 84.0;
134const FLOWCHART_NODE_W: f32 = 180.0;
135const FLOWCHART_NODE_H: f32 = 52.0;
136const FLOWCHART_GAP_Y: f32 = 92.0;
137const FLOWCHART_BRANCH_GAP_X: f32 = 240.0;
138/// Longest edge of a freshly placed image, screen px (aspect preserved).
139const IMAGE_PLACE_PX: f32 = 280.0;
140
141/// The active tool. UI state — not part of the persisted scene.
142#[derive(Clone, Copy, PartialEq, Eq, Debug)]
143pub enum Tool {
144    /// Drag to pan the canvas (the default — navigation before drawing).
145    Pan,
146    Select,
147    Pen,
148    Rect,
149    Ellipse,
150    Diamond,
151    Triangle,
152    RoundRect,
153    Star,
154    Hexagon,
155    Line,
156    Arrow,
157    DashedArrow,
158    Text,
159    MindMap,
160    Flowchart,
161    Embed,
162    Image,
163}
164
165impl Tool {
166    /// A glyph for the toolbar button (dependency-free; the host has no icon set
167    /// in this crate).
168    fn glyph(self) -> &'static str {
169        match self {
170            // A dingbat hand (pre-emoji, so it always renders flat/monochrome —
171            // unlike ✋, which macOS re-colors even with a VS15 text request).
172            Tool::Pan => "☞",
173            Tool::Select => "↖",
174            Tool::Pen => "✎",
175            Tool::Rect => "▭",
176            Tool::Ellipse => "◯",
177            Tool::Diamond => "◇",
178            Tool::Triangle => "△",
179            Tool::RoundRect => "▢",
180            Tool::Star => "☆",
181            Tool::Hexagon => "⬡",
182            Tool::Line => "╱",
183            Tool::Arrow => "↗",
184            Tool::DashedArrow => "⇢",
185            Tool::Text => "T",
186            Tool::MindMap => "◎",
187            Tool::Flowchart => "⇅",
188            Tool::Embed => "▤",
189            Tool::Image => "▦",
190        }
191    }
192
193    /// A human label for the tooltip (the toolbar is icon-only), with the
194    /// keyboard shortcut where one exists (see [`shortcut`](Tool::shortcut)).
195    fn label(self) -> &'static str {
196        match self {
197            Tool::Pan => "Pan — drag to move (H)",
198            Tool::Select => "Select (V)",
199            Tool::Pen => "Pen (P)",
200            Tool::Rect => "Rectangle (R)",
201            Tool::Ellipse => "Ellipse (O)",
202            Tool::Diamond => "Diamond (D)",
203            Tool::Triangle => "Triangle (G)",
204            Tool::RoundRect => "Rounded rectangle (U)",
205            Tool::Star => "Star (S)",
206            Tool::Hexagon => "Hexagon (X)",
207            Tool::Line => "Line (L)",
208            Tool::Arrow => "Arrow (A)",
209            Tool::DashedArrow => "Dashed arrow (K)",
210            Tool::Text => "Text (T)",
211            Tool::MindMap => "Mind map (M)",
212            Tool::Flowchart => "Flowchart (F)",
213            Tool::Embed => "Page card",
214            Tool::Image => "Image (I) — click to place",
215        }
216    }
217
218    /// The single-key shortcut that selects this tool, if any.
219    fn shortcut(key: &str) -> Option<Tool> {
220        Some(match key {
221            "h" => Tool::Pan,
222            "v" => Tool::Select,
223            "p" => Tool::Pen,
224            "r" => Tool::Rect,
225            "o" => Tool::Ellipse,
226            "d" => Tool::Diamond,
227            "g" => Tool::Triangle,
228            "u" => Tool::RoundRect,
229            "s" => Tool::Star,
230            "x" => Tool::Hexagon,
231            "l" => Tool::Line,
232            "a" => Tool::Arrow,
233            "k" => Tool::DashedArrow,
234            "t" => Tool::Text,
235            "m" => Tool::MindMap,
236            "f" => Tool::Flowchart,
237            "i" => Tool::Image,
238            _ => return None,
239        })
240    }
241
242    /// The bundled SVG icon for this tool as `(cache-key, bytes)`, or `None` to
243    /// fall back to [`glyph`]. Rendered flat in the theme color via gpui's SVG
244    /// rasterizer (the SVG's own colors are ignored — it's tinted as an alpha
245    /// mask). Lucide, ISC-licensed (see `assets/icons/LICENSE`).
246    ///
247    /// [`glyph`]: Tool::glyph
248    fn icon(self) -> Option<(&'static str, &'static [u8])> {
249        const PAN: &[u8] = include_bytes!("../assets/icons/pan.svg");
250        const SELECT: &[u8] = include_bytes!("../assets/icons/select.svg");
251        const PEN: &[u8] = include_bytes!("../assets/icons/pen.svg");
252        const RECT: &[u8] = include_bytes!("../assets/icons/rect.svg");
253        const ELLIPSE: &[u8] = include_bytes!("../assets/icons/ellipse.svg");
254        const DIAMOND: &[u8] = include_bytes!("../assets/icons/diamond.svg");
255        const TRIANGLE: &[u8] = include_bytes!("../assets/icons/triangle.svg");
256        const ROUND_RECT: &[u8] = include_bytes!("../assets/icons/round-rect.svg");
257        const STAR: &[u8] = include_bytes!("../assets/icons/star.svg");
258        const HEXAGON: &[u8] = include_bytes!("../assets/icons/hexagon.svg");
259        const LINE: &[u8] = include_bytes!("../assets/icons/line.svg");
260        const ARROW: &[u8] = include_bytes!("../assets/icons/arrow.svg");
261        const TEXT: &[u8] = include_bytes!("../assets/icons/text.svg");
262        const MINDMAP: &[u8] = include_bytes!("../assets/icons/mindmap.svg");
263        const FLOWCHART: &[u8] = include_bytes!("../assets/icons/flowchart.svg");
264        const EMBED: &[u8] = include_bytes!("../assets/icons/embed.svg");
265        const IMAGE: &[u8] = include_bytes!("../assets/icons/image.svg");
266        match self {
267            Tool::Pan => Some(("wb-icon-pan", PAN)),
268            Tool::Select => Some(("wb-icon-select", SELECT)),
269            Tool::Pen => Some(("wb-icon-pen", PEN)),
270            Tool::Rect => Some(("wb-icon-rect", RECT)),
271            Tool::Ellipse => Some(("wb-icon-ellipse", ELLIPSE)),
272            Tool::Diamond => Some(("wb-icon-diamond", DIAMOND)),
273            Tool::Triangle => Some(("wb-icon-triangle", TRIANGLE)),
274            Tool::RoundRect => Some(("wb-icon-round-rect", ROUND_RECT)),
275            Tool::Star => Some(("wb-icon-star", STAR)),
276            Tool::Hexagon => Some(("wb-icon-hexagon", HEXAGON)),
277            Tool::Line => Some(("wb-icon-line", LINE)),
278            Tool::Arrow => Some(("wb-icon-arrow", ARROW)),
279            Tool::DashedArrow => None,
280            Tool::Text => Some(("wb-icon-text", TEXT)),
281            Tool::MindMap => Some(("wb-icon-mindmap", MINDMAP)),
282            Tool::Flowchart => Some(("wb-icon-flowchart", FLOWCHART)),
283            Tool::Embed => Some(("wb-icon-embed", EMBED)),
284            Tool::Image => Some(("wb-icon-image", IMAGE)),
285        }
286    }
287}
288
289/// A toolbar category whose tools live in a click-to-open flyout, keeping the
290/// main bar trim. The category button shows the active tool of the group (or a
291/// representative when none is active).
292#[derive(Clone, Copy, PartialEq, Eq)]
293enum ToolGroup {
294    /// Freehand pen and the closed shapes.
295    Shapes,
296    /// Line and arrow connectors.
297    Lines,
298    /// Page-cards (and, later, images).
299    PagesImages,
300}
301
302impl ToolGroup {
303    const ALL: [ToolGroup; 3] = [ToolGroup::Shapes, ToolGroup::Lines, ToolGroup::PagesImages];
304
305    /// The tools shown in this group's flyout.
306    fn tools(self) -> &'static [Tool] {
307        match self {
308            ToolGroup::Shapes => &[
309                Tool::Rect,
310                Tool::RoundRect,
311                Tool::Ellipse,
312                Tool::Diamond,
313                Tool::Triangle,
314                Tool::Hexagon,
315                Tool::Star,
316            ],
317            ToolGroup::Lines => &[Tool::Pen, Tool::Line, Tool::Arrow, Tool::DashedArrow],
318            ToolGroup::PagesImages => &[Tool::MindMap, Tool::Flowchart, Tool::Embed, Tool::Image],
319        }
320    }
321
322    fn contains(self, t: Tool) -> bool {
323        self.tools().contains(&t)
324    }
325
326    /// The icon shown on the category button when none of its tools is active.
327    fn representative(self) -> Tool {
328        match self {
329            ToolGroup::Shapes => Tool::Rect,
330            ToolGroup::Lines => Tool::Arrow,
331            ToolGroup::PagesImages => Tool::Flowchart,
332        }
333    }
334
335    fn label(self) -> &'static str {
336        match self {
337            ToolGroup::Shapes => "Shapes",
338            ToolGroup::Lines => "Lines",
339            ToolGroup::PagesImages => "Pages & images",
340        }
341    }
342}
343
344/// A flat, theme-colored toolbar icon: render the bundled SVG `bytes` (a 16×16
345/// Lucide glyph) tinted to `color` via gpui's rasterizer, in a `size`-px box.
346/// `key` is a stable per-icon cache id.
347fn svg_icon(key: &'static str, bytes: &'static [u8], color: Hsla, sz: f32) -> impl IntoElement {
348    canvas(
349        |_, _, _| {},
350        move |bounds, _, window, cx| {
351            let _ = window.paint_svg(
352                bounds,
353                SharedString::from(key),
354                Some(bytes),
355                TransformationMatrix::default(),
356                color,
357                cx,
358            );
359        },
360    )
361    .w(px(sz))
362    .h(px(sz))
363}
364
365/// A hairline vertical divider separating toolbar tool groups.
366fn toolbar_divider(color: Hsla, vertical: bool) -> gpui::AnyElement {
367    let d = div().bg(color);
368    // A row's dividers are vertical bars; a column's are horizontal.
369    if vertical {
370        d.h(px(1.0)).w(px(16.0)).my(px(3.0))
371    } else {
372        d.w(px(1.0)).h(px(16.0)).mx(px(3.0))
373    }
374    .into_any_element()
375}
376
377/// A minimal themed tooltip view. gpui has the `.tooltip()` *hook* but no
378/// tooltip *view* (those live in UI crates this crate doesn't depend on), so —
379/// like `gpui-pdf` — we render our own small label.
380struct Tip {
381    text: SharedString,
382    fg: Hsla,
383    bg: Hsla,
384    border: Hsla,
385}
386
387impl Render for Tip {
388    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
389        // gpui anchors the tooltip at the cursor; a small transparent top
390        // padding drops the visible box just clear of the hovered button.
391        div().pt(px(16.0)).child(
392            div()
393                .px(px(6.0))
394                .py(px(2.0))
395                .rounded(px(4.0))
396                .border_1()
397                .border_color(self.border)
398                .bg(self.bg)
399                .text_color(self.fg)
400                .text_size(px(11.0))
401                .child(self.text.clone()),
402        )
403    }
404}
405
406/// Theme colors, read at paint time (via [`WhiteboardStyleFn`]) so the board
407/// follows live theme changes per window.
408#[derive(Clone, Debug)]
409pub struct WhiteboardStyle {
410    /// The canvas background.
411    pub bg: Hsla,
412    /// The background grid dots.
413    pub grid: Hsla,
414    /// HUD / muted on-canvas text.
415    pub text: Hsla,
416    /// Ink (stroke/shape color). Per-element color comes with the color picker.
417    pub ink: Hsla,
418    /// Toolbar / flyout panel background — small pills, so it can be quite glassy.
419    pub panel: Hsla,
420    /// Background for the larger color-picker panel. Wants to stay readable over
421    /// a busy canvas, so it should be much more opaque than `panel`.
422    pub panel_strong: Hsla,
423    /// Active-tool highlight (a subtle fill behind the current tool button).
424    pub accent: Hsla,
425    /// Selection outline — wants to be clearly visible, so a strong color.
426    pub selection: Hsla,
427    /// Palette shown as quick swatches in the color picker. The host supplies
428    /// these (typically its theme colors) so the picker matches the app.
429    pub swatches: Vec<Hsla>,
430}
431
432/// A `() -> WhiteboardStyle` the host supplies; called each paint so the board
433/// tracks theme changes without the host pushing updates.
434pub type WhiteboardStyleFn = Rc<dyn Fn() -> WhiteboardStyle>;
435
436/// Called when the board changes (an element committed/moved/deleted, the camera
437/// moved), with the serialized scene JSON, so the host can persist it.
438pub type ChangeFn = Rc<dyn Fn(String, &mut Window, &mut App)>;
439
440/// Called when the page-card tool is clicked at world `(x, y)` — the host picks
441/// a page and calls [`WhiteboardView::add_embed`].
442pub type PlaceEmbedFn = Rc<dyn Fn(f32, f32, &mut Window, &mut App)>;
443
444/// Called to open a page (double-clicking a card) — the host opens it in a tab.
445pub type OpenPageFn = Rc<dyn Fn(i64, &mut Window, &mut App)>;
446
447/// Called when the user saves the current selection as a template, with the
448/// selected elements serialized (normalized to origin). The host names + stores
449/// it, then feeds the updated list back via [`WhiteboardView::set_templates`].
450pub type SaveTemplateFn = Rc<dyn Fn(String, &mut Window, &mut App)>;
451
452/// Called to delete a stored template by its host id (right-click a card).
453pub type DeleteTemplateFn = Rc<dyn Fn(i64, &mut Window, &mut App)>;
454
455/// Called on ⌘C / ⌘X with the selection serialized (same format as
456/// [`SaveTemplateFn`]); the host writes it to the system clipboard. Paste is the
457/// reverse: the host reads the clipboard and calls [`WhiteboardView::paste_elements`].
458pub type CopyFn = Rc<dyn Fn(String, &mut Window, &mut App)>;
459
460/// Called by the context-menu **Paste**: the host reads the clipboard and returns
461/// previously copied whiteboard elements (the JSON a [`CopyFn`] wrote — same format
462/// as [`SaveTemplateFn`]), or `None` if it holds no board elements. Pass the JSON to
463/// [`WhiteboardView::paste_elements`]. (Keyboard ⌘V is handled internally.)
464pub type PasteFn = Rc<dyn Fn(&mut Window, &mut App) -> Option<String>>;
465
466/// Called when the user's saved-color palette changes (a swatch added or removed),
467/// with the full list (packed `0xRRGGBBAA`). The host persists it and feeds it back
468/// via [`WhiteboardView::set_saved_colors`]. Without it, the palette is per-session.
469pub type SavedColorsFn = Rc<dyn Fn(Vec<u32>, &mut Window, &mut App)>;
470
471/// Called each render to fetch the decoded bitmap for an image element's `src`,
472/// rotated by `rotation` radians (0 = upright). The host serves it from its image
473/// cache, decoding/rotating on demand (returning `None` until ready, then
474/// re-rendering the board); a steady angle hits the cache, so it only re-rotates
475/// when the angle changes.
476pub type ImageFn = Rc<dyn Fn(&str, f32, &mut Window, &mut App) -> Option<gpui::ImageSource>>;
477
478/// Called when the image tool is clicked at world `(x, y)` — the host picks a
479/// file and calls [`WhiteboardView::add_image_at`].
480pub type PlaceImageFn = Rc<dyn Fn(f32, f32, &mut Window, &mut App)>;
481
482/// Called when files are dropped onto the canvas at world `(x, y)` — the host
483/// imports any images and places them via [`WhiteboardView::add_image_at`].
484pub type DropFilesFn = Rc<dyn Fn(Vec<std::path::PathBuf>, f32, f32, &mut Window, &mut App)>;
485
486/// Which face the Font flyout offers — upload one from disk or revert to the
487/// bundled default.
488#[derive(Clone, Copy, Debug, PartialEq, Eq)]
489pub enum FontPick {
490    /// Pick a `.ttf`/`.otf` from disk (the host shows the file dialog).
491    Upload,
492    /// Revert to the bundled default face.
493    Default,
494}
495
496/// Called when the user picks from the Font flyout. The host loads the face and
497/// calls [`WhiteboardView::set_font`] (and persists the per-board choice). Without
498/// it, the Font toolbar button is hidden.
499pub type PickFontFn = Rc<dyn Fn(FontPick, &mut Window, &mut App)>;
500
501/// Called when the toolbar is moved, reset, or re-oriented, with its new
502/// board-relative top-left (`None` = default top-center) and whether it's vertical.
503/// The host persists both and feeds them back via [`WhiteboardView::set_toolbar_pos`]
504/// / [`set_toolbar_vertical`](WhiteboardView::set_toolbar_vertical). Without it, the
505/// layout is per-session.
506pub type MoveToolbarFn = Rc<dyn Fn(Option<(f32, f32)>, bool, &mut Window, &mut App)>;
507
508/// Host callback fired by an embed view when the user requests "open / maximize
509/// for editing". The host owns the actual layout transition.
510pub type ExpandEmbedFn = Rc<dyn Fn(&mut Window, &mut App)>;
511
512/// A reusable group of elements the user can stamp onto a board. Element
513/// positions are normalized so the group's bounding box starts at the origin;
514/// applying re-bases them to the viewport. The host owns persistence and the
515/// `id`; the crate renders the preview + instantiates on click.
516#[derive(Clone, Debug)]
517pub struct Template {
518    pub id: i64,
519    pub name: String,
520    pub elements: Vec<Element>,
521}
522
523impl Template {
524    /// Build from the host's stored row. `elements_json` is a serialized
525    /// `Vec<Element>` (the JSON a [`SaveTemplateFn`] handed the host); malformed
526    /// JSON yields an empty (still-listable) template.
527    pub fn from_json(id: i64, name: impl Into<String>, elements_json: &str) -> Self {
528        Template {
529            id,
530            name: name.into(),
531            elements: serde_json::from_str(elements_json).unwrap_or_default(),
532        }
533    }
534}
535
536/// An element being created by the current left-drag.
537struct Pending {
538    anchor: [f32; 2],
539    kind: ElementKind,
540}
541
542/// A connector point shown on a hovered shape. `index` is 0/1/2/3 = top/right/bottom/left.
543#[derive(Clone, Copy, PartialEq)]
544struct ConnectPoint {
545    id: u64,
546    index: usize,
547    pos: [f32; 2],
548}
549
550/// A line being dragged from a shape connector while the Select tool is active.
551#[derive(Clone, Copy)]
552struct ConnectDrag {
553    from: ConnectPoint,
554}
555
556#[derive(Clone, Copy, Debug, Default, PartialEq)]
557struct AlignmentGuides {
558    vertical: Option<f32>,
559    horizontal: Option<f32>,
560}
561
562/// An in-progress resize of a single selected element by one of its handles.
563struct Resizing {
564    id: u64,
565    /// Which handle is being dragged (corner = free/proportional, edge = one axis).
566    handle: ResizeHandle,
567    /// The fixed (opposite) corner/edge the scale is about, world space.
568    anchor: [f32; 2],
569    /// The dragged handle's original position, world space.
570    from: [f32; 2],
571    /// World offset from the cursor to the dragged handle at grab time, kept so
572    /// it tracks the cursor 1:1 (no jump on grab).
573    grab: [f32; 2],
574    /// The element's geometry at the start of the resize.
575    orig: ElementKind,
576}
577
578/// Which handle drives a resize ([`Resizing`] or [`GroupResizing`]): a corner
579/// (both axes together) or an edge midpoint (one axis only).
580#[derive(Clone, Copy)]
581enum ResizeHandle {
582    /// A corner grip — uniform scale about the opposite corner.
583    Corner,
584    /// A left/right edge grip — scales x only, about the opposite edge.
585    EdgeX,
586    /// A top/bottom edge grip — scales y only, about the opposite edge.
587    EdgeY,
588}
589
590/// An in-progress resize of a multi-selection by a handle of its (axis-aligned)
591/// group bounds. A corner scales uniformly about the opposite corner (the group
592/// grows as one); an edge midpoint stretches a single axis about the opposite
593/// edge. Each member is scaled from its geometry at grab so it never compounds.
594struct GroupResizing {
595    /// Which handle is being dragged (corner = both axes, edge = one).
596    handle: ResizeHandle,
597    /// The fixed point the scale is about, world space (opposite corner/edge).
598    anchor: [f32; 2],
599    /// The dragged handle's original position, world space.
600    from: [f32; 2],
601    /// Cursor → dragged-handle offset at grab (1:1 tracking, no jump).
602    grab: [f32; 2],
603    /// Each selected element's id + geometry at the start of the resize.
604    orig: Vec<(u64, ElementKind)>,
605}
606
607/// An in-progress drag of one endpoint of a selected line/arrow.
608#[derive(Clone, Copy)]
609struct EndpointDrag {
610    id: u64,
611    /// Which endpoint: 0 = (x1,y1), 1 = (x2,y2).
612    which: usize,
613}
614
615/// An in-progress rotation of the selection (one element or a group) about a
616/// fixed center. Drives every selected element, so it needs no element id.
617#[derive(Clone, Copy)]
618struct Rotating {
619    /// Pivot (world), captured at grab so it can't drift between frames.
620    center: [f32; 2],
621    /// Pointer angle about `center` at grab (radians).
622    start_pointer: f32,
623    /// Rotation already applied since grab (radians).
624    applied: f32,
625    /// Orientation to snap to horizontal/vertical: a single element's angle (box
626    /// / text) or line direction; `Some(0)` for a group (snaps quarter-turns);
627    /// `None` when there's nothing meaningful to snap (a lone freehand stroke).
628    base: Option<f32>,
629}
630
631/// What a press on a selection handle begins.
632enum HandleGrab {
633    Corner(Resizing),
634    Endpoint(EndpointDrag),
635    Rotate,
636    GroupCorner(GroupResizing),
637}
638
639/// Which property the picker is editing.
640#[derive(Clone, Copy, PartialEq)]
641enum PickerTarget {
642    /// Outline / ink color (`None` = theme ink).
643    Stroke,
644    /// Shape fill (`None` = unfilled).
645    Fill,
646    /// Shape label color (`None` follows the stroke / theme ink).
647    Text,
648}
649
650/// Open color-picker state: the HSVA the controls currently reflect, and which
651/// property (stroke or fill) it edits. Recolors the selection live.
652#[derive(Clone, Copy)]
653struct Picker {
654    target: PickerTarget,
655    h: f32,
656    s: f32,
657    v: f32,
658    a: f32,
659}
660
661/// Which picker control an in-progress drag is manipulating.
662#[derive(Clone, Copy, PartialEq)]
663enum PickerDrag {
664    /// The saturation/brightness square.
665    Sv,
666    /// The hue strip.
667    Hue,
668    /// The alpha (opacity) strip.
669    Alpha,
670    /// The thickness flyout's custom-width slider.
671    Width,
672}
673
674/// The whiteboard view entity. The host holds it in an `Entity<WhiteboardView>`
675/// (keyed by board id) and renders it into a tab.
676pub struct WhiteboardView {
677    scene: Scene,
678    style: WhiteboardStyleFn,
679    read_only: bool,
680    on_change: Option<ChangeFn>,
681    on_place_embed: Option<PlaceEmbedFn>,
682    on_open: Option<OpenPageFn>,
683    on_save_template: Option<SaveTemplateFn>,
684    on_delete_template: Option<DeleteTemplateFn>,
685    on_image: Option<ImageFn>,
686    on_place_image: Option<PlaceImageFn>,
687    on_drop_files: Option<DropFilesFn>,
688    on_copy: Option<CopyFn>,
689    on_paste: Option<PasteFn>,
690    on_save_colors: Option<SavedColorsFn>,
691    on_pick_font: Option<PickFontFn>,
692    on_move_toolbar: Option<MoveToolbarFn>,
693    /// The user's saved colors (packed `0xRRGGBBAA`), shown in the picker's palette.
694    /// Supplied + persisted by the host (see [`SavedColorsFn`]).
695    saved_colors: Vec<u32>,
696    /// Stored templates, supplied by the host; shown as cards in the Pages &
697    /// Images flyout.
698    templates: Vec<Template>,
699    /// Screen position of an open right-click context menu (a selection's
700    /// "save as template"), or `None`.
701    context_menu: Option<Point<Pixels>>,
702    /// Whether the context menu's "Text ▸" formatting submenu is expanded.
703    ctx_text_sub: bool,
704    /// Whether the toolbar's text-formatting fly-out is open.
705    format_flyout: bool,
706    /// The face used to render text as vector outlines. Defaults to the bundled
707    /// JetBrains Mono; the host can swap in a custom/user-uploaded font.
708    font: Font,
709    /// Camera-independent glyph outlines keyed by element id and text/style
710    /// signature. Panning and zooming can reuse these directly.
711    text_layout_cache: HashMap<u64, CachedTextLayout>,
712    label_layout_cache: HashMap<u64, CachedLabelLayout>,
713    tool: Tool,
714    /// Keyboard focus — grabbed while editing a text element.
715    focus: FocusHandle,
716    /// The text element currently being edited (Text tool / double-click).
717    editing: Option<u64>,
718    /// Caret position (byte offset into the editing text's content).
719    caret: usize,
720    /// The fixed end of the text selection (byte offset); `== caret` means no
721    /// selection, just the caret.
722    sel_anchor: usize,
723    /// A click-drag text selection is in progress (extends the selection on move).
724    text_selecting: bool,
725    /// Active IME marked/composition byte range in the editing text.
726    marked_range: Option<Range<usize>>,
727    /// Canvas bounds in window coords, captured each paint so input handlers can
728    /// map window-relative event positions into the board.
729    bounds: Rc<Cell<Bounds<Pixels>>>,
730    /// The element being created by the in-progress left-drag.
731    pending: Option<Pending>,
732    /// The currently selected elements (Select tool).
733    selected: Vec<u64>,
734    /// In-progress marquee box (start, current) in world coords.
735    marquee: Option<([f32; 2], [f32; 2])>,
736    /// Connector point currently under/near the mouse, painted on hovered shapes.
737    hovered_connector: Option<ConnectPoint>,
738    /// Line creation started by pressing a connector point.
739    connecting: Option<ConnectDrag>,
740    /// The world point where an in-progress move-drag was grabbed (a *fixed*
741    /// anchor — the move uses the total cursor delta from here, so grid-snapping
742    /// stays cursor-synced and doesn't lose sub-grid motion).
743    drag_from: Option<[f32; 2]>,
744    /// The primary (first-selected) element's top-left at move-grab, the
745    /// reference the move drives toward (`move_origin + total_delta`).
746    move_origin: [f32; 2],
747    /// Whether the current move-drag has actually moved (undo is pushed once).
748    moved: bool,
749    /// Active world-space smart-alignment guides while moving a selection.
750    alignment_guides: AlignmentGuides,
751    /// In-progress corner-resize of the selected box/stroke.
752    resizing: Option<Resizing>,
753    /// In-progress proportional resize of a multi-selection.
754    group_resizing: Option<GroupResizing>,
755    /// In-progress endpoint-drag of the selected line/arrow.
756    endpoint: Option<EndpointDrag>,
757    /// In-progress rotation of the selected element.
758    rotating: Option<Rotating>,
759    /// Current ink color for new elements (`None` follows the theme ink).
760    active_stroke: Option<u32>,
761    /// Current fill for new shapes (`None` = unfilled).
762    active_fill: Option<u32>,
763    /// Current label color for new shapes (`None` follows the stroke / theme ink).
764    active_text: Option<u32>,
765    /// Formatting to apply to the next typed text when there's no selection (set
766    /// by a ⌘B/etc. toggle with a collapsed caret); cleared on caret move.
767    pending_style: Option<RunStyle>,
768    /// Current stroke thickness for new elements, in screen px (stored world-space
769    /// as `active_width / zoom`, like [`NIB`]). Defaults to `NIB`.
770    active_width: f32,
771    /// Open color picker, if any.
772    picker: Option<Picker>,
773    /// The tool category whose flyout is open, if any.
774    open_group: Option<ToolGroup>,
775    /// Whether the thickness-preset flyout is open.
776    width_open: bool,
777    /// Whether the font flyout (upload / default) is open.
778    font_open: bool,
779    /// Whether the templates gallery modal is open.
780    templates_open: bool,
781    /// In-progress drag inside the open picker.
782    picker_drag: Option<PickerDrag>,
783    /// Screen bounds of the picker panel and its draggable regions, captured each
784    /// paint so press/drag handlers can hit-test them.
785    picker_bounds: Rc<Cell<Bounds<Pixels>>>,
786    sv_bounds: Rc<Cell<Bounds<Pixels>>>,
787    hue_bounds: Rc<Cell<Bounds<Pixels>>>,
788    alpha_bounds: Rc<Cell<Bounds<Pixels>>>,
789    /// Screen bounds of the thickness flyout panel and its width slider (captured
790    /// each paint), so a press can route to the slider or dismiss the flyout.
791    width_panel_bounds: Rc<Cell<Bounds<Pixels>>>,
792    width_bounds: Rc<Cell<Bounds<Pixels>>>,
793    /// Screen bounds of the toolbar pill and its drag grip (captured each paint),
794    /// so a press routes to a drag (grip) or is consumed (pill) — the pill isn't
795    /// occluded, like the picker.
796    toolbar_bounds: Rc<Cell<Bounds<Pixels>>>,
797    toolbar_grip_bounds: Rc<Cell<Bounds<Pixels>>>,
798    /// The toolbar's board-relative top-left when the user has dragged it; `None`
799    /// keeps the default top-center. Persisted by the host.
800    toolbar_pos: Option<(f32, f32)>,
801    /// Whether the toolbar is laid out vertically (a column) rather than as a row.
802    /// Toggled with `R` while dragging it; persisted by the host.
803    toolbar_vertical: bool,
804    /// In-progress toolbar drag: the (pill origin − cursor) offset, board-relative.
805    toolbar_drag: Option<(f32, f32)>,
806    /// Undo / redo stacks of scene snapshots.
807    history: Vec<Scene>,
808    redo: Vec<Scene>,
809    /// True while a middle-drag pan is in progress.
810    panning: bool,
811    /// Last pointer position (window coords) during a pan.
812    last: Point<Pixels>,
813    /// Next element id.
814    next_id: u64,
815    /// Unsaved changes since the last flush (flushed on mouse-up).
816    dirty: bool,
817}
818
819/// A read-only whiteboard embedding surface for use inside rich-text editors and
820/// other host containers. It overlays a small "edit / maximize" affordance and
821/// delegates the actual expansion behavior back to the host.
822pub struct BoardEmbedView {
823    board: Entity<WhiteboardView>,
824    style: WhiteboardStyleFn,
825    on_expand: Option<ExpandEmbedFn>,
826}
827
828/// A lightweight, chrome-free thumbnail renderer for embedding a local board
829/// snapshot in documents, lists, and rich-text blocks.
830pub struct BoardThumbnailView {
831    snapshot: LocalThumbnailSnapshot,
832    style: WhiteboardStyleFn,
833    font: Font,
834}
835
836impl WhiteboardView {
837    /// Build a view over `scene`. Call inside `cx.new(|cx| WhiteboardView::new(..))`.
838    pub fn new(scene: Scene, style: WhiteboardStyleFn, cx: &mut Context<Self>) -> Self {
839        let next_id = scene
840            .elements
841            .iter()
842            .map(|e| e.id)
843            .max()
844            .map_or(0, |m| m + 1);
845        Self {
846            scene,
847            style,
848            read_only: false,
849            on_change: None,
850            on_place_embed: None,
851            on_open: None,
852            on_save_template: None,
853            on_delete_template: None,
854            on_image: None,
855            on_place_image: None,
856            on_drop_files: None,
857            on_copy: None,
858            on_paste: None,
859            on_save_colors: None,
860            on_pick_font: None,
861            on_move_toolbar: None,
862            saved_colors: Vec::new(),
863            templates: Vec::new(),
864            context_menu: None,
865            ctx_text_sub: false,
866            format_flyout: false,
867            font: Font::default(),
868            text_layout_cache: HashMap::new(),
869            label_layout_cache: HashMap::new(),
870            tool: Tool::Pan,
871            focus: cx.focus_handle(),
872            editing: None,
873            caret: 0,
874            sel_anchor: 0,
875            text_selecting: false,
876            marked_range: None,
877            bounds: Rc::new(Cell::new(Bounds::default())),
878            pending: None,
879            selected: Vec::new(),
880            marquee: None,
881            hovered_connector: None,
882            connecting: None,
883            drag_from: None,
884            move_origin: [0.0, 0.0],
885            moved: false,
886            alignment_guides: AlignmentGuides::default(),
887            resizing: None,
888            group_resizing: None,
889            endpoint: None,
890            rotating: None,
891            active_stroke: None,
892            active_fill: None,
893            active_text: None,
894            pending_style: None,
895            active_width: NIB,
896            picker: None,
897            open_group: None,
898            width_open: false,
899            font_open: false,
900            templates_open: false,
901            picker_drag: None,
902            picker_bounds: Rc::new(Cell::new(Bounds::default())),
903            sv_bounds: Rc::new(Cell::new(Bounds::default())),
904            hue_bounds: Rc::new(Cell::new(Bounds::default())),
905            alpha_bounds: Rc::new(Cell::new(Bounds::default())),
906            width_panel_bounds: Rc::new(Cell::new(Bounds::default())),
907            width_bounds: Rc::new(Cell::new(Bounds::default())),
908            toolbar_bounds: Rc::new(Cell::new(Bounds::default())),
909            toolbar_grip_bounds: Rc::new(Cell::new(Bounds::default())),
910            toolbar_pos: None,
911            toolbar_vertical: false,
912            toolbar_drag: None,
913            history: Vec::new(),
914            redo: Vec::new(),
915            panning: false,
916            last: Point::default(),
917            next_id,
918            dirty: false,
919        }
920    }
921
922    /// Build a read-only board view. Useful when embedding inside other editors
923    /// that should only preview the board and allow viewport movement.
924    pub fn new_read_only(scene: Scene, style: WhiteboardStyleFn, cx: &mut Context<Self>) -> Self {
925        let mut this = Self::new(scene, style, cx);
926        this.read_only = true;
927        this.tool = Tool::Pan;
928        this
929    }
930
931    /// Install the persistence hook (called with the serialized scene on change).
932    pub fn set_on_change(&mut self, f: ChangeFn) {
933        self.on_change = Some(f);
934    }
935
936    /// Install the page-card placement hook (page-card tool click).
937    pub fn set_on_place_embed(&mut self, f: PlaceEmbedFn) {
938        self.on_place_embed = Some(f);
939    }
940
941    /// Install the open-page hook (double-click a card).
942    pub fn set_on_open(&mut self, f: OpenPageFn) {
943        self.on_open = Some(f);
944    }
945
946    /// Install the save-template hook (right-click selection → save).
947    pub fn set_on_save_template(&mut self, f: SaveTemplateFn) {
948        self.on_save_template = Some(f);
949    }
950
951    /// Install the delete-template hook (right-click a template card → delete).
952    pub fn set_on_delete_template(&mut self, f: DeleteTemplateFn) {
953        self.on_delete_template = Some(f);
954    }
955
956    /// Install the image-fetch hook (decoded bitmap for an element's `src`).
957    pub fn set_on_image(&mut self, f: ImageFn) {
958        self.on_image = Some(f);
959    }
960
961    /// Install the place-image hook (image tool click → host file picker).
962    pub fn set_on_place_image(&mut self, f: PlaceImageFn) {
963        self.on_place_image = Some(f);
964    }
965
966    /// Install the file-drop hook (files dropped on the canvas).
967    pub fn set_on_drop_files(&mut self, f: DropFilesFn) {
968        self.on_drop_files = Some(f);
969    }
970
971    /// Install the copy hook (⌘C / ⌘X → write the selection to the clipboard).
972    pub fn set_on_copy(&mut self, f: CopyFn) {
973        self.on_copy = Some(f);
974    }
975
976    /// Install the paste hook (context-menu Paste → read board elements from the
977    /// clipboard). Without it, the Paste menu item is hidden.
978    pub fn set_on_paste(&mut self, f: PasteFn) {
979        self.on_paste = Some(f);
980    }
981
982    /// Install the saved-colors hook (the palette changed → host persists it).
983    pub fn set_on_save_colors(&mut self, f: SavedColorsFn) {
984        self.on_save_colors = Some(f);
985    }
986
987    /// Install the font-picker hook (the Font toolbar button). Without it, the
988    /// Font button is hidden. The host shows the file dialog, builds the face, and
989    /// calls [`set_font`](Self::set_font).
990    pub fn set_on_pick_font(&mut self, f: PickFontFn) {
991        self.on_pick_font = Some(f);
992    }
993
994    /// Install the toolbar-moved hook (the host persists the new position).
995    pub fn set_on_move_toolbar(&mut self, f: MoveToolbarFn) {
996        self.on_move_toolbar = Some(f);
997    }
998
999    /// Toggle read-only mode. In this mode the board behaves like a fixed move
1000    /// tool: left-drag pans the canvas and edit interactions are ignored.
1001    pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
1002        self.read_only = read_only;
1003        if read_only {
1004            self.tool = Tool::Pan;
1005            self.selected.clear();
1006            self.editing = None;
1007            self.pending = None;
1008            self.connecting = None;
1009            self.hovered_connector = None;
1010            self.context_menu = None;
1011            self.open_group = None;
1012            self.font_open = false;
1013            self.width_open = false;
1014            self.templates_open = false;
1015            self.picker = None;
1016            self.format_flyout = false;
1017            self.text_selecting = false;
1018            self.marked_range = None;
1019        }
1020        cx.notify();
1021    }
1022
1023    /// Whether the board is currently in read-only (forced pan) mode.
1024    pub fn read_only(&self) -> bool {
1025        self.read_only
1026    }
1027
1028    /// Set the toolbar's board-relative top-left (`None` = default top-center). The
1029    /// host pushes the persisted position on open and after a change.
1030    pub fn set_toolbar_pos(&mut self, pos: Option<(f32, f32)>, cx: &mut Context<Self>) {
1031        self.toolbar_pos = pos;
1032        cx.notify();
1033    }
1034
1035    /// Set the toolbar orientation (vertical = a column). The host pushes the
1036    /// persisted value on open and after a change.
1037    pub fn set_toolbar_vertical(&mut self, vertical: bool, cx: &mut Context<Self>) {
1038        self.toolbar_vertical = vertical;
1039        cx.notify();
1040    }
1041
1042    /// Flip the toolbar orientation (row ↔ column) and persist. Bound to `R` while
1043    /// the bar is being dragged.
1044    fn toggle_toolbar_orientation(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1045        self.toolbar_vertical = !self.toolbar_vertical;
1046        if let Some(f) = self.on_move_toolbar.clone() {
1047            f(self.toolbar_pos, self.toolbar_vertical, window, cx);
1048        }
1049        cx.notify();
1050    }
1051
1052    /// Clamp a board-relative toolbar top-left so the pill stays fully on-board.
1053    fn clamp_toolbar(&self, x: f32, y: f32) -> (f32, f32) {
1054        let board = self.bounds.get().size;
1055        let pill = self.toolbar_bounds.get().size;
1056        let maxx = (f32::from(board.width) - f32::from(pill.width)).max(0.0);
1057        let maxy = (f32::from(board.height) - f32::from(pill.height)).max(0.0);
1058        (x.clamp(0.0, maxx), y.clamp(0.0, maxy))
1059    }
1060
1061    /// Start dragging the toolbar from a grip press (window coords). A double-click
1062    /// resets it to the default top-center.
1063    fn start_toolbar_drag(
1064        &mut self,
1065        p: Point<Pixels>,
1066        double: bool,
1067        window: &mut Window,
1068        cx: &mut Context<Self>,
1069    ) {
1070        if double {
1071            self.toolbar_drag = None;
1072            self.toolbar_pos = None;
1073            if let Some(f) = self.on_move_toolbar.clone() {
1074                f(None, self.toolbar_vertical, window, cx);
1075            }
1076            cx.notify();
1077            return;
1078        }
1079        // Close any popover so it doesn't trail the bar while it's dragged.
1080        self.picker = None;
1081        self.open_group = None;
1082        self.width_open = false;
1083        self.font_open = false;
1084        self.templates_open = false;
1085        self.context_menu = None;
1086        // Take focus so `R` (flip orientation) reaches the key handler mid-drag.
1087        self.focus.focus(window, cx);
1088        let pill = self.toolbar_bounds.get().origin;
1089        self.toolbar_drag = Some((
1090            f32::from(pill.x) - f32::from(p.x),
1091            f32::from(pill.y) - f32::from(p.y),
1092        ));
1093        cx.notify();
1094    }
1095
1096    /// Update the toolbar position while dragging (window-coords cursor).
1097    fn drag_toolbar(&mut self, p: Point<Pixels>, cx: &mut Context<Self>) {
1098        let Some((ox, oy)) = self.toolbar_drag else {
1099            return;
1100        };
1101        let board = self.bounds.get().origin;
1102        let x = f32::from(p.x) + ox - f32::from(board.x);
1103        let y = f32::from(p.y) + oy - f32::from(board.y);
1104        self.toolbar_pos = Some(self.clamp_toolbar(x, y));
1105        cx.notify();
1106    }
1107
1108    /// Finish a toolbar drag and persist the new position.
1109    fn commit_toolbar_drag(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1110        if self.toolbar_drag.take().is_none() {
1111            return;
1112        }
1113        if let Some(f) = self.on_move_toolbar.clone() {
1114            f(self.toolbar_pos, self.toolbar_vertical, window, cx);
1115        }
1116    }
1117
1118    /// Replace the user's saved-color palette (the host pushes the persisted list
1119    /// on open and after a change).
1120    pub fn set_saved_colors(&mut self, colors: Vec<u32>, cx: &mut Context<Self>) {
1121        self.saved_colors = colors;
1122        cx.notify();
1123    }
1124
1125    /// Save the picker's current color to the palette (the `+` in the picker),
1126    /// then notify the host to persist. Ignores duplicates.
1127    fn save_current_color(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1128        if let Some(c) = self.picker_u32()
1129            && !self.saved_colors.contains(&c)
1130        {
1131            self.saved_colors.push(c);
1132            if let Some(f) = self.on_save_colors.clone() {
1133                f(self.saved_colors.clone(), window, cx);
1134            }
1135        }
1136        cx.notify();
1137    }
1138
1139    /// Remove a saved color from the palette (right-click a swatch), then persist.
1140    fn remove_saved_color(&mut self, c: u32, window: &mut Window, cx: &mut Context<Self>) {
1141        self.saved_colors.retain(|&x| x != c);
1142        if let Some(f) = self.on_save_colors.clone() {
1143            f(self.saved_colors.clone(), window, cx);
1144        }
1145        cx.notify();
1146    }
1147
1148    /// Replace the stored templates shown in the Pages & Images flyout. The host
1149    /// calls this on open and after any save/delete.
1150    pub fn set_templates(&mut self, templates: Vec<Template>, cx: &mut Context<Self>) {
1151        self.templates = templates;
1152        cx.notify();
1153    }
1154
1155    /// Swap the font used to render text (e.g. a user-uploaded face). Build one
1156    /// with [`Font::from_bytes`].
1157    pub fn set_font(&mut self, font: Font, cx: &mut Context<Self>) {
1158        self.font = font;
1159        self.text_layout_cache.clear();
1160        self.label_layout_cache.clear();
1161        cx.notify();
1162    }
1163
1164    /// Build a `.tooltip(..)` closure for a toolbar control — a small themed
1165    /// [`Tip`], reading colors through the style closure at show time.
1166    fn tip(
1167        &self,
1168        text: impl Into<SharedString>,
1169    ) -> impl Fn(&mut Window, &mut App) -> AnyView + 'static {
1170        let style_fn = self.style.clone();
1171        let text = text.into();
1172        move |_window, cx| {
1173            let s = style_fn();
1174            let text = text.clone();
1175            cx.new(move |_| Tip {
1176                text,
1177                fg: s.ink,
1178                bg: s.panel,
1179                border: s.grid,
1180            })
1181            .into()
1182        }
1183    }
1184
1185    /// Insert a page-card at world `(x, y)` and select it. Called by the host
1186    /// after the user picks a page (in response to [`PlaceEmbedFn`]). Does *not*
1187    /// fire `on_change` — the host calls this mid-update, so a re-entrant save
1188    /// would panic; the host persists explicitly via [`scene`](Self::scene).
1189    pub fn add_embed(
1190        &mut self,
1191        page_id: i64,
1192        title: impl Into<String>,
1193        x: f32,
1194        y: f32,
1195        cx: &mut Context<Self>,
1196    ) {
1197        self.push_undo();
1198        let id = self.next_id;
1199        self.next_id += 1;
1200        let zoom = self.scene.camera.zoom.max(MIN_ZOOM);
1201        self.scene.elements.push(Element {
1202            id,
1203            kind: ElementKind::Embed(EmbedGeom {
1204                page_id,
1205                title: title.into(),
1206                x,
1207                y,
1208                w: EMBED_W / zoom,
1209                h: EMBED_H / zoom,
1210            }),
1211            stroke: None,
1212            fill: None,
1213            label: None,
1214            label_color: None,
1215            styles: Vec::new(),
1216            mindmap: None,
1217        });
1218        self.selected = vec![id];
1219        self.tool = Tool::Select;
1220        cx.notify();
1221    }
1222
1223    /// Add an image element referencing `src`, centered at world `(cx_world,
1224    /// cy_world)` and sized from its pixel dimensions (`px_w`/`px_h`) so the longest
1225    /// edge gets a sensible default on-screen size (aspect preserved). Like
1226    /// [`add_embed`], the host persists afterward (this is called mid-host-update).
1227    ///
1228    /// [`add_embed`]: Self::add_embed
1229    pub fn add_image_at(
1230        &mut self,
1231        src: impl Into<String>,
1232        px_w: f32,
1233        px_h: f32,
1234        cx_world: f32,
1235        cy_world: f32,
1236        cx: &mut Context<Self>,
1237    ) {
1238        self.push_undo();
1239        let id = self.next_id;
1240        self.next_id += 1;
1241        let zoom = self.scene.camera.zoom.max(MIN_ZOOM);
1242        let longest = px_w.max(px_h).max(1.0);
1243        let scale = IMAGE_PLACE_PX / longest / zoom;
1244        let (w, h) = (px_w * scale, px_h * scale);
1245        self.scene.elements.push(Element {
1246            id,
1247            kind: ElementKind::Image(ImageGeom {
1248                src: src.into(),
1249                x: cx_world - w / 2.0,
1250                y: cy_world - h / 2.0,
1251                w,
1252                h,
1253                rotation: 0.0,
1254            }),
1255            stroke: None,
1256            fill: None,
1257            label: None,
1258            label_color: None,
1259            styles: Vec::new(),
1260            mindmap: None,
1261        });
1262        self.selected = vec![id];
1263        self.tool = Tool::Select;
1264        cx.notify();
1265    }
1266
1267    /// Insert a native mind-map seed built from whiteboard round-rect nodes and
1268    /// anchored arrows. This stays entirely inside the whiteboard scene model, so
1269    /// selection, movement, text editing, IME, and connector-follow all reuse the
1270    /// existing whiteboard machinery.
1271    pub fn add_mindmap_seed(&mut self, center_x: f32, center_y: f32, cx: &mut Context<Self>) {
1272        self.push_undo();
1273        let zoom = self.scene.camera.zoom.max(MIN_ZOOM);
1274        let root_w = MINDMAP_ROOT_W / zoom;
1275        let root_h = MINDMAP_ROOT_H / zoom;
1276        let node_w = MINDMAP_NODE_W / zoom;
1277        let node_h = MINDMAP_NODE_H / zoom;
1278        let gap_x = MINDMAP_BRANCH_GAP_X / zoom;
1279        let gap_y = MINDMAP_BRANCH_GAP_Y / zoom;
1280        let stroke = Some(0x2563ebff);
1281        let root_fill = Some(0xdbeafeff);
1282        let node_fill = Some(0xffffffff);
1283
1284        let add_node = |scene: &mut Scene,
1285                        next_id: &mut u64,
1286                        x: f32,
1287                        y: f32,
1288                        w: f32,
1289                        h: f32,
1290                        label: &str,
1291                        fill: Option<u32>,
1292                        mindmap: Option<MindMapNodeMeta>| {
1293            let id = *next_id;
1294            *next_id += 1;
1295            scene.elements.push(Element {
1296                id,
1297                kind: ElementKind::RoundRect(BoxGeom {
1298                    x,
1299                    y,
1300                    w,
1301                    h,
1302                    width: NIB / zoom,
1303                    rotation: 0.0,
1304                }),
1305                stroke,
1306                fill,
1307                label: Some(label.to_string()),
1308                label_color: Some(0x0f172aff),
1309                styles: Vec::new(),
1310                mindmap,
1311            });
1312            id
1313        };
1314
1315        let root_id = add_node(
1316            &mut self.scene,
1317            &mut self.next_id,
1318            center_x - root_w / 2.0,
1319            center_y - root_h / 2.0,
1320            root_w,
1321            root_h,
1322            "Central topic",
1323            root_fill,
1324            Some(MindMapNodeMeta {
1325                parent: None,
1326                side: MindMapSide::Right,
1327                order: 0,
1328                root_direction: MindMapRootDirection::Both,
1329                connector_style: MindMapConnectorStyle::Bezier,
1330            }),
1331        );
1332        let right_top_id = add_node(
1333            &mut self.scene,
1334            &mut self.next_id,
1335            center_x + root_w / 2.0 + gap_x,
1336            center_y - gap_y - node_h / 2.0,
1337            node_w,
1338            node_h,
1339            "Branch 1",
1340            node_fill,
1341            Some(MindMapNodeMeta {
1342                parent: Some(root_id),
1343                side: MindMapSide::Right,
1344                order: 0,
1345                root_direction: MindMapRootDirection::Both,
1346                connector_style: MindMapConnectorStyle::Bezier,
1347            }),
1348        );
1349        let right_bottom_id = add_node(
1350            &mut self.scene,
1351            &mut self.next_id,
1352            center_x + root_w / 2.0 + gap_x,
1353            center_y + gap_y - node_h / 2.0,
1354            node_w,
1355            node_h,
1356            "Branch 2",
1357            node_fill,
1358            Some(MindMapNodeMeta {
1359                parent: Some(root_id),
1360                side: MindMapSide::Right,
1361                order: 1,
1362                root_direction: MindMapRootDirection::Both,
1363                connector_style: MindMapConnectorStyle::Bezier,
1364            }),
1365        );
1366        let left_top_id = add_node(
1367            &mut self.scene,
1368            &mut self.next_id,
1369            center_x - root_w / 2.0 - gap_x - node_w,
1370            center_y - gap_y - node_h / 2.0,
1371            node_w,
1372            node_h,
1373            "Branch 3",
1374            node_fill,
1375            Some(MindMapNodeMeta {
1376                parent: Some(root_id),
1377                side: MindMapSide::Left,
1378                order: 0,
1379                root_direction: MindMapRootDirection::Both,
1380                connector_style: MindMapConnectorStyle::Bezier,
1381            }),
1382        );
1383        let left_bottom_id = add_node(
1384            &mut self.scene,
1385            &mut self.next_id,
1386            center_x - root_w / 2.0 - gap_x - node_w,
1387            center_y + gap_y - node_h / 2.0,
1388            node_w,
1389            node_h,
1390            "Branch 4",
1391            node_fill,
1392            Some(MindMapNodeMeta {
1393                parent: Some(root_id),
1394                side: MindMapSide::Left,
1395                order: 1,
1396                root_direction: MindMapRootDirection::Both,
1397                connector_style: MindMapConnectorStyle::Bezier,
1398            }),
1399        );
1400
1401        let add_branch = |scene: &mut Scene,
1402                          next_id: &mut u64,
1403                          from_id: u64,
1404                          from_connector: usize,
1405                          to_id: u64,
1406                          to_connector: usize| {
1407            let start_anchor = SegmentAnchor {
1408                element_id: from_id,
1409                connector: from_connector,
1410            };
1411            let end_anchor = SegmentAnchor {
1412                element_id: to_id,
1413                connector: to_connector,
1414            };
1415            let [x1, y1] =
1416                connector_world_pos_in(&scene.elements, start_anchor).unwrap_or([0.0, 0.0]);
1417            let [x2, y2] =
1418                connector_world_pos_in(&scene.elements, end_anchor).unwrap_or([0.0, 0.0]);
1419            let id = *next_id;
1420            *next_id += 1;
1421            scene.elements.push(Element {
1422                id,
1423                kind: ElementKind::Arrow(SegGeom {
1424                    x1,
1425                    y1,
1426                    x2,
1427                    y2,
1428                    width: NIB / zoom,
1429                    style: SegmentStyle::Solid,
1430                    start_anchor: Some(start_anchor),
1431                    end_anchor: Some(end_anchor),
1432                }),
1433                stroke,
1434                fill: None,
1435                label: None,
1436                label_color: None,
1437                styles: Vec::new(),
1438                mindmap: None,
1439            });
1440        };
1441
1442        add_branch(
1443            &mut self.scene,
1444            &mut self.next_id,
1445            root_id,
1446            1,
1447            right_top_id,
1448            3,
1449        );
1450        add_branch(
1451            &mut self.scene,
1452            &mut self.next_id,
1453            root_id,
1454            1,
1455            right_bottom_id,
1456            3,
1457        );
1458        add_branch(
1459            &mut self.scene,
1460            &mut self.next_id,
1461            root_id,
1462            3,
1463            left_top_id,
1464            1,
1465        );
1466        add_branch(
1467            &mut self.scene,
1468            &mut self.next_id,
1469            root_id,
1470            3,
1471            left_bottom_id,
1472            1,
1473        );
1474
1475        self.selected = vec![root_id];
1476        self.tool = Tool::Select;
1477        cx.notify();
1478    }
1479
1480    pub fn add_mindmap_seed_at_viewport_center(&mut self, cx: &mut Context<Self>) {
1481        let center = self.viewport_center();
1482        self.add_mindmap_seed(center[0], center[1], cx);
1483    }
1484
1485    /// Insert a native flowchart seed made from regular whiteboard nodes and
1486    /// anchored arrows. This is the first structured flowchart primitive inside
1487    /// the board and can later grow auto-layout / auto-branch behavior.
1488    pub fn add_flowchart_seed(&mut self, center_x: f32, center_y: f32, cx: &mut Context<Self>) {
1489        self.push_undo();
1490        let zoom = self.scene.camera.zoom.max(MIN_ZOOM);
1491        let node_w = FLOWCHART_NODE_W / zoom;
1492        let node_h = FLOWCHART_NODE_H / zoom;
1493        let gap_y = FLOWCHART_GAP_Y / zoom;
1494        let branch_gap_x = FLOWCHART_BRANCH_GAP_X / zoom;
1495        let stroke = Some(0x0f172aff);
1496        let fill = Some(0xffffffff);
1497
1498        let add_box = |scene: &mut Scene, next_id: &mut u64, kind: ElementKind, label: &str| {
1499            let id = *next_id;
1500            *next_id += 1;
1501            scene.elements.push(Element {
1502                id,
1503                kind,
1504                stroke,
1505                fill,
1506                label: Some(label.to_string()),
1507                label_color: Some(0x0f172aff),
1508                styles: Vec::new(),
1509                mindmap: None,
1510            });
1511            id
1512        };
1513        let add_arrow = |scene: &mut Scene,
1514                         next_id: &mut u64,
1515                         from_id: u64,
1516                         from_connector: usize,
1517                         to_id: u64,
1518                         to_connector: usize| {
1519            let start_anchor = SegmentAnchor {
1520                element_id: from_id,
1521                connector: from_connector,
1522            };
1523            let end_anchor = SegmentAnchor {
1524                element_id: to_id,
1525                connector: to_connector,
1526            };
1527            let [x1, y1] =
1528                connector_world_pos_in(&scene.elements, start_anchor).unwrap_or([0.0, 0.0]);
1529            let [x2, y2] =
1530                connector_world_pos_in(&scene.elements, end_anchor).unwrap_or([0.0, 0.0]);
1531            let id = *next_id;
1532            *next_id += 1;
1533            scene.elements.push(Element {
1534                id,
1535                kind: ElementKind::Arrow(SegGeom {
1536                    x1,
1537                    y1,
1538                    x2,
1539                    y2,
1540                    width: NIB / zoom,
1541                    style: SegmentStyle::Solid,
1542                    start_anchor: Some(start_anchor),
1543                    end_anchor: Some(end_anchor),
1544                }),
1545                stroke,
1546                fill: None,
1547                label: None,
1548                label_color: None,
1549                styles: Vec::new(),
1550                mindmap: None,
1551            });
1552        };
1553
1554        let start_id = add_box(
1555            &mut self.scene,
1556            &mut self.next_id,
1557            ElementKind::Ellipse(BoxGeom {
1558                x: center_x - node_w / 2.0,
1559                y: center_y - gap_y - node_h * 1.5,
1560                w: node_w,
1561                h: node_h,
1562                width: NIB / zoom,
1563                rotation: 0.0,
1564            }),
1565            "Start",
1566        );
1567        let process_id = add_box(
1568            &mut self.scene,
1569            &mut self.next_id,
1570            ElementKind::RoundRect(BoxGeom {
1571                x: center_x - node_w / 2.0,
1572                y: center_y - node_h / 2.0,
1573                w: node_w,
1574                h: node_h,
1575                width: NIB / zoom,
1576                rotation: 0.0,
1577            }),
1578            "Process",
1579        );
1580        let decision_id = add_box(
1581            &mut self.scene,
1582            &mut self.next_id,
1583            ElementKind::Diamond(BoxGeom {
1584                x: center_x - node_w / 2.0,
1585                y: center_y + gap_y - node_h / 2.0,
1586                w: node_w,
1587                h: node_h,
1588                width: NIB / zoom,
1589                rotation: 0.0,
1590            }),
1591            "Decision",
1592        );
1593        let branch_yes_id = add_box(
1594            &mut self.scene,
1595            &mut self.next_id,
1596            ElementKind::RoundRect(BoxGeom {
1597                x: center_x + branch_gap_x - node_w / 2.0,
1598                y: center_y + gap_y - node_h / 2.0,
1599                w: node_w,
1600                h: node_h,
1601                width: NIB / zoom,
1602                rotation: 0.0,
1603            }),
1604            "Yes",
1605        );
1606        let branch_no_id = add_box(
1607            &mut self.scene,
1608            &mut self.next_id,
1609            ElementKind::RoundRect(BoxGeom {
1610                x: center_x - branch_gap_x - node_w / 2.0,
1611                y: center_y + gap_y - node_h / 2.0,
1612                w: node_w,
1613                h: node_h,
1614                width: NIB / zoom,
1615                rotation: 0.0,
1616            }),
1617            "No",
1618        );
1619        let end_id = add_box(
1620            &mut self.scene,
1621            &mut self.next_id,
1622            ElementKind::Ellipse(BoxGeom {
1623                x: center_x - node_w / 2.0,
1624                y: center_y + gap_y * 2.0 + node_h * 0.5,
1625                w: node_w,
1626                h: node_h,
1627                width: NIB / zoom,
1628                rotation: 0.0,
1629            }),
1630            "End",
1631        );
1632
1633        add_arrow(
1634            &mut self.scene,
1635            &mut self.next_id,
1636            start_id,
1637            2,
1638            process_id,
1639            0,
1640        );
1641        add_arrow(
1642            &mut self.scene,
1643            &mut self.next_id,
1644            process_id,
1645            2,
1646            decision_id,
1647            0,
1648        );
1649        add_arrow(
1650            &mut self.scene,
1651            &mut self.next_id,
1652            decision_id,
1653            1,
1654            branch_yes_id,
1655            3,
1656        );
1657        add_arrow(
1658            &mut self.scene,
1659            &mut self.next_id,
1660            decision_id,
1661            3,
1662            branch_no_id,
1663            1,
1664        );
1665        add_arrow(
1666            &mut self.scene,
1667            &mut self.next_id,
1668            branch_yes_id,
1669            2,
1670            end_id,
1671            1,
1672        );
1673        add_arrow(
1674            &mut self.scene,
1675            &mut self.next_id,
1676            branch_no_id,
1677            2,
1678            end_id,
1679            3,
1680        );
1681
1682        self.selected = vec![process_id];
1683        self.tool = Tool::Select;
1684        cx.notify();
1685    }
1686
1687    pub fn add_flowchart_seed_at_viewport_center(&mut self, cx: &mut Context<Self>) {
1688        let center = self.viewport_center();
1689        self.add_flowchart_seed(center[0], center[1], cx);
1690    }
1691
1692    fn mindmap_meta(&self, id: u64) -> Option<MindMapNodeMeta> {
1693        self.scene
1694            .elements
1695            .iter()
1696            .find(|element| element.id == id)
1697            .and_then(|element| element.mindmap)
1698    }
1699
1700    fn is_mindmap_node(&self, id: u64) -> bool {
1701        self.mindmap_meta(id).is_some()
1702    }
1703
1704    fn is_mindmap_root(&self, id: u64) -> bool {
1705        self.mindmap_meta(id)
1706            .is_some_and(|meta| meta.parent.is_none())
1707    }
1708
1709    fn selected_mindmap_root(&self) -> Option<u64> {
1710        self.selected_single()
1711            .filter(|id| self.is_mindmap_root(*id))
1712    }
1713
1714    fn mindmap_root_direction(&self, root_id: u64) -> MindMapRootDirection {
1715        self.mindmap_meta(root_id)
1716            .map(|meta| meta.root_direction)
1717            .unwrap_or_default()
1718    }
1719
1720    fn mindmap_connector_style_for_root(&self, root_id: u64) -> MindMapConnectorStyle {
1721        self.mindmap_meta(root_id)
1722            .map(|meta| meta.connector_style)
1723            .unwrap_or_default()
1724    }
1725
1726    fn mindmap_root_of(&self, id: u64) -> Option<u64> {
1727        let mut current = id;
1728        loop {
1729            let meta = self.mindmap_meta(current)?;
1730            match meta.parent {
1731                Some(parent) => current = parent,
1732                None => return Some(current),
1733            }
1734        }
1735    }
1736
1737    fn mindmap_children(&self, parent: u64, side: MindMapSide) -> Vec<u64> {
1738        let mut children: Vec<(usize, u64)> = self
1739            .scene
1740            .elements
1741            .iter()
1742            .filter_map(|element| {
1743                let meta = element.mindmap?;
1744                (meta.parent == Some(parent) && meta.side == side)
1745                    .then_some((meta.order, element.id))
1746            })
1747            .collect();
1748        children.sort_by_key(|(order, id)| (*order, *id));
1749        children.into_iter().map(|(_, id)| id).collect()
1750    }
1751
1752    fn set_mindmap_node_side(&mut self, id: u64, side: MindMapSide) {
1753        if let Some(element) = self
1754            .scene
1755            .elements
1756            .iter_mut()
1757            .find(|element| element.id == id)
1758            && let Some(meta) = &mut element.mindmap
1759        {
1760            meta.side = side;
1761        }
1762        self.sync_mindmap_parent_link(id);
1763    }
1764
1765    fn sync_mindmap_parent_link(&mut self, child_id: u64) {
1766        let Some(meta) = self.mindmap_meta(child_id) else {
1767            return;
1768        };
1769        let Some(parent_id) = meta.parent else {
1770            return;
1771        };
1772        let parent_connector = match meta.side {
1773            MindMapSide::Right => 1,
1774            MindMapSide::Left => 3,
1775        };
1776        let child_connector = match meta.side {
1777            MindMapSide::Right => 3,
1778            MindMapSide::Left => 1,
1779        };
1780        for element in &mut self.scene.elements {
1781            let segment = match &mut element.kind {
1782                ElementKind::Line(segment) | ElementKind::Arrow(segment) => segment,
1783                _ => continue,
1784            };
1785            let start_id = segment.start_anchor.map(|anchor| anchor.element_id);
1786            let end_id = segment.end_anchor.map(|anchor| anchor.element_id);
1787            let links_parent_child = matches!((start_id, end_id), (Some(a), Some(b)) if (a == parent_id && b == child_id) || (a == child_id && b == parent_id));
1788            if !links_parent_child {
1789                continue;
1790            }
1791            if let Some(anchor) = &mut segment.start_anchor {
1792                if anchor.element_id == parent_id {
1793                    anchor.connector = parent_connector;
1794                } else if anchor.element_id == child_id {
1795                    anchor.connector = child_connector;
1796                }
1797            }
1798            if let Some(anchor) = &mut segment.end_anchor {
1799                if anchor.element_id == parent_id {
1800                    anchor.connector = parent_connector;
1801                } else if anchor.element_id == child_id {
1802                    anchor.connector = child_connector;
1803                }
1804            }
1805        }
1806        self.sync_segment_anchors_for(&[parent_id, child_id]);
1807    }
1808
1809    fn sync_mindmap_links_for_root(&mut self, root_id: u64) {
1810        let child_ids: Vec<u64> = self
1811            .scene
1812            .elements
1813            .iter()
1814            .filter_map(|element| {
1815                let meta = element.mindmap?;
1816                meta.parent?;
1817                (self.mindmap_root_of(element.id) == Some(root_id)).then_some(element.id)
1818            })
1819            .collect();
1820        for child_id in &child_ids {
1821            let Some(meta) = self.mindmap_meta(*child_id) else {
1822                continue;
1823            };
1824            let Some(parent_id) = meta.parent else {
1825                continue;
1826            };
1827            let parent_connector = match meta.side {
1828                MindMapSide::Right => 1,
1829                MindMapSide::Left => 3,
1830            };
1831            let child_connector = match meta.side {
1832                MindMapSide::Right => 3,
1833                MindMapSide::Left => 1,
1834            };
1835            for element in &mut self.scene.elements {
1836                let segment = match &mut element.kind {
1837                    ElementKind::Line(segment) | ElementKind::Arrow(segment) => segment,
1838                    _ => continue,
1839                };
1840                let start_id = segment.start_anchor.map(|anchor| anchor.element_id);
1841                let end_id = segment.end_anchor.map(|anchor| anchor.element_id);
1842                let links_parent_child = matches!((start_id, end_id), (Some(a), Some(b)) if (a == parent_id && b == *child_id) || (a == *child_id && b == parent_id));
1843                if !links_parent_child {
1844                    continue;
1845                }
1846                if let Some(anchor) = &mut segment.start_anchor {
1847                    if anchor.element_id == parent_id {
1848                        anchor.connector = parent_connector;
1849                    } else if anchor.element_id == *child_id {
1850                        anchor.connector = child_connector;
1851                    }
1852                }
1853                if let Some(anchor) = &mut segment.end_anchor {
1854                    if anchor.element_id == parent_id {
1855                        anchor.connector = parent_connector;
1856                    } else if anchor.element_id == *child_id {
1857                        anchor.connector = child_connector;
1858                    }
1859                }
1860            }
1861        }
1862        self.sync_segment_anchors_for(&child_ids);
1863    }
1864
1865    fn ordered_mindmap_children(&self, parent: u64) -> Vec<u64> {
1866        let mut children: Vec<(f32, f32, usize, u64)> = self
1867            .scene
1868            .elements
1869            .iter()
1870            .filter_map(|element| {
1871                let meta = element.mindmap?;
1872                (meta.parent == Some(parent)).then(|| {
1873                    let (x, y, _, h, _) =
1874                        box_like(&element.kind).unwrap_or((0.0, 0.0, 0.0, 0.0, 0.0));
1875                    (y + h / 2.0, x, meta.order, element.id)
1876                })
1877            })
1878            .collect();
1879        children.sort_by(|a, b| {
1880            a.0.total_cmp(&b.0)
1881                .then_with(|| a.1.total_cmp(&b.1))
1882                .then_with(|| a.2.cmp(&b.2))
1883                .then_with(|| a.3.cmp(&b.3))
1884        });
1885        children.into_iter().map(|(_, _, _, id)| id).collect()
1886    }
1887
1888    fn set_mindmap_children_side(&mut self, parent: u64, side: MindMapSide) {
1889        for child_id in self.ordered_mindmap_children(parent) {
1890            self.set_mindmap_node_side(child_id, side);
1891        }
1892    }
1893
1894    fn set_mindmap_children_side_alternating(&mut self, parent: u64) {
1895        for (index, child_id) in self
1896            .ordered_mindmap_children(parent)
1897            .into_iter()
1898            .enumerate()
1899        {
1900            let side = if index % 2 == 0 {
1901                MindMapSide::Right
1902            } else {
1903                MindMapSide::Left
1904            };
1905            self.set_mindmap_node_side(child_id, side);
1906        }
1907    }
1908
1909    fn reindex_mindmap_children(&mut self, parent: u64) {
1910        for side in [MindMapSide::Left, MindMapSide::Right] {
1911            let children = self.mindmap_children(parent, side);
1912            for (order, child_id) in children.into_iter().enumerate() {
1913                if let Some(element) = self
1914                    .scene
1915                    .elements
1916                    .iter_mut()
1917                    .find(|element| element.id == child_id)
1918                    && let Some(meta) = &mut element.mindmap
1919                {
1920                    meta.order = order;
1921                }
1922                self.reindex_mindmap_children(child_id);
1923            }
1924        }
1925    }
1926
1927    fn set_mindmap_root_direction(
1928        &mut self,
1929        root_id: u64,
1930        direction: MindMapRootDirection,
1931        cx: &mut Context<Self>,
1932    ) {
1933        if let Some(element) = self
1934            .scene
1935            .elements
1936            .iter_mut()
1937            .find(|element| element.id == root_id)
1938            && let Some(meta) = &mut element.mindmap
1939        {
1940            meta.root_direction = direction;
1941        }
1942        match direction {
1943            MindMapRootDirection::Left => {
1944                self.set_mindmap_children_side(root_id, MindMapSide::Left)
1945            }
1946            MindMapRootDirection::Right => {
1947                self.set_mindmap_children_side(root_id, MindMapSide::Right)
1948            }
1949            MindMapRootDirection::Both => self.set_mindmap_children_side_alternating(root_id),
1950        }
1951        self.reindex_mindmap_children(root_id);
1952        self.relayout_mindmap_tree(root_id);
1953        cx.notify();
1954    }
1955
1956    fn set_mindmap_connector_style(
1957        &mut self,
1958        root_id: u64,
1959        style: MindMapConnectorStyle,
1960        cx: &mut Context<Self>,
1961    ) {
1962        if let Some(element) = self
1963            .scene
1964            .elements
1965            .iter_mut()
1966            .find(|element| element.id == root_id)
1967            && let Some(meta) = &mut element.mindmap
1968        {
1969            meta.connector_style = style;
1970        }
1971        cx.notify();
1972    }
1973
1974    fn set_mindmap_node_position(&mut self, id: u64, x: f32, y: f32) {
1975        if let Some(element) = self
1976            .scene
1977            .elements
1978            .iter_mut()
1979            .find(|element| element.id == id)
1980            && let ElementKind::RoundRect(geom) = &mut element.kind
1981        {
1982            geom.x = x;
1983            geom.y = y;
1984        }
1985    }
1986
1987    fn mindmap_node_size(&self, id: u64, zoom: f32) -> (f32, f32) {
1988        self.scene
1989            .elements
1990            .iter()
1991            .find(|element| element.id == id)
1992            .and_then(|element| box_like(&element.kind).map(|(_, _, w, h, _)| (w, h)))
1993            .unwrap_or((MINDMAP_NODE_W / zoom, MINDMAP_NODE_H / zoom))
1994    }
1995
1996    fn side_stack_height(&self, parent: u64, side: MindMapSide, zoom: f32) -> f32 {
1997        let children = self.mindmap_children(parent, side);
1998        if children.is_empty() {
1999            return 0.0;
2000        }
2001        let gap_y = MINDMAP_BRANCH_GAP_Y / zoom;
2002        children
2003            .into_iter()
2004            .enumerate()
2005            .fold(0.0, |acc, (index, child_id)| {
2006                acc + if index > 0 { gap_y } else { 0.0 }
2007                    + self.mindmap_subtree_height(child_id, zoom)
2008            })
2009    }
2010
2011    fn mindmap_subtree_height(&self, id: u64, zoom: f32) -> f32 {
2012        let (_, node_h) = self.mindmap_node_size(id, zoom);
2013        node_h.max(
2014            self.side_stack_height(id, MindMapSide::Left, zoom)
2015                .max(self.side_stack_height(id, MindMapSide::Right, zoom)),
2016        )
2017    }
2018
2019    fn relayout_mindmap_subtree(&mut self, node_id: u64, moved: &mut Vec<u64>, zoom: f32) {
2020        self.relayout_mindmap_children(node_id, MindMapSide::Left, moved, zoom);
2021        self.relayout_mindmap_children(node_id, MindMapSide::Right, moved, zoom);
2022    }
2023
2024    fn relayout_mindmap_children(
2025        &mut self,
2026        parent_id: u64,
2027        side: MindMapSide,
2028        moved: &mut Vec<u64>,
2029        zoom: f32,
2030    ) {
2031        let children = self.mindmap_children(parent_id, side);
2032        if children.is_empty() {
2033            return;
2034        }
2035        let Some((px, py, pw, ph, _)) = self
2036            .scene
2037            .elements
2038            .iter()
2039            .find(|element| element.id == parent_id)
2040            .and_then(|element| box_like(&element.kind))
2041        else {
2042            return;
2043        };
2044        let gap_x = MINDMAP_BRANCH_GAP_X / zoom;
2045        let gap_y = MINDMAP_BRANCH_GAP_Y / zoom;
2046        let total_h = children
2047            .iter()
2048            .enumerate()
2049            .fold(0.0, |acc, (index, child_id)| {
2050                acc + if index > 0 { gap_y } else { 0.0 }
2051                    + self.mindmap_subtree_height(*child_id, zoom)
2052            });
2053        let mut cursor_y = py + ph / 2.0 - total_h / 2.0;
2054        for child_id in children {
2055            let subtree_h = self.mindmap_subtree_height(child_id, zoom);
2056            let (cw, ch) = self.mindmap_node_size(child_id, zoom);
2057            let cy = cursor_y + subtree_h / 2.0;
2058            let x = match side {
2059                MindMapSide::Right => px + pw + gap_x,
2060                MindMapSide::Left => px - gap_x - cw,
2061            };
2062            self.set_mindmap_node_position(child_id, x, cy - ch / 2.0);
2063            moved.push(child_id);
2064            self.relayout_mindmap_subtree(child_id, moved, zoom);
2065            cursor_y += subtree_h + gap_y;
2066        }
2067    }
2068
2069    fn relayout_mindmap_tree(&mut self, root_id: u64) {
2070        let zoom = self.scene.camera.zoom.max(MIN_ZOOM);
2071        let mut moved = vec![root_id];
2072        self.relayout_mindmap_subtree(root_id, &mut moved, zoom);
2073        self.sync_mindmap_links_for_root(root_id);
2074        self.sync_segment_anchors_for(&moved);
2075    }
2076
2077    fn bump_mindmap_sibling_orders(&mut self, parent: u64, side: MindMapSide, from_order: usize) {
2078        for element in &mut self.scene.elements {
2079            if let Some(meta) = &mut element.mindmap
2080                && meta.parent == Some(parent)
2081                && meta.side == side
2082                && meta.order >= from_order
2083            {
2084                meta.order += 1;
2085            }
2086        }
2087    }
2088
2089    fn create_mindmap_node(
2090        &mut self,
2091        parent: u64,
2092        side: MindMapSide,
2093        order: usize,
2094        label: &str,
2095    ) -> u64 {
2096        self.bump_mindmap_sibling_orders(parent, side, order);
2097        let zoom = self.scene.camera.zoom.max(MIN_ZOOM);
2098        let id = self.next_id;
2099        self.next_id += 1;
2100        let w = MINDMAP_NODE_W / zoom;
2101        let h = MINDMAP_NODE_H / zoom;
2102        let (x, y) = self
2103            .scene
2104            .elements
2105            .iter()
2106            .find(|element| element.id == parent)
2107            .and_then(|element| box_like(&element.kind))
2108            .map(|(px, py, pw, ph, _)| match side {
2109                MindMapSide::Right => (
2110                    px + pw + MINDMAP_BRANCH_GAP_X / zoom,
2111                    py + ph / 2.0 - h / 2.0,
2112                ),
2113                MindMapSide::Left => (
2114                    px - MINDMAP_BRANCH_GAP_X / zoom - w,
2115                    py + ph / 2.0 - h / 2.0,
2116                ),
2117            })
2118            .unwrap_or((0.0, 0.0));
2119        self.scene.elements.push(Element {
2120            id,
2121            kind: ElementKind::RoundRect(BoxGeom {
2122                x,
2123                y,
2124                w,
2125                h,
2126                width: NIB / zoom,
2127                rotation: 0.0,
2128            }),
2129            stroke: Some(0x2563ebff),
2130            fill: Some(0xffffffff),
2131            label: Some(label.to_string()),
2132            label_color: Some(0x0f172aff),
2133            styles: Vec::new(),
2134            mindmap: Some(MindMapNodeMeta {
2135                parent: Some(parent),
2136                side,
2137                order,
2138                root_direction: MindMapRootDirection::Both,
2139                connector_style: MindMapConnectorStyle::Bezier,
2140            }),
2141        });
2142        let start_anchor = SegmentAnchor {
2143            element_id: parent,
2144            connector: match side {
2145                MindMapSide::Right => 1,
2146                MindMapSide::Left => 3,
2147            },
2148        };
2149        let end_anchor = SegmentAnchor {
2150            element_id: id,
2151            connector: match side {
2152                MindMapSide::Right => 3,
2153                MindMapSide::Left => 1,
2154            },
2155        };
2156        let [x1, y1] = connector_world_pos_in(&self.scene.elements, start_anchor).unwrap_or([x, y]);
2157        let [x2, y2] = connector_world_pos_in(&self.scene.elements, end_anchor).unwrap_or([x, y]);
2158        let line_id = self.next_id;
2159        self.next_id += 1;
2160        self.scene.elements.push(Element {
2161            id: line_id,
2162            kind: ElementKind::Arrow(SegGeom {
2163                x1,
2164                y1,
2165                x2,
2166                y2,
2167                width: NIB / zoom,
2168                style: SegmentStyle::Solid,
2169                start_anchor: Some(start_anchor),
2170                end_anchor: Some(end_anchor),
2171            }),
2172            stroke: Some(0x2563ebff),
2173            fill: None,
2174            label: None,
2175            label_color: None,
2176            styles: Vec::new(),
2177            mindmap: None,
2178        });
2179        if let Some(root_id) = self.mindmap_root_of(parent) {
2180            self.relayout_mindmap_tree(root_id);
2181        }
2182        id
2183    }
2184
2185    fn add_mindmap_relative(
2186        &mut self,
2187        source_id: u64,
2188        sibling: bool,
2189        window: &mut Window,
2190        cx: &mut Context<Self>,
2191    ) -> bool {
2192        let Some(meta) = self.mindmap_meta(source_id) else {
2193            return false;
2194        };
2195        let (parent, side, order) = if sibling {
2196            match meta.parent {
2197                Some(parent) => (parent, meta.side, meta.order + 1),
2198                None => (
2199                    source_id,
2200                    MindMapSide::Right,
2201                    self.mindmap_children(source_id, MindMapSide::Right).len(),
2202                ),
2203            }
2204        } else {
2205            (
2206                source_id,
2207                meta.side,
2208                self.mindmap_children(source_id, meta.side).len(),
2209            )
2210        };
2211        self.push_undo();
2212        let new_id = self.create_mindmap_node(parent, side, order, "");
2213        self.selected = vec![new_id];
2214        self.begin_text_edit(new_id, 0, window, cx);
2215        self.dirty = true;
2216        cx.notify();
2217        true
2218    }
2219
2220    fn mindmap_connector_style_for_element(
2221        &self,
2222        kind: &ElementKind,
2223    ) -> Option<MindMapConnectorStyle> {
2224        let seg = match kind {
2225            ElementKind::Line(seg) | ElementKind::Arrow(seg) => seg,
2226            _ => return None,
2227        };
2228        let start_root = seg
2229            .start_anchor
2230            .and_then(|anchor| self.mindmap_root_of(anchor.element_id));
2231        let end_root = seg
2232            .end_anchor
2233            .and_then(|anchor| self.mindmap_root_of(anchor.element_id));
2234        match (start_root, end_root) {
2235            (Some(a), Some(b)) if a == b => Some(self.mindmap_connector_style_for_root(a)),
2236            _ => None,
2237        }
2238    }
2239
2240    /// The world point at the center of the current viewport — where paste drops
2241    /// an image (the host has no access to the camera otherwise).
2242    pub fn viewport_center(&self) -> [f32; 2] {
2243        let b = self.bounds.get();
2244        let cam = self.scene.camera;
2245        let z = cam.zoom.max(MIN_ZOOM);
2246        [
2247            cam.x + f32::from(b.size.width) / 2.0 / z,
2248            cam.y + f32::from(b.size.height) / 2.0 / z,
2249        ]
2250    }
2251
2252    /// The current board document (for the host to persist).
2253    pub fn scene(&self) -> &Scene {
2254        &self.scene
2255    }
2256
2257    /// Build a local-thumbnail focus spec for the board. The board default has
2258    /// no natural root, so `Auto` means:
2259    ///
2260    /// - selected content, if any
2261    /// - otherwise the current viewport
2262    /// - otherwise all content
2263    pub fn local_thumbnail_spec(
2264        &self,
2265        width_px: f32,
2266        height_px: f32,
2267    ) -> Option<LocalThumbnailSpec> {
2268        self.local_thumbnail_spec_for_mode(LocalThumbnailMode::Auto, width_px, height_px)
2269    }
2270
2271    pub fn local_thumbnail_snapshot(
2272        &self,
2273        width_px: f32,
2274        height_px: f32,
2275    ) -> Option<LocalThumbnailSnapshot> {
2276        self.local_thumbnail_snapshot_for_mode(LocalThumbnailMode::Auto, width_px, height_px)
2277    }
2278
2279    pub fn local_thumbnail_spec_for_mode(
2280        &self,
2281        mode: LocalThumbnailMode,
2282        width_px: f32,
2283        height_px: f32,
2284    ) -> Option<LocalThumbnailSpec> {
2285        let scene_bounds = self.scene_bbox();
2286        let (anchor_element_id, focus) = match mode {
2287            LocalThumbnailMode::Auto => self
2288                .selection_bbox()
2289                .map(|bb| (self.selected_single(), bb))
2290                .or_else(|| self.viewport_world_bbox().map(|bb| (None, bb)))
2291                .or_else(|| scene_bounds.map(|bb| (None, bb)))?,
2292            LocalThumbnailMode::Selection => {
2293                let bb = self.selection_bbox()?;
2294                (self.selected_single(), bb)
2295            }
2296            LocalThumbnailMode::Viewport => (None, self.viewport_world_bbox()?),
2297            LocalThumbnailMode::AllContent => (None, scene_bounds?),
2298            LocalThumbnailMode::Element(id) => (Some(id), self.element_bbox(id)?),
2299        };
2300        Some(self.thumbnail_spec_from_bbox(
2301            anchor_element_id,
2302            focus,
2303            scene_bounds,
2304            width_px,
2305            height_px,
2306        ))
2307    }
2308
2309    pub fn local_thumbnail_snapshot_for_mode(
2310        &self,
2311        mode: LocalThumbnailMode,
2312        width_px: f32,
2313        height_px: f32,
2314    ) -> Option<LocalThumbnailSnapshot> {
2315        Some(LocalThumbnailSnapshot {
2316            scene: self.scene.clone(),
2317            spec: self.local_thumbnail_spec_for_mode(mode, width_px, height_px)?,
2318        })
2319    }
2320
2321    /// The lone selected id, if exactly one element is selected. Single-element
2322    /// manipulation (resize, endpoints, edit) only applies then.
2323    fn selected_single(&self) -> Option<u64> {
2324        match self.selected.as_slice() {
2325            [id] => Some(*id),
2326            _ => None,
2327        }
2328    }
2329
2330    fn is_selected(&self, id: u64) -> bool {
2331        self.selected.contains(&id)
2332    }
2333
2334    fn element_bbox(&self, id: u64) -> Option<(f32, f32, f32, f32)> {
2335        self.scene
2336            .elements
2337            .iter()
2338            .find(|e| e.id == id)
2339            .map(|e| bbox(&e.kind))
2340    }
2341
2342    fn scene_bbox(&self) -> Option<(f32, f32, f32, f32)> {
2343        scene_bbox_for_local_thumbnail(&self.scene)
2344    }
2345
2346    fn viewport_world_bbox(&self) -> Option<(f32, f32, f32, f32)> {
2347        let b = self.bounds.get();
2348        let vw = f32::from(b.size.width);
2349        let vh = f32::from(b.size.height);
2350        if vw <= 1.0 || vh <= 1.0 {
2351            return None;
2352        }
2353        let cam = self.scene.camera;
2354        let z = cam.zoom.max(MIN_ZOOM);
2355        Some((cam.x, cam.y, cam.x + vw / z, cam.y + vh / z))
2356    }
2357
2358    fn render_viewport(&self, fallback_size: Option<gpui::Size<Pixels>>) -> Option<WorldViewport> {
2359        let bounds = self.bounds.get();
2360        let size = if f32::from(bounds.size.width) > 1.0 && f32::from(bounds.size.height) > 1.0 {
2361            bounds.size
2362        } else {
2363            fallback_size?
2364        };
2365        let camera = self.scene.camera;
2366        WorldViewport::from_canvas(
2367            f32::from(size.width),
2368            f32::from(size.height),
2369            camera.x,
2370            camera.y,
2371            camera.zoom.max(MIN_ZOOM),
2372            VIEWPORT_CULL_MARGIN_PX,
2373        )
2374    }
2375
2376    fn thumbnail_spec_from_bbox(
2377        &self,
2378        anchor_element_id: Option<u64>,
2379        focus: (f32, f32, f32, f32),
2380        scene_bounds: Option<(f32, f32, f32, f32)>,
2381        width_px: f32,
2382        height_px: f32,
2383    ) -> LocalThumbnailSpec {
2384        local_thumbnail_spec_from_bbox(anchor_element_id, focus, scene_bounds, width_px, height_px)
2385    }
2386
2387    /// World-space bounds enclosing the whole selection, or `None` if empty.
2388    fn selection_bbox(&self) -> Option<(f32, f32, f32, f32)> {
2389        let mut it = self
2390            .scene
2391            .elements
2392            .iter()
2393            .filter(|e| self.selected.contains(&e.id))
2394            .map(|e| bbox(&e.kind));
2395        let first = it.next()?;
2396        Some(it.fold(first, |a, b| {
2397            (a.0.min(b.0), a.1.min(b.1), a.2.max(b.2), a.3.max(b.3))
2398        }))
2399    }
2400
2401    fn aligned_move_delta(&self, dx: f32, dy: f32) -> (f32, f32, AlignmentGuides) {
2402        const SNAP_PX: f32 = 6.0;
2403        let Some(selection) = self.selection_bbox() else {
2404            return (dx, dy, AlignmentGuides::default());
2405        };
2406        let threshold = SNAP_PX / self.scene.camera.zoom.max(MIN_ZOOM);
2407        let moving_x = [
2408            selection.0 + dx,
2409            (selection.0 + selection.2) / 2.0 + dx,
2410            selection.2 + dx,
2411        ];
2412        let moving_y = [
2413            selection.1 + dy,
2414            (selection.1 + selection.3) / 2.0 + dy,
2415            selection.3 + dy,
2416        ];
2417        let mut best_x: Option<(f32, f32)> = None;
2418        let mut best_y: Option<(f32, f32)> = None;
2419
2420        for element in self
2421            .scene
2422            .elements
2423            .iter()
2424            .filter(|element| !self.selected.contains(&element.id))
2425        {
2426            let bb = bbox(&element.kind);
2427            for moving in moving_x {
2428                for target in [bb.0, (bb.0 + bb.2) / 2.0, bb.2] {
2429                    let correction = target - moving;
2430                    if correction.abs() <= threshold
2431                        && best_x.is_none_or(|(best, _)| correction.abs() < best.abs())
2432                    {
2433                        best_x = Some((correction, target));
2434                    }
2435                }
2436            }
2437            for moving in moving_y {
2438                for target in [bb.1, (bb.1 + bb.3) / 2.0, bb.3] {
2439                    let correction = target - moving;
2440                    if correction.abs() <= threshold
2441                        && best_y.is_none_or(|(best, _)| correction.abs() < best.abs())
2442                    {
2443                        best_y = Some((correction, target));
2444                    }
2445                }
2446            }
2447        }
2448
2449        (
2450            dx + best_x.map_or(0.0, |(correction, _)| correction),
2451            dy + best_y.map_or(0.0, |(correction, _)| correction),
2452            AlignmentGuides {
2453                vertical: best_x.map(|(_, target)| target),
2454                horizontal: best_y.map(|(_, target)| target),
2455            },
2456        )
2457    }
2458
2459    fn sync_segment_anchors_for(&mut self, changed_ids: &[u64]) {
2460        if changed_ids.is_empty() {
2461            return;
2462        }
2463        let elements = self.scene.elements.clone();
2464        for element in &mut self.scene.elements {
2465            let segment = match &mut element.kind {
2466                ElementKind::Line(segment) | ElementKind::Arrow(segment) => segment,
2467                _ => continue,
2468            };
2469            if let Some(anchor) = segment.start_anchor
2470                && changed_ids.contains(&anchor.element_id)
2471                && let Some(pos) = connector_world_pos_in(&elements, anchor)
2472            {
2473                segment.x1 = pos[0];
2474                segment.y1 = pos[1];
2475            }
2476            if let Some(anchor) = segment.end_anchor
2477                && changed_ids.contains(&anchor.element_id)
2478                && let Some(pos) = connector_world_pos_in(&elements, anchor)
2479            {
2480                segment.x2 = pos[0];
2481                segment.y2 = pos[1];
2482            }
2483        }
2484    }
2485
2486    fn detach_segment_bindings_for_move(&mut self, ids: &[u64]) {
2487        for element in &mut self.scene.elements {
2488            if !ids.contains(&element.id) {
2489                continue;
2490            }
2491            if let ElementKind::Line(segment) | ElementKind::Arrow(segment) = &mut element.kind {
2492                if segment
2493                    .start_anchor
2494                    .is_some_and(|anchor| !ids.contains(&anchor.element_id))
2495                {
2496                    segment.start_anchor = None;
2497                }
2498                if segment
2499                    .end_anchor
2500                    .is_some_and(|anchor| !ids.contains(&anchor.element_id))
2501                {
2502                    segment.end_anchor = None;
2503                }
2504            }
2505        }
2506    }
2507
2508    fn set_segment_endpoint_anchor(
2509        &mut self,
2510        segment_id: u64,
2511        endpoint: usize,
2512        anchor: Option<SegmentAnchor>,
2513    ) {
2514        let pos = anchor.and_then(|anchor| {
2515            connector_world_pos_in(&self.scene.elements, anchor).map(|pos| (anchor, pos))
2516        });
2517        let Some(element) = self
2518            .scene
2519            .elements
2520            .iter_mut()
2521            .find(|element| element.id == segment_id)
2522        else {
2523            return;
2524        };
2525        let segment = match &mut element.kind {
2526            ElementKind::Line(segment) | ElementKind::Arrow(segment) => segment,
2527            _ => return,
2528        };
2529        if endpoint == 0 {
2530            segment.start_anchor = anchor;
2531            if let Some((_, pos)) = pos {
2532                segment.x1 = pos[0];
2533                segment.y1 = pos[1];
2534            }
2535        } else {
2536            segment.end_anchor = anchor;
2537            if let Some((_, pos)) = pos {
2538                segment.x2 = pos[0];
2539                segment.y2 = pos[1];
2540            }
2541        }
2542    }
2543
2544    /// Whether a *group* rotation applies: more than one element selected, at
2545    /// least one of which can rotate (so an all-cards group offers no grip).
2546    fn group_rotatable(&self) -> bool {
2547        self.selected.len() > 1
2548            && self
2549                .scene
2550                .elements
2551                .iter()
2552                .any(|e| self.selected.contains(&e.id) && rotatable(&e.kind))
2553    }
2554
2555    /// The active tool (e.g. for host-driven chrome).
2556    pub fn tool(&self) -> Tool {
2557        self.tool
2558    }
2559
2560    /// Switch the active drawing tool. Leaving Select clears the selection.
2561    /// Always closes an open tool flyout (the tool was just chosen).
2562    pub fn set_tool(&mut self, tool: Tool, cx: &mut Context<Self>) {
2563        if self.read_only {
2564            self.tool = Tool::Pan;
2565            self.selected.clear();
2566            self.open_group = None;
2567            cx.notify();
2568            return;
2569        }
2570        self.open_group = None;
2571        if self.tool != tool {
2572            self.tool = tool;
2573            if tool != Tool::Select {
2574                self.selected.clear();
2575            }
2576        }
2577        cx.notify();
2578    }
2579
2580    /// Reset the viewport to the origin at 100% (also bound to double-click).
2581    pub fn reset_view(&mut self, cx: &mut Context<Self>) {
2582        self.scene.camera = Camera::default();
2583        self.dirty = true;
2584        cx.notify();
2585    }
2586
2587    /// Zoom in/out a step, centered on the canvas.
2588    pub fn zoom_in(&mut self, cx: &mut Context<Self>) {
2589        self.zoom_centered(1.2, cx);
2590    }
2591    pub fn zoom_out(&mut self, cx: &mut Context<Self>) {
2592        self.zoom_centered(1.0 / 1.2, cx);
2593    }
2594
2595    fn zoom_centered(&mut self, factor: f32, cx: &mut Context<Self>) {
2596        let b = self.bounds.get();
2597        let rx = f32::from(b.size.width) / 2.0;
2598        let ry = f32::from(b.size.height) / 2.0;
2599        self.scene.camera.zoom_about(rx, ry, factor);
2600        self.dirty = true;
2601        cx.notify();
2602    }
2603
2604    /// Snapshot the scene for undo (before a mutation), capping history.
2605    fn push_undo(&mut self) {
2606        self.history.push(self.scene.clone());
2607        if self.history.len() > UNDO_CAP {
2608            self.history.remove(0);
2609        }
2610        self.redo.clear();
2611    }
2612
2613    /// Revert the last change.
2614    pub fn undo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2615        if let Some(prev) = self.history.pop() {
2616            self.redo.push(std::mem::replace(&mut self.scene, prev));
2617            self.selected.clear();
2618            self.dirty = true;
2619            cx.notify();
2620            self.flush(window, cx);
2621        }
2622    }
2623
2624    /// Re-apply the last undone change.
2625    pub fn redo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2626        if let Some(next) = self.redo.pop() {
2627            self.history.push(std::mem::replace(&mut self.scene, next));
2628            self.selected.clear();
2629            self.dirty = true;
2630            cx.notify();
2631            self.flush(window, cx);
2632        }
2633    }
2634
2635    /// Delete the selected elements.
2636    fn delete_selected(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2637        if self.selected.is_empty() {
2638            return;
2639        }
2640        self.push_undo();
2641        let gone = std::mem::take(&mut self.selected);
2642        self.scene.elements.retain(|e| !gone.contains(&e.id));
2643        self.editing = None;
2644        self.dirty = true;
2645        cx.notify();
2646        self.flush(window, cx);
2647    }
2648
2649    /// Move the selected elements through the paint order (their position in
2650    /// `elements`; later = painted on top, so it can cover earlier ones). One step
2651    /// or all the way, per `op`. A no-op (already at that edge) leaves undo/redo
2652    /// untouched.
2653    fn reorder_selection(&mut self, op: ZOrder, window: &mut Window, cx: &mut Context<Self>) {
2654        if self.selected.is_empty() {
2655            return;
2656        }
2657        let sel = self.selected.clone();
2658        let on = |id: u64| sel.contains(&id);
2659        self.push_undo();
2660        let before: Vec<u64> = self.scene.elements.iter().map(|e| e.id).collect();
2661        let els = &mut self.scene.elements;
2662        match op {
2663            // Stable partition: the non-selected keep their order and the selected
2664            // keep theirs, so a multi-selection moves as a block.
2665            ZOrder::ToFront => els.sort_by_key(|e| on(e.id)),
2666            ZOrder::ToBack => els.sort_by_key(|e| !on(e.id)),
2667            // One step: swap each selected past its adjacent non-selected neighbor,
2668            // walking away from the destination edge so an element isn't moved twice
2669            // and selected elements don't leapfrog each other.
2670            ZOrder::Forward => {
2671                for i in (0..els.len().saturating_sub(1)).rev() {
2672                    if on(els[i].id) && !on(els[i + 1].id) {
2673                        els.swap(i, i + 1);
2674                    }
2675                }
2676            }
2677            ZOrder::Backward => {
2678                for i in 1..els.len() {
2679                    if on(els[i].id) && !on(els[i - 1].id) {
2680                        els.swap(i, i - 1);
2681                    }
2682                }
2683            }
2684        }
2685        if self.scene.elements.iter().map(|e| e.id).eq(before) {
2686            self.history.pop(); // nothing moved — drop the speculative snapshot
2687            return;
2688        }
2689        self.dirty = true;
2690        cx.notify();
2691        self.flush(window, cx);
2692    }
2693
2694    /// Flush pending changes through the host's persistence hook.
2695    fn flush(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2696        if !self.dirty {
2697            return;
2698        }
2699        self.dirty = false;
2700        if let Some(f) = self.on_change.clone() {
2701            f(self.scene.to_json(), window, cx);
2702        }
2703    }
2704
2705    // --- templates ---------------------------------------------------------
2706
2707    /// Serialize the current selection: the selected elements translated so their
2708    /// collective bounding box starts at the origin (so the group can be re-based
2709    /// anywhere when applied). `None` if nothing is selected. Used for both saving
2710    /// a template and copying to the clipboard — the two share this format, so a
2711    /// copied selection can be pasted on any board (see [`Self::paste_elements`]).
2712    fn selection_json(&self) -> Option<String> {
2713        let sel: Vec<&Element> = self
2714            .scene
2715            .elements
2716            .iter()
2717            .filter(|e| self.selected.contains(&e.id))
2718            .collect();
2719        if sel.is_empty() {
2720            return None;
2721        }
2722        let (minx, miny) = sel
2723            .iter()
2724            .fold((f32::INFINITY, f32::INFINITY), |(mx, my), e| {
2725                let (x0, y0, ..) = bbox(&e.kind);
2726                (mx.min(x0), my.min(y0))
2727            });
2728        let elems: Vec<Element> = sel
2729            .iter()
2730            .map(|e| {
2731                let mut c = (*e).clone();
2732                translate(&mut c.kind, -minx, -miny);
2733                c
2734            })
2735            .collect();
2736        serde_json::to_string(&elems).ok()
2737    }
2738
2739    /// Hand the current selection to the host to be saved as a named template.
2740    fn save_selection_as_template(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2741        self.context_menu = None;
2742        if let Some(json) = self.selection_json()
2743            && let Some(f) = self.on_save_template.clone()
2744        {
2745            f(json, window, cx);
2746        }
2747        cx.notify();
2748    }
2749
2750    /// Stamp template `index` onto the board, centered in the current viewport,
2751    /// with fresh ids; the new elements become the selection.
2752    fn apply_template(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
2753        let Some(elems) = self.templates.get(index).map(|t| t.elements.clone()) else {
2754            return;
2755        };
2756        self.templates_open = false;
2757        self.stamp_elements(&elems, window, cx);
2758    }
2759
2760    /// Place `elems` (origin-normalized, as produced by [`Self::selection_json`])
2761    /// onto the board, centered in the current viewport with fresh ids; they
2762    /// become the new selection. Shared by template apply and clipboard paste.
2763    /// No-op for an empty group.
2764    fn stamp_elements(&mut self, elems: &[Element], window: &mut Window, cx: &mut Context<Self>) {
2765        if elems.is_empty() {
2766            return;
2767        }
2768        self.open_group = None;
2769        self.push_undo();
2770        // Center the (origin-normalized) group in the viewport.
2771        let b = self.bounds.get();
2772        let cam = self.scene.camera;
2773        let z = cam.zoom.max(MIN_ZOOM);
2774        let (tw, th) = elements_extent(elems);
2775        let off = [
2776            cam.x + (f32::from(b.size.width) / 2.0) / z - tw / 2.0,
2777            cam.y + (f32::from(b.size.height) / 2.0) / z - th / 2.0,
2778        ];
2779        let mut new_ids = Vec::with_capacity(elems.len());
2780        for e in elems {
2781            let mut c = e.clone();
2782            translate(&mut c.kind, off[0], off[1]);
2783            c.id = self.next_id;
2784            self.next_id += 1;
2785            new_ids.push(c.id);
2786            self.scene.elements.push(c);
2787        }
2788        self.selected = new_ids;
2789        self.tool = Tool::Select;
2790        self.dirty = true;
2791        self.flush(window, cx);
2792        cx.notify();
2793    }
2794
2795    /// Copy the selection to the clipboard via the host's `on_copy` hook (the
2796    /// crate can't touch the system clipboard). Returns whether anything was
2797    /// copied. `⌘X` reuses this, then deletes.
2798    fn copy_selection(&self, window: &mut Window, cx: &mut Context<Self>) -> bool {
2799        let Some(json) = self.selection_json() else {
2800            return false;
2801        };
2802        if let Some(f) = self.on_copy.clone() {
2803            f(json, window, cx);
2804        }
2805        true
2806    }
2807
2808    /// Paste a serialized `Vec<Element>` (the JSON a [`CopyFn`] wrote) onto the
2809    /// board — centered in the viewport, selected, with fresh ids. Ignores invalid
2810    /// JSON. The host calls this from its [`PasteFn`] when the clipboard holds
2811    /// whiteboard elements rather than an image.
2812    pub fn paste_elements(&mut self, json: &str, window: &mut Window, cx: &mut Context<Self>) {
2813        if let Ok(elems) = serde_json::from_str::<Vec<Element>>(json) {
2814            self.stamp_elements(&elems, window, cx);
2815        }
2816    }
2817
2818    /// Ask the host to delete a stored template (right-click a card). The host
2819    /// confirms, removes it, and feeds the updated list back.
2820    fn delete_template(&mut self, id: i64, window: &mut Window, cx: &mut Context<Self>) {
2821        if let Some(f) = self.on_delete_template.clone() {
2822            f(id, window, cx);
2823        }
2824    }
2825
2826    /// A template preview card for the gallery modal: a scaled mini-paint of the
2827    /// template's shapes over its name. Click to stamp it; right-click to delete.
2828    /// (Text and page-cards don't appear in the mini-paint — only drawn shapes —
2829    /// but they're still placed on apply.)
2830    fn template_card(
2831        &self,
2832        index: usize,
2833        ink: Hsla,
2834        text: Hsla,
2835        grid: Hsla,
2836        bg: Hsla,
2837        cx: &mut Context<Self>,
2838    ) -> gpui::AnyElement {
2839        let t = &self.templates[index];
2840        let id = t.id;
2841        let name: SharedString = t.name.clone().into();
2842        let elems = t.elements.clone();
2843        let (tw, th) = elements_extent(&elems);
2844        let preview = canvas(
2845            |_, _, _| {},
2846            move |bounds, _, window: &mut Window, _: &mut App| {
2847                let pad = 8.0;
2848                let aw = f32::from(bounds.size.width) - 2.0 * pad;
2849                let ah = f32::from(bounds.size.height) - 2.0 * pad;
2850                if tw <= 0.0 || th <= 0.0 || aw <= 0.0 || ah <= 0.0 {
2851                    return;
2852                }
2853                // Fit the (origin-normalized) template into the card, centered,
2854                // never magnifying past 1:1.
2855                let scale = (aw / tw).min(ah / th).min(1.0);
2856                let ox = (f32::from(bounds.size.width) - tw * scale) / 2.0;
2857                let oy = (f32::from(bounds.size.height) - th * scale) / 2.0;
2858                let cam = Camera {
2859                    x: -ox / scale,
2860                    y: -oy / scale,
2861                    zoom: scale,
2862                };
2863                for e in &elems {
2864                    let stroke = e.stroke.map_or(ink, u32_to_hsla);
2865                    let fill = e.fill.map(u32_to_hsla);
2866                    paint_element(&e.kind, None, cam, bounds.origin, stroke, fill, window);
2867                }
2868            },
2869        )
2870        .size_full();
2871        div()
2872            .id(("wb-template", index))
2873            .flex()
2874            .flex_col()
2875            .items_center()
2876            .gap(px(5.0))
2877            .p(px(6.0))
2878            .rounded(px(8.0))
2879            .hover(|s| s.bg(grid))
2880            .child(
2881                div()
2882                    .w(px(150.0))
2883                    .h(px(104.0))
2884                    .rounded(px(6.0))
2885                    .bg(bg)
2886                    .border_1()
2887                    .border_color(grid)
2888                    .child(preview),
2889            )
2890            .child(
2891                div()
2892                    .w(px(150.0))
2893                    .h(px(15.0))
2894                    .overflow_hidden()
2895                    .text_size(px(11.0))
2896                    .text_color(text)
2897                    .child(name),
2898            )
2899            .on_click(
2900                cx.listener(move |this, _ev, window, cx| this.apply_template(index, window, cx)),
2901            )
2902            .on_mouse_down(
2903                MouseButton::Right,
2904                cx.listener(move |this, _ev, window, cx| this.delete_template(id, window, cx)),
2905            )
2906            .into_any_element()
2907    }
2908
2909    // --- color picker ------------------------------------------------------
2910
2911    /// The color the picker should start from for `target`: the single
2912    /// selection's color (if any), else the active color, else a default.
2913    fn seed_color(&self, target: PickerTarget) -> u32 {
2914        let from_sel = self
2915            .selected_single()
2916            .and_then(|id| self.scene.elements.iter().find(|e| e.id == id))
2917            .and_then(|e| match target {
2918                PickerTarget::Stroke => e.stroke,
2919                PickerTarget::Fill => e.fill,
2920                PickerTarget::Text => e.label_color,
2921            });
2922        let active = match target {
2923            PickerTarget::Stroke => self.active_stroke,
2924            PickerTarget::Fill => self.active_fill,
2925            PickerTarget::Text => self.active_text,
2926        };
2927        from_sel.or(active).unwrap_or(0x4080f0ff)
2928    }
2929
2930    /// Point the picker's HSVA controls at `target`'s current color.
2931    fn seed_picker(&mut self, target: PickerTarget) {
2932        let c = self.seed_color(target);
2933        let (h, s, v) = u32_to_hsv(c);
2934        self.picker = Some(Picker {
2935            target,
2936            h,
2937            s,
2938            v,
2939            a: u32_alpha(c),
2940        });
2941    }
2942
2943    /// Open or close the color picker. Opening seeds the controls from the
2944    /// stroke color (selection's, else active, else a default).
2945    fn toggle_picker(&mut self, cx: &mut Context<Self>) {
2946        self.open_group = None;
2947        self.templates_open = false;
2948        self.width_open = false;
2949        self.font_open = false;
2950        if self.picker.is_some() {
2951            self.picker = None;
2952        } else {
2953            self.seed_picker(PickerTarget::Stroke);
2954        }
2955        cx.notify();
2956    }
2957
2958    /// Open / close the thickness-preset flyout (closing the other popovers).
2959    fn toggle_width(&mut self, cx: &mut Context<Self>) {
2960        self.picker = None;
2961        self.open_group = None;
2962        self.templates_open = false;
2963        self.context_menu = None;
2964        self.font_open = false;
2965        self.width_open = !self.width_open;
2966        cx.notify();
2967    }
2968
2969    /// Set the active stroke thickness (screen px) for new elements and apply it to
2970    /// the selection, *without* undo/flush — used for live slider drags (undo is
2971    /// pushed at drag start, flush on release, like the color strips).
2972    fn set_width_live(&mut self, w: f32, cx: &mut Context<Self>) {
2973        self.active_width = w;
2974        if !self.selected.is_empty() {
2975            let zoom = self.scene.camera.zoom.max(MIN_ZOOM);
2976            let sel = self.selected.clone();
2977            for e in self.scene.elements.iter_mut() {
2978                if sel.contains(&e.id) {
2979                    set_kind_width(&mut e.kind, w / zoom);
2980                }
2981            }
2982            self.dirty = true;
2983        }
2984        cx.notify();
2985    }
2986
2987    /// A discrete thickness choice (a preset swatch): pushes undo, applies, and
2988    /// flushes, then closes the flyout.
2989    fn set_width(&mut self, w: f32, window: &mut Window, cx: &mut Context<Self>) {
2990        self.width_open = false;
2991        if !self.selected.is_empty() {
2992            self.push_undo();
2993        }
2994        self.set_width_live(w, cx);
2995        self.flush(window, cx);
2996    }
2997
2998    /// Map a 0..1 slider fraction to a width (screen px), snapped to 0.5px steps.
2999    fn width_from_frac(frac: f32) -> f32 {
3000        let w = WIDTH_MIN + frac.clamp(0.0, 1.0) * (WIDTH_MAX - WIDTH_MIN);
3001        (w * 2.0).round() / 2.0
3002    }
3003
3004    /// Open the given tool category's flyout (or close it if already open).
3005    /// Closes the color picker so only one popover shows at a time.
3006    fn toggle_group(&mut self, group: ToolGroup, cx: &mut Context<Self>) {
3007        self.picker = None;
3008        self.templates_open = false;
3009        self.width_open = false;
3010        self.font_open = false;
3011        self.open_group = if self.open_group == Some(group) {
3012            None
3013        } else {
3014            Some(group)
3015        };
3016        cx.notify();
3017    }
3018
3019    /// Open / close the templates gallery modal (closing the other popovers).
3020    fn toggle_templates(&mut self, cx: &mut Context<Self>) {
3021        self.picker = None;
3022        self.open_group = None;
3023        self.width_open = false;
3024        self.context_menu = None;
3025        self.font_open = false;
3026        self.templates_open = !self.templates_open;
3027        cx.notify();
3028    }
3029
3030    /// Open / close the font flyout (upload a face / revert to default), closing
3031    /// the other popovers.
3032    fn toggle_font(&mut self, cx: &mut Context<Self>) {
3033        self.picker = None;
3034        self.open_group = None;
3035        self.width_open = false;
3036        self.templates_open = false;
3037        self.context_menu = None;
3038        self.font_open = !self.font_open;
3039        cx.notify();
3040    }
3041
3042    /// Switch which property (stroke / fill) the picker edits, re-seeding its
3043    /// controls from that property's current color.
3044    fn set_picker_target(&mut self, target: PickerTarget, cx: &mut Context<Self>) {
3045        if self.picker.map(|p| p.target) != Some(target) {
3046            self.seed_picker(target);
3047            cx.notify();
3048        }
3049    }
3050
3051    /// The picker's current target (stroke unless the picker says otherwise).
3052    fn picker_target(&self) -> PickerTarget {
3053        self.picker.map_or(PickerTarget::Stroke, |p| p.target)
3054    }
3055
3056    /// Apply `color` to the active target on the active swatch and the selection,
3057    /// *without* undo/flush — used for live picker drags (undo is pushed once at
3058    /// drag start; the flush happens on release).
3059    fn set_color_live(&mut self, color: Option<u32>, cx: &mut Context<Self>) {
3060        let target = self.picker_target();
3061        match target {
3062            PickerTarget::Stroke => self.active_stroke = color,
3063            PickerTarget::Fill => self.active_fill = color,
3064            PickerTarget::Text => self.active_text = color,
3065        }
3066        if !self.selected.is_empty() {
3067            let sel = self.selected.clone();
3068            for e in self.scene.elements.iter_mut() {
3069                if !sel.contains(&e.id) {
3070                    continue;
3071                }
3072                match target {
3073                    PickerTarget::Stroke => e.stroke = color,
3074                    // Fill + label color only attach to closed shapes.
3075                    PickerTarget::Fill => {
3076                        if is_closed_shape(&e.kind) {
3077                            e.fill = color;
3078                        }
3079                    }
3080                    PickerTarget::Text => {
3081                        if is_closed_shape(&e.kind) {
3082                            e.label_color = color;
3083                        }
3084                    }
3085                }
3086            }
3087            self.dirty = true;
3088        }
3089        cx.notify();
3090    }
3091
3092    /// A discrete, undoable color choice (a swatch, or the Auto / None reset).
3093    /// Recolors the selection and syncs the picker controls to the chosen color.
3094    fn pick_color(&mut self, color: Option<u32>, window: &mut Window, cx: &mut Context<Self>) {
3095        if !self.selected.is_empty() {
3096            self.push_undo();
3097        }
3098        if let (Some(c), Some(p)) = (color, self.picker.as_mut()) {
3099            let (h, s, v) = u32_to_hsv(c);
3100            // Keep the hue stable on greys (s == 0) so the strip thumb won't jump.
3101            if s > 0.0 {
3102                p.h = h;
3103            }
3104            p.s = s;
3105            p.v = v;
3106            p.a = u32_alpha(c);
3107        }
3108        self.set_color_live(color, cx);
3109        self.flush(window, cx);
3110    }
3111
3112    /// Saturation/brightness under a window-coords position in the SV square.
3113    fn sv_from_pos(&self, pos: Point<Pixels>) -> (f32, f32) {
3114        let b = self.sv_bounds.get();
3115        let w = f32::from(b.size.width).max(1.0);
3116        let h = f32::from(b.size.height).max(1.0);
3117        let s = ((f32::from(pos.x) - f32::from(b.origin.x)) / w).clamp(0.0, 1.0);
3118        let v = 1.0 - ((f32::from(pos.y) - f32::from(b.origin.y)) / h).clamp(0.0, 1.0);
3119        (s, v)
3120    }
3121
3122    /// A 0..1 fraction along a horizontal strip (hue or alpha) under `pos`.
3123    fn frac_x(&self, bounds: Bounds<Pixels>, pos: Point<Pixels>) -> f32 {
3124        let w = f32::from(bounds.size.width).max(1.0);
3125        ((f32::from(pos.x) - f32::from(bounds.origin.x)) / w).clamp(0.0, 1.0)
3126    }
3127
3128    /// The picker's current color as a packed int (for live application).
3129    fn picker_u32(&self) -> Option<u32> {
3130        self.picker.map(|p| hsva_to_u32(p.h, p.s, p.v, p.a))
3131    }
3132
3133    /// World point under a window-coords event position.
3134    fn event_to_world(&self, p: Point<Pixels>) -> [f32; 2] {
3135        let (rx, ry) = self.relative(p);
3136        let (wx, wy) = self.scene.camera.screen_to_world(rx, ry);
3137        [wx, wy]
3138    }
3139
3140    /// If `pos` (window coords) is on a manipulation handle of the current
3141    /// selection, what to begin. Lines/arrows manipulate by their two
3142    /// endpoints; everything else by its bounding-box corners (a line's bbox is
3143    /// degenerate, which would make corner-resize wildly imprecise).
3144    fn handle_hit(&self, pos: Point<Pixels>) -> Option<HandleGrab> {
3145        let cam = self.scene.camera;
3146        let origin = self.bounds.get().origin;
3147        let cursor = self.event_to_world(pos);
3148        let near = |wx: f32, wy: f32, ox: f32, oy: f32| {
3149            let s = to_screen(wx, wy, cam, origin);
3150            let (dx, dy) = (
3151                f32::from(pos.x) - (f32::from(s.x) + ox),
3152                f32::from(pos.y) - (f32::from(s.y) + oy),
3153            );
3154            dx * dx + dy * dy <= HANDLE_GRAB * HANDLE_GRAB
3155        };
3156
3157        // A multi-selection offers a group rotate grip (if anything's rotatable)
3158        // and proportional corner-resize of the group bounds.
3159        if self.selected.len() > 1 {
3160            let bb = self.selection_bbox()?;
3161            if self.group_rotatable() {
3162                let (rx, ry) = rotate_handle_for_bbox(bb, cam, origin);
3163                let (dx, dy) = (f32::from(pos.x) - rx, f32::from(pos.y) - ry);
3164                if dx * dx + dy * dy <= HANDLE_GRAB * HANDLE_GRAB {
3165                    return Some(HandleGrab::Rotate);
3166                }
3167            }
3168            let wc = [(bb.0, bb.1), (bb.2, bb.1), (bb.0, bb.3), (bb.2, bb.3)];
3169            let collect_orig = || -> Vec<(u64, ElementKind)> {
3170                self.scene
3171                    .elements
3172                    .iter()
3173                    .filter(|e| self.is_selected(e.id))
3174                    .map(|e| (e.id, e.kind.clone()))
3175                    .collect()
3176            };
3177            for i in 0..4 {
3178                if near(wc[i].0, wc[i].1, 0.0, 0.0) {
3179                    let opp = wc[3 - i];
3180                    return Some(HandleGrab::GroupCorner(GroupResizing {
3181                        handle: ResizeHandle::Corner,
3182                        anchor: [opp.0, opp.1],
3183                        from: [wc[i].0, wc[i].1],
3184                        grab: [wc[i].0 - cursor[0], wc[i].1 - cursor[1]],
3185                        orig: collect_orig(),
3186                    }));
3187                }
3188            }
3189            // Edge midpoints stretch one axis (per-axis group resize), each about
3190            // the opposite edge: a left/right grip scales x, a top/bottom grip y.
3191            let (mx, my) = ((bb.0 + bb.2) / 2.0, (bb.1 + bb.3) / 2.0);
3192            let edges = [
3193                (ResizeHandle::EdgeX, [bb.0, my], (0.0, 0.0), [bb.2, my]),
3194                (ResizeHandle::EdgeX, [bb.2, my], (0.0, 0.0), [bb.0, my]),
3195                (ResizeHandle::EdgeY, [mx, bb.1], (0.0, 0.0), [mx, bb.3]),
3196                (ResizeHandle::EdgeY, [mx, bb.3], (0.0, 0.0), [mx, bb.1]),
3197            ];
3198            for (handle, from, (ox, oy), anchor) in edges {
3199                if near(from[0], from[1], ox, oy) {
3200                    return Some(HandleGrab::GroupCorner(GroupResizing {
3201                        handle,
3202                        anchor,
3203                        from,
3204                        grab: [from[0] - cursor[0], from[1] - cursor[1]],
3205                        orig: collect_orig(),
3206                    }));
3207                }
3208            }
3209            return None;
3210        }
3211
3212        let id = self.selected_single()?;
3213        let kind = &self.scene.elements.iter().find(|e| e.id == id)?.kind;
3214
3215        // The rotate handle floats above every rotatable element (not text/cards).
3216        if rotatable(kind) {
3217            let (rx, ry) = rotate_handle_screen(kind, cam, origin);
3218            let (dx, dy) = (f32::from(pos.x) - rx, f32::from(pos.y) - ry);
3219            if dx * dx + dy * dy <= HANDLE_GRAB * HANDLE_GRAB {
3220                return Some(HandleGrab::Rotate);
3221            }
3222        }
3223
3224        if let ElementKind::Line(s) | ElementKind::Arrow(s) = kind {
3225            for (which, (wx, wy)) in [(s.x1, s.y1), (s.x2, s.y2)].into_iter().enumerate() {
3226                if near(wx, wy, 0.0, 0.0) {
3227                    return Some(HandleGrab::Endpoint(EndpointDrag { id, which }));
3228                }
3229            }
3230            return None;
3231        }
3232
3233        // Box-like (rect/ellipse/text): corners on the (possibly rotated) box.
3234        // Upright resizes about the opposite corner (free aspect ratio); rotated
3235        // resizes proportionally about the center — a similarity transform that
3236        // stays correct under rotation (set up here, applied in `on_move`).
3237        if let Some((x, y, w, h, rot)) = box_like(kind) {
3238            let cu = box_padded_corners(x, y, w, h, rot, 0.0);
3239            let cp = cu;
3240            let center = [x + w / 2.0, y + h / 2.0];
3241            let rotated = rot.abs() > ROT_EPS;
3242            for i in 0..4 {
3243                if near(cp[i][0], cp[i][1], 0.0, 0.0) {
3244                    let anchor = if rotated { center } else { cu[(i + 2) % 4] };
3245                    return Some(HandleGrab::Corner(Resizing {
3246                        id,
3247                        handle: ResizeHandle::Corner,
3248                        anchor,
3249                        from: cu[i],
3250                        grab: [cu[i][0] - cursor[0], cu[i][1] - cursor[1]],
3251                        orig: kind.clone(),
3252                    }));
3253                }
3254            }
3255            // Edge midpoints stretch one axis. Offered only upright (a rotated
3256            // box's edges aren't world-axis-aligned) and not for text — a single
3257            // font size can't stretch one axis, so its edges would just duplicate
3258            // the proportional corners.
3259            if !rotated && !matches!(kind, ElementKind::Text(_)) {
3260                let mid = |a: [f32; 2], b: [f32; 2]| [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0];
3261                if let Some(r) = self.edge_handle_hit(
3262                    id,
3263                    kind,
3264                    &near,
3265                    cursor,
3266                    mid(cu[0], cu[1]),
3267                    mid(cu[1], cu[2]),
3268                    mid(cu[2], cu[3]),
3269                    mid(cu[3], cu[0]),
3270                ) {
3271                    return Some(r);
3272                }
3273            }
3274            return None;
3275        }
3276
3277        // Draw / Embed: corners on the padded AABB (offset the hit to match).
3278        let bb = bbox(kind);
3279        let wc = [(bb.0, bb.1), (bb.2, bb.1), (bb.0, bb.3), (bb.2, bb.3)];
3280        for i in 0..4 {
3281            if near(wc[i].0, wc[i].1, 0.0, 0.0) {
3282                let opp = wc[3 - i];
3283                return Some(HandleGrab::Corner(Resizing {
3284                    id,
3285                    handle: ResizeHandle::Corner,
3286                    anchor: [opp.0, opp.1],
3287                    from: [wc[i].0, wc[i].1],
3288                    grab: [wc[i].0 - cursor[0], wc[i].1 - cursor[1]],
3289                    orig: kind.clone(),
3290                }));
3291            }
3292        }
3293        // Edge midpoints stretch one axis (these kinds are always upright).
3294        let (mx, my) = ((bb.0 + bb.2) / 2.0, (bb.1 + bb.3) / 2.0);
3295        self.edge_handle_hit(
3296            id,
3297            kind,
3298            &near,
3299            cursor,
3300            [mx, bb.1],
3301            [bb.2, my],
3302            [mx, bb.3],
3303            [bb.0, my],
3304        )
3305    }
3306
3307    /// Shared edge-handle hit-test for a single element: the four edge midpoints
3308    /// (`top`/`right`/`bottom`/`left`, world space) each stretch one axis about the
3309    /// opposite edge. `near` is the caller's screen-space proximity test.
3310    #[allow(clippy::too_many_arguments)]
3311    fn edge_handle_hit(
3312        &self,
3313        id: u64,
3314        kind: &ElementKind,
3315        near: &dyn Fn(f32, f32, f32, f32) -> bool,
3316        cursor: [f32; 2],
3317        top: [f32; 2],
3318        right: [f32; 2],
3319        bottom: [f32; 2],
3320        left: [f32; 2],
3321    ) -> Option<HandleGrab> {
3322        let edges = [
3323            (ResizeHandle::EdgeY, top, (0.0, 0.0), bottom),
3324            (ResizeHandle::EdgeY, bottom, (0.0, 0.0), top),
3325            (ResizeHandle::EdgeX, right, (0.0, 0.0), left),
3326            (ResizeHandle::EdgeX, left, (0.0, 0.0), right),
3327        ];
3328        for (handle, from, (ox, oy), anchor) in edges {
3329            if near(from[0], from[1], ox, oy) {
3330                return Some(HandleGrab::Corner(Resizing {
3331                    id,
3332                    handle,
3333                    anchor,
3334                    from,
3335                    grab: [from[0] - cursor[0], from[1] - cursor[1]],
3336                    orig: kind.clone(),
3337                }));
3338            }
3339        }
3340        None
3341    }
3342
3343    /// The topmost connector point under the cursor. Connectors are exposed on
3344    /// box-like visual elements (closed shapes, text, image) at the midpoints of
3345    /// their rotated top/right/bottom/left edges.
3346    fn connector_at(&self, pos: Point<Pixels>) -> Option<ConnectPoint> {
3347        let origin = self.bounds.get().origin;
3348        let near_px = CONNECTOR_BUTTON_SIZE * 0.65;
3349        let (sx, sy) = (f32::from(pos.x), f32::from(pos.y));
3350        let id = self.selected_single()?;
3351        let element = self
3352            .scene
3353            .elements
3354            .iter()
3355            .find(|element| element.id == id && connector_capable(&element.kind))?;
3356        let points = connector_points(&element.kind);
3357        let buttons = connector_button_centers(&element.kind, self.scene.camera, origin);
3358        buttons.into_iter().enumerate().find_map(|(index, button)| {
3359            let dx = f32::from(button.x) - sx;
3360            let dy = f32::from(button.y) - sy;
3361            (dx * dx + dy * dy <= near_px * near_px).then_some(ConnectPoint {
3362                id,
3363                index,
3364                pos: points[index],
3365            })
3366        })
3367    }
3368
3369    /// Update hover connector state and request repaint only when it changes.
3370    fn update_hover_connector(&mut self, pos: Point<Pixels>, cx: &mut Context<Self>) {
3371        let next = if self.tool == Tool::Select
3372            && self.editing.is_none()
3373            && self.pending.is_none()
3374            && self.connecting.is_none()
3375            && self.drag_from.is_none()
3376            && self.resizing.is_none()
3377            && self.group_resizing.is_none()
3378            && self.endpoint.is_none()
3379            && self.rotating.is_none()
3380            && self.marquee.is_none()
3381        {
3382            self.connector_at(pos)
3383        } else {
3384            None
3385        };
3386        if self.hovered_connector != next {
3387            self.hovered_connector = next;
3388            cx.notify();
3389        }
3390    }
3391
3392    /// The topmost text element under a world point (within `pad`), if any.
3393    fn text_at(&self, p: [f32; 2], pad: f32) -> Option<u64> {
3394        self.scene
3395            .elements
3396            .iter()
3397            .rev()
3398            .find(|e| matches!(e.kind, ElementKind::Text(_)) && hit_test(&e.kind, p[0], p[1], pad))
3399            .map(|e| e.id)
3400    }
3401
3402    /// The topmost closed shape (rect / ellipse / …) under a world point — for
3403    /// editing its centered label.
3404    fn shape_at(&self, p: [f32; 2], pad: f32) -> Option<u64> {
3405        self.scene
3406            .elements
3407            .iter()
3408            .rev()
3409            .find(|e| is_closed_shape(&e.kind) && hit_test(&e.kind, p[0], p[1], pad))
3410            .map(|e| e.id)
3411    }
3412
3413    /// The topmost page-card under a world point: `(element id, page id)`.
3414    fn embed_at(&self, p: [f32; 2], pad: f32) -> Option<(u64, i64)> {
3415        self.scene
3416            .elements
3417            .iter()
3418            .rev()
3419            .find_map(|e| match &e.kind {
3420                ElementKind::Embed(em) if hit_test(&e.kind, p[0], p[1], pad) => {
3421                    Some((e.id, em.page_id))
3422                }
3423                _ => None,
3424            })
3425    }
3426
3427    /// Nearest edge connector on another shape while a connection is being
3428    /// dragged. This path is intentionally separate from `connector_at`, whose
3429    /// hit targets are the selected source shape's outward arrow buttons.
3430    fn snap_connector_at(
3431        &self,
3432        pos: Point<Pixels>,
3433        source_id: u64,
3434    ) -> Option<(ConnectPoint, bool)> {
3435        const SHOW_DISTANCE_PX: f32 = 64.0;
3436        const SNAP_DISTANCE_PX: f32 = 20.0;
3437        let origin = self.bounds.get().origin;
3438        let (sx, sy) = (f32::from(pos.x), f32::from(pos.y));
3439        let world = self.event_to_world(pos);
3440        let target = self
3441            .scene
3442            .elements
3443            .iter()
3444            .rev()
3445            .filter(|element| element.id != source_id && connector_capable(&element.kind))
3446            .find(|element| {
3447                hit_test(
3448                    &element.kind,
3449                    world[0],
3450                    world[1],
3451                    SHOW_DISTANCE_PX / self.scene.camera.zoom.max(MIN_ZOOM),
3452                )
3453            })?;
3454        connector_points(&target.kind)
3455            .into_iter()
3456            .enumerate()
3457            .map(|(index, point)| {
3458                let screen = to_screen(point[0], point[1], self.scene.camera, origin);
3459                let dx = f32::from(screen.x) - sx;
3460                let dy = f32::from(screen.y) - sy;
3461                let distance_sq = dx * dx + dy * dy;
3462                (
3463                    distance_sq,
3464                    ConnectPoint {
3465                        id: target.id,
3466                        index,
3467                        pos: point,
3468                    },
3469                )
3470            })
3471            .min_by(|a, b| a.0.total_cmp(&b.0))
3472            .map(|(distance_sq, connector)| {
3473                (
3474                    connector,
3475                    distance_sq <= SNAP_DISTANCE_PX * SNAP_DISTANCE_PX,
3476                )
3477            })
3478    }
3479
3480    fn on_left_down(&mut self, ev: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
3481        if self.panning {
3482            return;
3483        }
3484        if self.read_only {
3485            self.panning = true;
3486            self.last = ev.position;
3487            return;
3488        }
3489
3490        // The draggable toolbar (its pill isn't occluded — like the picker): a
3491        // press on the grip starts a drag (double-click resets to top-center); a
3492        // press anywhere else on the pill is consumed so its buttons handle their
3493        // own clicks. Both must be caught before any canvas logic below.
3494        if self.toolbar_grip_bounds.get().contains(&ev.position) {
3495            self.start_toolbar_drag(ev.position, ev.click_count >= 2, window, cx);
3496            return;
3497        }
3498        if self.toolbar_bounds.get().contains(&ev.position) {
3499            return;
3500        }
3501
3502        // A press dismisses an open right-click menu (its own button is occluded,
3503        // so a press reaching here is outside it).
3504        if self.context_menu.take().is_some() {
3505            cx.notify();
3506            return;
3507        }
3508        // A press on the canvas closes an open tool flyout (the flyout itself is
3509        // occluded, so a press reaching here is outside it).
3510        if self.open_group.is_some() {
3511            self.open_group = None;
3512            cx.notify();
3513            return;
3514        }
3515        // Same for the font flyout (occluded; a press here is outside it).
3516        if self.font_open {
3517            self.font_open = false;
3518            cx.notify();
3519            return;
3520        }
3521        // The thickness flyout: a press on its slider starts a width drag; a press
3522        // elsewhere on the panel is consumed (presets fire via their own `on_click`);
3523        // a press outside dismisses it. The panel isn't occluded so drags reach here,
3524        // like the color picker.
3525        if self.width_open {
3526            let pos = ev.position;
3527            if self.width_bounds.get().contains(&pos) {
3528                if !self.selected.is_empty() {
3529                    self.push_undo();
3530                }
3531                self.picker_drag = Some(PickerDrag::Width);
3532                let w = Self::width_from_frac(self.frac_x(self.width_bounds.get(), pos));
3533                self.set_width_live(w, cx);
3534                return;
3535            }
3536            if self.width_panel_bounds.get().contains(&pos) {
3537                return;
3538            }
3539            self.width_open = false;
3540            cx.notify();
3541            return;
3542        }
3543
3544        // The color picker takes input priority while open. Its draggable regions
3545        // (SV square, hue strip) start a drag here; presses on the rest of the
3546        // panel are consumed (the swatch / Auto buttons fire via their own
3547        // `on_click`); a press anywhere else closes it.
3548        if self.picker.is_some() {
3549            let pos = ev.position;
3550            if self.sv_bounds.get().contains(&pos) {
3551                if !self.selected.is_empty() {
3552                    self.push_undo();
3553                }
3554                self.picker_drag = Some(PickerDrag::Sv);
3555                let (s, v) = self.sv_from_pos(pos);
3556                if let Some(p) = self.picker.as_mut() {
3557                    (p.s, p.v) = (s, v);
3558                }
3559                if let Some(c) = self.picker_u32() {
3560                    self.set_color_live(Some(c), cx);
3561                }
3562                return;
3563            }
3564            if self.hue_bounds.get().contains(&pos) {
3565                if !self.selected.is_empty() {
3566                    self.push_undo();
3567                }
3568                self.picker_drag = Some(PickerDrag::Hue);
3569                let h = self.frac_x(self.hue_bounds.get(), pos);
3570                if let Some(p) = self.picker.as_mut() {
3571                    p.h = h;
3572                }
3573                if let Some(c) = self.picker_u32() {
3574                    self.set_color_live(Some(c), cx);
3575                }
3576                return;
3577            }
3578            if self.alpha_bounds.get().contains(&pos) {
3579                if !self.selected.is_empty() {
3580                    self.push_undo();
3581                }
3582                self.picker_drag = Some(PickerDrag::Alpha);
3583                let a = self.frac_x(self.alpha_bounds.get(), pos);
3584                if let Some(p) = self.picker.as_mut() {
3585                    p.a = a;
3586                }
3587                if let Some(c) = self.picker_u32() {
3588                    self.set_color_live(Some(c), cx);
3589                }
3590                return;
3591            }
3592            if self.picker_bounds.get().contains(&pos) {
3593                return;
3594            }
3595            self.picker = None;
3596            cx.notify();
3597            return;
3598        }
3599
3600        // Take keyboard focus so the board's shortcuts (tool keys, ⌫, ⌘Z…) work
3601        // after a click on the canvas.
3602        self.focus.focus(window, cx);
3603
3604        let p = self.event_to_world(ev.position);
3605        let zoom = self.scene.camera.zoom.max(MIN_ZOOM);
3606
3607        // A press inside the text being edited drives its caret / selection (no
3608        // commit). A press anywhere else commits the edit, then falls through.
3609        if let Some(id) = self.editing {
3610            if self.point_in_editing_text(id, p) {
3611                self.place_caret_from_click(id, p, ev, window, cx);
3612                return;
3613            }
3614            self.commit_text(window, cx);
3615        }
3616
3617        // Ctrl + left-drag always pans the canvas, regardless of the active tool
3618        // or what's under the pointer. Reuses the same panning state as the Pan
3619        // tool and middle-button drag.
3620        if ev.modifiers.control {
3621            self.panning = true;
3622            self.last = ev.position;
3623            return;
3624        }
3625
3626        if ev.click_count >= 2 {
3627            self.pending = None;
3628            // Existing text and shape labels have one consistent edit gesture:
3629            // double-click, regardless of which tool happened to be active.
3630            if let Some(id) = self.text_at(p, SELECT_PAD / zoom) {
3631                self.selected = vec![id];
3632                self.editing = Some(id);
3633                self.place_caret_from_click(id, p, ev, window, cx);
3634                return;
3635            }
3636            if let Some(id) = self.shape_at(p, SELECT_PAD / zoom) {
3637                self.selected = vec![id];
3638                self.editing = Some(id);
3639                self.place_caret_from_click(id, p, ev, window, cx);
3640                return;
3641            }
3642            if self.tool == Tool::Select {
3643                // Double-click a page-card opens its page.
3644                if let Some((id, page_id)) = self.embed_at(p, SELECT_PAD / zoom) {
3645                    self.selected = vec![id];
3646                    if let Some(f) = self.on_open.clone() {
3647                        f(page_id, window, cx);
3648                    }
3649                    cx.notify();
3650                    return;
3651                }
3652            }
3653            self.reset_view(cx);
3654            return;
3655        }
3656
3657        // A single click on any existing element always means selection, even if
3658        // a drawing/text tool is currently active. This prevents accidentally
3659        // drawing over a shape when the user only meant to select it. A second
3660        // click (handled above) is the sole path into text/label editing.
3661        if self.tool != Tool::Select {
3662            let pad = SELECT_PAD / zoom;
3663            let hit = self
3664                .scene
3665                .elements
3666                .iter()
3667                .rev()
3668                .find(|element| hit_test(&element.kind, p[0], p[1], pad))
3669                .map(|element| element.id);
3670            if let Some(id) = hit {
3671                self.tool = Tool::Select;
3672                if ev.modifiers.shift {
3673                    if let Some(index) = self.selected.iter().position(|&selected| selected == id) {
3674                        self.selected.remove(index);
3675                    } else {
3676                        self.selected.push(id);
3677                    }
3678                } else {
3679                    self.selected = vec![id];
3680                }
3681                self.drag_from = None;
3682                cx.notify();
3683                return;
3684            }
3685        }
3686
3687        // Pan tool: a left-drag pans the canvas (the default navigation tool;
3688        // double-click above still recenters). Reuses the middle-drag machinery.
3689        if self.tool == Tool::Pan {
3690            self.panning = true;
3691            self.last = ev.position;
3692            return;
3693        }
3694
3695        if self.tool == Tool::Text {
3696            // A single click on existing text only selects it. Editing existing
3697            // content is deliberately double-click-only; clicking empty canvas
3698            // still creates a fresh text element and immediately edits that.
3699            if let Some(id) = self.text_at(p, SELECT_PAD / zoom) {
3700                self.selected = vec![id];
3701                cx.notify();
3702            } else {
3703                self.push_undo();
3704                let id = self.next_id;
3705                self.next_id += 1;
3706                self.scene.elements.push(Element {
3707                    id,
3708                    kind: ElementKind::Text(TextGeom {
3709                        x: p[0],
3710                        y: p[1],
3711                        content: String::new(),
3712                        size: TEXT_SIZE / zoom,
3713                        rotation: 0.0,
3714                        measured_w: 0.0,
3715                        measured_h: 0.0,
3716                    }),
3717                    stroke: self.active_stroke,
3718                    fill: None,
3719                    label: None,
3720                    label_color: None,
3721                    styles: Vec::new(),
3722                    mindmap: None,
3723                });
3724                self.selected = vec![id];
3725                self.begin_text_edit(id, 0, window, cx);
3726                self.dirty = true;
3727                cx.notify();
3728            }
3729            return;
3730        }
3731
3732        if self.tool == Tool::MindMap {
3733            self.add_mindmap_seed(p[0], p[1], cx);
3734            return;
3735        }
3736
3737        if self.tool == Tool::Flowchart {
3738            self.add_flowchart_seed(p[0], p[1], cx);
3739            return;
3740        }
3741
3742        if self.tool == Tool::Embed {
3743            // The host picks a page, then calls back into `add_embed`.
3744            if let Some(f) = self.on_place_embed.clone() {
3745                f(p[0], p[1], window, cx);
3746            }
3747            return;
3748        }
3749
3750        if self.tool == Tool::Image {
3751            // The host picks an image file, then calls back into `add_image_at`.
3752            if let Some(f) = self.on_place_image.clone() {
3753                f(p[0], p[1], window, cx);
3754            }
3755            return;
3756        }
3757
3758        if self.tool == Tool::Select {
3759            // A connector point on a hovered shape starts drawing a line from that
3760            // exact side/midpoint without switching away from Select.
3761            if let Some(cp) = self.connector_at(ev.position) {
3762                let width = self.active_width / zoom;
3763                self.pending = Some(Pending {
3764                    anchor: cp.pos,
3765                    kind: ElementKind::Arrow(SegGeom {
3766                        x1: cp.pos[0],
3767                        y1: cp.pos[1],
3768                        x2: cp.pos[0],
3769                        y2: cp.pos[1],
3770                        width,
3771                        style: SegmentStyle::Solid,
3772                        start_anchor: Some(SegmentAnchor {
3773                            element_id: cp.id,
3774                            connector: cp.index,
3775                        }),
3776                        end_anchor: None,
3777                    }),
3778                });
3779                self.connecting = Some(ConnectDrag { from: cp });
3780                self.hovered_connector = Some(cp);
3781                cx.notify();
3782                return;
3783            }
3784            // A handle on the current selection takes priority.
3785            if let Some(grab) = self.handle_hit(ev.position) {
3786                self.push_undo();
3787                match grab {
3788                    HandleGrab::Corner(rs) => self.resizing = Some(rs),
3789                    HandleGrab::GroupCorner(gr) => self.group_resizing = Some(gr),
3790                    HandleGrab::Endpoint(ep) => self.endpoint = Some(ep),
3791                    HandleGrab::Rotate => {
3792                        // Pivot = the whole selection's bounds center (a single
3793                        // element's own center, or the group's). Snap on the lone
3794                        // element's orientation, or — for a group — the first
3795                        // oriented member's, so it squares to horizontal/vertical
3796                        // (falling back to quarter-turns if nothing's oriented).
3797                        if let Some(bb) = self.selection_bbox() {
3798                            let center = [(bb.0 + bb.2) / 2.0, (bb.1 + bb.3) / 2.0];
3799                            let base = match self.selected_single() {
3800                                Some(id) => self
3801                                    .scene
3802                                    .elements
3803                                    .iter()
3804                                    .find(|e| e.id == id)
3805                                    .and_then(|e| reference_angle(&e.kind)),
3806                                None => self
3807                                    .scene
3808                                    .elements
3809                                    .iter()
3810                                    .filter(|e| self.is_selected(e.id))
3811                                    .find_map(|e| reference_angle(&e.kind))
3812                                    .or(Some(0.0)),
3813                            };
3814                            let start_pointer = (p[1] - center[1]).atan2(p[0] - center[0]);
3815                            self.rotating = Some(Rotating {
3816                                center,
3817                                start_pointer,
3818                                applied: 0.0,
3819                                base,
3820                            });
3821                        }
3822                    }
3823                }
3824                cx.notify();
3825                return;
3826            }
3827            // Otherwise hit-test topmost-first.
3828            let pad = SELECT_PAD / zoom;
3829            let hit = self
3830                .scene
3831                .elements
3832                .iter()
3833                .rev()
3834                .find(|e| hit_test(&e.kind, p[0], p[1], pad))
3835                .map(|e| e.id);
3836            match hit {
3837                Some(id) if ev.modifiers.shift => {
3838                    // Shift-click toggles membership (no move).
3839                    if let Some(pos) = self.selected.iter().position(|&s| s == id) {
3840                        self.selected.remove(pos);
3841                    } else {
3842                        self.selected.push(id);
3843                    }
3844                    self.drag_from = None;
3845                }
3846                Some(id) => {
3847                    // Click an unselected element selects only it; clicking one
3848                    // already in the selection keeps the group (so a drag moves
3849                    // them all). Either way, arm a move.
3850                    if !self.is_selected(id) {
3851                        self.selected = vec![id];
3852                    }
3853                    self.drag_from = Some(p);
3854                    // Capture the primary element's top-left so the move can drive
3855                    // an absolute target (and snap it) without drifting.
3856                    self.move_origin = self
3857                        .selected
3858                        .first()
3859                        .and_then(|&pid| self.scene.elements.iter().find(|e| e.id == pid))
3860                        .map(|e| {
3861                            let (x, y, ..) = bbox(&e.kind);
3862                            [x, y]
3863                        })
3864                        .unwrap_or(p);
3865                    self.moved = false;
3866                }
3867                None => {
3868                    // Empty space: clear (unless extending) and start a marquee.
3869                    if !ev.modifiers.shift {
3870                        self.selected.clear();
3871                    }
3872                    self.marquee = Some((p, p));
3873                    self.drag_from = None;
3874                }
3875            }
3876            cx.notify();
3877            return;
3878        }
3879
3880        let width = self.active_width / zoom;
3881        // While the snap modifier (Option) is held, start the shape on a grid
3882        // line; the move handler snaps the opposite corner / endpoint too.
3883        let anchor = if ev.modifiers.alt {
3884            [snap_grid(p[0]), snap_grid(p[1])]
3885        } else {
3886            p
3887        };
3888        // A zero-size box anchored at the press; the move handler grows it.
3889        let box0 = BoxGeom {
3890            x: anchor[0],
3891            y: anchor[1],
3892            w: 0.0,
3893            h: 0.0,
3894            width,
3895            rotation: 0.0,
3896        };
3897        let kind = match self.tool {
3898            // Freehand keeps the raw point — strokes aren't grid-aligned.
3899            Tool::Pen => ElementKind::Draw(Stroke {
3900                points: vec![p],
3901                width,
3902            }),
3903            Tool::Rect => ElementKind::Rect(box0),
3904            Tool::Ellipse => ElementKind::Ellipse(box0),
3905            Tool::Diamond => ElementKind::Diamond(box0),
3906            Tool::Triangle => ElementKind::Triangle(box0),
3907            Tool::RoundRect => ElementKind::RoundRect(box0),
3908            Tool::Star => ElementKind::Star(box0),
3909            Tool::Hexagon => ElementKind::Hexagon(box0),
3910            Tool::Line => ElementKind::Line(SegGeom {
3911                x1: anchor[0],
3912                y1: anchor[1],
3913                x2: anchor[0],
3914                y2: anchor[1],
3915                width,
3916                style: SegmentStyle::Solid,
3917                start_anchor: None,
3918                end_anchor: None,
3919            }),
3920            Tool::Arrow => ElementKind::Arrow(SegGeom {
3921                x1: anchor[0],
3922                y1: anchor[1],
3923                x2: anchor[0],
3924                y2: anchor[1],
3925                width,
3926                style: SegmentStyle::Solid,
3927                start_anchor: None,
3928                end_anchor: None,
3929            }),
3930            Tool::DashedArrow => ElementKind::Arrow(SegGeom {
3931                x1: anchor[0],
3932                y1: anchor[1],
3933                x2: anchor[0],
3934                y2: anchor[1],
3935                width,
3936                style: SegmentStyle::Dashed,
3937                start_anchor: None,
3938                end_anchor: None,
3939            }),
3940            // These tools don't create a drag-element here (handled earlier).
3941            Tool::Pan
3942            | Tool::Select
3943            | Tool::Text
3944            | Tool::MindMap
3945            | Tool::Flowchart
3946            | Tool::Embed
3947            | Tool::Image => return,
3948        };
3949        self.pending = Some(Pending { anchor, kind });
3950        cx.notify();
3951    }
3952
3953    fn on_left_up(&mut self, _ev: &MouseUpEvent, window: &mut Window, cx: &mut Context<Self>) {
3954        // Finish a toolbar drag (persist the new position).
3955        if self.toolbar_drag.is_some() {
3956            self.commit_toolbar_drag(window, cx);
3957            return;
3958        }
3959        // End a text-selection drag (the selection is applied live in `on_move`).
3960        if self.text_selecting {
3961            self.text_selecting = false;
3962            return;
3963        }
3964        // End a Pan-tool drag (left-button pan).
3965        if self.panning {
3966            self.panning = false;
3967            cx.notify();
3968            self.flush(window, cx);
3969            return;
3970        }
3971        // End a picker drag: the live changes are already applied; just persist.
3972        if self.picker_drag.take().is_some() {
3973            self.flush(window, cx);
3974            return;
3975        }
3976        if self.resizing.take().is_some()
3977            || self.group_resizing.take().is_some()
3978            || self.endpoint.take().is_some()
3979            || self.rotating.take().is_some()
3980        {
3981            self.dirty = true;
3982            cx.notify();
3983            self.flush(window, cx);
3984            return;
3985        }
3986        if self.drag_from.take().is_some() {
3987            if self.moved {
3988                self.dirty = true;
3989            }
3990            self.moved = false;
3991            self.alignment_guides = AlignmentGuides::default();
3992            cx.notify();
3993            self.flush(window, cx);
3994            return;
3995        }
3996        // Finish a marquee: add every element whose bounds intersect the box.
3997        if let Some((a, b)) = self.marquee.take() {
3998            let (x0, x1) = (a[0].min(b[0]), a[0].max(b[0]));
3999            let (y0, y1) = (a[1].min(b[1]), a[1].max(b[1]));
4000            for e in &self.scene.elements {
4001                let bb = bbox(&e.kind);
4002                let hits = bb.0 <= x1 && bb.2 >= x0 && bb.1 <= y1 && bb.3 >= y0;
4003                if hits && !self.selected.contains(&e.id) {
4004                    self.selected.push(e.id);
4005                }
4006            }
4007            cx.notify();
4008            return;
4009        }
4010        if let Some(pending) = self.pending.take() {
4011            let completed_connection = self.connecting.take().is_some();
4012            if committable(&pending.kind) {
4013                self.push_undo();
4014                let id = self.next_id;
4015                self.next_id += 1;
4016                // Fill applies only to closed shapes.
4017                let fill = if is_closed_shape(&pending.kind) {
4018                    self.active_fill
4019                } else {
4020                    None
4021                };
4022                self.scene.elements.push(Element {
4023                    id,
4024                    kind: pending.kind,
4025                    stroke: self.active_stroke,
4026                    fill,
4027                    label: None,
4028                    label_color: self.active_text,
4029                    styles: Vec::new(),
4030                    mindmap: None,
4031                });
4032                if completed_connection {
4033                    // The newly-created connector becomes the active object so
4034                    // its endpoints can be adjusted immediately.
4035                    self.selected = vec![id];
4036                    self.focus.focus(window, cx);
4037                }
4038                self.dirty = true;
4039            }
4040            cx.notify();
4041        }
4042        self.flush(window, cx);
4043    }
4044
4045    /// Right-click: with a selection (and a host save hook), open a small menu to
4046    /// save it as a template; otherwise just dismiss any open menu.
4047    fn on_right_down(&mut self, ev: &MouseDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
4048        if self.read_only {
4049            self.context_menu = None;
4050            cx.notify();
4051            return;
4052        }
4053        // A right-click inside the open color picker (e.g. removing a saved swatch)
4054        // shouldn't also open the board context menu.
4055        if self.picker.is_some() && self.picker_bounds.get().contains(&ev.position) {
4056            return;
4057        }
4058        // Show the menu when there's a selection (copy / cut / z-order / save) or
4059        // paste is wired (so you can paste onto empty canvas). Positioned at the click.
4060        if self.selected.is_empty() && self.on_paste.is_none() {
4061            self.context_menu = None;
4062        } else {
4063            let b = self.bounds.get();
4064            self.context_menu = Some(point(
4065                ev.position.x - b.origin.x,
4066                ev.position.y - b.origin.y,
4067            ));
4068            self.ctx_text_sub = false;
4069        }
4070        cx.notify();
4071    }
4072
4073    /// Paste board elements from the clipboard (via the host's `on_paste` hook),
4074    /// centered + selected. Returns whether anything was pasted, so ⌘V can fall
4075    /// through to image paste when the clipboard holds no board elements.
4076    fn try_paste(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
4077        if let Some(f) = self.on_paste.clone()
4078            && let Some(json) = f(window, cx)
4079        {
4080            self.paste_elements(&json, window, cx);
4081            true
4082        } else {
4083            false
4084        }
4085    }
4086
4087    /// Context-menu Paste.
4088    fn paste_from_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4089        self.context_menu = None;
4090        self.try_paste(window, cx);
4091    }
4092
4093    fn on_middle_down(
4094        &mut self,
4095        ev: &MouseDownEvent,
4096        _window: &mut Window,
4097        _cx: &mut Context<Self>,
4098    ) {
4099        if self.pending.is_some()
4100            || self.drag_from.is_some()
4101            || self.resizing.is_some()
4102            || self.group_resizing.is_some()
4103            || self.endpoint.is_some()
4104            || self.rotating.is_some()
4105            || self.picker_drag.is_some()
4106            || self.marquee.is_some()
4107        {
4108            return;
4109        }
4110        self.panning = true;
4111        self.last = ev.position;
4112    }
4113
4114    fn on_middle_up(&mut self, _ev: &MouseUpEvent, window: &mut Window, cx: &mut Context<Self>) {
4115        if self.panning {
4116            self.panning = false;
4117            cx.notify();
4118        }
4119        self.flush(window, cx);
4120    }
4121
4122    fn on_move(&mut self, ev: &MouseMoveEvent, _window: &mut Window, cx: &mut Context<Self>) {
4123        // Dragging the toolbar (its pill follows the cursor).
4124        if self.toolbar_drag.is_some() {
4125            self.drag_toolbar(ev.position, cx);
4126            return;
4127        }
4128        // Extending a text selection by dragging — the caret tracks the cursor
4129        // while the anchor stays put.
4130        if self.text_selecting
4131            && let Some(id) = self.editing
4132            && let Some(tg) = self.edit_target(id)
4133        {
4134            let local = block_local(
4135                tg.x,
4136                tg.y,
4137                tg.rotation,
4138                tg.pivot,
4139                self.event_to_world(ev.position),
4140            );
4141            self.caret = self
4142                .font
4143                .index_at_wrapped(&tg.content, tg.size, tg.wrap, local);
4144            cx.notify();
4145            return;
4146        }
4147        // Dragging a line out of a connector point. Snaps the endpoint to another
4148        // connector if the cursor is close to one, so shape-to-shape links land cleanly.
4149        if let Some(conn) = self.connecting {
4150            let target = self.snap_connector_at(ev.position, conn.from.id);
4151            let cur = if let Some((target, snapped)) = target {
4152                self.hovered_connector = Some(target);
4153                if snapped {
4154                    target.pos
4155                } else {
4156                    self.event_to_world(ev.position)
4157                }
4158            } else {
4159                self.hovered_connector = None;
4160                self.event_to_world(ev.position)
4161            };
4162            if let Some(pending) = self.pending.as_mut()
4163                && let ElementKind::Line(s) | ElementKind::Arrow(s) = &mut pending.kind
4164            {
4165                s.x1 = conn.from.pos[0];
4166                s.y1 = conn.from.pos[1];
4167                s.x2 = cur[0];
4168                s.y2 = cur[1];
4169                s.start_anchor = Some(SegmentAnchor {
4170                    element_id: conn.from.id,
4171                    connector: conn.from.index,
4172                });
4173                s.end_anchor = target.and_then(|(target, snapped)| {
4174                    snapped.then_some(SegmentAnchor {
4175                        element_id: target.id,
4176                        connector: target.index,
4177                    })
4178                });
4179            }
4180            cx.notify();
4181            return;
4182        }
4183        // Dragging inside the color picker (SV square, hue strip, alpha strip) or
4184        // the thickness flyout's width slider.
4185        if let Some(drag) = self.picker_drag {
4186            let pos = ev.position;
4187            if drag == PickerDrag::Width {
4188                let w = Self::width_from_frac(self.frac_x(self.width_bounds.get(), pos));
4189                self.set_width_live(w, cx);
4190                return;
4191            }
4192            match drag {
4193                PickerDrag::Sv => {
4194                    let (s, v) = self.sv_from_pos(pos);
4195                    if let Some(p) = self.picker.as_mut() {
4196                        (p.s, p.v) = (s, v);
4197                    }
4198                }
4199                PickerDrag::Hue => {
4200                    let h = self.frac_x(self.hue_bounds.get(), pos);
4201                    if let Some(p) = self.picker.as_mut() {
4202                        p.h = h;
4203                    }
4204                }
4205                PickerDrag::Alpha => {
4206                    let a = self.frac_x(self.alpha_bounds.get(), pos);
4207                    if let Some(p) = self.picker.as_mut() {
4208                        p.a = a;
4209                    }
4210                }
4211                PickerDrag::Width => unreachable!("handled above"),
4212            }
4213            if let Some(c) = self.picker_u32() {
4214                self.set_color_live(Some(c), cx);
4215            }
4216            return;
4217        }
4218        // Rotating the selection (rotate-handle drag). Shift snaps to 15° steps.
4219        if let Some(mut rot) = self.rotating.take() {
4220            let cur = self.event_to_world(ev.position);
4221            let ang = (cur[1] - rot.center[1]).atan2(cur[0] - rot.center[0]);
4222            let mut total = ang - rot.start_pointer;
4223            match rot.base {
4224                // Box/text/line: work in absolute orientation so Shift gives
4225                // clean 15° angles and, unmodified, it snaps to horizontal /
4226                // vertical when within ROT_SNAP (the easy-squaring the user wants).
4227                Some(base) => total = snap_angle(base + total, ev.modifiers.shift) - base,
4228                // Freehand: no absolute orientation; Shift still steps relatively.
4229                None => {
4230                    if ev.modifiers.shift {
4231                        let step = std::f32::consts::PI / 12.0;
4232                        total = (total / step).round() * step;
4233                    }
4234                }
4235            }
4236            // Apply only the change since last frame, normalized to [-π, π] so the
4237            // atan2 wrap-around at ±π doesn't spin the element a full turn.
4238            let tau = std::f32::consts::TAU;
4239            let mut delta = total - rot.applied;
4240            delta -= (delta / tau).round() * tau;
4241            // Every selected element turns about the shared pivot (a single
4242            // selection is just the one, pivoting on its own center).
4243            let sel = self.selected.clone();
4244            for e in self.scene.elements.iter_mut() {
4245                if sel.contains(&e.id) {
4246                    rotate_element(&mut e.kind, rot.center[0], rot.center[1], delta);
4247                }
4248            }
4249            self.sync_segment_anchors_for(&sel);
4250            rot.applied += delta;
4251            self.rotating = Some(rot);
4252            cx.notify();
4253            return;
4254        }
4255        // Resizing a multi-selection by a group-bounds corner: scale every
4256        // member uniformly (proportional) about the opposite corner, each from
4257        // its geometry at grab so the scaling never compounds.
4258        if let Some(gr) = self.group_resizing.take() {
4259            let cur = self.event_to_world(ev.position);
4260            let mut target = [cur[0] + gr.grab[0], cur[1] + gr.grab[1]];
4261            if ev.modifiers.alt {
4262                target = [snap_grid(target[0]), snap_grid(target[1])];
4263            }
4264            // A corner scales both axes together (proportional); an edge stretches
4265            // just its own axis, the other held at 1.
4266            let (sx, sy) = match gr.handle {
4267                ResizeHandle::Corner => {
4268                    let s = diagonal_scale(gr.anchor, gr.from, target);
4269                    (s, s)
4270                }
4271                ResizeHandle::EdgeX => (axis_scale(gr.anchor[0], gr.from[0], target[0]), 1.0),
4272                ResizeHandle::EdgeY => (1.0, axis_scale(gr.anchor[1], gr.from[1], target[1])),
4273            };
4274            let font = self.font.clone();
4275            for (id, orig) in &gr.orig {
4276                let mut kind = orig.clone();
4277                resize_about(&mut kind, gr.anchor[0], gr.anchor[1], sx, sy);
4278                if let ElementKind::Text(t) = &mut kind {
4279                    let (w, h) = font.measure(&t.content, t.size);
4280                    (t.measured_w, t.measured_h) = (w, h);
4281                }
4282                if let Some(e) = self.scene.elements.iter_mut().find(|e| e.id == *id) {
4283                    e.kind = kind;
4284                }
4285            }
4286            let changed: Vec<u64> = gr.orig.iter().map(|(id, _)| *id).collect();
4287            self.sync_segment_anchors_for(&changed);
4288            self.group_resizing = Some(gr);
4289            cx.notify();
4290            return;
4291        }
4292        // Resizing the selection (corner- or edge-handle drag).
4293        if let Some(r) = self.resizing.as_ref() {
4294            let (id, handle, anchor, from, grab, mut kind) =
4295                (r.id, r.handle, r.anchor, r.from, r.grab, r.orig.clone());
4296            let cur = self.event_to_world(ev.position);
4297            // Where the dragged handle should sit: cursor + the grab offset, so it
4298            // tracks the cursor without jumping when the drag starts. The snap
4299            // modifier (Option) lands it on the grid.
4300            let mut target = [cur[0] + grab[0], cur[1] + grab[1]];
4301            if ev.modifiers.alt {
4302                target = [snap_grid(target[0]), snap_grid(target[1])];
4303            }
4304            let (sx, sy) = match handle {
4305                // An edge grip stretches just its axis (the explicit per-axis ask,
4306                // so it overrides the proportional defaults — even for text/image).
4307                ResizeHandle::EdgeX => (axis_scale(anchor[0], from[0], target[0]), 1.0),
4308                ResizeHandle::EdgeY => (1.0, axis_scale(anchor[1], from[1], target[1])),
4309                // A corner: text and images scale proportionally (text is a single
4310                // font size; an image would distort otherwise); Shift does so for
4311                // shapes; and a *rotated* box-like element must (its anchor is the
4312                // center, so a uniform scale keeps it correct under rotation). All
4313                // use the diagonal projection so the corner tracks the cursor at the
4314                // right rate. Otherwise free resize is per-axis.
4315                ResizeHandle::Corner => {
4316                    let rotated = box_like(&kind).is_some_and(|(.., r)| r.abs() > ROT_EPS);
4317                    let proportional = ev.modifiers.shift
4318                        || rotated
4319                        || matches!(kind, ElementKind::Text(_) | ElementKind::Image(_));
4320                    if proportional {
4321                        let s = diagonal_scale(anchor, from, target);
4322                        (s, s)
4323                    } else {
4324                        (
4325                            axis_scale(anchor[0], from[0], target[0]),
4326                            axis_scale(anchor[1], from[1], target[1]),
4327                        )
4328                    }
4329                }
4330            };
4331            resize_about(&mut kind, anchor[0], anchor[1], sx, sy);
4332            let font = self.font.clone();
4333            if let Some(e) = self.scene.elements.iter_mut().find(|e| e.id == id) {
4334                e.kind = kind;
4335                // Re-measure text now so its box tracks the cursor this frame.
4336                if let ElementKind::Text(t) = &mut e.kind {
4337                    let (w, h) = font.measure(&t.content, t.size);
4338                    t.measured_w = w;
4339                    t.measured_h = h;
4340                }
4341            }
4342            self.sync_segment_anchors_for(&[id]);
4343            cx.notify();
4344            return;
4345        }
4346        // Dragging a line/arrow endpoint (Shift snaps the angle to 45°, Option
4347        // snaps the endpoint to the grid).
4348        if let Some(ep) = self.endpoint {
4349            let cur = self.event_to_world(ev.position);
4350            let shift = ev.modifiers.shift;
4351            if let Some(e) = self.scene.elements.iter_mut().find(|e| e.id == ep.id)
4352                && let ElementKind::Line(s) | ElementKind::Arrow(s) = &mut e.kind
4353            {
4354                let (ox, oy) = if ep.which == 0 {
4355                    (s.x2, s.y2)
4356                } else {
4357                    (s.x1, s.y1)
4358                };
4359                let (nx, ny) = if shift {
4360                    snap_45(ox, oy, cur[0], cur[1])
4361                } else if ev.modifiers.alt {
4362                    (snap_grid(cur[0]), snap_grid(cur[1]))
4363                } else {
4364                    (cur[0], cur[1])
4365                };
4366                if ep.which == 0 {
4367                    s.x1 = nx;
4368                    s.y1 = ny;
4369                    s.start_anchor = None;
4370                } else {
4371                    s.x2 = nx;
4372                    s.y2 = ny;
4373                    s.end_anchor = None;
4374                }
4375            }
4376            if !ev.modifiers.shift && !ev.modifiers.alt {
4377                if let Some((target, snapped)) = self.snap_connector_at(ev.position, ep.id)
4378                    && snapped
4379                {
4380                    self.hovered_connector = Some(target);
4381                    self.set_segment_endpoint_anchor(
4382                        ep.id,
4383                        ep.which,
4384                        Some(SegmentAnchor {
4385                            element_id: target.id,
4386                            connector: target.index,
4387                        }),
4388                    );
4389                } else {
4390                    self.hovered_connector = None;
4391                }
4392            } else {
4393                self.hovered_connector = None;
4394            }
4395            cx.notify();
4396            return;
4397        }
4398        // Moving the selection (all selected elements together). The target is
4399        // the primary's grab position plus the *total* cursor delta from the
4400        // fixed grab anchor; the snap modifier (Option) rounds that target to the
4401        // grid. Computing the absolute target each frame (vs. snapping the
4402        // per-frame delta) keeps the shape under the cursor and never loses
4403        // sub-grid motion — so it moves on every axis, not just one.
4404        if let Some(from) = self.drag_from {
4405            let cur = self.event_to_world(ev.position);
4406            let target = move_target(self.move_origin, from, cur, ev.modifiers.alt);
4407            // Where the primary sits now → the delta to apply this frame. Every
4408            // element kind's bbox-min translates 1:1, so this tracks exactly.
4409            let cur_min = self
4410                .selected
4411                .first()
4412                .and_then(|&pid| self.scene.elements.iter().find(|e| e.id == pid))
4413                .map(|e| {
4414                    let (x, y, ..) = bbox(&e.kind);
4415                    [x, y]
4416                })
4417                .unwrap_or(self.move_origin);
4418            let (raw_dx, raw_dy) = (target[0] - cur_min[0], target[1] - cur_min[1]);
4419            let (dx, dy, guides) = if ev.modifiers.alt {
4420                (raw_dx, raw_dy, AlignmentGuides::default())
4421            } else {
4422                self.aligned_move_delta(raw_dx, raw_dy)
4423            };
4424            self.alignment_guides = guides;
4425            if dx != 0.0 || dy != 0.0 {
4426                if !self.moved {
4427                    self.push_undo();
4428                    self.moved = true;
4429                }
4430                let sel = self.selected.clone();
4431                self.detach_segment_bindings_for_move(&sel);
4432                for e in self.scene.elements.iter_mut() {
4433                    if sel.contains(&e.id) {
4434                        translate(&mut e.kind, dx, dy);
4435                    }
4436                }
4437                self.sync_segment_anchors_for(&sel);
4438                cx.notify();
4439            }
4440            return;
4441        }
4442        // Dragging a marquee box (started on empty space).
4443        if let Some((start, _)) = self.marquee {
4444            let cur = self.event_to_world(ev.position);
4445            self.marquee = Some((start, cur));
4446            cx.notify();
4447            return;
4448        }
4449        // Creating an element.
4450        if self.pending.is_some() {
4451            let cur = self.event_to_world(ev.position);
4452            let z = self.scene.camera.zoom.max(MIN_ZOOM);
4453            let Some(pending) = self.pending.as_mut() else {
4454                return;
4455            };
4456            let anchor = pending.anchor;
4457            // Snap the growing corner / endpoint to the grid while Option is held
4458            // (freehand strokes keep the raw point).
4459            let c = if ev.modifiers.alt {
4460                [snap_grid(cur[0]), snap_grid(cur[1])]
4461            } else {
4462                cur
4463            };
4464            match &mut pending.kind {
4465                ElementKind::Draw(s) => {
4466                    if let Some(last) = s.points.last() {
4467                        let (ddx, ddy) = ((cur[0] - last[0]) * z, (cur[1] - last[1]) * z);
4468                        if ddx * ddx + ddy * ddy < MIN_POINT_PX * MIN_POINT_PX {
4469                            return;
4470                        }
4471                    }
4472                    s.points.push(cur);
4473                }
4474                ElementKind::Rect(b)
4475                | ElementKind::Ellipse(b)
4476                | ElementKind::Diamond(b)
4477                | ElementKind::Triangle(b)
4478                | ElementKind::RoundRect(b)
4479                | ElementKind::Star(b)
4480                | ElementKind::Hexagon(b) => {
4481                    b.x = anchor[0].min(c[0]);
4482                    b.y = anchor[1].min(c[1]);
4483                    b.w = (c[0] - anchor[0]).abs();
4484                    b.h = (c[1] - anchor[1]).abs();
4485                }
4486                ElementKind::Line(s) | ElementKind::Arrow(s) => {
4487                    s.x2 = c[0];
4488                    s.y2 = c[1];
4489                }
4490                // Text/cards/images aren't created by dragging, never pending here.
4491                ElementKind::Text(_) | ElementKind::Embed(_) | ElementKind::Image(_) => {}
4492            }
4493            cx.notify();
4494            return;
4495        }
4496        self.update_hover_connector(ev.position, cx);
4497        // Panning.
4498        if self.panning {
4499            let dx = f32::from(ev.position.x - self.last.x);
4500            let dy = f32::from(ev.position.y - self.last.y);
4501            self.last = ev.position;
4502            self.scene.camera.pan_by(dx, dy);
4503            self.dirty = true;
4504            cx.notify();
4505        }
4506    }
4507
4508    fn on_scroll(&mut self, ev: &ScrollWheelEvent, _window: &mut Window, cx: &mut Context<Self>) {
4509        let (dx, dy) = match ev.delta {
4510            ScrollDelta::Pixels(p) => (f32::from(p.x), f32::from(p.y)),
4511            ScrollDelta::Lines(p) => (p.x * LINE_PX, p.y * LINE_PX),
4512        };
4513        if ev.modifiers.platform || ev.modifiers.control {
4514            let (rx, ry) = self.relative(ev.position);
4515            let factor = (1.0 + dy * 0.0025).clamp(0.5, 2.0);
4516            self.scene.camera.zoom_about(rx, ry, factor);
4517        } else {
4518            self.scene.camera.pan_by(dx, dy);
4519        }
4520        self.dirty = true;
4521        cx.notify();
4522    }
4523
4524    fn on_pinch(&mut self, ev: &PinchEvent, _window: &mut Window, cx: &mut Context<Self>) {
4525        let (rx, ry) = self.relative(ev.position);
4526        self.scene.camera.zoom_about(rx, ry, 1.0 + ev.delta);
4527        self.dirty = true;
4528        cx.notify();
4529    }
4530
4531    /// Canvas-relative position of a window-coords event point.
4532    fn relative(&self, p: Point<Pixels>) -> (f32, f32) {
4533        let o = self.bounds.get().origin;
4534        (f32::from(p.x - o.x), f32::from(p.y - o.y))
4535    }
4536
4537    /// A clone of the text element being edited (its content + size + placement).
4538    /// The text currently being edited — a `Text` element's content, or a closed
4539    /// shape's centered label — with everything the caret math and click
4540    /// hit-testing need. `wrap` is `None` for free text, `Some(inner_width)` for a
4541    /// label (which word-wraps inside its shape). `x`/`y`/`w`/`h`/`rotation` place
4542    /// the laid-out block in the world.
4543    fn edit_target(&self, id: u64) -> Option<EditTarget> {
4544        let e = self.scene.elements.iter().find(|e| e.id == id)?;
4545        match &e.kind {
4546            ElementKind::Text(t) => Some(EditTarget {
4547                content: t.content.clone(),
4548                size: t.size,
4549                wrap: None,
4550                x: t.x,
4551                y: t.y,
4552                rotation: t.rotation,
4553                pivot: [t.x + t.measured_w / 2.0, t.y + t.measured_h / 2.0],
4554            }),
4555            kind if is_closed_shape(kind) => {
4556                let (bx, by, bw, bh, rot) = box_like(kind)?;
4557                let label = e.label.clone().unwrap_or_default();
4558                let blk = shape_label_block(&self.font, kind, bx, by, bw, bh, &label);
4559                Some(EditTarget {
4560                    content: label,
4561                    size: blk.size,
4562                    wrap: Some(blk.wrap),
4563                    x: blk.x,
4564                    y: blk.y,
4565                    rotation: rot,
4566                    pivot: [bx + bw / 2.0, by + bh / 2.0],
4567                })
4568            }
4569            _ => None,
4570        }
4571    }
4572
4573    /// The selection as an ordered byte range `[start, end)` (empty when the caret
4574    /// and anchor coincide).
4575    fn sel_range(&self) -> (usize, usize) {
4576        (
4577            self.caret.min(self.sel_anchor),
4578            self.caret.max(self.sel_anchor),
4579        )
4580    }
4581
4582    /// Move the caret to byte offset `to`; unless `extend` (Shift), collapse the
4583    /// selection there too.
4584    fn move_caret(&mut self, to: usize, extend: bool, cx: &mut Context<Self>) {
4585        self.caret = to;
4586        if !extend {
4587            self.sel_anchor = to;
4588        }
4589        self.pending_style = None; // a deliberate move ends a pending toggle
4590        cx.notify();
4591    }
4592
4593    /// Replace the editing text's `[s, e)` with `ins`, landing the caret just after
4594    /// it (collapsed). The single mutation point for typing, deletion, and paste.
4595    fn replace_range(&mut self, id: u64, s: usize, e: usize, ins: &str, cx: &mut Context<Self>) {
4596        let pending = self.pending_style;
4597        let Some(el) = self.scene.elements.iter_mut().find(|el| el.id == id) else {
4598            return;
4599        };
4600        // The inserted text takes a pending toggle (⌘B with no selection), else it
4601        // inherits the run to the left of the caret.
4602        let insert_style = pending.unwrap_or_else(|| style_at(&el.styles, s.saturating_sub(1)));
4603        // Mutate the text — a `Text` element's content, or a closed shape's label.
4604        let edited = if let ElementKind::Text(t) = &mut el.kind {
4605            t.content.replace_range(s..e, ins);
4606            true
4607        } else if is_closed_shape(&el.kind) {
4608            el.label
4609                .get_or_insert_with(String::new)
4610                .replace_range(s..e, ins);
4611            true
4612        } else {
4613            false
4614        };
4615        if edited {
4616            // Keep the styling aligned to the edited text.
4617            el.styles = splice_styles(&el.styles, s, e, ins.len(), insert_style);
4618            self.caret = s + ins.len();
4619            self.sel_anchor = self.caret;
4620            self.marked_range = None;
4621            self.dirty = true;
4622            cx.notify();
4623        }
4624    }
4625
4626    /// Replace the current selection (or insert at the caret) with `ins`.
4627    fn replace_selection(&mut self, id: u64, ins: &str, cx: &mut Context<Self>) {
4628        let (s, e) = self.sel_range();
4629        self.replace_range(id, s, e, ins, cx);
4630    }
4631
4632    fn editing_content(&self) -> Option<String> {
4633        self.editing
4634            .and_then(|id| self.edit_target(id).map(|tg| tg.content))
4635    }
4636
4637    // Kept byte-for-byte equivalent to zorite-markdown-editor's UTF-16 bridge.
4638    fn utf16_to_utf8_in(text: &str, offset: usize) -> usize {
4639        let mut utf8_offset = 0;
4640        let mut utf16_count = 0;
4641
4642        for ch in text.chars() {
4643            if utf16_count >= offset {
4644                break;
4645            }
4646            utf16_count += ch.len_utf16();
4647            utf8_offset += ch.len_utf8();
4648        }
4649
4650        utf8_offset
4651    }
4652
4653    fn utf8_to_utf16_in(text: &str, offset: usize) -> usize {
4654        let mut utf16_offset = 0;
4655        let mut utf8_count = 0;
4656
4657        for ch in text.chars() {
4658            if utf8_count >= offset {
4659                break;
4660            }
4661            utf8_count += ch.len_utf8();
4662            utf16_offset += ch.len_utf16();
4663        }
4664
4665        utf16_offset
4666    }
4667
4668    fn utf16_range_to_utf8_in(text: &str, range_utf16: &Range<usize>) -> Range<usize> {
4669        Self::utf16_to_utf8_in(text, range_utf16.start)
4670            ..Self::utf16_to_utf8_in(text, range_utf16.end)
4671    }
4672
4673    fn utf8_range_to_utf16_in(text: &str, range: &Range<usize>) -> Range<usize> {
4674        Self::utf8_to_utf16_in(text, range.start)..Self::utf8_to_utf16_in(text, range.end)
4675    }
4676
4677    /// Whiteboard storage adapter for the editor's `replace_text_in_visible_range`.
4678    /// The full inserted text is the marked range; the IME's relative selection is
4679    /// tracked independently, exactly as in zorite-markdown-editor.
4680    fn replace_text_in_visible_range(
4681        &mut self,
4682        visible_range: Range<usize>,
4683        new_text: &str,
4684        selected_range_relative: Option<Range<usize>>,
4685        mark_inserted_text: bool,
4686        cx: &mut Context<Self>,
4687    ) {
4688        let Some(id) = self.editing else {
4689            return;
4690        };
4691        let insert_start = visible_range.start;
4692        self.replace_range(id, visible_range.start, visible_range.end, new_text, cx);
4693
4694        self.marked_range = if mark_inserted_text && !new_text.is_empty() {
4695            Some(insert_start..insert_start + new_text.len())
4696        } else {
4697            None
4698        };
4699        let selected_range = selected_range_relative
4700            .map(|relative| insert_start + relative.start..insert_start + relative.end);
4701        self.caret = selected_range
4702            .as_ref()
4703            .map(|range| range.end)
4704            .unwrap_or(insert_start + new_text.len());
4705        self.sel_anchor = selected_range
4706            .as_ref()
4707            .map(|range| range.start)
4708            .unwrap_or(self.caret);
4709        cx.notify();
4710    }
4711
4712    /// The formatting active across the current selection (or, collapsed, the
4713    /// pending toggle / the run left of the caret) — for menu checkmarks. Plain
4714    /// when not editing text.
4715    fn selection_style(&self) -> RunStyle {
4716        let Some(id) = self.editing else {
4717            return RunStyle::default();
4718        };
4719        let (s, e) = self.sel_range();
4720        if s >= e
4721            && let Some(p) = self.pending_style
4722        {
4723            return p;
4724        }
4725        self.scene
4726            .elements
4727            .iter()
4728            .find(|el| el.id == id)
4729            .map_or(RunStyle::default(), |el| active_style(&el.styles, s, e))
4730    }
4731
4732    /// Toggle a boolean format over the selection while editing text; with a
4733    /// collapsed caret, arm a pending toggle for the next typed text instead.
4734    fn apply_format(&mut self, format: Format, cx: &mut Context<Self>) {
4735        let Some(id) = self.editing else {
4736            return;
4737        };
4738        let (s, e) = self.sel_range();
4739        if s < e {
4740            if let Some(el) = self.scene.elements.iter_mut().find(|el| el.id == id) {
4741                el.styles = toggle_format(&el.styles, s, e, format);
4742                self.dirty = true;
4743            }
4744        } else {
4745            let mut p = self.selection_style();
4746            let on = !format.get(&p);
4747            format.set(&mut p, on);
4748            self.pending_style = Some(p);
4749        }
4750        cx.notify();
4751    }
4752
4753    /// Like [`apply_format`](Self::apply_format) for the highlight color.
4754    fn apply_highlight(&mut self, color: u32, cx: &mut Context<Self>) {
4755        let Some(id) = self.editing else {
4756            return;
4757        };
4758        let (s, e) = self.sel_range();
4759        if s < e {
4760            if let Some(el) = self.scene.elements.iter_mut().find(|el| el.id == id) {
4761                el.styles = toggle_highlight(&el.styles, s, e, color);
4762                self.dirty = true;
4763            }
4764        } else {
4765            let mut p = self.selection_style();
4766            p.highlight = (p.highlight != Some(color)).then_some(color);
4767            self.pending_style = Some(p);
4768        }
4769        cx.notify();
4770    }
4771
4772    /// The formatting menu panel — a ✓-marked toggle per format — shared by the
4773    /// right-click submenu and the toolbar fly-out. Toggling a row keeps the menu
4774    /// open so the checkmarks update live.
4775    fn format_menu(
4776        &self,
4777        ink: Hsla,
4778        text: Hsla,
4779        grid: Hsla,
4780        bg: Hsla,
4781        cx: &mut Context<Self>,
4782    ) -> Div {
4783        let st = self.selection_style();
4784        let frow = |id: &'static str, label: &'static str, sc: &'static str, on: bool| {
4785            div()
4786                .id(id)
4787                .flex()
4788                .items_center()
4789                .gap(px(8.0))
4790                .px(px(10.0))
4791                .py(px(5.0))
4792                .mx(px(4.0))
4793                .rounded(px(6.0))
4794                .text_size(px(12.0))
4795                .text_color(ink)
4796                .hover(|s| s.bg(grid))
4797                .child(div().w(px(12.0)).child(if on { "✓" } else { "" }))
4798                .child(div().flex_1().child(label))
4799                .child(div().text_size(px(11.0)).text_color(text).child(sc))
4800        };
4801        div()
4802            .min_w(px(184.0))
4803            .py(px(4.0))
4804            .rounded(px(8.0))
4805            .bg(bg)
4806            .shadow_lg()
4807            .border_1()
4808            .border_color(grid)
4809            .flex()
4810            .flex_col()
4811            .child(
4812                frow("wb-fmt-bold", "Bold", "⌘B", st.bold)
4813                    .on_click(cx.listener(|this, _ev, _w, cx| this.apply_format(Format::Bold, cx))),
4814            )
4815            .child(
4816                frow("wb-fmt-italic", "Italic", "⌘I", st.italic).on_click(
4817                    cx.listener(|this, _ev, _w, cx| this.apply_format(Format::Italic, cx)),
4818                ),
4819            )
4820            .child(
4821                frow("wb-fmt-underline", "Underline", "⌘U", st.underline).on_click(
4822                    cx.listener(|this, _ev, _w, cx| this.apply_format(Format::Underline, cx)),
4823                ),
4824            )
4825            .child(
4826                frow("wb-fmt-strike", "Strikethrough", "⇧⌘X", st.strike).on_click(
4827                    cx.listener(|this, _ev, _w, cx| this.apply_format(Format::Strike, cx)),
4828                ),
4829            )
4830            .child(
4831                frow(
4832                    "wb-fmt-highlight",
4833                    "Highlight",
4834                    "⇧⌘H",
4835                    st.highlight.is_some(),
4836                )
4837                .on_click(
4838                    cx.listener(|this, _ev, _w, cx| this.apply_highlight(HIGHLIGHT_DEFAULT, cx)),
4839                ),
4840            )
4841    }
4842
4843    /// The caret offset one line up (`dir = -1`) or down (`dir = 1`), keeping the
4844    /// current column (x). Clamps at the first / last line.
4845    fn caret_vertical(&self, content: &str, size: f32, wrap: Option<f32>, dir: i32) -> usize {
4846        let pos = self.font.caret_pos_wrapped(content, size, wrap, self.caret);
4847        let lh = self.font.measure("", size).1.max(1.0);
4848        // Aim mid-target-line so `index_at`'s floor lands on it despite rounding.
4849        let y = (pos[1] + dir as f32 * lh + lh * 0.5).max(0.0);
4850        self.font.index_at_wrapped(content, size, wrap, [pos[0], y])
4851    }
4852
4853    /// Apply one key press while editing text: caret navigation (arrows / Home /
4854    /// End, ⇧ extends), selection (⌘A, click-drag set elsewhere), clipboard
4855    /// (⌘C/X/V on the system clipboard), and insertion / deletion. Escape commits.
4856    fn text_edit_key(&mut self, ev: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
4857        let Some(id) = self.editing else {
4858            return;
4859        };
4860        let Some(tg) = self.edit_target(id) else {
4861            self.commit_text(window, cx);
4862            return;
4863        };
4864        let (content, size, wrap) = (tg.content, tg.size, tg.wrap);
4865        // Keep the caret/anchor valid against the live content (defensive).
4866        self.caret = floor_boundary(&content, self.caret);
4867        self.sel_anchor = floor_boundary(&content, self.sel_anchor);
4868        let ks = &ev.keystroke;
4869        if ks.is_ime_in_progress() {
4870            return;
4871        }
4872        let cmd = ks.modifiers.platform || ks.modifiers.control;
4873        let shift = ks.modifiers.shift;
4874
4875        if ks.key == "escape" {
4876            self.commit_text(window, cx);
4877            return;
4878        }
4879        if cmd {
4880            match ks.key.as_str() {
4881                "a" => {
4882                    self.sel_anchor = 0;
4883                    self.caret = content.len();
4884                    cx.notify();
4885                }
4886                "b" => self.apply_format(Format::Bold, cx),
4887                "i" => self.apply_format(Format::Italic, cx),
4888                "u" => self.apply_format(Format::Underline, cx),
4889                "x" if shift => self.apply_format(Format::Strike, cx),
4890                "h" if shift => self.apply_highlight(HIGHLIGHT_DEFAULT, cx),
4891                "c" | "x" => {
4892                    let (s, e) = self.sel_range();
4893                    if s < e {
4894                        cx.write_to_clipboard(gpui::ClipboardItem::new_string(
4895                            content[s..e].into(),
4896                        ));
4897                        if ks.key == "x" {
4898                            self.replace_range(id, s, e, "", cx);
4899                        }
4900                    }
4901                }
4902                "v" => {
4903                    if let Some(text) = cx.read_from_clipboard().and_then(|c| c.text()) {
4904                        self.replace_selection(id, &text, cx);
4905                    }
4906                }
4907                _ => cx.propagate(), // ⌘Z / ⌘W / … belong to the host
4908            }
4909            return;
4910        }
4911
4912        if ks.key == "tab" && self.is_mindmap_node(id) {
4913            self.commit_text(window, cx);
4914            self.add_mindmap_relative(id, false, window, cx);
4915            return;
4916        }
4917        if ks.key == "enter" && self.is_mindmap_node(id) {
4918            self.commit_text(window, cx);
4919            self.add_mindmap_relative(id, true, window, cx);
4920            return;
4921        }
4922
4923        match ks.key.as_str() {
4924            "left" => {
4925                let (s, e) = self.sel_range();
4926                let to = if !shift && s < e {
4927                    s
4928                } else {
4929                    caret_left(&content, self.caret)
4930                };
4931                self.move_caret(to, shift, cx);
4932            }
4933            "right" => {
4934                let (s, e) = self.sel_range();
4935                let to = if !shift && s < e {
4936                    e
4937                } else {
4938                    caret_right(&content, self.caret)
4939                };
4940                self.move_caret(to, shift, cx);
4941            }
4942            "up" => {
4943                let to = self.caret_vertical(&content, size, wrap, -1);
4944                self.move_caret(to, shift, cx);
4945            }
4946            "down" => {
4947                let to = self.caret_vertical(&content, size, wrap, 1);
4948                self.move_caret(to, shift, cx);
4949            }
4950            "home" => self.move_caret(line_start(&content, self.caret), shift, cx),
4951            "end" => self.move_caret(line_end(&content, self.caret), shift, cx),
4952            "backspace" => {
4953                let (s, e) = self.sel_range();
4954                if s < e {
4955                    self.replace_range(id, s, e, "", cx);
4956                } else if self.caret > 0 {
4957                    self.replace_range(id, caret_left(&content, self.caret), self.caret, "", cx);
4958                }
4959            }
4960            "delete" => {
4961                let (s, e) = self.sel_range();
4962                if s < e {
4963                    self.replace_range(id, s, e, "", cx);
4964                } else if self.caret < content.len() {
4965                    self.replace_range(id, self.caret, caret_right(&content, self.caret), "", cx);
4966                }
4967            }
4968            "enter" => self.replace_selection(id, "\n", cx),
4969            "tab" => cx.propagate(),
4970            _ => {
4971                // Printable text is handled by GPUI's ElementInputHandler path.
4972                // Inserting `key_char` here duplicates IME composition: pinyin is
4973                // inserted by keydown, then the committed Chinese text is inserted
4974                // by the input handler. Keep keydown for navigation/deletion only.
4975                if ks
4976                    .key_char
4977                    .as_deref()
4978                    .is_none_or(|c| c.chars().next().is_none_or(|ch| ch.is_control()))
4979                {
4980                    cx.propagate();
4981                }
4982            }
4983        }
4984    }
4985
4986    /// Enter edit mode on text `id`, placing the caret at byte offset `at`.
4987    fn begin_text_edit(&mut self, id: u64, at: usize, window: &mut Window, cx: &mut Context<Self>) {
4988        self.editing = Some(id);
4989        self.caret = at;
4990        self.sel_anchor = at;
4991        self.marked_range = None;
4992        self.focus.focus(window, cx);
4993    }
4994
4995    /// Whether world point `p` lands on the text being edited (its padded bounds).
4996    fn point_in_editing_text(&self, id: u64, p: [f32; 2]) -> bool {
4997        let pad = SELECT_PAD / self.scene.camera.zoom.max(MIN_ZOOM);
4998        self.scene
4999            .elements
5000            .iter()
5001            .find(|e| e.id == id)
5002            .is_some_and(|e| {
5003                let (x0, y0, x1, y1) = bbox(&e.kind);
5004                p[0] >= x0 - pad && p[0] <= x1 + pad && p[1] >= y0 - pad && p[1] <= y1 + pad
5005            })
5006    }
5007
5008    /// A press inside the text being edited: place the caret at the nearest letter,
5009    /// extend on Shift, select the word on a double-click, else start a drag-select.
5010    fn place_caret_from_click(
5011        &mut self,
5012        id: u64,
5013        p: [f32; 2],
5014        ev: &MouseDownEvent,
5015        window: &mut Window,
5016        cx: &mut Context<Self>,
5017    ) {
5018        let Some(tg) = self.edit_target(id) else {
5019            return;
5020        };
5021        let local = block_local(tg.x, tg.y, tg.rotation, tg.pivot, p);
5022        let idx = self
5023            .font
5024            .index_at_wrapped(&tg.content, tg.size, tg.wrap, local);
5025        if ev.click_count >= 2 {
5026            let (s, e) = word_range(&tg.content, idx);
5027            self.sel_anchor = s;
5028            self.caret = e;
5029            self.text_selecting = false;
5030        } else {
5031            self.caret = idx;
5032            if !ev.modifiers.shift {
5033                self.sel_anchor = idx;
5034            }
5035            self.text_selecting = true;
5036        }
5037        // Clicking establishes a new native selection and cancels any stale
5038        // composition range left by an IME that did not send `unmark_text`.
5039        self.marked_range = None;
5040        self.focus.focus(window, cx);
5041        cx.notify();
5042    }
5043
5044    /// Finish editing the current text element, dropping it if it's empty.
5045    fn commit_text(&mut self, window: &mut Window, cx: &mut Context<Self>) {
5046        self.text_selecting = false;
5047        self.pending_style = None;
5048        self.marked_range = None;
5049        self.format_flyout = false;
5050        let Some(id) = self.editing.take() else {
5051            return;
5052        };
5053        if let Some(e) = self.scene.elements.iter_mut().find(|e| e.id == id)
5054            && is_closed_shape(&e.kind)
5055            && e.label.as_deref().is_none_or(|s| s.trim().is_empty())
5056        {
5057            // A shape stays put; an empty label is just cleared (not persisted).
5058            e.label = None;
5059        }
5060        // An empty free-text element has no purpose of its own → remove it.
5061        self.scene.elements.retain(|e| {
5062            e.id != id || !matches!(&e.kind, ElementKind::Text(t) if t.content.trim().is_empty())
5063        });
5064        self.dirty = true;
5065        cx.notify();
5066        self.flush(window, cx);
5067    }
5068
5069    /// Handle a board keyboard shortcut (the board has focus and isn't editing
5070    /// text). Returns whether the key was consumed. Single letters pick a tool;
5071    /// ⌫/Del clears the selection's elements; ⌘Z / ⌘⇧Z undo / redo; ⌘C / ⌘X / ⌘V
5072    /// copy / cut / paste; ⌘] / ⌘[ (± ⇧) reorder z-order; Esc deselects. ⌘V with no
5073    /// copied elements and other modified chords (⌘W, …) pass through to the host.
5074    fn handle_shortcut(
5075        &mut self,
5076        ev: &KeyDownEvent,
5077        window: &mut Window,
5078        cx: &mut Context<Self>,
5079    ) -> bool {
5080        let ks = &ev.keystroke;
5081        let cmd = ks.modifiers.platform || ks.modifiers.control;
5082        if cmd && ks.key == "z" {
5083            if ks.modifiers.shift {
5084                self.redo(window, cx);
5085            } else {
5086                self.undo(window, cx);
5087            }
5088            return true;
5089        }
5090        // Z-order: ⌘] / ⌘[ nudge one step, ⌘⇧] / ⌘⇧[ go all the way. Some keymaps
5091        // report the shifted bracket as `}` / `{`, so treat that as "all the way"
5092        // too. Only consumed when something is selected.
5093        let close = ks.key == "]" || ks.key == "}";
5094        let open = ks.key == "[" || ks.key == "{";
5095        if cmd && (close || open) {
5096            if self.selected.is_empty() {
5097                return false;
5098            }
5099            let all_the_way = ks.modifiers.shift || ks.key == "}" || ks.key == "{";
5100            let op = match (close, all_the_way) {
5101                (true, true) => ZOrder::ToFront,
5102                (true, false) => ZOrder::Forward,
5103                (false, true) => ZOrder::ToBack,
5104                (false, false) => ZOrder::Backward,
5105            };
5106            self.reorder_selection(op, window, cx);
5107            return true;
5108        }
5109        // Copy / cut the selection to the clipboard (the host's `on_copy` writes
5110        // it). ⌘V paste is left to propagate so the host can read the clipboard and
5111        // prefer elements over an image. ⌘C/⌘X are consumed even with nothing
5112        // selected, so they never fall through to a text copy on the board.
5113        if cmd && ks.key == "c" {
5114            self.copy_selection(window, cx);
5115            return true;
5116        }
5117        if cmd && ks.key == "x" {
5118            if self.copy_selection(window, cx) {
5119                self.delete_selected(window, cx);
5120            }
5121            return true;
5122        }
5123        if cmd && ks.key == "v" {
5124            // Paste copied elements; if the clipboard holds none, fall through so
5125            // the host can paste a clipboard image instead.
5126            return self.try_paste(window, cx);
5127        }
5128        if cmd || ks.modifiers.alt {
5129            return false;
5130        }
5131        if let Some(tool) = Tool::shortcut(&ks.key) {
5132            self.set_tool(tool, cx);
5133            return true;
5134        }
5135        match ks.key.as_str() {
5136            "backspace" | "delete" => self.delete_selected(window, cx),
5137            "escape" if !self.selected.is_empty() => {
5138                self.selected.clear();
5139                cx.notify();
5140            }
5141            _ => return false,
5142        }
5143        true
5144    }
5145
5146    fn on_key(&mut self, ev: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
5147        if self.read_only {
5148            cx.propagate();
5149            return;
5150        }
5151        // Escape cancels an in-progress connector without losing the source
5152        // shape selection; its four direction buttons become visible again.
5153        if ev.keystroke.key == "escape" && self.connecting.is_some() {
5154            self.connecting = None;
5155            self.pending = None;
5156            self.hovered_connector = None;
5157            cx.notify();
5158            return;
5159        }
5160        // Escape closes an open color picker or the templates modal (when the
5161        // board holds focus).
5162        if ev.keystroke.key == "escape" && (self.picker.is_some() || self.templates_open) {
5163            self.picker = None;
5164            self.templates_open = false;
5165            cx.notify();
5166            return;
5167        }
5168        // While dragging the toolbar, `R` flips its orientation (row ↔ column);
5169        // other keys are swallowed so they can't change tools mid-drag.
5170        if self.toolbar_drag.is_some() {
5171            let ks = &ev.keystroke;
5172            if ks.key == "r" && !(ks.modifiers.platform || ks.modifiers.control || ks.modifiers.alt)
5173            {
5174                self.toggle_toolbar_orientation(window, cx);
5175            }
5176            return;
5177        }
5178        // Not editing text → keys are board shortcuts (tools, delete, undo/redo).
5179        if self.editing.is_none() {
5180            if !self.handle_shortcut(ev, window, cx) {
5181                cx.propagate();
5182            }
5183            return;
5184        }
5185        // Editing → full text-box key handling (caret, selection, edit, clipboard).
5186        self.text_edit_key(ev, window, cx);
5187    }
5188}
5189
5190impl BoardEmbedView {
5191    /// Build a read-only embedded board view. The inner board starts in
5192    /// read-only forced-pan mode.
5193    pub fn new(scene: Scene, style: WhiteboardStyleFn, cx: &mut Context<Self>) -> Self {
5194        let board_style = style.clone();
5195        let board = cx.new(|cx| WhiteboardView::new_read_only(scene, board_style, cx));
5196        Self {
5197            board,
5198            style,
5199            on_expand: None,
5200        }
5201    }
5202
5203    /// Access the inner board entity for host-driven inspection or updates.
5204    pub fn board(&self) -> Entity<WhiteboardView> {
5205        self.board.clone()
5206    }
5207
5208    /// Install the callback fired by the embed view's "edit" affordance.
5209    pub fn set_on_expand(&mut self, f: ExpandEmbedFn) {
5210        self.on_expand = Some(f);
5211    }
5212}
5213
5214impl Render for BoardEmbedView {
5215    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
5216        let st = (self.style)();
5217        let ink = st.ink;
5218        let panel = st.panel_strong;
5219        let grid = st.grid;
5220        let accent = st.accent;
5221        let button = self.on_expand.as_ref().map(|_| {
5222            div()
5223                .id("board-embed-expand")
5224                .absolute()
5225                .top(px(10.0))
5226                .right(px(10.0))
5227                .h(px(30.0))
5228                .px(px(10.0))
5229                .flex()
5230                .items_center()
5231                .justify_center()
5232                .gap(px(6.0))
5233                .rounded(px(8.0))
5234                .bg(panel)
5235                .border_1()
5236                .border_color(grid.opacity(0.5))
5237                .hover(|s| s.bg(accent))
5238                .text_size(px(12.0))
5239                .text_color(ink)
5240                .child("↗")
5241                .child("Edit")
5242                .on_click(cx.listener(|this, _ev, window, cx| {
5243                    if let Some(f) = this.on_expand.clone() {
5244                        f(window, cx);
5245                    }
5246                }))
5247        });
5248        div()
5249            .size_full()
5250            .relative()
5251            .child(self.board.clone())
5252            .children(button)
5253    }
5254}
5255
5256impl BoardThumbnailView {
5257    pub fn new(snapshot: LocalThumbnailSnapshot, style: WhiteboardStyleFn) -> Self {
5258        Self {
5259            snapshot,
5260            style,
5261            font: Font::default(),
5262        }
5263    }
5264
5265    pub fn snapshot(&self) -> &LocalThumbnailSnapshot {
5266        &self.snapshot
5267    }
5268
5269    pub fn set_snapshot(&mut self, snapshot: LocalThumbnailSnapshot) {
5270        self.snapshot = snapshot;
5271    }
5272}
5273
5274impl Render for BoardThumbnailView {
5275    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
5276        let WhiteboardStyle {
5277            bg,
5278            grid,
5279            text,
5280            ink,
5281            panel,
5282            ..
5283        } = (self.style)();
5284        let cam = self.snapshot.spec.camera;
5285        let layers = build_thumbnail_layers(
5286            &self.snapshot.scene,
5287            &self.font,
5288            cam,
5289            ink,
5290            text,
5291            grid,
5292            panel,
5293            None,
5294            None,
5295            None,
5296        );
5297        let board_layer = canvas(
5298            |_, _, _| {},
5299            move |bounds, _, window, _| paint_board(bounds, cam, bg, grid, window),
5300        )
5301        .absolute()
5302        .size_full();
5303        let element_layers: Vec<gpui::AnyElement> = layers
5304            .into_iter()
5305            .map(|l| match l {
5306                Layer::Band(es) => band_canvas(es, cam).into_any_element(),
5307                Layer::Overlay(el) => el,
5308            })
5309            .collect();
5310        div()
5311            .size_full()
5312            .relative()
5313            .overflow_hidden()
5314            .child(board_layer)
5315            .children(element_layers)
5316    }
5317}
5318
5319/// How [`WhiteboardView::reorder_selection`] moves the selection through the
5320/// paint order (`elements` order; later = on top).
5321#[derive(Clone, Copy)]
5322enum ZOrder {
5323    ToFront,
5324    Forward,
5325    Backward,
5326    ToBack,
5327}
5328
5329// --- Text-editing string navigation (byte offsets into the content) ---
5330
5331/// The previous char boundary before byte offset `at` (0 at the start).
5332fn caret_left(content: &str, at: usize) -> usize {
5333    content[..at.min(content.len())]
5334        .chars()
5335        .next_back()
5336        .map_or(0, |c| at - c.len_utf8())
5337}
5338
5339/// The next char boundary after byte offset `at` (clamped to the end).
5340fn caret_right(content: &str, at: usize) -> usize {
5341    content[at.min(content.len())..]
5342        .chars()
5343        .next()
5344        .map_or(content.len(), |c| at + c.len_utf8())
5345}
5346
5347/// Start of the line holding `at` (just past the previous '\n', else 0).
5348fn line_start(content: &str, at: usize) -> usize {
5349    content[..at.min(content.len())]
5350        .rfind('\n')
5351        .map_or(0, |i| i + 1)
5352}
5353
5354/// End of the line holding `at` (just before the next '\n', else the end).
5355fn line_end(content: &str, at: usize) -> usize {
5356    let at = at.min(content.len());
5357    content[at..].find('\n').map_or(content.len(), |i| at + i)
5358}
5359
5360/// Round `idx` down to the nearest char boundary (clamped to the length), so a
5361/// stale offset can never split a multi-byte char and panic.
5362fn floor_boundary(content: &str, idx: usize) -> usize {
5363    let mut i = idx.min(content.len());
5364    while i > 0 && !content.is_char_boundary(i) {
5365        i -= 1;
5366    }
5367    i
5368}
5369
5370/// The word (run of alphanumerics / `_`) around `at`, as a `[start, end)` range —
5371/// for double-click selection. Empty when `at` isn't on a word char.
5372fn word_range(content: &str, at: usize) -> (usize, usize) {
5373    let is_word = |c: char| c.is_alphanumeric() || c == '_';
5374    let at = floor_boundary(content, at);
5375    let start = content[..at]
5376        .char_indices()
5377        .rev()
5378        .take_while(|(_, c)| is_word(*c))
5379        .last()
5380        .map_or(at, |(i, _)| i);
5381    let end = content[at..]
5382        .char_indices()
5383        .take_while(|(_, c)| is_word(*c))
5384        .last()
5385        .map_or(at, |(i, c)| at + i + c.len_utf8());
5386    (start, end)
5387}
5388
5389/// World point → text-local space (origin at the block's top-left), undoing the
5390/// text's rotation about its center so a click maps to the right glyph.
5391/// The text currently being edited, captured for the caret math + click
5392/// hit-testing. See [`WhiteboardView::edit_target`].
5393struct EditTarget {
5394    content: String,
5395    size: f32,
5396    wrap: Option<f32>,
5397    x: f32,
5398    y: f32,
5399    rotation: f32,
5400    /// Rotation pivot (world) for click → local mapping — the shape's center.
5401    pivot: [f32; 2],
5402}
5403
5404/// A closed shape's label block: world top-left `(x, y)`, the auto-shrunk font
5405/// size, and the wrap width.
5406struct LabelBlock {
5407    x: f32,
5408    y: f32,
5409    size: f32,
5410    wrap: f32,
5411}
5412
5413/// Map a model [`RunStyle`] to the renderer's [`font::GlyphStyle`].
5414fn glyph_style(s: RunStyle) -> font::GlyphStyle {
5415    font::GlyphStyle {
5416        bold: s.bold,
5417        italic: s.italic,
5418        underline: s.underline,
5419        strike: s.strike,
5420        highlight: s.highlight,
5421    }
5422}
5423
5424/// Lay out a closed shape's label inside its box (minus [`LABEL_PAD`]): the
5425/// auto-shrunk font size, the wrap width, and the block's world placement,
5426/// centered. Shared by the paint path and the editor so the caret matches the
5427/// rendered glyphs exactly.
5428fn shape_label_block(
5429    font: &Font,
5430    kind: &ElementKind,
5431    bx: f32,
5432    by: f32,
5433    bw: f32,
5434    bh: f32,
5435    label: &str,
5436) -> LabelBlock {
5437    // The label wraps + shrinks to fit the shape's *inscribed rectangle* (a
5438    // fraction of the bounding box), not the box itself — so text never crosses a
5439    // slanted / round outline. Largest centered inscribed rect: ellipse 1/√2 each
5440    // axis, diamond ½. A triangle narrows toward its apex, so its band is ½×½
5441    // sitting on the base (text anchored low, not vertically centered). Star /
5442    // pointy-top hexagon use a safe central band. (Rect / round-rect = the box.)
5443    let (wf, hf, bottom) = match kind {
5444        ElementKind::Ellipse(_) => (
5445            std::f32::consts::FRAC_1_SQRT_2,
5446            std::f32::consts::FRAC_1_SQRT_2,
5447            false,
5448        ),
5449        ElementKind::Diamond(_) => (0.5, 0.5, false),
5450        ElementKind::Triangle(_) => (0.5, 0.5, true),
5451        ElementKind::Star(_) => (0.5, 0.4, false),
5452        ElementKind::Hexagon(_) => (0.8, 0.5, false),
5453        _ => (1.0, 1.0, false),
5454    };
5455    let wrap = (bw * wf - 2.0 * LABEL_PAD).max(1.0);
5456    let ih = (bh * hf - 2.0 * LABEL_PAD).max(1.0);
5457    let size = font.fit_size(label, wrap, ih, TEXT_SIZE);
5458    let (w, h) = font.measure_wrapped(label, size, Some(wrap));
5459    // Always horizontally centered; the triangle's band sits on the base, every
5460    // other shape is vertically centered too.
5461    let x = bx + (bw - w) / 2.0;
5462    let y = if bottom {
5463        by + bh - LABEL_PAD - h
5464    } else {
5465        by + (bh - h) / 2.0
5466    };
5467    LabelBlock { x, y, size, wrap }
5468}
5469
5470impl WhiteboardView {
5471    fn render_read_only(&mut self, window: &Window, cx: &mut Context<Self>) -> AnyElement {
5472        let WhiteboardStyle {
5473            bg,
5474            grid,
5475            text,
5476            ink,
5477            panel,
5478            ..
5479        } = (self.style)();
5480        let camera = self.scene.camera;
5481        let bounds_cell = self.bounds.clone();
5482        let render_viewport = self.render_viewport(Some(window.viewport_size()));
5483        let visible_element_ids = self
5484            .scene
5485            .elements
5486            .iter()
5487            .filter(|element| {
5488                render_viewport.is_none_or(|viewport| viewport.intersects(bbox(&element.kind)))
5489            })
5490            .map(|element| element.id)
5491            .collect::<HashSet<_>>();
5492        self.text_layout_cache
5493            .retain(|element_id, _| visible_element_ids.contains(element_id));
5494        self.label_layout_cache
5495            .retain(|element_id, _| visible_element_ids.contains(element_id));
5496        let layers = build_thumbnail_layers(
5497            &self.scene,
5498            &self.font,
5499            camera,
5500            ink,
5501            text,
5502            grid,
5503            panel,
5504            render_viewport,
5505            Some(&mut self.text_layout_cache),
5506            Some(&mut self.label_layout_cache),
5507        );
5508        let board_layer = canvas(
5509            move |bounds, _, _| bounds_cell.set(bounds),
5510            move |bounds, _, window, _| paint_board(bounds, camera, bg, grid, window),
5511        )
5512        .absolute()
5513        .size_full();
5514        let element_layers = layers.into_iter().map(|layer| match layer {
5515            Layer::Band(elements) => band_canvas(elements, camera).into_any_element(),
5516            Layer::Overlay(element) => element,
5517        });
5518
5519        div()
5520            .size_full()
5521            .relative()
5522            .overflow_hidden()
5523            .cursor(if self.panning {
5524                CursorStyle::ClosedHand
5525            } else {
5526                CursorStyle::OpenHand
5527            })
5528            .child(board_layer)
5529            .children(element_layers)
5530            .on_mouse_down(MouseButton::Left, cx.listener(Self::on_left_down))
5531            .on_mouse_up(MouseButton::Left, cx.listener(Self::on_left_up))
5532            .on_mouse_down(MouseButton::Middle, cx.listener(Self::on_middle_down))
5533            .on_mouse_up(MouseButton::Middle, cx.listener(Self::on_middle_up))
5534            .on_mouse_move(cx.listener(Self::on_move))
5535            .on_pinch(cx.listener(Self::on_pinch))
5536            .child(
5537                div()
5538                    .absolute()
5539                    .left(px(10.0))
5540                    .bottom(px(8.0))
5541                    .text_size(px(11.0))
5542                    .text_color(text)
5543                    .child(SharedString::from(format!("{:.0}%", camera.zoom * 100.0))),
5544            )
5545            .into_any_element()
5546    }
5547}
5548
5549impl Render for WhiteboardView {
5550    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
5551        if self.read_only {
5552            return self.render_read_only(window, cx);
5553        }
5554        let WhiteboardStyle {
5555            bg,
5556            grid,
5557            text,
5558            ink,
5559            panel,
5560            panel_strong,
5561            accent,
5562            selection,
5563            swatches,
5564        } = (self.style)();
5565        let cam = self.scene.camera;
5566        let zoom = cam.zoom.max(MIN_ZOOM);
5567        let bounds_cell = self.bounds.clone();
5568        let board_bounds = self.bounds.get();
5569        let render_viewport = self.render_viewport(Some(window.viewport_size()));
5570        let visible_element_ids = self
5571            .scene
5572            .elements
5573            .iter()
5574            .filter(|element| {
5575                render_viewport.is_none_or(|viewport| viewport.intersects(bbox(&element.kind)))
5576            })
5577            .map(|element| element.id)
5578            .collect::<HashSet<_>>();
5579        self.text_layout_cache
5580            .retain(|element_id, _| visible_element_ids.contains(element_id));
5581        self.label_layout_cache
5582            .retain(|element_id, _| visible_element_ids.contains(element_id));
5583
5584        // Decoded bitmaps for image elements, fetched from the host (which decodes
5585        // off-thread and re-renders when ready). Pre-fetched here — before the
5586        // element walk below — so the host callback can borrow `window`/`cx`
5587        // without clashing with the `iter_mut`.
5588        // Keyed by element id (not src) so two elements sharing a file but at
5589        // different angles don't collide. The rotation is snapped to a quarter
5590        // turn (images rotate in 90° steps), so a steady angle hits the host's
5591        // cache and only re-rotates as the drag crosses a 90° boundary.
5592        let img_sources: HashMap<u64, gpui::ImageSource> = {
5593            let items: Vec<(u64, String, f32)> = self
5594                .scene
5595                .elements
5596                .iter()
5597                .filter(|element| visible_element_ids.contains(&element.id))
5598                .filter_map(|e| match &e.kind {
5599                    ElementKind::Image(im) => {
5600                        Some((e.id, im.src.clone(), snap_quarter(im.rotation)))
5601                    }
5602                    _ => None,
5603                })
5604                .collect();
5605            let mut map = HashMap::new();
5606            if let Some(f) = self.on_image.clone() {
5607                for (id, src, rot) in items {
5608                    if let Some(s) = f(&src, rot, window, cx) {
5609                        map.insert(id, s);
5610                    }
5611                }
5612            }
5613            map
5614        };
5615
5616        // One ordered pass over the elements, building the paint stack as a list
5617        // of layers in `elements` order (later = on top). Canvas-drawn kinds
5618        // (shapes / lines / pen / text) accumulate into a "band" canvas; an image
5619        // or page-card flushes the band and adds its overlay div, so a shape can
5620        // sit above or below an image. Text is laid out here (measured extent for
5621        // selection/hit-test + outline segments) so it z-orders and rotates with
5622        // shapes. Camera-independent glyph outlines are cached by content/style;
5623        // camera movement only rebuilds their screen-space paths.
5624        let font = self.font.clone();
5625        let editing = self.editing;
5626        let (caret_at, sel_anchor) = (self.caret, self.sel_anchor);
5627        // A translucent accent fills the selected glyphs (kept readable).
5628        let sel_fill = gpui::hsla(selection.h, selection.s, selection.l, 0.30);
5629        let mindmap_connector_styles: HashMap<u64, MindMapConnectorStyle> = self
5630            .scene
5631            .elements
5632            .iter()
5633            .filter(|element| visible_element_ids.contains(&element.id))
5634            .filter_map(|element| {
5635                self.mindmap_connector_style_for_element(&element.kind)
5636                    .map(|style| (element.id, style))
5637            })
5638            .collect();
5639        let text_layout_cache = &mut self.text_layout_cache;
5640        let label_layout_cache = &mut self.label_layout_cache;
5641        let mut layers: Vec<Layer> = Vec::new();
5642        let mut band: Vec<ElemPaint> = Vec::new();
5643        for e in self.scene.elements.iter_mut() {
5644            if !visible_element_ids.contains(&e.id) {
5645                continue;
5646            }
5647            let id = e.id;
5648            let stroke = e.stroke.map_or(ink, u32_to_hsla);
5649            let fill = e.fill.map(u32_to_hsla);
5650            // Disjoint field borrows (vs `&mut e.kind` below) so the text arms can
5651            // read the label, its color, and the styling without cloning.
5652            let label = e.label.as_deref();
5653            let label_color = e.label_color;
5654            let styles = e.styles.as_slice();
5655            match &mut e.kind {
5656                // Page-card: a titled box (top-aligned header + hint) that links
5657                // to a host page. Subtle border — the accent is the selection.
5658                ElementKind::Embed(em) => {
5659                    if !band.is_empty() {
5660                        layers.push(Layer::Band(std::mem::take(&mut band)));
5661                    }
5662                    layers.push(Layer::Overlay(
5663                        div()
5664                            .absolute()
5665                            .left(px((em.x - cam.x) * zoom))
5666                            .top(px((em.y - cam.y) * zoom))
5667                            .w(px(em.w * zoom))
5668                            .h(px(em.h * zoom))
5669                            .bg(panel)
5670                            .border_1()
5671                            .border_color(grid)
5672                            .rounded(px(8.0))
5673                            .overflow_hidden()
5674                            .p(px(10.0 * zoom))
5675                            .flex()
5676                            .flex_col()
5677                            .gap(px(3.0 * zoom))
5678                            .child(
5679                                div()
5680                                    .flex()
5681                                    .items_center()
5682                                    .gap(px(6.0 * zoom))
5683                                    .text_size(px(14.0 * zoom))
5684                                    .text_color(ink)
5685                                    .child(div().text_color(accent).child("▤"))
5686                                    .child(SharedString::from(em.title.clone())),
5687                            )
5688                            .child(
5689                                div()
5690                                    .text_size(px(11.0 * zoom))
5691                                    .text_color(text)
5692                                    .child("Double-click to open"),
5693                            )
5694                            .into_any_element(),
5695                    ));
5696                }
5697                // Image: the decoded bitmap (when the host has it ready), placed
5698                // in the element box's quarter-turn-rotated AABB; else a
5699                // placeholder while it loads.
5700                ElementKind::Image(im) => {
5701                    if !band.is_empty() {
5702                        layers.push(Layer::Band(std::mem::take(&mut band)));
5703                    }
5704                    let rot = snap_quarter(im.rotation);
5705                    let (bx, by, bw, bh) = if rot.abs() < ROT_EPS {
5706                        (im.x, im.y, im.w, im.h)
5707                    } else {
5708                        let c = box_padded_corners(im.x, im.y, im.w, im.h, rot, 0.0);
5709                        let (x0, y0, x1, y1) = aabb(&c);
5710                        (x0, y0, x1 - x0, y1 - y0)
5711                    };
5712                    let frame = div()
5713                        .absolute()
5714                        .left(px((bx - cam.x) * zoom))
5715                        .top(px((by - cam.y) * zoom))
5716                        .w(px(bw * zoom))
5717                        .h(px(bh * zoom))
5718                        .overflow_hidden()
5719                        .rounded(px(2.0));
5720                    let el = match img_sources.get(&id) {
5721                        // Set only the width and let gpui derive the height from the
5722                        // bitmap's aspect (its `Img` forces an `aspect_ratio` from the
5723                        // image, then ignores it unless a dimension is `Auto` — so
5724                        // `size_full` makes it overflow the box and clip). The bitmap is
5725                        // pre-rotated to the box's quarter-turn aspect, so width alone
5726                        // reproduces the rotated AABB exactly. `Contain` guards rounding.
5727                        Some(src) => frame.child(
5728                            gpui::img(src.clone())
5729                                .w(px(bw * zoom))
5730                                .object_fit(ObjectFit::Contain),
5731                        ),
5732                        None => frame
5733                            .bg(panel)
5734                            .border_1()
5735                            .border_color(grid)
5736                            .flex()
5737                            .items_center()
5738                            .justify_center()
5739                            .child(
5740                                div()
5741                                    .text_size(px(11.0 * zoom))
5742                                    .text_color(text)
5743                                    .child("Loading…"),
5744                            ),
5745                    };
5746                    layers.push(Layer::Overlay(el.into_any_element()));
5747                }
5748                // Canvas-drawn kinds: shapes / lines / pen / text.
5749                kind => {
5750                    let text = if let ElementKind::Text(t) = kind {
5751                        let layout = cached_text_layout(
5752                            text_layout_cache,
5753                            &font,
5754                            id,
5755                            &t.content,
5756                            t.size,
5757                            None,
5758                            styles,
5759                        );
5760                        t.measured_w = layout.width;
5761                        t.measured_h = layout.height;
5762                        // While editing: the caret at its byte offset and the
5763                        // selection rects (both text-local).
5764                        let active = editing == Some(id);
5765                        let caret = active.then(|| font.caret_pos(&t.content, t.size, caret_at));
5766                        let (s, e) = (caret_at.min(sel_anchor), caret_at.max(sel_anchor));
5767                        let selection = if active {
5768                            font.selection_rects(&t.content, t.size, s, e)
5769                        } else {
5770                            Vec::new()
5771                        };
5772                        Some(TextOutline {
5773                            segs: layout.segs.clone(),
5774                            bold_segs: layout.bold_segs.clone(),
5775                            bold_width: layout.bold_width,
5776                            color: stroke,
5777                            x: t.x,
5778                            y: t.y,
5779                            rotation: t.rotation,
5780                            pivot: [t.x + layout.width / 2.0, t.y + layout.height / 2.0],
5781                            line_height: layout.line_height,
5782                            caret,
5783                            selection,
5784                            sel_color: sel_fill,
5785                            decorations: layout.decorations.clone(),
5786                        })
5787                    } else if is_closed_shape(kind)
5788                        && let Some((bx, by, bw, bh, rot)) = box_like(kind)
5789                        && (editing == Some(id) || label.is_some_and(|s| !s.trim().is_empty()))
5790                    {
5791                        // Auto-shrink + word-wrap the label to fit inside the shape,
5792                        // centered. Its block center coincides with the box center, so
5793                        // it rotates with the shape. The shared `shape_label_block`
5794                        // keeps this identical to what the editor's caret math uses.
5795                        // Built while editing even when empty, so the caret shows the
5796                        // moment you double-click (before the first keystroke).
5797                        let active = editing == Some(id);
5798                        let text = label.map_or("", str::trim);
5799                        let label_layout = cached_label_layout(
5800                            label_layout_cache,
5801                            &font,
5802                            id,
5803                            kind,
5804                            bx,
5805                            by,
5806                            bw,
5807                            bh,
5808                            text,
5809                            styles,
5810                        );
5811                        let caret = active.then(|| {
5812                            font.caret_pos_wrapped(
5813                                text,
5814                                label_layout.size,
5815                                Some(label_layout.wrap),
5816                                caret_at,
5817                            )
5818                        });
5819                        let (s, e) = (caret_at.min(sel_anchor), caret_at.max(sel_anchor));
5820                        let selection = if active {
5821                            font.selection_rects_wrapped(
5822                                text,
5823                                label_layout.size,
5824                                Some(label_layout.wrap),
5825                                s,
5826                                e,
5827                            )
5828                        } else {
5829                            Vec::new()
5830                        };
5831                        Some(TextOutline {
5832                            segs: label_layout.text.segs.clone(),
5833                            bold_segs: label_layout.text.bold_segs.clone(),
5834                            bold_width: label_layout.text.bold_width,
5835                            color: label_color.map_or(stroke, u32_to_hsla),
5836                            x: bx + label_layout.offset_x,
5837                            y: by + label_layout.offset_y,
5838                            rotation: rot,
5839                            pivot: [bx + bw / 2.0, by + bh / 2.0],
5840                            line_height: label_layout.text.line_height,
5841                            caret,
5842                            selection,
5843                            sel_color: sel_fill,
5844                            decorations: label_layout.text.decorations.clone(),
5845                        })
5846                    } else {
5847                        None
5848                    };
5849                    band.push(ElemPaint {
5850                        kind: kind.clone(),
5851                        stroke,
5852                        fill,
5853                        text,
5854                        mindmap_connector_style: mindmap_connector_styles.get(&id).copied(),
5855                    });
5856                }
5857            }
5858        }
5859        if !band.is_empty() {
5860            layers.push(Layer::Band(band));
5861        }
5862
5863        // The in-progress element previews in the current active color / fill.
5864        let pending_ink = self.active_stroke.map_or(ink, u32_to_hsla);
5865        let pending_fill = self.active_fill.map(u32_to_hsla);
5866        let pending = self.pending.as_ref().map(|p| p.kind.clone());
5867        // A single selection gets the full box + handles (unless it's the text
5868        // being edited — then just the caret). A multi-selection shows a single
5869        // enclosing group box instead of per-element outlines (one box stays
5870        // legible while rotating), with resize corners and — when at least one
5871        // member can rotate — a shared rotate grip.
5872        let single_sel = self
5873            .selected_single()
5874            .filter(|id| Some(*id) != self.editing)
5875            .and_then(|id| self.scene.elements.iter().find(|e| e.id == id))
5876            .map(|e| e.kind.clone());
5877        let group_sel = (self.selected.len() > 1)
5878            .then(|| self.selection_bbox())
5879            .flatten()
5880            .map(|bb| (bb, self.group_rotatable()));
5881        let marquee = self.marquee;
5882        let alignment_guides = self.alignment_guides;
5883        let snap_target = self.connecting.and_then(|connection| {
5884            self.hovered_connector
5885                .filter(|target| target.id != connection.from.id)
5886                .and_then(|target| {
5887                    self.scene
5888                        .elements
5889                        .iter()
5890                        .find(|element| element.id == target.id)
5891                        .map(|element| (element.kind.clone(), target.index))
5892                })
5893        });
5894        const CONNECTOR_ICONS: [(&str, &[u8]); 4] = [
5895            ("wb-connector-up", include_bytes!("../assets/icons/up.svg")),
5896            (
5897                "wb-connector-right",
5898                include_bytes!("../assets/icons/right.svg"),
5899            ),
5900            (
5901                "wb-connector-down",
5902                include_bytes!("../assets/icons/down.svg"),
5903            ),
5904            (
5905                "wb-connector-left",
5906                include_bytes!("../assets/icons/left.svg"),
5907            ),
5908        ];
5909        let connector_buttons: Vec<gpui::AnyElement> = single_sel
5910            .as_ref()
5911            .filter(|_| self.connecting.is_none() && self.pending.is_none())
5912            .filter(|kind| connector_capable(kind))
5913            .map(|kind| {
5914                connector_button_centers(kind, cam, board_bounds.origin)
5915                    .into_iter()
5916                    .enumerate()
5917                    .map(|(index, center)| {
5918                        let (key, bytes) = CONNECTOR_ICONS[index];
5919                        div()
5920                            .id(("wb-connector-button", index))
5921                            .absolute()
5922                            .left(
5923                                center.x - board_bounds.origin.x - px(CONNECTOR_BUTTON_SIZE / 2.0),
5924                            )
5925                            .top(center.y - board_bounds.origin.y - px(CONNECTOR_BUTTON_SIZE / 2.0))
5926                            .size(px(CONNECTOR_BUTTON_SIZE))
5927                            .flex()
5928                            .items_center()
5929                            .justify_center()
5930                            .rounded_full()
5931                            .bg(panel_strong)
5932                            .text_color(selection)
5933                            .shadow_sm()
5934                            .cursor_pointer()
5935                            .child(svg_icon(key, bytes, selection, CONNECTOR_BUTTON_SIZE))
5936                            .into_any_element()
5937                    })
5938                    .collect()
5939            })
5940            .unwrap_or_default();
5941        const ROTATE_ICON: &[u8] = include_bytes!("../assets/icons/refresh.svg");
5942        let rotate_position = single_sel
5943            .as_ref()
5944            .filter(|kind| rotatable(kind))
5945            .map(|kind| rotate_handle_screen(kind, cam, board_bounds.origin))
5946            .or_else(|| {
5947                group_sel
5948                    .filter(|(_, can_rotate)| *can_rotate)
5949                    .map(|(bounds, _)| rotate_handle_for_bbox(bounds, cam, board_bounds.origin))
5950            });
5951        let rotate_button = rotate_position.map(|(x, y)| {
5952            div()
5953                .id("wb-rotate-button")
5954                .absolute()
5955                .left(px(x) - board_bounds.origin.x - px(CONNECTOR_BUTTON_SIZE / 2.0))
5956                .top(px(y) - board_bounds.origin.y - px(CONNECTOR_BUTTON_SIZE / 2.0))
5957                .size(px(CONNECTOR_BUTTON_SIZE))
5958                .flex()
5959                .items_center()
5960                .justify_center()
5961                .rounded_full()
5962                .bg(panel_strong)
5963                .shadow_sm()
5964                .cursor_pointer()
5965                .child(svg_icon(
5966                    "wb-icon-refresh",
5967                    ROTATE_ICON,
5968                    selection,
5969                    CONNECTOR_BUTTON_SIZE - 4.0,
5970                ))
5971        });
5972        let selected_mindmap_root = self.selected_mindmap_root();
5973
5974        // Tool palette + actions (top-center). The pill `occlude()`s so a press
5975        // on a button doesn't also act on the board beneath it. Layout, left→right:
5976        //   pan · select · mindmap · color │ shapes&text▾ · pages&images▾ │ undo · redo · delete
5977        // `MindMap` is promoted to a first-class toolbar button in the main tool area.
5978        let active = self.tool;
5979        let open_group = self.open_group;
5980
5981        // A bare tool button (icon + active highlight). The caller attaches the
5982        // tooltip and click handler, so this borrows nothing from `self`/`cx` and
5983        // can be reused for both the main bar and the flyout.
5984        let tool_btn = |t: Tool| {
5985            let icon: gpui::AnyElement = match t.icon() {
5986                Some((key, bytes)) => svg_icon(key, bytes, ink, 16.0).into_any_element(),
5987                None => t.glyph().into_any_element(),
5988            };
5989            let mut b = div()
5990                .id(("wb-tool", t as usize))
5991                .size(px(30.0))
5992                .flex()
5993                .items_center()
5994                .justify_center()
5995                .rounded(px(6.0))
5996                .text_size(px(15.0))
5997                .text_color(ink)
5998                .child(icon);
5999            // The hover tint also makes gpui repaint on hover transitions, which
6000            // is what lets a tooltip dismiss when the cursor leaves the button
6001            // (the canvas doesn't repaint on a bare mouse-move otherwise).
6002            if t == active {
6003                b = b.bg(accent);
6004            } else {
6005                b = b.hover(|s| s.bg(grid));
6006            }
6007            b
6008        };
6009
6010        // A category button: shows the group's active tool (else a representative)
6011        // with a ▾ affordance, and highlights while its group owns the active tool
6012        // or its flyout is open.
6013        let cat_btn = |g: ToolGroup| {
6014            let shown = if g.contains(active) {
6015                active
6016            } else {
6017                g.representative()
6018            };
6019            let icon: gpui::AnyElement = match shown.icon() {
6020                Some((key, bytes)) => svg_icon(key, bytes, ink, 16.0).into_any_element(),
6021                None => shown.glyph().into_any_element(),
6022            };
6023            let mut b = div()
6024                .id(("wb-group", g as usize))
6025                .h(px(30.0))
6026                .px(px(6.0))
6027                .flex()
6028                .items_center()
6029                .justify_center()
6030                .gap(px(1.0))
6031                .rounded(px(6.0))
6032                .text_color(ink)
6033                .child(icon)
6034                .child(div().text_size(px(8.0)).text_color(text).child("▾"));
6035            if open_group == Some(g) || g.contains(active) {
6036                b = b.bg(accent);
6037            } else {
6038                b = b.hover(|s| s.bg(grid));
6039            }
6040            b
6041        };
6042
6043        // The category buttons (one per `ToolGroup`), with the standalone Text
6044        // tool slotted in right after the Lines group.
6045        let mut cats: Vec<gpui::AnyElement> = Vec::with_capacity(ToolGroup::ALL.len() + 1);
6046        for &g in ToolGroup::ALL.iter() {
6047            cats.push(
6048                cat_btn(g)
6049                    .tooltip(self.tip(g.label()))
6050                    .on_click(cx.listener(move |this, _ev, window, cx| {
6051                        this.focus.focus(window, cx);
6052                        this.toggle_group(g, cx);
6053                    }))
6054                    .into_any_element(),
6055            );
6056            if g == ToolGroup::Lines {
6057                cats.push(
6058                    tool_btn(Tool::Text)
6059                        .tooltip(self.tip(Tool::Text.label()))
6060                        .on_click(cx.listener(|this, _ev, _w, cx| this.set_tool(Tool::Text, cx)))
6061                        .into_any_element(),
6062                );
6063            }
6064        }
6065
6066        const UNDO_ICON: &[u8] = include_bytes!("../assets/icons/undo.svg");
6067        const REDO_ICON: &[u8] = include_bytes!("../assets/icons/redo.svg");
6068        const DELETE_ICON: &[u8] = include_bytes!("../assets/icons/delete.svg");
6069        let act = |id: usize, key: &'static str, bytes: &'static [u8]| {
6070            div()
6071                .id(("wb-act", id))
6072                .size(px(30.0))
6073                .flex()
6074                .items_center()
6075                .justify_center()
6076                .rounded(px(6.0))
6077                .hover(|s| s.bg(grid))
6078                .child(svg_icon(key, bytes, ink, 16.0))
6079        };
6080        // Color button: a swatch of the current ink that toggles the picker.
6081        let cur_swatch = self.active_stroke.map_or(ink, u32_to_hsla);
6082        let mut color_btn = div()
6083            .id("wb-color")
6084            .size(px(30.0))
6085            .flex()
6086            .items_center()
6087            .justify_center()
6088            .rounded(px(6.0));
6089        if self.picker.is_some() {
6090            color_btn = color_btn.bg(accent);
6091        } else {
6092            color_btn = color_btn.hover(|s| s.bg(grid));
6093        }
6094        let color_btn = color_btn
6095            .child(
6096                div()
6097                    .size(px(16.0))
6098                    .rounded(px(4.0))
6099                    .bg(cur_swatch)
6100                    .border_1()
6101                    .border_color(grid),
6102            )
6103            .tooltip(self.tip("Color"))
6104            .on_click(cx.listener(|this, _ev, window, cx| {
6105                this.focus.focus(window, cx);
6106                this.toggle_picker(cx);
6107            }));
6108        // Thickness button: a bar of the current stroke weight (in the current ink)
6109        // that toggles the thickness flyout — sits next to color.
6110        let mut width_btn = div()
6111            .id("wb-width")
6112            .size(px(30.0))
6113            .flex()
6114            .items_center()
6115            .justify_center()
6116            .rounded(px(6.0));
6117        if self.width_open {
6118            width_btn = width_btn.bg(accent);
6119        } else {
6120            width_btn = width_btn.hover(|s| s.bg(grid));
6121        }
6122        let width_btn = width_btn
6123            .child(
6124                div()
6125                    .w(px(16.0))
6126                    .h(px(self.active_width.clamp(1.0, 8.0)))
6127                    .rounded_full()
6128                    .bg(cur_swatch),
6129            )
6130            .tooltip(self.tip("Thickness"))
6131            .on_click(cx.listener(|this, _ev, window, cx| {
6132                this.focus.focus(window, cx);
6133                this.toggle_width(cx);
6134            }));
6135        // Font button: a per-board text face ("Aa"); opens a small flyout to upload
6136        // a `.ttf`/`.otf` or revert to the default. Hidden without a host hook.
6137        let font_btn = self.on_pick_font.is_some().then(|| {
6138            let mut b = div()
6139                .id("wb-font")
6140                .size(px(30.0))
6141                .flex()
6142                .items_center()
6143                .justify_center()
6144                .rounded(px(6.0));
6145            if self.font_open {
6146                b = b.bg(accent);
6147            } else {
6148                b = b.hover(|s| s.bg(grid));
6149            }
6150            b.child(div().text_size(px(15.0)).text_color(ink).child("Aa"))
6151                .tooltip(self.tip("Font"))
6152                .on_click(cx.listener(|this, _ev, window, cx| {
6153                    this.focus.focus(window, cx);
6154                    this.toggle_font(cx);
6155                }))
6156        });
6157        // Templates button: opens the gallery modal (its own toolbar item, since
6158        // a gallery of cards doesn't belong among the tool icons).
6159        const TEMPLATES_ICON: &[u8] = include_bytes!("../assets/icons/templates.svg");
6160        let mut templates_btn = div()
6161            .id("wb-templates")
6162            .size(px(30.0))
6163            .flex()
6164            .items_center()
6165            .justify_center()
6166            .rounded(px(6.0))
6167            .child(svg_icon("wb-icon-templates", TEMPLATES_ICON, ink, 16.0));
6168        if self.templates_open {
6169            templates_btn = templates_btn.bg(accent);
6170        } else {
6171            templates_btn = templates_btn.hover(|s| s.bg(grid));
6172        }
6173        let templates_btn = templates_btn
6174            .tooltip(self.tip("Templates"))
6175            .on_click(cx.listener(|this, _ev, window, cx| {
6176                this.focus.focus(window, cx);
6177                this.toggle_templates(cx);
6178            }));
6179        // Dotted drag grip + bounds capture so the toolbar can be moved. The pill
6180        // is NOT occluded (like the color picker): a grip press starts a drag and a
6181        // press elsewhere on the pill is consumed in `on_left_down`, so the buttons
6182        // still fire their own clicks.
6183        let grip_cell = self.toolbar_grip_bounds.clone();
6184        let pill_cell = self.toolbar_bounds.clone();
6185        let vertical = self.toolbar_vertical;
6186        let dot_row = move || {
6187            div()
6188                .flex()
6189                .gap(px(3.0))
6190                .child(div().size(px(2.5)).rounded_full().bg(text))
6191                .child(div().size(px(2.5)).rounded_full().bg(text))
6192        };
6193        let grip = div()
6194            .id("wb-grip")
6195            .relative()
6196            .flex()
6197            .flex_col()
6198            .justify_center()
6199            .gap(px(3.0))
6200            .px(px(4.0))
6201            .h(px(30.0))
6202            .cursor(CursorStyle::OpenHand)
6203            .tooltip(self.tip("Drag to move · Tap R to flip · double-click to reset"))
6204            .child(
6205                canvas(move |b, _, _| grip_cell.set(b), |_, _, _, _| {})
6206                    .absolute()
6207                    .size_full(),
6208            )
6209            .child(dot_row())
6210            .child(dot_row())
6211            .child(dot_row());
6212        // A "Format" button — shown only while editing text — toggling the
6213        // text-formatting fly-out.
6214        let format_btn = self.editing.is_some().then(|| {
6215            let mut b = div()
6216                .id("wb-format-btn")
6217                .size(px(30.0))
6218                .flex()
6219                .items_center()
6220                .justify_center()
6221                .rounded(px(6.0))
6222                .text_size(px(14.0))
6223                .text_color(ink)
6224                .tooltip(self.tip("Text formatting"))
6225                .child("A");
6226            if self.format_flyout {
6227                b = b.bg(accent);
6228            } else {
6229                b = b.hover(|s| s.bg(grid));
6230            }
6231            b.on_click(cx.listener(|this, _ev, _w, cx| {
6232                this.format_flyout = !this.format_flyout;
6233                cx.notify();
6234            }))
6235        });
6236        let mut pill = div()
6237            .relative()
6238            .flex()
6239            .items_center()
6240            .gap(px(2.0))
6241            .p(px(3.0))
6242            .rounded(px(9.0))
6243            .bg(panel);
6244        if vertical {
6245            pill = pill.flex_col();
6246        }
6247        let mut pill = pill
6248            .child(
6249                canvas(move |b, _, _| pill_cell.set(b), |_, _, _, _| {})
6250                    .absolute()
6251                    .size_full(),
6252            )
6253            .child(grip)
6254            .child(toolbar_divider(grid, vertical))
6255            // navigate + color
6256            .child(
6257                tool_btn(Tool::Pan)
6258                    .tooltip(self.tip(Tool::Pan.label()))
6259                    .on_click(cx.listener(|this, _ev, _w, cx| this.set_tool(Tool::Pan, cx))),
6260            )
6261            .child(
6262                tool_btn(Tool::Select)
6263                    .tooltip(self.tip(Tool::Select.label()))
6264                    .on_click(cx.listener(|this, _ev, _w, cx| this.set_tool(Tool::Select, cx))),
6265            );
6266        if let Some(root_id) = selected_mindmap_root {
6267            let direction = self.mindmap_root_direction(root_id);
6268            let connector_style = self.mindmap_connector_style_for_root(root_id);
6269            let chip = |id: &'static str, active: bool, icon: gpui::AnyElement| {
6270                let mut d = div()
6271                    .id(id)
6272                    .px(px(8.0))
6273                    .h(px(24.0))
6274                    .flex()
6275                    .items_center()
6276                    .justify_center()
6277                    .rounded(px(6.0))
6278                    .text_size(px(11.0))
6279                    .text_color(ink);
6280                if active {
6281                    d = d.bg(accent);
6282                } else {
6283                    d = d.hover(|s| s.bg(grid));
6284                }
6285                d.child(icon)
6286            };
6287            let icon_color = ink;
6288            let draw_mm_icon = move |_id: &'static str, kind: &'static str| -> gpui::AnyElement {
6289                canvas(
6290                    |_, _, _| {},
6291                    move |bounds, _, window, _| {
6292                        let w = f32::from(bounds.size.width);
6293                        let h = f32::from(bounds.size.height);
6294                        let ox = f32::from(bounds.origin.x);
6295                        let oy = f32::from(bounds.origin.y);
6296                        let p = |x: f32, y: f32| point(px(ox + x), px(oy + y));
6297                        let mut stroke = |segments: &[([f32; 2], [f32; 2])]| {
6298                            let mut pb = PathBuilder::stroke(px(1.75));
6299                            for &([x1, y1], [x2, y2]) in segments {
6300                                pb.move_to(p(x1, y1));
6301                                pb.line_to(p(x2, y2));
6302                            }
6303                            if let Ok(path) = pb.build() {
6304                                window.paint_path(path, icon_color);
6305                            }
6306                        };
6307                        match kind {
6308                            "dir-both" => stroke(&[
6309                                ([3.0, h / 2.0], [w - 3.0, h / 2.0]),
6310                                ([3.0, h / 2.0], [6.0, h / 2.0 - 3.0]),
6311                                ([3.0, h / 2.0], [6.0, h / 2.0 + 3.0]),
6312                                ([w - 3.0, h / 2.0], [w - 6.0, h / 2.0 - 3.0]),
6313                                ([w - 3.0, h / 2.0], [w - 6.0, h / 2.0 + 3.0]),
6314                            ]),
6315                            "dir-right" => stroke(&[
6316                                ([3.0, h / 2.0], [w - 3.0, h / 2.0]),
6317                                ([w - 3.0, h / 2.0], [w - 6.0, h / 2.0 - 3.0]),
6318                                ([w - 3.0, h / 2.0], [w - 6.0, h / 2.0 + 3.0]),
6319                            ]),
6320                            "dir-left" => stroke(&[
6321                                ([3.0, h / 2.0], [w - 3.0, h / 2.0]),
6322                                ([3.0, h / 2.0], [6.0, h / 2.0 - 3.0]),
6323                                ([3.0, h / 2.0], [6.0, h / 2.0 + 3.0]),
6324                            ]),
6325                            "line-straight" => stroke(&[([3.0, h / 2.0], [w - 3.0, h / 2.0])]),
6326                            "line-bezier" => {
6327                                let mut pb = PathBuilder::stroke(px(1.75));
6328                                pb.move_to(p(2.5, h - 4.0));
6329                                pb.cubic_bezier_to(
6330                                    p(w - 2.5, 4.0),
6331                                    p(w * 0.35, h - 4.0),
6332                                    p(w * 0.65, 4.0),
6333                                );
6334                                if let Ok(path) = pb.build() {
6335                                    window.paint_path(path, icon_color);
6336                                }
6337                            }
6338                            "line-orthogonal" => stroke(&[
6339                                ([3.0, h - 4.0], [w * 0.45, h - 4.0]),
6340                                ([w * 0.45, h - 4.0], [w * 0.45, 4.0]),
6341                                ([w * 0.45, 4.0], [w - 3.0, 4.0]),
6342                            ]),
6343                            _ => {}
6344                        }
6345                    },
6346                )
6347                .w(px(14.0))
6348                .h(px(14.0))
6349                .into_any_element()
6350            };
6351            pill = pill
6352                .child(toolbar_divider(grid, vertical))
6353                .child(
6354                    div()
6355                        .px(px(4.0))
6356                        .text_size(px(11.0))
6357                        .text_color(text)
6358                        .child("Direction"),
6359                )
6360                .child(
6361                    chip(
6362                        "wb-mm-dir-both",
6363                        direction == MindMapRootDirection::Both,
6364                        draw_mm_icon("wb-mm-dir-both-icon", "dir-both"),
6365                    )
6366                    .on_click(cx.listener(move |this, _ev, _window, cx| {
6367                        this.set_mindmap_root_direction(root_id, MindMapRootDirection::Both, cx);
6368                    })),
6369                )
6370                .child(
6371                    chip(
6372                        "wb-mm-dir-right",
6373                        direction == MindMapRootDirection::Right,
6374                        draw_mm_icon("wb-mm-dir-right-icon", "dir-right"),
6375                    )
6376                    .on_click(cx.listener(move |this, _ev, _window, cx| {
6377                        this.set_mindmap_root_direction(root_id, MindMapRootDirection::Right, cx);
6378                    })),
6379                )
6380                .child(
6381                    chip(
6382                        "wb-mm-dir-left",
6383                        direction == MindMapRootDirection::Left,
6384                        draw_mm_icon("wb-mm-dir-left-icon", "dir-left"),
6385                    )
6386                    .on_click(cx.listener(move |this, _ev, _window, cx| {
6387                        this.set_mindmap_root_direction(root_id, MindMapRootDirection::Left, cx);
6388                    })),
6389                )
6390                .child(toolbar_divider(grid, vertical))
6391                .child(
6392                    div()
6393                        .px(px(4.0))
6394                        .text_size(px(11.0))
6395                        .text_color(text)
6396                        .child("Connector"),
6397                )
6398                .child(
6399                    chip(
6400                        "wb-mm-line-straight",
6401                        connector_style == MindMapConnectorStyle::Straight,
6402                        draw_mm_icon("wb-mm-line-straight-icon", "line-straight"),
6403                    )
6404                    .on_click(cx.listener(move |this, _ev, _window, cx| {
6405                        this.set_mindmap_connector_style(
6406                            root_id,
6407                            MindMapConnectorStyle::Straight,
6408                            cx,
6409                        );
6410                    })),
6411                )
6412                .child(
6413                    chip(
6414                        "wb-mm-line-bezier",
6415                        connector_style == MindMapConnectorStyle::Bezier,
6416                        draw_mm_icon("wb-mm-line-bezier-icon", "line-bezier"),
6417                    )
6418                    .on_click(cx.listener(move |this, _ev, _window, cx| {
6419                        this.set_mindmap_connector_style(
6420                            root_id,
6421                            MindMapConnectorStyle::Bezier,
6422                            cx,
6423                        );
6424                    })),
6425                )
6426                .child(
6427                    chip(
6428                        "wb-mm-line-orthogonal",
6429                        connector_style == MindMapConnectorStyle::Orthogonal,
6430                        draw_mm_icon("wb-mm-line-orthogonal-icon", "line-orthogonal"),
6431                    )
6432                    .on_click(cx.listener(move |this, _ev, _window, cx| {
6433                        this.set_mindmap_connector_style(
6434                            root_id,
6435                            MindMapConnectorStyle::Orthogonal,
6436                            cx,
6437                        );
6438                    })),
6439                );
6440        } else {
6441            pill = pill
6442                .child(color_btn)
6443                .child(width_btn)
6444                .children(font_btn)
6445                .children(format_btn)
6446                .child(toolbar_divider(grid, vertical))
6447                // tool categories (each opens a flyout of its tools)
6448                .children(cats)
6449                .child(templates_btn);
6450        }
6451        let pill = pill
6452            .child(toolbar_divider(grid, vertical))
6453            // actions
6454            .child(
6455                act(0, "wb-icon-undo", UNDO_ICON)
6456                    .tooltip(self.tip("Undo (⌘Z)"))
6457                    .on_click(cx.listener(|this, _ev, window, cx| this.undo(window, cx))),
6458            )
6459            .child(
6460                act(1, "wb-icon-redo", REDO_ICON)
6461                    .tooltip(self.tip("Redo (⌘⇧Z)"))
6462                    .on_click(cx.listener(|this, _ev, window, cx| this.redo(window, cx))),
6463            )
6464            .child(
6465                act(2, "wb-icon-delete", DELETE_ICON)
6466                    .tooltip(self.tip("Delete selection (⌫)"))
6467                    .on_click(
6468                        cx.listener(|this, _ev, window, cx| this.delete_selected(window, cx)),
6469                    ),
6470            );
6471        // Default top-center; once dragged, an absolute board-relative position
6472        // (clamped to the board each paint, so a position persisted under a larger
6473        // window can't strand the bar — and its grip — off-screen).
6474        let tb_pos = self.toolbar_pos.map(|(x, y)| self.clamp_toolbar(x, y));
6475        let toolbar = match tb_pos {
6476            Some((x, y)) => div().absolute().left(px(x)).top(px(y)).child(pill),
6477            None => div()
6478                .absolute()
6479                .top(px(10.0))
6480                .left_0()
6481                .right_0()
6482                .flex()
6483                .justify_center()
6484                .child(pill),
6485        };
6486        // Flyouts / picker hang off the toolbar: under a horizontal bar (centered
6487        // by default, else under its top-left — 42px matches the 10→52 gap), or to
6488        // the right of a vertical bar (anchored to its captured bounds, so it works
6489        // whether centered or dragged). Call `.child(panel)` on the result.
6490        let pill_b = self.toolbar_bounds.get();
6491        let board_o = self.bounds.get().origin;
6492        let pill_top = f32::from(pill_b.origin.y) - f32::from(board_o.y);
6493        let pill_right =
6494            f32::from(pill_b.origin.x) - f32::from(board_o.x) + f32::from(pill_b.size.width);
6495        let has_bounds = f32::from(pill_b.size.width) > 1.0;
6496        let popover_anchor = move || -> Div {
6497            if vertical && has_bounds {
6498                div()
6499                    .absolute()
6500                    .left(px(pill_right + 6.0))
6501                    .top(px(pill_top))
6502            } else {
6503                match tb_pos {
6504                    Some((x, y)) => div().absolute().left(px(x)).top(px(y + 42.0)),
6505                    None => div()
6506                        .absolute()
6507                        .top(px(52.0))
6508                        .left_0()
6509                        .right_0()
6510                        .flex()
6511                        .justify_center(),
6512                }
6513            }
6514        };
6515
6516        // Tool-category flyout (centered below the toolbar), built only while a
6517        // group is open. Occluded like the main bar; picking a tool activates it
6518        // and closes the flyout (via `set_tool`), and a press elsewhere on the
6519        // canvas closes it (see `on_left_down`).
6520        let flyout =
6521            open_group.map(|g| {
6522                let mut row = div()
6523                    .flex()
6524                    .items_center()
6525                    .gap(px(2.0))
6526                    .p(px(3.0))
6527                    .rounded(px(9.0))
6528                    .bg(panel_strong)
6529                    .shadow_lg()
6530                    .occlude();
6531                for &t in g.tools() {
6532                    row = row.child(tool_btn(t).tooltip(self.tip(t.label())).on_click(
6533                        cx.listener(move |this, _ev, window, cx| {
6534                            this.focus.focus(window, cx);
6535                            this.set_tool(t, cx);
6536                        }),
6537                    ));
6538                }
6539                popover_anchor().child(row)
6540            });
6541
6542        // The toolbar's text-formatting fly-out (the same panel as the right-click
6543        // submenu), shown while the "Format" button is toggled on during a text edit.
6544        let format_panel = (self.editing.is_some() && self.format_flyout).then(|| {
6545            popover_anchor().child(
6546                self.format_menu(ink, text, grid, panel_strong, cx)
6547                    .occlude(),
6548            )
6549        });
6550
6551        // Thickness flyout (centered below the toolbar): a row of preset weights
6552        // (the active one highlighted) over a slider for any custom width. Presets
6553        // fire via `on_click`; the slider drags via `on_left_down`/`on_move` (so the
6554        // panel is *not* occluded — presses fall through, like the color picker).
6555        // A press outside the panel dismisses it (see `on_left_down`).
6556        let width_cell = self.width_bounds.clone();
6557        let width_panel_cell = self.width_panel_bounds.clone();
6558        let width_frac =
6559            ((self.active_width - WIDTH_MIN) / (WIDTH_MAX - WIDTH_MIN)).clamp(0.0, 1.0);
6560        let width_flyout = self.width_open.then(|| {
6561            let mut presets = div().flex().items_center().gap(px(2.0));
6562            for (i, w) in WIDTH_PRESETS.into_iter().enumerate() {
6563                let active = (self.active_width - w).abs() < 0.01;
6564                let mut opt = div()
6565                    .id(("wb-width-opt", i))
6566                    .size(px(30.0))
6567                    .flex()
6568                    .items_center()
6569                    .justify_center()
6570                    .rounded(px(6.0));
6571                if active {
6572                    opt = opt.bg(accent);
6573                } else {
6574                    opt = opt.hover(|s| s.bg(grid));
6575                }
6576                presets = presets.child(
6577                    opt.child(
6578                        div()
6579                            .w(px(18.0))
6580                            .h(px(w.clamp(1.0, 9.0)))
6581                            .rounded_full()
6582                            .bg(cur_swatch),
6583                    )
6584                    .on_click(
6585                        cx.listener(move |this, _ev, window, cx| this.set_width(w, window, cx)),
6586                    ),
6587                );
6588            }
6589            // The custom-width slider: a bar whose height *is* the current weight,
6590            // with a thumb at the value. Dragging it lands in `on_left_down`/`on_move`.
6591            let slider = div()
6592                .relative()
6593                .w(px(WIDTH_SLIDER_W))
6594                .h(px(WIDTH_MAX + 6.0))
6595                .flex()
6596                .items_center()
6597                .child(
6598                    canvas(move |b, _, _| width_cell.set(b), |_, _, _, _| {})
6599                        .absolute()
6600                        .size_full(),
6601                )
6602                .child(
6603                    div()
6604                        .w_full()
6605                        .h(px(self.active_width.clamp(1.0, WIDTH_MAX)))
6606                        .rounded_full()
6607                        .bg(cur_swatch),
6608                )
6609                .child(
6610                    div()
6611                        .absolute()
6612                        .top_0()
6613                        .bottom_0()
6614                        .left(px(width_frac * WIDTH_SLIDER_W - 1.5))
6615                        .w(px(3.0))
6616                        .rounded(px(2.0))
6617                        .bg(hsla(0.0, 0.0, 1.0, 1.0))
6618                        .border_1()
6619                        .border_color(hsla(0.0, 0.0, 0.0, 0.45)),
6620                );
6621            let panel = div()
6622                .relative()
6623                .flex()
6624                .flex_col()
6625                .items_center()
6626                .gap(px(6.0))
6627                .p(px(6.0))
6628                .rounded(px(9.0))
6629                .bg(panel_strong)
6630                .shadow_lg()
6631                .child(
6632                    canvas(move |b, _, _| width_panel_cell.set(b), |_, _, _, _| {})
6633                        .absolute()
6634                        .size_full(),
6635                )
6636                .child(presets)
6637                .child(slider);
6638            popover_anchor().child(panel)
6639        });
6640
6641        // Font flyout: upload a `.ttf`/`.otf`, or revert to the bundled default.
6642        // Occluded (a press outside dismisses it via `on_left_down`); each row fires
6643        // the host hook, which loads the face and calls `set_font` for this board.
6644        let font_flyout = (self.font_open && self.on_pick_font.is_some()).then(|| {
6645            let row = |id: &'static str, label: &'static str| {
6646                div()
6647                    .id(id)
6648                    .px(px(12.0))
6649                    .py(px(6.0))
6650                    .mx(px(4.0))
6651                    .rounded(px(6.0))
6652                    .text_size(px(12.0))
6653                    .text_color(ink)
6654                    .hover(|s| s.bg(grid))
6655                    .child(label)
6656            };
6657            let panel = div()
6658                .occlude()
6659                .py(px(4.0))
6660                .min_w(px(168.0))
6661                .rounded(px(9.0))
6662                .bg(panel_strong)
6663                .shadow_lg()
6664                .border_1()
6665                .border_color(grid)
6666                .flex()
6667                .flex_col()
6668                .child(row("wb-font-upload", "Upload font…").on_click(cx.listener(
6669                    |this, _ev, window, cx| {
6670                        this.font_open = false;
6671                        if let Some(f) = this.on_pick_font.clone() {
6672                            f(FontPick::Upload, window, cx);
6673                        }
6674                        cx.notify();
6675                    },
6676                )))
6677                .child(row("wb-font-default", "Use default").on_click(cx.listener(
6678                    |this, _ev, window, cx| {
6679                        this.font_open = false;
6680                        if let Some(f) = this.on_pick_font.clone() {
6681                            f(FontPick::Default, window, cx);
6682                        }
6683                        cx.notify();
6684                    },
6685                )));
6686            popover_anchor().child(panel)
6687        });
6688
6689        // Right-click context menu (a selection's "Save as template"), anchored at
6690        // the cursor. Occluded so its button doesn't fall through to the canvas;
6691        // any other press dismisses it (see `on_left_down`).
6692        let menu =
6693            self.context_menu.map(|pos| {
6694                // One clickable row; clicking runs `act` and closes the menu.
6695                let row = |id: &'static str, label: &'static str, shortcut: &'static str| {
6696                    div()
6697                        .id(id)
6698                        .flex()
6699                        .items_center()
6700                        .justify_between()
6701                        .gap(px(16.0))
6702                        .px(px(10.0))
6703                        .py(px(5.0))
6704                        .mx(px(4.0))
6705                        .rounded(px(6.0))
6706                        .text_size(px(12.0))
6707                        .text_color(ink)
6708                        .hover(|s| s.bg(grid))
6709                        .child(label)
6710                        .child(div().text_size(px(11.0)).text_color(text).child(shortcut))
6711                };
6712                let divider = || div().my(px(4.0)).mx(px(8.0)).h(px(1.0)).bg(grid);
6713                let has_sel = !self.selected.is_empty();
6714                let mut panel = div()
6715                    .absolute()
6716                    .left(pos.x)
6717                    .top(pos.y)
6718                    .occlude()
6719                    .min_w(px(176.0))
6720                    .py(px(4.0))
6721                    .rounded(px(8.0))
6722                    .bg(panel_strong)
6723                    .shadow_lg()
6724                    .border_1()
6725                    .border_color(grid)
6726                    .flex()
6727                    .flex_col();
6728                // While editing text, a "Text ▸" row expands the formatting submenu.
6729                if self.editing.is_some() {
6730                    panel = panel
6731                        .child(row("wb-ctx-text", "Text", "▸").on_click(cx.listener(
6732                            |this, _ev, _w, cx| {
6733                                this.ctx_text_sub = !this.ctx_text_sub;
6734                                cx.notify();
6735                            },
6736                        )))
6737                        .child(divider());
6738                }
6739                // Z-order + copy / cut act on the selection, so they show only with one.
6740                if has_sel {
6741                    panel =
6742                        panel
6743                            .child(row("wb-ctx-front", "Bring to Front", "⌘⇧]").on_click(
6744                                cx.listener(|this, _ev, window, cx| {
6745                                    this.context_menu = None;
6746                                    this.reorder_selection(ZOrder::ToFront, window, cx);
6747                                }),
6748                            ))
6749                            .child(row("wb-ctx-forward", "Bring Forward", "⌘]").on_click(
6750                                cx.listener(|this, _ev, window, cx| {
6751                                    this.context_menu = None;
6752                                    this.reorder_selection(ZOrder::Forward, window, cx);
6753                                }),
6754                            ))
6755                            .child(row("wb-ctx-backward", "Send Backward", "⌘[").on_click(
6756                                cx.listener(|this, _ev, window, cx| {
6757                                    this.context_menu = None;
6758                                    this.reorder_selection(ZOrder::Backward, window, cx);
6759                                }),
6760                            ))
6761                            .child(
6762                                row("wb-ctx-back", "Send to Back", "⌘⇧[").on_click(cx.listener(
6763                                    |this, _ev, window, cx| {
6764                                        this.context_menu = None;
6765                                        this.reorder_selection(ZOrder::ToBack, window, cx);
6766                                    },
6767                                )),
6768                            )
6769                            .child(divider())
6770                            .child(row("wb-ctx-copy", "Copy", "⌘C").on_click(cx.listener(
6771                                |this, _ev, window, cx| {
6772                                    this.context_menu = None;
6773                                    this.copy_selection(window, cx);
6774                                },
6775                            )))
6776                            .child(row("wb-ctx-cut", "Cut", "⌘X").on_click(cx.listener(
6777                                |this, _ev, window, cx| {
6778                                    this.context_menu = None;
6779                                    if this.copy_selection(window, cx) {
6780                                        this.delete_selected(window, cx);
6781                                    }
6782                                },
6783                            )));
6784                }
6785                // Paste shows whenever the host wired it (so it works on empty canvas).
6786                if self.on_paste.is_some() {
6787                    panel = panel.child(row("wb-ctx-paste", "Paste", "⌘V").on_click(
6788                        cx.listener(|this, _ev, window, cx| this.paste_from_menu(window, cx)),
6789                    ));
6790                }
6791                // "Save as template" only with a selection and a wired host callback.
6792                if has_sel && self.on_save_template.is_some() {
6793                    panel = panel.child(divider()).child(
6794                        row("wb-ctx-save-template", "Save as template", "").on_click(cx.listener(
6795                            |this, _ev, window, cx| {
6796                                this.context_menu = None;
6797                                this.save_selection_as_template(window, cx);
6798                            },
6799                        )),
6800                    );
6801                }
6802                panel
6803            });
6804
6805        // The "Text ▸" formatting submenu — a fly-out beside the context menu with
6806        // a ✓ on each active format. Toggling a row keeps the menu open so the
6807        // checkmarks update live; clicking off (anywhere else) dismisses it.
6808        let text_submenu = self
6809            .context_menu
6810            .filter(|_| self.ctx_text_sub && self.editing.is_some())
6811            .map(|pos| {
6812                self.format_menu(ink, text, grid, panel_strong, cx)
6813                    .absolute()
6814                    .left(pos.x + px(184.0))
6815                    .top(pos.y)
6816                    .occlude()
6817            });
6818
6819        // Templates gallery modal: a dimming scrim (click to dismiss) centering a
6820        // panel of preview cards. The panel `occlude()`s so clicks on it don't
6821        // reach the scrim; a card stamps its template and closes (see
6822        // `apply_template`), and Escape closes it (see `on_key`).
6823        let templates_modal = self.templates_open.then(|| {
6824            let body = if self.templates.is_empty() {
6825                div()
6826                    .flex_1()
6827                    .flex()
6828                    .items_center()
6829                    .justify_center()
6830                    .p(px(28.0))
6831                    .child(
6832                        div()
6833                            .max_w(px(320.0))
6834                            .text_size(px(12.0))
6835                            .text_color(text)
6836                            .child(
6837                                "No templates yet. Select shapes on the canvas, right-click, \
6838                                 and choose “Save as template”.",
6839                            ),
6840                    )
6841                    .into_any_element()
6842            } else {
6843                let mut grid_el = div().flex().flex_wrap().gap(px(8.0)).justify_center();
6844                for i in 0..self.templates.len() {
6845                    grid_el = grid_el.child(self.template_card(i, ink, text, grid, bg, cx));
6846                }
6847                div()
6848                    .id("wb-tmpl-scroll")
6849                    .flex_1()
6850                    .min_h_0()
6851                    .overflow_y_scroll()
6852                    .p(px(12.0))
6853                    .child(grid_el)
6854                    .into_any_element()
6855            };
6856            let panel = div()
6857                .w(px(540.0))
6858                .max_h(px(460.0))
6859                .flex()
6860                .flex_col()
6861                .rounded(px(12.0))
6862                .bg(panel_strong)
6863                .shadow_lg()
6864                .border_1()
6865                .border_color(grid)
6866                .occlude()
6867                // header
6868                .child(
6869                    div()
6870                        .flex()
6871                        .items_center()
6872                        .justify_between()
6873                        .px(px(14.0))
6874                        .py(px(10.0))
6875                        .border_b_1()
6876                        .border_color(grid)
6877                        .child(div().text_size(px(14.0)).text_color(ink).child("Templates"))
6878                        .child(
6879                            div()
6880                                .id("wb-tmpl-close")
6881                                .size(px(22.0))
6882                                .flex()
6883                                .items_center()
6884                                .justify_center()
6885                                .rounded(px(6.0))
6886                                .text_size(px(15.0))
6887                                .text_color(text)
6888                                .hover(|s| s.bg(grid))
6889                                .child("✕")
6890                                .on_click(cx.listener(|this, _ev, _w, cx| {
6891                                    this.templates_open = false;
6892                                    cx.notify();
6893                                })),
6894                        ),
6895                )
6896                .child(body)
6897                // footer hint
6898                .child(
6899                    div()
6900                        .px(px(14.0))
6901                        .py(px(8.0))
6902                        .border_t_1()
6903                        .border_color(grid)
6904                        .text_size(px(10.0))
6905                        .text_color(text)
6906                        .child("Click to add · right-click to delete"),
6907                );
6908            div()
6909                .absolute()
6910                .size_full()
6911                .flex()
6912                .items_center()
6913                .justify_center()
6914                .bg(hsla(0.0, 0.0, 0.0, 0.35))
6915                .occlude()
6916                .on_mouse_down(
6917                    MouseButton::Left,
6918                    cx.listener(|this, _ev, _w, cx| {
6919                        this.templates_open = false;
6920                        cx.notify();
6921                    }),
6922                )
6923                .child(panel)
6924        });
6925
6926        // Color picker panel (below the toolbar), built only while open. Not
6927        // occluded: presses fall through to `on_left_down`, which routes the SV
6928        // square / hue strip to drags (via the captured bounds), consumes presses
6929        // elsewhere on the panel, and closes on a press outside it.
6930        let sv_cell = self.sv_bounds.clone();
6931        let hue_cell = self.hue_bounds.clone();
6932        let alpha_cell = self.alpha_bounds.clone();
6933        let panel_cell = self.picker_bounds.clone();
6934        let swatch_list = swatches;
6935        let white = hsla(0.0, 0.0, 1.0, 1.0);
6936        // The stroke / fill colors backing the two target tabs (selection's, else
6937        // the active value). `None` = theme ink (stroke) or unfilled (fill).
6938        let stroke_disp = self
6939            .selected_single()
6940            .and_then(|id| self.scene.elements.iter().find(|e| e.id == id))
6941            .and_then(|e| e.stroke)
6942            .or(self.active_stroke);
6943        let fill_disp = self
6944            .selected_single()
6945            .and_then(|id| self.scene.elements.iter().find(|e| e.id == id))
6946            .and_then(|e| e.fill)
6947            .or(self.active_fill);
6948        let text_disp = self
6949            .selected_single()
6950            .and_then(|id| self.scene.elements.iter().find(|e| e.id == id))
6951            .and_then(|e| e.label_color)
6952            .or(self.active_text);
6953        let picker_panel = self.picker.map(|p| {
6954            let cur = hsva_to_u32(p.h, p.s, p.v, p.a);
6955            let hex = format!("#{:06X}", cur >> 8);
6956            let clear = hsla(0.0, 0.0, 0.0, 0.0);
6957
6958            // Stroke / fill target tabs. The active one is highlighted; clicking
6959            // re-seeds the controls from that property's color.
6960            let tab = |active: bool, sw: Hsla, label: &'static str, id: &'static str| {
6961                let mut d = div()
6962                    .id(id)
6963                    .flex()
6964                    .items_center()
6965                    .gap(px(6.0))
6966                    .px(px(8.0))
6967                    .py(px(4.0))
6968                    .rounded(px(6.0))
6969                    .text_size(px(12.0))
6970                    .text_color(ink);
6971                if active {
6972                    d = d.bg(accent);
6973                }
6974                d.child(
6975                    div()
6976                        .size(px(12.0))
6977                        .rounded(px(3.0))
6978                        .bg(sw)
6979                        .border_1()
6980                        .border_color(grid),
6981                )
6982                .child(label)
6983            };
6984            let tabs = div()
6985                .flex()
6986                .gap(px(6.0))
6987                .child(
6988                    tab(
6989                        p.target == PickerTarget::Stroke,
6990                        stroke_disp.map_or(ink, u32_to_hsla),
6991                        "Stroke",
6992                        "wb-tab-stroke",
6993                    )
6994                    .on_click(cx.listener(|this, _ev, _w, cx| {
6995                        this.set_picker_target(PickerTarget::Stroke, cx)
6996                    })),
6997                )
6998                .child(
6999                    tab(
7000                        p.target == PickerTarget::Fill,
7001                        fill_disp.map_or(clear, u32_to_hsla),
7002                        "Fill",
7003                        "wb-tab-fill",
7004                    )
7005                    .on_click(cx.listener(|this, _ev, _w, cx| {
7006                        this.set_picker_target(PickerTarget::Fill, cx)
7007                    })),
7008                )
7009                .child(
7010                    tab(
7011                        p.target == PickerTarget::Text,
7012                        text_disp.map_or(ink, u32_to_hsla),
7013                        "Text",
7014                        "wb-tab-text",
7015                    )
7016                    .on_click(cx.listener(|this, _ev, _w, cx| {
7017                        this.set_picker_target(PickerTarget::Text, cx)
7018                    })),
7019                );
7020
7021            let sv_square = div()
7022                .relative()
7023                .w(px(SV_W))
7024                .h(px(SV_H))
7025                .rounded(px(5.0))
7026                .overflow_hidden()
7027                .bg(hsla(p.h, 1.0, 0.5, 1.0))
7028                .child(div().absolute().size_full().bg(linear_gradient(
7029                    90.0,
7030                    linear_color_stop(white, 0.0),
7031                    linear_color_stop(hsla(0.0, 0.0, 1.0, 0.0), 1.0),
7032                )))
7033                .child(div().absolute().size_full().bg(linear_gradient(
7034                    180.0,
7035                    linear_color_stop(hsla(0.0, 0.0, 0.0, 0.0), 0.0),
7036                    linear_color_stop(hsla(0.0, 0.0, 0.0, 1.0), 1.0),
7037                )))
7038                .child(
7039                    canvas(move |b, _, _| sv_cell.set(b), |_, _, _, _| {})
7040                        .absolute()
7041                        .size_full(),
7042                )
7043                .child(
7044                    div()
7045                        .absolute()
7046                        .left(px(p.s * SV_W - 7.0))
7047                        .top(px((1.0 - p.v) * SV_H - 7.0))
7048                        .size(px(14.0))
7049                        .rounded_full()
7050                        .border_2()
7051                        .border_color(white),
7052                );
7053
7054            let seg = |from: f32, to: f32| {
7055                div().flex_1().h_full().bg(linear_gradient(
7056                    90.0,
7057                    linear_color_stop(hsla(from, 1.0, 0.5, 1.0), 0.0),
7058                    linear_color_stop(hsla(to, 1.0, 0.5, 1.0), 1.0),
7059                ))
7060            };
7061            let hue_strip = div()
7062                .relative()
7063                .w(px(SV_W))
7064                .h(px(HUE_H))
7065                .rounded(px(4.0))
7066                .overflow_hidden()
7067                .flex()
7068                .child(seg(0.0, 1.0 / 6.0))
7069                .child(seg(1.0 / 6.0, 2.0 / 6.0))
7070                .child(seg(2.0 / 6.0, 3.0 / 6.0))
7071                .child(seg(3.0 / 6.0, 4.0 / 6.0))
7072                .child(seg(4.0 / 6.0, 5.0 / 6.0))
7073                .child(seg(5.0 / 6.0, 1.0))
7074                .child(
7075                    canvas(move |b, _, _| hue_cell.set(b), |_, _, _, _| {})
7076                        .absolute()
7077                        .size_full(),
7078                )
7079                .child(
7080                    div()
7081                        .absolute()
7082                        .left(px(p.h * SV_W - 1.5))
7083                        .top(px(-2.0))
7084                        .w(px(3.0))
7085                        .h(px(HUE_H + 4.0))
7086                        .rounded(px(2.0))
7087                        .bg(white)
7088                        .border_1()
7089                        .border_color(hsla(0.0, 0.0, 0.0, 0.5)),
7090                );
7091
7092            // Alpha (opacity) strip: transparent → the current color, opaque.
7093            let alpha_strip = div()
7094                .relative()
7095                .w(px(SV_W))
7096                .h(px(HUE_H))
7097                .rounded(px(4.0))
7098                .overflow_hidden()
7099                .bg(linear_gradient(
7100                    90.0,
7101                    linear_color_stop(clear, 0.0),
7102                    linear_color_stop(u32_to_hsla(hsv_to_u32(p.h, p.s, p.v)), 1.0),
7103                ))
7104                .child(
7105                    canvas(move |b, _, _| alpha_cell.set(b), |_, _, _, _| {})
7106                        .absolute()
7107                        .size_full(),
7108                )
7109                .child(
7110                    div()
7111                        .absolute()
7112                        .left(px(p.a * SV_W - 1.5))
7113                        .top(px(-2.0))
7114                        .w(px(3.0))
7115                        .h(px(HUE_H + 4.0))
7116                        .rounded(px(2.0))
7117                        .bg(white)
7118                        .border_1()
7119                        .border_color(hsla(0.0, 0.0, 0.0, 0.5)),
7120                );
7121
7122            // Reset means "back to theme ink" for stroke, "no fill" for fill.
7123            let reset_label = if p.target == PickerTarget::Fill {
7124                "None"
7125            } else {
7126                "Auto"
7127            };
7128            let info_row = div()
7129                .flex()
7130                .items_center()
7131                .gap(px(8.0))
7132                .child(
7133                    div()
7134                        .size(px(22.0))
7135                        .rounded(px(4.0))
7136                        .bg(u32_to_hsla(cur))
7137                        .border_1()
7138                        .border_color(grid),
7139                )
7140                .child(
7141                    div()
7142                        .flex_1()
7143                        .text_size(px(12.0))
7144                        .text_color(text)
7145                        .child(SharedString::from(hex)),
7146                )
7147                .child(
7148                    div()
7149                        .id("wb-color-auto")
7150                        .px(px(8.0))
7151                        .py(px(3.0))
7152                        .rounded(px(5.0))
7153                        .border_1()
7154                        .border_color(grid)
7155                        .text_size(px(12.0))
7156                        .text_color(ink)
7157                        .child(reset_label)
7158                        .on_click(
7159                            cx.listener(|this, _ev, window, cx| this.pick_color(None, window, cx)),
7160                        ),
7161                );
7162
7163            let mut swatch_views = Vec::with_capacity(swatch_list.len());
7164            for (i, c) in swatch_list.iter().enumerate() {
7165                let col = *c;
7166                swatch_views.push(
7167                    div()
7168                        .id(("wb-swatch", i))
7169                        .size(px(20.0))
7170                        .rounded(px(4.0))
7171                        .bg(col)
7172                        .border_1()
7173                        .border_color(grid)
7174                        .on_click(cx.listener(move |this, _ev, window, cx| {
7175                            this.pick_color(Some(hsla_to_u32(col)), window, cx)
7176                        })),
7177                );
7178            }
7179            // Theme swatches, kept on one line. Its width (`n` swatches of 20px +
7180            // 6px gaps) sets the panel width, and the Saved column is sized to the
7181            // space it leaves beside the controls — so the panel crops to this row
7182            // (no dead space) and saved colors wrap rather than run off the edge.
7183            let theme_row_w = (swatch_views.len() as f32 * 26.0 - 6.0).max(0.0);
7184            let saved_col_w = (theme_row_w - SV_W - 12.0).max(64.0);
7185            let swatch_grid = div().flex().flex_wrap().gap(px(6.0)).children(swatch_views);
7186
7187            // The gradient controls (the swatch row spans the full panel below).
7188            let controls_col = div()
7189                .flex()
7190                .flex_col()
7191                .gap(px(10.0))
7192                .child(tabs)
7193                .child(sv_square)
7194                .child(hue_strip)
7195                .child(alpha_strip)
7196                .child(info_row);
7197
7198            // The user's saved palette: the right column (filling the dead space). A
7199            // `+` saves the current color; each swatch applies on click, removes on
7200            // right-click. Persisted by the host via `on_save_colors`.
7201            let mut saved_grid = div().flex().flex_wrap().gap(px(6.0));
7202            if self.saved_colors.is_empty() {
7203                saved_grid = saved_grid.child(
7204                    div()
7205                        .w_full()
7206                        .text_size(px(11.0))
7207                        .text_color(text)
7208                        .child("Tap + to save a color"),
7209                );
7210            } else {
7211                for (i, &c) in self.saved_colors.iter().enumerate() {
7212                    saved_grid = saved_grid.child(
7213                        div()
7214                            .id(("wb-saved", i))
7215                            .size(px(20.0))
7216                            .rounded(px(4.0))
7217                            .bg(u32_to_hsla(c))
7218                            .border_1()
7219                            .border_color(grid)
7220                            .tooltip(self.tip("Click to use · right-click to remove"))
7221                            .on_click(cx.listener(move |this, _ev, window, cx| {
7222                                this.pick_color(Some(c), window, cx)
7223                            }))
7224                            .on_mouse_down(
7225                                MouseButton::Right,
7226                                cx.listener(move |this, _ev, window, cx| {
7227                                    this.remove_saved_color(c, window, cx)
7228                                }),
7229                            ),
7230                    );
7231                }
7232            }
7233            // Sized to the space the one-line swatch row leaves beside the controls,
7234            // so the panel crops to that row (no dead space) and the saved swatches
7235            // wrap within this column instead of forming one long row.
7236            let saved_col = div()
7237                .flex()
7238                .flex_col()
7239                .flex_none()
7240                .w(px(saved_col_w))
7241                .gap(px(8.0))
7242                .child(
7243                    div()
7244                        .flex()
7245                        .items_center()
7246                        .justify_between()
7247                        .child(div().text_size(px(11.0)).text_color(text).child("Saved"))
7248                        .child(
7249                            div()
7250                                .id("wb-save-color")
7251                                .size(px(20.0))
7252                                .flex()
7253                                .items_center()
7254                                .justify_center()
7255                                .rounded(px(4.0))
7256                                .border_1()
7257                                .border_color(grid)
7258                                .text_size(px(14.0))
7259                                .text_color(ink)
7260                                .hover(|s| s.bg(grid))
7261                                .child("+")
7262                                .tooltip(self.tip("Save current color"))
7263                                .on_click(cx.listener(|this, _ev, window, cx| {
7264                                    this.save_current_color(window, cx)
7265                                })),
7266                        ),
7267                )
7268                .child(saved_grid);
7269
7270            // Top: the gradient controls with the Saved palette beside them (in the
7271            // space the one-line swatch row leaves free). Swatch row spans below.
7272            let top_row = div()
7273                .flex()
7274                .flex_row()
7275                .items_start()
7276                .gap(px(12.0))
7277                .child(controls_col)
7278                .child(saved_col);
7279
7280            popover_anchor().child(
7281                div()
7282                    .relative()
7283                    .flex()
7284                    .flex_col()
7285                    .gap(px(10.0))
7286                    .p(px(10.0))
7287                    .rounded(px(10.0))
7288                    .bg(panel_strong)
7289                    .shadow_lg()
7290                    .border_1()
7291                    .border_color(grid)
7292                    .child(
7293                        canvas(move |b, _, _| panel_cell.set(b), |_, _, _, _| {})
7294                            .absolute()
7295                            .size_full(),
7296                    )
7297                    .child(top_row)
7298                    .child(swatch_grid),
7299            )
7300        });
7301
7302        // Pan tool shows a grab cursor (closed while dragging) to read as "drag
7303        // to move the canvas"; other tools use the default arrow.
7304        let board_cursor = if self.panning {
7305            CursorStyle::ClosedHand
7306        } else if self.tool == Tool::Pan {
7307            CursorStyle::OpenHand
7308        } else {
7309            CursorStyle::Arrow
7310        };
7311
7312        // The board paints as a stack of layers (back → front): the grid /
7313        // background; then the element layers (canvas "bands" interleaved with
7314        // image / page-card overlays, in z-order); then a top "chrome" canvas for
7315        // the in-progress element, selection box, and marquee — kept above the
7316        // content so handles stay visible over images.
7317        let board_layer = canvas(
7318            move |bounds, _, _| bounds_cell.set(bounds),
7319            move |bounds, _, window, _| paint_board(bounds, cam, bg, grid, window),
7320        )
7321        .absolute()
7322        .size_full();
7323        let element_layers: Vec<gpui::AnyElement> = layers
7324            .into_iter()
7325            .map(|l| match l {
7326                Layer::Band(es) => band_canvas(es, cam).into_any_element(),
7327                Layer::Overlay(el) => el,
7328            })
7329            .collect();
7330        let chrome_layer = canvas(
7331            |_, _, _| {},
7332            move |bounds, _, window, _| {
7333                if let Some(k) = &pending {
7334                    paint_element(
7335                        k,
7336                        None,
7337                        cam,
7338                        bounds.origin,
7339                        pending_ink,
7340                        pending_fill,
7341                        window,
7342                    );
7343                }
7344                if let Some(k) = &single_sel {
7345                    paint_selection(k, cam, bounds.origin, selection, window);
7346                }
7347                if let Some((kind, active)) = &snap_target {
7348                    paint_snap_points(kind, *active, cam, bounds.origin, selection, window);
7349                }
7350                // Group: resize handles without an enclosing blue frame, plus a
7351                // shared rotate grip when the group can rotate.
7352                if let Some((bb, _can_rotate)) = group_sel {
7353                    let tl = to_screen(bb.0, bb.1, cam, bounds.origin);
7354                    let br = to_screen(bb.2, bb.3, cam, bounds.origin);
7355                    let m = 0.0;
7356                    let (x0, y0) = (f32::from(tl.x) - m, f32::from(tl.y) - m);
7357                    let (x1, y1) = (f32::from(br.x) + m, f32::from(br.y) + m);
7358                    // Four corners (proportional) plus four edge midpoints (per-axis
7359                    // stretch). The midpoints align with `handle_hit`'s edge grips.
7360                    let (mx, my) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
7361                    for (hx, hy) in [
7362                        (x0, y0),
7363                        (x1, y0),
7364                        (x0, y1),
7365                        (x1, y1),
7366                        (mx, y0),
7367                        (mx, y1),
7368                        (x0, my),
7369                        (x1, my),
7370                    ] {
7371                        draw_handle(hx, hy, selection, window);
7372                    }
7373                }
7374                if let Some((a, b)) = marquee {
7375                    paint_marquee(a, b, cam, bounds.origin, selection, window);
7376                }
7377                paint_alignment_guides(alignment_guides, bounds, cam, selection, window);
7378            },
7379        )
7380        .absolute()
7381        .size_full();
7382
7383        let root = div()
7384            .track_focus(&self.focus)
7385            .size_full()
7386            .relative()
7387            .overflow_hidden()
7388            .cursor(board_cursor)
7389            .child(board_layer)
7390            .children(connector_buttons)
7391            .children(rotate_button)
7392            .child(
7393                div()
7394                    .absolute()
7395                    .size_full()
7396                    .child(WhiteboardInputElement::new(cx.entity())),
7397            )
7398            .on_mouse_down(MouseButton::Left, cx.listener(Self::on_left_down))
7399            .on_mouse_up(MouseButton::Left, cx.listener(Self::on_left_up))
7400            .on_mouse_down(MouseButton::Right, cx.listener(Self::on_right_down))
7401            .on_mouse_down(MouseButton::Middle, cx.listener(Self::on_middle_down))
7402            .on_mouse_up(MouseButton::Middle, cx.listener(Self::on_middle_up))
7403            .on_mouse_move(cx.listener(Self::on_move));
7404        let root = if accepts_wheel_input(self.read_only) {
7405            root.on_scroll_wheel(cx.listener(Self::on_scroll))
7406        } else {
7407            root
7408        };
7409        root.on_pinch(cx.listener(Self::on_pinch))
7410            .on_key_down(cx.listener(Self::on_key))
7411            // Files dragged from the OS land as `ExternalPaths`; hand them to the
7412            // host (which imports any images) at the drop point.
7413            .on_drop::<gpui::ExternalPaths>(cx.listener(
7414                |this, paths: &gpui::ExternalPaths, window, cx| {
7415                    if let Some(f) = this.on_drop_files.clone() {
7416                        let w = this.event_to_world(window.mouse_position());
7417                        f(paths.paths().to_vec(), w[0], w[1], window, cx);
7418                    }
7419                },
7420            ))
7421            .children(element_layers)
7422            .child(chrome_layer)
7423            .child(toolbar)
7424            .children(flyout)
7425            .children(format_panel)
7426            .children(width_flyout)
7427            .children(font_flyout)
7428            .children(menu)
7429            .children(text_submenu)
7430            .children(picker_panel)
7431            .children(templates_modal)
7432            .child(
7433                div()
7434                    .absolute()
7435                    .left(px(10.0))
7436                    .bottom(px(8.0))
7437                    .text_size(px(11.0))
7438                    .text_color(text)
7439                    .child(SharedString::from(format!("{:.0}%", cam.zoom * 100.0))),
7440            )
7441            .into_any_element()
7442    }
7443}
7444
7445#[cfg(test)]
7446mod tests {
7447    use super::*;
7448
7449    #[test]
7450    fn text_layout_cache_reuses_outlines_until_content_changes() {
7451        let font = Font::default();
7452        let mut cache = HashMap::new();
7453        let first = cached_text_layout(&mut cache, &font, 9, "hello", 16.0, None, &[]);
7454        let reused = cached_text_layout(&mut cache, &font, 9, "hello", 16.0, None, &[]);
7455        let changed = cached_text_layout(&mut cache, &font, 9, "hello!", 16.0, None, &[]);
7456
7457        assert!(Arc::ptr_eq(&first.segs, &reused.segs));
7458        assert!(!Arc::ptr_eq(&first.segs, &changed.segs));
7459    }
7460
7461    #[test]
7462    fn shape_label_cache_survives_movement_and_invalidates_on_resize() {
7463        let font = Font::default();
7464        let mut cache = HashMap::new();
7465        let kind = ElementKind::RoundRect(BoxGeom {
7466            x: 10.0,
7467            y: 20.0,
7468            w: 180.0,
7469            h: 60.0,
7470            width: 2.0,
7471            rotation: 0.0,
7472        });
7473        let first = cached_label_layout(
7474            &mut cache,
7475            &font,
7476            3,
7477            &kind,
7478            10.0,
7479            20.0,
7480            180.0,
7481            60.0,
7482            "Node",
7483            &[],
7484        );
7485        let moved = cached_label_layout(
7486            &mut cache,
7487            &font,
7488            3,
7489            &kind,
7490            80.0,
7491            90.0,
7492            180.0,
7493            60.0,
7494            "Node",
7495            &[],
7496        );
7497        let resized = cached_label_layout(
7498            &mut cache,
7499            &font,
7500            3,
7501            &kind,
7502            80.0,
7503            90.0,
7504            240.0,
7505            80.0,
7506            "Node",
7507            &[],
7508        );
7509
7510        assert!(Arc::ptr_eq(&first.text.segs, &moved.text.segs));
7511        assert!(!Arc::ptr_eq(&first.text.segs, &resized.text.segs));
7512    }
7513
7514    #[test]
7515    fn read_only_embed_does_not_accept_wheel_input() {
7516        assert!(!accepts_wheel_input(true));
7517        assert!(accepts_wheel_input(false));
7518    }
7519
7520    #[test]
7521    fn empty_or_garbage_loads_a_blank_board() {
7522        for s in ["", "   ", "not json", "{}", r#"{"camera":{"zoom":0}}"#] {
7523            let scene = Scene::from_json(s);
7524            assert_eq!(scene.camera.zoom, 1.0, "input {s:?}");
7525            assert!(scene.elements.is_empty(), "input {s:?}");
7526        }
7527    }
7528
7529    #[test]
7530    fn ime_offsets_bridge_utf16_and_utf8() {
7531        // Chinese uses one UTF-16 code unit but three UTF-8 bytes; emoji uses a
7532        // surrogate pair. These are the offsets GPUI's native IME APIs exchange.
7533        let text = "A中😀B";
7534        let utf8_boundaries = [0, 1, 4, 8, 9];
7535        let utf16_boundaries = [0, 1, 2, 4, 5];
7536        for (utf8, utf16) in utf8_boundaries.into_iter().zip(utf16_boundaries) {
7537            assert_eq!(WhiteboardView::utf8_to_utf16_in(text, utf8), utf16);
7538            assert_eq!(WhiteboardView::utf16_to_utf8_in(text, utf16), utf8);
7539        }
7540
7541        // Like the editor bridge, offsets inside a code point advance to the next
7542        // valid boundary, so slicing the scene's UTF-8 string remains safe.
7543        assert_eq!(WhiteboardView::utf8_to_utf16_in(text, 3), 2);
7544        assert_eq!(WhiteboardView::utf16_to_utf8_in(text, 3), 8);
7545    }
7546
7547    #[test]
7548    fn camera_round_trips_through_json() {
7549        let scene = Scene {
7550            camera: Camera {
7551                x: 12.5,
7552                y: -4.0,
7553                zoom: 2.0,
7554            },
7555            ..Default::default()
7556        };
7557        let restored = Scene::from_json(&scene.to_json());
7558        assert_eq!(restored.camera.x, 12.5);
7559        assert_eq!(restored.camera.zoom, 2.0);
7560    }
7561
7562    #[test]
7563    fn all_content_thumbnail_snapshot_uses_scene_bounds_without_mounting_view() {
7564        let scene = Scene {
7565            elements: vec![Element {
7566                id: 1,
7567                kind: ElementKind::Rect(BoxGeom {
7568                    x: 10.0,
7569                    y: 20.0,
7570                    w: 100.0,
7571                    h: 60.0,
7572                    width: 2.0,
7573                    rotation: 0.0,
7574                }),
7575                stroke: None,
7576                fill: None,
7577                label: None,
7578                label_color: None,
7579                styles: Vec::new(),
7580                mindmap: None,
7581            }],
7582            ..Scene::default()
7583        };
7584
7585        let snapshot = LocalThumbnailSnapshot::for_scene_all_content(scene, 320.0, 180.0);
7586
7587        assert_eq!(snapshot.spec.scene_bounds, Some([10.0, 20.0, 110.0, 80.0]));
7588        assert_eq!(snapshot.spec.focus_bounds, [10.0, 20.0, 110.0, 80.0]);
7589        assert!(snapshot.spec.camera.zoom > 0.0);
7590    }
7591
7592    #[test]
7593    fn empty_scene_still_builds_a_renderable_thumbnail_snapshot() {
7594        let snapshot =
7595            LocalThumbnailSnapshot::for_scene_all_content(Scene::default(), 320.0, 180.0);
7596
7597        assert_eq!(snapshot.spec.scene_bounds, None);
7598        assert_eq!(snapshot.spec.focus_bounds, [0.0, 0.0, 320.0, 180.0]);
7599        assert_eq!(snapshot.spec.camera.zoom, 1.0);
7600    }
7601
7602    #[test]
7603    fn every_element_kind_round_trips_through_json() {
7604        let scene = Scene {
7605            camera: Camera::default(),
7606            elements: vec![
7607                Element {
7608                    id: 1,
7609                    kind: ElementKind::Draw(Stroke {
7610                        points: vec![[0.0, 0.0], [10.0, 5.0]],
7611                        width: 3.0,
7612                    }),
7613                    stroke: None,
7614                    fill: None,
7615                    label: None,
7616                    label_color: None,
7617                    styles: Vec::new(),
7618                    mindmap: None,
7619                },
7620                Element {
7621                    id: 2,
7622                    kind: ElementKind::Rect(BoxGeom {
7623                        x: 1.0,
7624                        y: 2.0,
7625                        w: 30.0,
7626                        h: 40.0,
7627                        width: 2.0,
7628                        rotation: 0.0,
7629                    }),
7630                    stroke: Some(0xff0000ff),
7631                    fill: Some(0x00ff0080),
7632                    label: Some("hi".into()),
7633                    label_color: Some(0x112233ff),
7634                    styles: Vec::new(),
7635                    mindmap: None,
7636                },
7637                Element {
7638                    id: 3,
7639                    kind: ElementKind::Arrow(SegGeom {
7640                        x1: 1.0,
7641                        y1: 1.0,
7642                        x2: 2.0,
7643                        y2: 8.0,
7644                        width: 2.5,
7645                        style: SegmentStyle::Solid,
7646                        start_anchor: None,
7647                        end_anchor: None,
7648                    }),
7649                    stroke: None,
7650                    fill: None,
7651                    label: None,
7652                    label_color: None,
7653                    styles: Vec::new(),
7654                    mindmap: None,
7655                },
7656            ],
7657        };
7658        let restored = Scene::from_json(&scene.to_json());
7659        assert_eq!(restored.elements.len(), 3);
7660        match &restored.elements[2].kind {
7661            ElementKind::Arrow(s) => assert_eq!(s.y2, 8.0),
7662            other => panic!("expected arrow, got {other:?}"),
7663        }
7664        // Per-element color round-trips; an uncolored element stays `None`.
7665        assert_eq!(restored.elements[1].stroke, Some(0xff0000ff));
7666        assert_eq!(restored.elements[1].fill, Some(0x00ff0080));
7667        // The shape label + its color round-trip too.
7668        assert_eq!(restored.elements[1].label.as_deref(), Some("hi"));
7669        assert_eq!(restored.elements[1].label_color, Some(0x112233ff));
7670        assert_eq!(restored.elements[0].stroke, None);
7671        assert_eq!(restored.elements[0].fill, None);
7672    }
7673
7674    #[test]
7675    fn label_defaults_to_none_for_older_boards() {
7676        // A board saved before labels existed has no `label` key → `None`, and an
7677        // unlabeled element never writes the key back.
7678        let old = r#"{"id":2,"kind":{"rect":{"x":0.0,"y":0.0,"w":1.0,"h":1.0,"width":1.0}}}"#;
7679        let back: Element = serde_json::from_str(old).unwrap();
7680        assert_eq!(back.label, None);
7681        assert!(!serde_json::to_string(&back).unwrap().contains("label"));
7682    }
7683
7684    #[test]
7685    fn shape_label_block_fits_inscribed_region() {
7686        let font = Font::default();
7687        let bg = BoxGeom {
7688            x: 0.0,
7689            y: 0.0,
7690            w: 0.0,
7691            h: 0.0,
7692            width: 0.0,
7693            rotation: 0.0,
7694        };
7695        let (bx, by, bw, bh) = (10.0, 20.0, 200.0, 120.0);
7696
7697        // Rect: default size for a roomy box; wraps to ~the full padded width.
7698        let rect = shape_label_block(&font, &ElementKind::Rect(bg), bx, by, bw, bh, "hello world");
7699        assert!(rect.size <= TEXT_SIZE + 0.01, "size {}", rect.size);
7700        assert!(
7701            (rect.wrap - (bw - 2.0 * LABEL_PAD)).abs() < 0.5,
7702            "rect wraps full width: {}",
7703            rect.wrap
7704        );
7705
7706        // Diamond: wraps to the central inscribed rectangle (½ width), so it wraps
7707        // narrower and shrinks at least as much as the rect.
7708        let dia = shape_label_block(
7709            &font,
7710            &ElementKind::Diamond(bg),
7711            bx,
7712            by,
7713            bw,
7714            bh,
7715            "hello world",
7716        );
7717        assert!(
7718            (dia.wrap - (bw * 0.5 - 2.0 * LABEL_PAD)).abs() < 0.5,
7719            "diamond half width: {}",
7720            dia.wrap
7721        );
7722        assert!(
7723            dia.size <= rect.size,
7724            "diamond shrinks ≥ rect: {} vs {}",
7725            dia.size,
7726            rect.size
7727        );
7728
7729        // Triangle: its band sits in the lower half (anchored near the base).
7730        let tri = shape_label_block(
7731            &font,
7732            &ElementKind::Triangle(bg),
7733            bx,
7734            by,
7735            bw,
7736            bh,
7737            "hello world",
7738        );
7739        assert!(
7740            tri.y >= by + bh / 2.0 - 0.5,
7741            "triangle label low: y={}",
7742            tri.y
7743        );
7744
7745        // A long label in a small box shrinks below the default to avoid overflow.
7746        let tiny = shape_label_block(
7747            &font,
7748            &ElementKind::Rect(bg),
7749            0.0,
7750            0.0,
7751            44.0,
7752            28.0,
7753            "a long label that must shrink",
7754        );
7755        assert!(tiny.size < TEXT_SIZE, "shrinks: {}", tiny.size);
7756    }
7757
7758    #[test]
7759    fn style_span_toggle_and_layer() {
7760        let bold = RunStyle {
7761            bold: true,
7762            ..Default::default()
7763        };
7764        let s = toggle_format(&[], 0, 4, Format::Bold);
7765        assert_eq!(s.len(), 1);
7766        assert_eq!((s[0].start, s[0].end, s[0].style), (0, 4, bold));
7767        assert!(style_at(&s, 2).bold && !style_at(&s, 4).bold);
7768        // Toggling the same range off clears it.
7769        assert!(toggle_format(&s, 0, 4, Format::Bold).is_empty());
7770        // Extending past the run (partly unstyled) adds, merging into one run.
7771        let s2 = toggle_format(&s, 2, 6, Format::Bold);
7772        assert_eq!((s2.len(), s2[0].start, s2[0].end), (1, 0, 6));
7773        // Layering italic over part of the bold run yields three runs.
7774        let s3 = toggle_format(&s, 1, 3, Format::Italic);
7775        assert_eq!(s3.len(), 3, "{s3:?}");
7776        assert!(style_at(&s3, 1).bold && style_at(&s3, 1).italic);
7777        assert!(style_at(&s3, 0).bold && !style_at(&s3, 0).italic);
7778        // Highlight toggles its color independently.
7779        let h = toggle_highlight(&[], 0, 3, 0xffff00ff);
7780        assert_eq!(style_at(&h, 1).highlight, Some(0xffff00ff));
7781        assert!(toggle_highlight(&h, 0, 3, 0xffff00ff).is_empty());
7782    }
7783
7784    #[test]
7785    fn active_style_reports_common_formatting() {
7786        let s = toggle_format(&[], 2, 5, Format::Bold); // bytes 2..5 bold
7787        assert!(active_style(&s, 2, 5).bold, "whole selection bold");
7788        assert!(
7789            !active_style(&s, 0, 5).bold,
7790            "selection spills onto plain text"
7791        );
7792        // Collapsed caret inherits the char to its left.
7793        assert!(
7794            active_style(&s, 5, 5).bold,
7795            "just after the run inherits bold"
7796        );
7797        assert!(!active_style(&s, 2, 2).bold, "just before it is plain");
7798    }
7799
7800    #[test]
7801    fn splice_keeps_runs_aligned() {
7802        let plain = RunStyle::default();
7803        let s = toggle_format(&[], 2, 5, Format::Bold);
7804        // Insert two chars at the start → the run shifts right.
7805        let a = splice_styles(&s, 0, 0, 2, plain);
7806        assert_eq!((a[0].start, a[0].end), (4, 7));
7807        // Delete a char inside the run → it shrinks by one.
7808        let b = splice_styles(&s, 3, 4, 0, plain);
7809        assert_eq!((b[0].start, b[0].end), (2, 4), "{b:?}");
7810        // Replacing a middle slice of a bold run with plain text splits it.
7811        let full = toggle_format(&[], 0, 6, Format::Bold);
7812        let c = splice_styles(&full, 2, 4, 2, plain);
7813        assert_eq!(c.len(), 2, "{c:?}");
7814        assert_eq!(
7815            ((c[0].start, c[0].end), (c[1].start, c[1].end)),
7816            ((0, 2), (4, 6))
7817        );
7818    }
7819
7820    #[test]
7821    fn styles_round_trip_and_back_compat() {
7822        let el = Element {
7823            id: 1,
7824            kind: ElementKind::Text(TextGeom {
7825                x: 0.0,
7826                y: 0.0,
7827                content: "hello world".into(),
7828                size: 12.0,
7829                rotation: 0.0,
7830                measured_w: 0.0,
7831                measured_h: 0.0,
7832            }),
7833            stroke: None,
7834            fill: None,
7835            label: None,
7836            label_color: None,
7837            styles: vec![StyleSpan {
7838                start: 0,
7839                end: 5,
7840                style: RunStyle {
7841                    bold: true,
7842                    highlight: Some(0xffff00ff),
7843                    ..Default::default()
7844                },
7845            }],
7846            mindmap: None,
7847        };
7848        let back: Element = serde_json::from_str(&serde_json::to_string(&el).unwrap()).unwrap();
7849        assert_eq!(back.styles.len(), 1);
7850        assert!(back.styles[0].style.bold);
7851        assert_eq!(back.styles[0].style.highlight, Some(0xffff00ff));
7852        // A board saved before rich text loads with no styles.
7853        let old = r#"{"id":2,"kind":{"rect":{"x":0.0,"y":0.0,"w":1.0,"h":1.0,"width":1.0}}}"#;
7854        assert!(
7855            serde_json::from_str::<Element>(old)
7856                .unwrap()
7857                .styles
7858                .is_empty()
7859        );
7860    }
7861
7862    #[test]
7863    fn pan_and_zoom_math() {
7864        let mut c = Camera::default();
7865        c.pan_by(50.0, -20.0);
7866        assert_eq!((c.x, c.y), (-50.0, 20.0));
7867
7868        let mut c = Camera {
7869            x: 10.0,
7870            y: 5.0,
7871            zoom: 1.0,
7872        };
7873        let before = c.screen_to_world(300.0, 200.0);
7874        c.zoom_about(300.0, 200.0, 2.5);
7875        let after = c.screen_to_world(300.0, 200.0);
7876        assert!((before.0 - after.0).abs() < 1e-3);
7877        assert!((before.1 - after.1).abs() < 1e-3);
7878        assert_eq!(c.zoom, 2.5);
7879    }
7880
7881    #[test]
7882    fn bbox_translate_and_hit_test() {
7883        let mut k = ElementKind::Line(SegGeom {
7884            x1: 0.0,
7885            y1: 0.0,
7886            x2: 10.0,
7887            y2: 4.0,
7888            width: 1.0,
7889            style: SegmentStyle::Solid,
7890            start_anchor: None,
7891            end_anchor: None,
7892        });
7893        assert_eq!(bbox(&k), (0.0, 0.0, 10.0, 4.0));
7894        translate(&mut k, 5.0, -2.0);
7895        assert_eq!(bbox(&k), (5.0, -2.0, 15.0, 2.0));
7896        // Within the padded bounds hits; far away misses.
7897        assert!(hit_test(&k, 5.0, -2.0, 1.0));
7898        assert!(hit_test(&k, 4.5, -2.5, 1.0)); // inside pad
7899        assert!(!hit_test(&k, 100.0, 100.0, 1.0));
7900    }
7901
7902    #[test]
7903    fn diagonal_scale_projects_the_cursor_onto_the_diagonal() {
7904        // On the diagonal: cursor twice as far from the anchor → 2×.
7905        let s = diagonal_scale([0.0, 0.0], [10.0, 10.0], [20.0, 20.0]);
7906        assert!((s - 2.0).abs() < 1e-4, "{s}");
7907        // Off-diagonal projects onto it: (20,0) onto the (10,10) line → 1×.
7908        let s = diagonal_scale([0.0, 0.0], [10.0, 10.0], [20.0, 0.0]);
7909        assert!((s - 1.0).abs() < 1e-4, "{s}");
7910    }
7911
7912    #[test]
7913    fn snap_45_locks_angle_and_keeps_length() {
7914        // Near 45° snaps onto the exact diagonal (x == y).
7915        let (x, y) = snap_45(0.0, 0.0, 10.0, 9.0);
7916        assert!((x - y).abs() < 1e-3, "{x} vs {y}");
7917        // Near-horizontal snaps flat, preserving the distance.
7918        let (x, y) = snap_45(0.0, 0.0, 10.0, 1.0);
7919        assert!(y.abs() < 1e-3);
7920        assert!((x - 101.0f32.sqrt()).abs() < 1e-2);
7921    }
7922
7923    #[test]
7924    fn snap_grid_rounds_to_nearest_line() {
7925        // GRID is 24: values round to the nearest multiple, halves away from zero.
7926        assert_eq!(snap_grid(0.0), 0.0);
7927        assert_eq!(snap_grid(11.0), 0.0);
7928        assert_eq!(snap_grid(13.0), GRID);
7929        assert_eq!(snap_grid(GRID), GRID);
7930        assert_eq!(snap_grid(-13.0), -GRID);
7931        assert_eq!(snap_grid(1.5 * GRID), 2.0 * GRID);
7932    }
7933
7934    #[test]
7935    fn move_target_drives_an_absolute_snapped_target() {
7936        // Origin off-grid (100 % 24 == 4); grab anchor at the cursor's start.
7937        let origin = [100.0, 100.0];
7938        let anchor = [0.0, 0.0];
7939
7940        // Free move tracks the cursor exactly on both axes.
7941        assert_eq!(
7942            move_target(origin, anchor, [37.0, -11.0], false),
7943            [137.0, 89.0]
7944        );
7945
7946        // Snapped: the target is `snap(origin + total)`, computed fresh each
7947        // frame — never the running position, so it can't stick. A 50,50 total
7948        // lands on snap(150) = 144 (150/24 = 6.25 → 6).
7949        assert_eq!(
7950            move_target(origin, anchor, [50.0, 50.0], true),
7951            [144.0, 144.0]
7952        );
7953
7954        // Regression: twelve sub-threshold 4px steps (each < half a grid cell)
7955        // must still accumulate across grid lines on BOTH axes — the old logic
7956        // snapped each tiny step from the already-snapped spot and stuck.
7957        let mut cursor = [0.0, 0.0];
7958        for _ in 0..12 {
7959            cursor = [cursor[0] + 4.0, cursor[1] + 4.0];
7960        }
7961        // 48px total → snap(148) = 144 on each axis.
7962        assert_eq!(move_target(origin, anchor, cursor, true), [144.0, 144.0]);
7963    }
7964
7965    #[test]
7966    fn resize_scales_geometry_about_the_anchor() {
7967        // Drag the bottom-right corner of a 20×20 rect to double it, anchored
7968        // at the top-left — origin stays put, size doubles.
7969        let mut k = ElementKind::Rect(BoxGeom {
7970            x: 10.0,
7971            y: 10.0,
7972            w: 20.0,
7973            h: 20.0,
7974            width: 1.0,
7975            rotation: 0.0,
7976        });
7977        resize_about(&mut k, 10.0, 10.0, 2.0, 2.0);
7978        match k {
7979            ElementKind::Rect(b) => {
7980                assert_eq!((b.x, b.y), (10.0, 10.0));
7981                assert_eq!((b.w, b.h), (40.0, 40.0));
7982            }
7983            other => panic!("expected rect, got {other:?}"),
7984        }
7985    }
7986
7987    #[test]
7988    fn axis_scale_measures_one_axis_about_the_anchor() {
7989        // `target` twice as far from the anchor as `from` → 2×.
7990        assert!((axis_scale(0.0, 10.0, 20.0) - 2.0).abs() < 1e-4);
7991        // Halfway back toward the anchor → 0.5×.
7992        assert!((axis_scale(0.0, 10.0, 5.0) - 0.5).abs() < 1e-4);
7993        // Degenerate (anchor == from) → 1.0, no divide-by-zero.
7994        assert!((axis_scale(7.0, 7.0, 99.0) - 1.0).abs() < 1e-6);
7995    }
7996
7997    #[test]
7998    fn per_axis_resize_stretches_one_axis_and_keeps_text_uniform() {
7999        // A rect stretched on x only (sx=2, sy=1): width doubles, height holds.
8000        let mut k = ElementKind::Rect(BoxGeom {
8001            x: 10.0,
8002            y: 10.0,
8003            w: 20.0,
8004            h: 20.0,
8005            width: 1.0,
8006            rotation: 0.0,
8007        });
8008        resize_about(&mut k, 10.0, 10.0, 2.0, 1.0);
8009        match k {
8010            ElementKind::Rect(b) => {
8011                assert_eq!((b.x, b.y), (10.0, 10.0));
8012                assert_eq!((b.w, b.h), (40.0, 20.0));
8013            }
8014            other => panic!("expected rect, got {other:?}"),
8015        }
8016        // Text under a per-axis (4×, 1×) stretch keeps a single size: the geometric
8017        // mean (sqrt(4) = 2×), never distorted to the raw 4× horizontal factor.
8018        let mut t = ElementKind::Text(TextGeom {
8019            x: 0.0,
8020            y: 0.0,
8021            content: "hi".into(),
8022            size: 10.0,
8023            rotation: 0.0,
8024            measured_w: 0.0,
8025            measured_h: 0.0,
8026        });
8027        resize_about(&mut t, 0.0, 0.0, 4.0, 1.0);
8028        match t {
8029            ElementKind::Text(t) => assert!((t.size - 20.0).abs() < 1e-3, "{}", t.size),
8030            other => panic!("expected text, got {other:?}"),
8031        }
8032    }
8033
8034    #[test]
8035    fn color_round_trips_through_hsv_and_packed_ints() {
8036        // Pure primaries survive HSV → packed → HSV.
8037        for c in [0xff0000ff, 0x00ff00ff, 0x0000ffff, 0x808080ff, 0xffffffff] {
8038            let (h, s, v) = u32_to_hsv(c);
8039            assert_eq!(hsv_to_u32(h, s, v), c, "{c:#010x}");
8040        }
8041        // Hue endpoints both land on red.
8042        assert_eq!(hsv_to_u32(0.0, 1.0, 1.0), 0xff0000ff);
8043        assert_eq!(hsv_to_u32(1.0, 1.0, 1.0), 0xff0000ff);
8044        // A 2/3 hue is pure blue.
8045        assert_eq!(hsv_to_u32(2.0 / 3.0, 1.0, 1.0), 0x0000ffff);
8046        // pack clamps out-of-range and rounds to 0..255.
8047        assert_eq!(pack_rgba(1.5, -0.2, 0.5, 1.0), 0xff0080ff);
8048    }
8049
8050    #[test]
8051    fn rotation_accumulates_on_boxes_and_bakes_into_segments() {
8052        use std::f32::consts::FRAC_PI_2;
8053        // A box stores the angle and its center-anchored bounds don't move.
8054        let mut k = ElementKind::Rect(BoxGeom {
8055            x: -10.0,
8056            y: -10.0,
8057            w: 20.0,
8058            h: 20.0,
8059            width: 1.0,
8060            rotation: 0.0,
8061        });
8062        rotate_element(&mut k, 0.0, 0.0, FRAC_PI_2);
8063        match &k {
8064            ElementKind::Rect(b) => assert!((b.rotation - FRAC_PI_2).abs() < 1e-5),
8065            other => panic!("expected rect, got {other:?}"),
8066        }
8067        // A square's bounds are unchanged by a 90° turn about its center.
8068        let bb = bbox(&k);
8069        assert!(
8070            (bb.0 + 10.0).abs() < 1e-3 && (bb.2 - 10.0).abs() < 1e-3,
8071            "{bb:?}"
8072        );
8073
8074        // A line bakes the rotation into its endpoints: +90° about the origin
8075        // sends (10,0) → (0,10).
8076        let mut seg = ElementKind::Line(SegGeom {
8077            x1: 0.0,
8078            y1: 0.0,
8079            x2: 10.0,
8080            y2: 0.0,
8081            width: 1.0,
8082            style: SegmentStyle::Solid,
8083            start_anchor: None,
8084            end_anchor: None,
8085        });
8086        rotate_element(&mut seg, 0.0, 0.0, FRAC_PI_2);
8087        match seg {
8088            ElementKind::Line(s) => {
8089                assert!(s.x2.abs() < 1e-3 && (s.y2 - 10.0).abs() < 1e-3, "{s:?}");
8090            }
8091            other => panic!("expected line, got {other:?}"),
8092        }
8093
8094        // Text rotates like a box: spun about its own center, it accumulates an
8095        // angle and stays put (centered on the pivot here, so no orbit).
8096        let mut txt = ElementKind::Text(TextGeom {
8097            x: -20.0,
8098            y: -8.0,
8099            content: "hi".into(),
8100            size: 16.0,
8101            rotation: 0.0,
8102            measured_w: 40.0,
8103            measured_h: 16.0,
8104        });
8105        rotate_element(&mut txt, 0.0, 0.0, FRAC_PI_2);
8106        match txt {
8107            ElementKind::Text(t) => {
8108                assert!((t.rotation - FRAC_PI_2).abs() < 1e-5);
8109                assert!(
8110                    (t.x + 20.0).abs() < 1e-3 && (t.y + 8.0).abs() < 1e-3,
8111                    "{t:?}"
8112                );
8113            }
8114            other => panic!("expected text, got {other:?}"),
8115        }
8116
8117        // Orbiting: rotating a box about a *different* pivot moves its center
8118        // along the arc. A unit box at (1,0) turned 90° about the origin → (0,1).
8119        let mut orb = ElementKind::Rect(BoxGeom {
8120            x: 0.5,
8121            y: -0.5,
8122            w: 1.0,
8123            h: 1.0,
8124            width: 1.0,
8125            rotation: 0.0,
8126        });
8127        rotate_element(&mut orb, 0.0, 0.0, FRAC_PI_2);
8128        match orb {
8129            ElementKind::Rect(b) => {
8130                let (ccx, ccy) = (b.x + 0.5, b.y + 0.5);
8131                assert!(ccx.abs() < 1e-3 && (ccy - 1.0).abs() < 1e-3, "{b:?}");
8132            }
8133            other => panic!("expected rect, got {other:?}"),
8134        }
8135    }
8136
8137    #[test]
8138    fn rotation_snaps_to_horizontal_and_vertical() {
8139        use std::f32::consts::{FRAC_PI_2, FRAC_PI_4};
8140        let step = std::f32::consts::PI / 12.0;
8141        // Within the snap zone of a cardinal snaps onto it...
8142        assert!((snap_angle(FRAC_PI_2 - 0.05, false) - FRAC_PI_2).abs() < 1e-6);
8143        assert!(snap_angle(0.04, false).abs() < 1e-6);
8144        assert!((snap_angle(-FRAC_PI_2 + 0.03, false) + FRAC_PI_2).abs() < 1e-6);
8145        // ...but a hair outside it, and at 45°, the angle is left free.
8146        assert!((snap_angle(FRAC_PI_2 - 0.2, false) - (FRAC_PI_2 - 0.2)).abs() < 1e-6);
8147        assert!((snap_angle(FRAC_PI_4, false) - FRAC_PI_4).abs() < 1e-6);
8148        // Shift snaps to the nearest 15° everywhere.
8149        assert!((snap_angle(0.30, true) - step).abs() < 1e-4);
8150    }
8151
8152    #[test]
8153    fn caret_navigation_walks_chars_and_lines() {
8154        // Multi-byte: "é" is 2 bytes, so caret steps by whole chars, never panics.
8155        let s = "aébc";
8156        assert_eq!(caret_right(s, 0), 1); // past 'a'
8157        assert_eq!(caret_right(s, 1), 3); // past 'é' (2 bytes)
8158        assert_eq!(caret_left(s, 3), 1); // back over 'é'
8159        assert_eq!(caret_left(s, 0), 0); // clamps at start
8160        assert_eq!(caret_right(s, s.len()), s.len()); // clamps at end
8161        // Line edges around a newline.
8162        let m = "ab\ncde";
8163        assert_eq!(line_start(m, 5), 3); // within "cde" → after the '\n'
8164        assert_eq!(line_end(m, 0), 2); // end of "ab" (before '\n')
8165        assert_eq!(line_start(m, 1), 0);
8166        assert_eq!(line_end(m, 4), m.len());
8167        // floor_boundary never splits a char.
8168        assert_eq!(floor_boundary(s, 2), 1); // mid-'é' → its start
8169        assert_eq!(floor_boundary(s, 99), s.len());
8170    }
8171
8172    #[test]
8173    fn word_range_selects_the_word_under_the_caret() {
8174        let s = "foo bar_baz qux";
8175        assert_eq!(word_range(s, 1), (0, 3)); // inside "foo"
8176        assert_eq!(word_range(s, 7), (4, 11)); // "bar_baz" (underscore is a word char)
8177        // At a word/space boundary the adjacent word wins (caret just after "foo").
8178        assert_eq!(word_range(s, 3), (0, 3));
8179        // Between two spaces → empty (no word under the caret).
8180        assert_eq!(word_range("a  b", 2), (2, 2));
8181    }
8182
8183    #[test]
8184    fn text_bbox_anchors_at_origin_and_grows() {
8185        let t = TextGeom {
8186            x: 5.0,
8187            y: 6.0,
8188            content: "ab\ncde".into(),
8189            size: 10.0,
8190            rotation: 0.0,
8191            measured_w: 0.0,
8192            measured_h: 0.0,
8193        };
8194        let bb = bbox(&ElementKind::Text(t));
8195        assert_eq!((bb.0, bb.1), (5.0, 6.0));
8196        assert!(bb.2 > bb.0 && bb.3 > bb.1);
8197    }
8198
8199    #[test]
8200    fn tiny_drags_are_not_committed() {
8201        assert!(!committable(&ElementKind::Draw(Stroke {
8202            points: vec![[0.0, 0.0]],
8203            width: 1.0,
8204        })));
8205        assert!(committable(&ElementKind::Rect(BoxGeom {
8206            x: 0.0,
8207            y: 0.0,
8208            w: 20.0,
8209            h: 5.0,
8210            width: 1.0,
8211            rotation: 0.0,
8212        })));
8213    }
8214
8215    #[test]
8216    fn image_round_trips_and_behaves_like_a_box() {
8217        let kind = ElementKind::Image(ImageGeom {
8218            src: "images/x.png".into(),
8219            x: 10.0,
8220            y: 20.0,
8221            w: 100.0,
8222            h: 60.0,
8223            rotation: 0.0,
8224        });
8225        // Bounds = the box; not a fillable closed shape.
8226        assert_eq!(bbox(&kind), (10.0, 20.0, 110.0, 80.0));
8227        assert!(!is_closed_shape(&kind));
8228        // Round-trips through JSON under the "image" tag, keeping its src.
8229        let elem = Element {
8230            id: 1,
8231            kind,
8232            stroke: None,
8233            fill: None,
8234            label: None,
8235            label_color: None,
8236            styles: Vec::new(),
8237            mindmap: None,
8238        };
8239        let json = serde_json::to_string(&elem).unwrap();
8240        assert!(json.contains("\"image\""), "{json}");
8241        assert!(json.contains("images/x.png"));
8242        let mut back = serde_json::from_str::<Element>(&json).unwrap().kind;
8243        assert_eq!(bbox(&back), (10.0, 20.0, 110.0, 80.0));
8244        // Translates like the other box kinds.
8245        translate(&mut back, 5.0, -3.0);
8246        assert_eq!(bbox(&back), (15.0, 17.0, 115.0, 77.0));
8247    }
8248
8249    #[test]
8250    fn new_box_shapes_share_box_behavior_and_round_trip() {
8251        let b = BoxGeom {
8252            x: 1.0,
8253            y: 2.0,
8254            w: 30.0,
8255            h: 40.0,
8256            width: 2.0,
8257            rotation: 0.5,
8258        };
8259        // (serde tag, kind) — the tag is what gets persisted in JSON.
8260        let cases = [
8261            ("diamond", ElementKind::Diamond(b)),
8262            ("triangle", ElementKind::Triangle(b)),
8263            ("round_rect", ElementKind::RoundRect(b)),
8264            ("star", ElementKind::Star(b)),
8265            ("hexagon", ElementKind::Hexagon(b)),
8266        ];
8267        for (tag, kind) in cases {
8268            // Every new shape is a fillable closed shape, commits like a box, and
8269            // flows through the shared `box_like` path (bounds / select / resize /
8270            // rotate) just like rect/ellipse.
8271            assert!(is_closed_shape(&kind), "{tag} should be fillable");
8272            assert!(committable(&kind), "{tag} should commit");
8273            assert_eq!(
8274                box_like(&kind),
8275                Some((1.0, 2.0, 30.0, 40.0, 0.5)),
8276                "{tag} box_like"
8277            );
8278            // Round-trips through JSON under its snake_case tag.
8279            let elem = Element {
8280                id: 7,
8281                kind,
8282                stroke: None,
8283                fill: None,
8284                label: None,
8285                label_color: None,
8286                styles: Vec::new(),
8287                mindmap: None,
8288            };
8289            let json = serde_json::to_string(&elem).unwrap();
8290            assert!(json.contains(tag), "{tag} not in json: {json}");
8291            let back: Element = serde_json::from_str(&json).unwrap();
8292            assert_eq!(box_like(&back.kind), Some((1.0, 2.0, 30.0, 40.0, 0.5)));
8293        }
8294    }
8295}