simple_editor\ui/
tools.rs

1//! Tool bar: a thin, movable strip that sits between the viewport and the timeline (its own dockable
2//! pane, so it can also be popped out). One row of icon buttons:
3//!   Select (V) · Text (T) · Rectangle · Ellipse · Triangle · Polygon · Star · Line · Arrow · Draw (D) ·
4//!   Mask (rect/ellipse/polygon/path) · Zoom
5//! plus a magnet button that lights up while snapping is on (S, or Settings.snap's own `N` shortcut),
6//! then, for shape tools, fill and stroke colour buttons and a stroke-width DragValue; for Draw, a
7//! play/record button, the brush colour/width, the playback speed (0.5x / 1x / 2x) and a page toggle.
8//!
9//! The active tool changes what a click-drag in the Preview does (see `ui::preview`): Select edits the
10//! selected clip, a shape tool drags out a new Shape clip at the playhead (Shift locks its aspect ratio,
11//! Alt grows it from the press point instead of corner-to-corner), Draw records strokes while the mouse
12//! is down (timed, so the sketch replays), Mask edits the selected effect's / clip's mask.
13
14use crate::model::{MaskShape, ShapeKind};
15use crate::theme::Palette;
16use eframe::egui;
17use egui::{Align2, Color32, CornerRadius, FontId, Key, Modifiers, Sense, Stroke, StrokeKind};
18
19#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
20pub enum Tool {
21    #[default]
22    Select,
23    Text,
24    Shape(ShapeKind),
25    Draw,
26    Mask(MaskShape),
27    Zoom,
28    /// Razor: click a clip in the timeline to split it there.
29    Cut,
30    /// Click the timeline to drop a project marker.
31    Marker,
32    /// Drag a clip edge to change its speed instead of trimming it.
33    Stretch,
34    /// Drag the timeline lanes to open (or close) a gap from the press time onward.
35    Spacer,
36}
37
38pub struct ToolsState {
39    pub tool: Tool,
40    /// Style applied to the next shape drawn.
41    pub fill: [u8; 4],
42    pub stroke: [u8; 4],
43    pub stroke_width: f32,
44    pub sides: u32,
45    pub corner: f32,
46    /// Draw tool: brush and recording rate.
47    pub brush: [u8; 4],
48    pub brush_width: f32,
49    pub draw_rate: f32,
50    pub page: [u8; 4],
51    /// Draw tool: a take is running — the app plays the video and drops every stroke into one drawing
52    /// until this goes back off (see `App::toggle_draw_recording`).
53    pub recording: bool,
54}
55
56impl Default for ToolsState {
57    fn default() -> Self {
58        Self {
59            tool: Tool::Select,
60            fill: [255, 255, 255, 255],
61            stroke: [0, 0, 0, 0],
62            stroke_width: 4.0,
63            sides: 5,
64            corner: 0.0,
65            brush: [255, 80, 80, 255],
66            brush_width: 6.0,
67            draw_rate: 1.0,
68            page: [0, 0, 0, 0],
69            recording: false,
70        }
71    }
72}
73
74/// Which way a triangle / arrow glyph points.
75#[derive(Clone, Copy, PartialEq, Eq, Debug)]
76pub(crate) enum Dir {
77    Up,
78    Down,
79    Left,
80    Right,
81}
82
83impl Dir {
84    /// Unit vector along the pointing direction (y grows downward, as everywhere in egui).
85    fn unit(self) -> egui::Vec2 {
86        match self {
87            Dir::Up => egui::vec2(0.0, -1.0),
88            Dir::Down => egui::vec2(0.0, 1.0),
89            Dir::Left => egui::vec2(-1.0, 0.0),
90            Dir::Right => egui::vec2(1.0, 0.0),
91        }
92    }
93}
94
95/// The strip, left to right: (tool, icon, name). `Tool::Mask` stands for whichever mask shape is
96/// selected (the combo next to it picks one), the shape tools each get their own button.
97/// What `icon_button` draws. Painted with the painter, not typed: nearly every obvious character
98/// (polygon, pencil, half-disc, magnifier, close, play, reorder arrows) is missing from Segoe UI and
99/// egui's bundled fonts and came out as a tofu box.
100#[derive(Clone, Copy, PartialEq, Eq, Debug)]
101pub(crate) enum Glyph {
102    Cursor,
103    Letter(char),
104    Rect,
105    Ellipse,
106    Poly(u32),
107    Star,
108    Line,
109    Arrow,
110    Pencil,
111    Mask,
112    Zoom,
113    Razor,
114    Flag,
115    Hourglass,
116    Eye,
117    EyeOff,
118    Diamond,
119    Record,
120    Mic,
121    Headphone,
122    SpeakerOn,
123    SpeakerOff,
124    Camera,
125    FilmStrip,
126    /// Two patch boxes joined by a cable — a node graph.
127    Nodes,
128    /// A stack of sheets — an adjustment layer.
129    Layers,
130    /// A shooting target: rings and cross ticks — motion tracking.
131    Target,
132    /// A horseshoe magnet — the snapping toggle.
133    Magnet,
134    /// Two posts with a double-headed arrow between them — a gap being widened.
135    Spacer,
136    /// An eighth note — the audio-effects catalogue card (no picture to render for those).
137    MusicNote,
138    /// A file-explorer folder: a tab sitting on a body.
139    Folder,
140    /// A container / slot clip.
141    Container,
142    /// Two crossed strokes — close, delete, clear.
143    Cross,
144    /// A filled dot — a colour swatch, a bullet, "in use".
145    Dot,
146    /// Two sheets, one behind the other — copy.
147    Copy,
148    /// A clipboard — paste.
149    Paste,
150    /// A filled triangle pointing `Dir` — reorder, collapse, step one frame.
151    Tri(Dir),
152    /// Two triangles — the previous / next cut.
153    Skip(Dir),
154    /// A triangle backed against a bar — go to the very start / end.
155    Jump(Dir),
156    Play,
157    Pause,
158    Stop,
159    /// Four corner brackets — fullscreen.
160    Fullscreen,
161    /// A window with an arrow leaving it — pop this pane out.
162    PopOut,
163    /// An arrow into a margin bar — indent (true) / outdent (false).
164    Indent(bool),
165    /// A lane carrying two blocks — a nested sequence.
166    Sequence,
167    /// A card with a folded corner — a saved clip template.
168    Template,
169    /// A six-armed snowflake — a frozen frame.
170    Snowflake,
171    /// A movie reel: rim, hub, four spoke holes and a tape tail.
172    FilmReel,
173    /// A filled lightning zigzag — effects / performance.
174    Bolt,
175    /// An open tray with an arrow dropping into it — import.
176    ImportArrow,
177    /// The same tray with the arrow rising out — export.
178    ExportArrow,
179    /// Two overlapping squares with a diagonal across the overlap — a transition.
180    Transition,
181    /// A caption box with two text bars in its lower half.
182    Subtitles,
183    /// A cog: ring, eight stubs and a hub — settings.
184    Gear,
185    /// Three slider tracks, each with its knob at a different position.
186    Sliders,
187    /// An open-ended wrench head with a diagonal handle.
188    Wrench,
189    /// A clapperboard: body plus a slanted, hatched top bar.
190    Clapperboard,
191    /// Five vertical bars around a midline — an audio waveform.
192    Waveform,
193    /// Axes with a rising curve and two square handles — a value curve.
194    CurveIcon,
195    /// A clock face with two hands.
196    Clock,
197    /// A sheet with three ruled lines.
198    Notepad,
199    /// A ribbon with a notched V bottom.
200    Bookmark,
201    /// A curved arrow — undo (`Dir::Left`) / redo (`Dir::Right`).
202    UndoArrow(Dir),
203    /// A save disk: notched square, shutter and label.
204    FloppyDisk,
205    /// A console window: '>' prompt and an underscore.
206    Terminal,
207}
208
209impl Glyph {
210    /// Every unit variant plus a representative of each parameterized one, for the settings icon
211    /// picker (`from_name` resolves back into this list).
212    pub const ALL: &'static [Glyph] = &[
213        Glyph::Cursor,
214        Glyph::Letter('T'),
215        Glyph::Rect,
216        Glyph::Ellipse,
217        Glyph::Poly(5),
218        Glyph::Star,
219        Glyph::Line,
220        Glyph::Arrow,
221        Glyph::Pencil,
222        Glyph::Mask,
223        Glyph::Zoom,
224        Glyph::Razor,
225        Glyph::Flag,
226        Glyph::Hourglass,
227        Glyph::Eye,
228        Glyph::EyeOff,
229        Glyph::Diamond,
230        Glyph::Record,
231        Glyph::Mic,
232        Glyph::Headphone,
233        Glyph::SpeakerOn,
234        Glyph::SpeakerOff,
235        Glyph::Camera,
236        Glyph::FilmStrip,
237        Glyph::Nodes,
238        Glyph::Layers,
239        Glyph::Target,
240        Glyph::Magnet,
241        Glyph::Spacer,
242        Glyph::MusicNote,
243        Glyph::Folder,
244        Glyph::Container,
245        Glyph::Cross,
246        Glyph::Dot,
247        Glyph::Copy,
248        Glyph::Paste,
249        Glyph::Tri(Dir::Up),
250        Glyph::Tri(Dir::Down),
251        Glyph::Tri(Dir::Left),
252        Glyph::Tri(Dir::Right),
253        Glyph::Skip(Dir::Left),
254        Glyph::Skip(Dir::Right),
255        Glyph::Jump(Dir::Left),
256        Glyph::Jump(Dir::Right),
257        Glyph::Play,
258        Glyph::Pause,
259        Glyph::Stop,
260        Glyph::Fullscreen,
261        Glyph::PopOut,
262        Glyph::Indent(true),
263        Glyph::Indent(false),
264        Glyph::Sequence,
265        Glyph::Template,
266        Glyph::Snowflake,
267        Glyph::FilmReel,
268        Glyph::Bolt,
269        Glyph::ImportArrow,
270        Glyph::ExportArrow,
271        Glyph::Transition,
272        Glyph::Subtitles,
273        Glyph::Gear,
274        Glyph::Sliders,
275        Glyph::Wrench,
276        Glyph::Clapperboard,
277        Glyph::Waveform,
278        Glyph::CurveIcon,
279        Glyph::Clock,
280        Glyph::Notepad,
281        Glyph::Bookmark,
282        Glyph::UndoArrow(Dir::Left),
283        Glyph::UndoArrow(Dir::Right),
284        Glyph::FloppyDisk,
285        Glyph::Terminal,
286    ];
287
288    /// Stable lower-case name of the variant, kept in sync with `from_name` — what a saved icon
289    /// choice is stored as. Parameterized variants fold their direction into the name.
290    pub fn name(self) -> &'static str {
291        match self {
292            Glyph::Cursor => "cursor",
293            Glyph::Letter(_) => "letter",
294            Glyph::Rect => "rect",
295            Glyph::Ellipse => "ellipse",
296            Glyph::Poly(_) => "poly",
297            Glyph::Star => "star",
298            Glyph::Line => "line",
299            Glyph::Arrow => "arrow",
300            Glyph::Pencil => "pencil",
301            Glyph::Mask => "mask",
302            Glyph::Zoom => "zoom",
303            Glyph::Razor => "razor",
304            Glyph::Flag => "flag",
305            Glyph::Hourglass => "hourglass",
306            Glyph::Eye => "eye",
307            Glyph::EyeOff => "eye-off",
308            Glyph::Diamond => "diamond",
309            Glyph::Record => "record",
310            Glyph::Mic => "mic",
311            Glyph::Headphone => "headphone",
312            Glyph::SpeakerOn => "speaker-on",
313            Glyph::SpeakerOff => "speaker-off",
314            Glyph::Camera => "camera",
315            Glyph::FilmStrip => "film-strip",
316            Glyph::Nodes => "nodes",
317            Glyph::Layers => "layers",
318            Glyph::Target => "target",
319            Glyph::Magnet => "magnet",
320            Glyph::Spacer => "spacer",
321            Glyph::MusicNote => "music-note",
322            Glyph::Folder => "folder",
323            Glyph::Container => "container",
324            Glyph::Cross => "cross",
325            Glyph::Dot => "dot",
326            Glyph::Copy => "copy",
327            Glyph::Paste => "paste",
328            Glyph::Tri(Dir::Up) => "tri-up",
329            Glyph::Tri(Dir::Down) => "tri-down",
330            Glyph::Tri(Dir::Left) => "tri-left",
331            Glyph::Tri(Dir::Right) => "tri-right",
332            Glyph::Skip(Dir::Up) => "skip-up",
333            Glyph::Skip(Dir::Down) => "skip-down",
334            Glyph::Skip(Dir::Left) => "skip-left",
335            Glyph::Skip(Dir::Right) => "skip-right",
336            Glyph::Jump(Dir::Up) => "jump-up",
337            Glyph::Jump(Dir::Down) => "jump-down",
338            Glyph::Jump(Dir::Left) => "jump-left",
339            Glyph::Jump(Dir::Right) => "jump-right",
340            Glyph::Play => "play",
341            Glyph::Pause => "pause",
342            Glyph::Stop => "stop",
343            Glyph::Fullscreen => "fullscreen",
344            Glyph::PopOut => "pop-out",
345            Glyph::Indent(true) => "indent",
346            Glyph::Indent(false) => "outdent",
347            Glyph::Sequence => "sequence",
348            Glyph::Template => "template",
349            Glyph::Snowflake => "snowflake",
350            Glyph::FilmReel => "film-reel",
351            Glyph::Bolt => "bolt",
352            Glyph::ImportArrow => "import",
353            Glyph::ExportArrow => "export",
354            Glyph::Transition => "transition",
355            Glyph::Subtitles => "subtitles",
356            Glyph::Gear => "gear",
357            Glyph::Sliders => "sliders",
358            Glyph::Wrench => "wrench",
359            Glyph::Clapperboard => "clapperboard",
360            Glyph::Waveform => "waveform",
361            Glyph::CurveIcon => "curve",
362            Glyph::Clock => "clock",
363            Glyph::Notepad => "notepad",
364            Glyph::Bookmark => "bookmark",
365            Glyph::UndoArrow(Dir::Left) => "undo",
366            Glyph::UndoArrow(_) => "redo",
367            Glyph::FloppyDisk => "floppy-disk",
368            Glyph::Terminal => "terminal",
369        }
370    }
371
372    /// The reverse of `name` over `ALL` (so parameterized names come back as their representative).
373    pub fn from_name(s: &str) -> Option<Glyph> {
374        Self::ALL.iter().copied().find(|g| g.name() == s)
375    }
376}
377
378const STRIP: [(Tool, Glyph, &str); 16] = [
379    (Tool::Select, Glyph::Cursor, "Select"),
380    (Tool::Cut, Glyph::Razor, "Cut"),
381    (Tool::Marker, Glyph::Flag, "Marker"),
382    (Tool::Stretch, Glyph::Hourglass, "Stretch"),
383    (Tool::Spacer, Glyph::Spacer, "Spacer"),
384    (Tool::Text, Glyph::Letter('T'), "Text"),
385    (Tool::Shape(ShapeKind::Rect), Glyph::Rect, "Rectangle"),
386    (Tool::Shape(ShapeKind::Ellipse), Glyph::Ellipse, "Ellipse"),
387    (Tool::Shape(ShapeKind::Triangle), Glyph::Poly(3), "Triangle"),
388    (Tool::Shape(ShapeKind::Polygon), Glyph::Poly(5), "Polygon"),
389    (Tool::Shape(ShapeKind::Star), Glyph::Star, "Star"),
390    (Tool::Shape(ShapeKind::Line), Glyph::Line, "Line"),
391    (Tool::Shape(ShapeKind::Arrow), Glyph::Arrow, "Arrow"),
392    (Tool::Draw, Glyph::Pencil, "Draw"),
393    (Tool::Mask(MaskShape::Rect), Glyph::Mask, "Mask"),
394    (Tool::Zoom, Glyph::Zoom, "Zoom"),
395];
396
397/// Single-key shortcut of a tool (used for the tooltips and by `handle_hotkeys`). The shape tools cycle
398/// on Shift+S instead (bare `S` toggles snapping), so their tooltip is built by hand in `show`.
399pub fn tool_hotkey(tool: Tool) -> Option<Key> {
400    match tool {
401        Tool::Select => Some(Key::V),
402        Tool::Text => Some(Key::T),
403        Tool::Draw => Some(Key::D),
404        Tool::Mask(_) => Some(Key::M),
405        Tool::Cut => Some(Key::C),
406        Tool::Stretch => Some(Key::R),
407        Tool::Shape(_) | Tool::Zoom | Tool::Marker | Tool::Spacer => None,
408    }
409}
410
411/// V / T / D / M and Shift+S switch tools (Shift+S also steps through the shape variants; M steps
412/// through the mask variants), ignored while a text field has focus or an unlisted modifier is held.
413/// Bare `S` is snapping's key (see `handle_snap_hotkey`), not a tool switch. Returns the new tool when it
414/// changed; the key is consumed, so calling this twice in a frame is harmless.
415pub fn handle_hotkeys(ctx: &egui::Context, state: &mut ToolsState) -> Option<Tool> {
416    if ctx.wants_keyboard_input() {
417        return None;
418    }
419    ctx.input_mut(|i| {
420        // most-specific shortcut first: consume_key's own ctrl/command matching is exact, so this can
421        // never fire for e.g. Ctrl+Shift+S (Save As)
422        // ponytail: this also claims hotkeys.rs's default Shift+S (Action::AddShape) before the action
423        // table sees it — same trade-off the tool strip already makes for V/T/D/M. AddShape stays
424        // reachable from the Insert menu; give it a fresh binding in Settings > Hotkeys if that regresses.
425        if i.consume_key(Modifiers::SHIFT, Key::S) {
426            let next = Tool::Shape(next_shape(state.tool));
427            state.tool = next;
428            return Some(next);
429        }
430        if !i.modifiers.is_none() {
431            return None;
432        }
433        let next = if i.consume_key(Modifiers::NONE, Key::V) {
434            Tool::Select
435        } else if i.consume_key(Modifiers::NONE, Key::T) {
436            Tool::Text
437        } else if i.consume_key(Modifiers::NONE, Key::D) {
438            Tool::Draw
439        } else if i.consume_key(Modifiers::NONE, Key::M) {
440            Tool::Mask(next_mask(state.tool))
441        } else if i.consume_key(Modifiers::NONE, Key::C) {
442            Tool::Cut
443        } else if i.consume_key(Modifiers::NONE, Key::R) {
444            Tool::Stretch
445        } else {
446            return None;
447        };
448        (next != state.tool).then(|| {
449            state.tool = next;
450            next
451        })
452    })
453}
454
455/// Bare `S` (no modifiers) toggles snapping — claimed here, ahead of the action table, so it can never
456/// race with Shift+S's shape cycle above or with the `N` binding in `hotkeys.rs` (`Action::ToggleSnap`,
457/// still live and unaffected). `*snap` flips in place; the caller persists it. Returns true when it fired.
458pub fn handle_snap_hotkey(ctx: &egui::Context, snap: &mut bool) -> bool {
459    if ctx.wants_keyboard_input() {
460        return false;
461    }
462    let pressed = ctx.input_mut(|i| i.modifiers.is_none() && i.consume_key(Modifiers::NONE, Key::S));
463    if pressed {
464        *snap = !*snap;
465    }
466    pressed
467}
468
469/// The shape tools in strip order (Draw has its own key, so it is not part of the S cycle).
470const SHAPE_CYCLE: [ShapeKind; 7] = [
471    ShapeKind::Rect,
472    ShapeKind::Ellipse,
473    ShapeKind::Triangle,
474    ShapeKind::Polygon,
475    ShapeKind::Star,
476    ShapeKind::Line,
477    ShapeKind::Arrow,
478];
479
480fn next_shape(cur: Tool) -> ShapeKind {
481    match cur {
482        Tool::Shape(k) => match SHAPE_CYCLE.iter().position(|c| *c == k) {
483            Some(i) => SHAPE_CYCLE[(i + 1) % SHAPE_CYCLE.len()],
484            None => SHAPE_CYCLE[0],
485        },
486        _ => SHAPE_CYCLE[0],
487    }
488}
489
490fn next_mask(cur: Tool) -> MaskShape {
491    match cur {
492        Tool::Mask(m) => match MaskShape::ALL.iter().position(|c| *c == m) {
493            Some(i) => MaskShape::ALL[(i + 1) % MaskShape::ALL.len()],
494            None => MaskShape::ALL[0],
495        },
496        _ => MaskShape::ALL[0],
497    }
498}
499
500/// Returns true when the tool, `*snap` or the style changed (the app may want to repaint the preview
501/// overlay). `snap` is `Settings.snap` — owned by the app, not this strip, so it comes in by reference.
502pub fn show(ui: &mut egui::Ui, state: &mut ToolsState, palette: &Palette, snap: &mut bool) -> bool {
503    let mut changed = handle_hotkeys(ui.ctx(), state).is_some();
504    changed |= handle_snap_hotkey(ui.ctx(), snap);
505    let base = ui.id();
506    // wrapped: at a small pane width the strip must fold onto a second row, not clip its last buttons
507    ui.horizontal_wrapped(|ui| {
508        ui.spacing_mut().item_spacing.x = 2.0;
509        for (tool, icon, name) in STRIP {
510            let active = same_tool(tool, state.tool);
511            let tip = if matches!(tool, Tool::Shape(_)) {
512                format!("{name} (Shift+S)")
513            } else {
514                match tool_hotkey(tool) {
515                    Some(k) => format!("{name} ({})", k.name()),
516                    // the spacer's gesture is not readable from its picture
517                    None if tool == Tool::Spacer => format!("{name} — drag the lanes to open or close a gap"),
518                    None => name.to_string(),
519                }
520            };
521            if icon_button(ui, palette, base.with(("tool", name)), icon, &tip, active).clicked() && !active {
522                state.tool = tool;
523                changed = true;
524            }
525        }
526        ui.separator();
527        if icon_button(ui, palette, base.with("snap"), Glyph::Magnet, "Snapping (S)", *snap).clicked() {
528            *snap = !*snap;
529            changed = true;
530        }
531        ui.separator();
532        changed |= style_controls(ui, state, palette);
533    });
534    changed
535}
536
537/// The Mask button lights up for every mask shape (the combo beside it picks one); everything else is
538/// an exact match.
539fn same_tool(entry: Tool, cur: Tool) -> bool {
540    matches!((entry, cur), (Tool::Mask(_), Tool::Mask(_))) || entry == cur
541}
542
543/// Style controls for the active tool. Returns true when anything changed.
544fn style_controls(ui: &mut egui::Ui, state: &mut ToolsState, palette: &Palette) -> bool {
545    let mut changed = false;
546    match state.tool {
547        Tool::Shape(kind) => {
548            let outline_only = matches!(kind, ShapeKind::Line | ShapeKind::Arrow);
549            if !outline_only {
550                ui.label("Fill");
551                changed |= ui.color_edit_button_srgba_unmultiplied(&mut state.fill).changed();
552            }
553            ui.label("Stroke");
554            changed |= ui.color_edit_button_srgba_unmultiplied(&mut state.stroke).changed();
555            changed |= ui
556                .add(egui::DragValue::new(&mut state.stroke_width).speed(0.2).range(0.0..=200.0))
557                .on_hover_text("Stroke width (px)")
558                .changed();
559            match kind {
560                ShapeKind::Polygon | ShapeKind::Star => {
561                    ui.label("Sides");
562                    changed |= ui.add(egui::DragValue::new(&mut state.sides).range(3..=64)).changed();
563                }
564                ShapeKind::Rect => {
565                    ui.label("Corner");
566                    changed |= ui.add(egui::DragValue::new(&mut state.corner).speed(0.5).range(0.0..=2000.0)).changed();
567                }
568                ShapeKind::Arrow => {
569                    ui.label("Head");
570                    changed |= ui
571                        .add(egui::DragValue::new(&mut state.corner).speed(0.5).range(0.0..=2000.0))
572                        .on_hover_text("Arrow head size (px, 0 = follow the stroke width)")
573                        .changed();
574                }
575                _ => {}
576            }
577        }
578        Tool::Draw => {
579            // play + record: the app starts the video and keeps every stroke of the take in one drawing
580            let rec = state.recording;
581            let tip = "Play and record: every stroke joins one drawing until the video stops";
582            if icon_button(ui, palette, ui.id().with("draw-rec"), Glyph::Record, tip, rec).clicked() {
583                state.recording = !rec;
584                changed = true;
585            }
586            ui.label("Brush");
587            changed |= ui.color_edit_button_srgba_unmultiplied(&mut state.brush).changed();
588            changed |= ui
589                .add(egui::DragValue::new(&mut state.brush_width).speed(0.2).range(0.5..=200.0))
590                .on_hover_text("Brush width (px)")
591                .changed();
592            ui.label("Speed");
593            for (rate, label) in [(0.5, "0.5x"), (1.0, "1x"), (2.0, "2x"), (0.0, "all")] {
594                let on = (state.draw_rate - rate).abs() < 1e-3;
595                if ui
596                    .selectable_label(on, label)
597                    .on_hover_text(if rate == 0.0 {
598                        "Show the whole sketch at once"
599                    } else {
600                        "Playback speed of the recorded drawing"
601                    })
602                    .clicked()
603                    && !on
604                {
605                    state.draw_rate = rate;
606                    changed = true;
607                }
608            }
609            let mut page = state.page[3] > 0;
610            if ui.checkbox(&mut page, "Page").changed() {
611                state.page[3] = if page { 255 } else { 0 };
612                if page && state.page[..3] == [0, 0, 0] {
613                    state.page = [255, 255, 255, 255];
614                }
615                changed = true;
616            }
617            if page {
618                changed |= ui.color_edit_button_srgba_unmultiplied(&mut state.page).changed();
619            }
620        }
621        Tool::Mask(shape) => {
622            ui.label("Mask");
623            let mut pick = shape;
624            egui::ComboBox::from_id_salt("tool-mask-shape").selected_text(shape.name()).width(90.0).show_ui(ui, |ui| {
625                for m in MaskShape::ALL {
626                    ui.selectable_value(&mut pick, m, m.name());
627                }
628            });
629            if pick != shape {
630                state.tool = Tool::Mask(pick);
631                changed = true;
632            }
633        }
634        Tool::Text | Tool::Select | Tool::Zoom | Tool::Cut | Tool::Marker | Tool::Stretch | Tool::Spacer => {}
635    }
636    changed
637}
638
639/// A button showing `icon` and then `text`. Used wherever a control needs a real-world picture rather
640/// than a symbol character (half of those render as tofu boxes in Segoe UI). An empty `text` gives a
641/// bare icon button, centred.
642pub(crate) fn glyph_text_button(ui: &mut egui::Ui, icon: Glyph, text: &str) -> egui::Response {
643    let font = egui::TextStyle::Button.resolve(ui.style());
644    let galley = ui.painter().layout_no_wrap(text.to_owned(), font, Color32::PLACEHOLDER);
645    let pad = ui.spacing().button_padding;
646    let icon_w = 18.0;
647    let size = egui::vec2(icon_w + galley.size().x + pad.x * 2.0, galley.size().y.max(20.0) + pad.y * 2.0);
648    let (rect, r) = ui.allocate_exact_size(size, Sense::click());
649    let v = ui.style().interact(&r);
650    ui.painter().rect(rect, v.corner_radius, v.weak_bg_fill, v.bg_stroke, StrokeKind::Inside);
651    let icon_x = if text.is_empty() { rect.center().x - icon_w / 2.0 } else { rect.left() + pad.x };
652    let icon_rect = egui::Rect::from_min_size(egui::pos2(icon_x, rect.top()), egui::vec2(icon_w, rect.height()));
653    draw_glyph(ui.painter(), icon_rect, icon, v.text_color());
654    let tp = egui::pos2(icon_rect.right(), rect.center().y - galley.size().y / 2.0);
655    ui.painter().galley(tp, galley, v.text_color());
656    r
657}
658
659/// Bare square icon button: accent fill when active, a hover outline otherwise.
660pub(crate) fn icon_button(
661    ui: &mut egui::Ui,
662    palette: &Palette,
663    id: egui::Id,
664    icon: Glyph,
665    tip: &str,
666    active: bool,
667) -> egui::Response {
668    let (rect, _) = ui.allocate_exact_size(egui::vec2(24.0, 22.0), Sense::hover());
669    let r = ui.interact(rect, id, Sense::click());
670    let p = ui.painter();
671    if active {
672        p.rect_filled(rect, CornerRadius::same(2), palette.accent);
673    } else if r.hovered() {
674        p.rect_filled(rect, CornerRadius::same(2), palette.header);
675        p.rect_stroke(rect, CornerRadius::same(2), Stroke::new(1.0, palette.border), StrokeKind::Inside);
676    }
677    let fg = if active { on_accent(palette.accent) } else { palette.text };
678    draw_glyph(p, rect, icon, fg);
679    r.on_hover_text(tip)
680}
681
682/// Paint one tool glyph inside `rect` (a 24x22 button) in `fg`.
683pub(crate) fn draw_glyph(p: &egui::Painter, rect: egui::Rect, g: Glyph, fg: Color32) {
684    let c = rect.center();
685    let r = 6.0; // half-extent of the drawn icon
686    let stroke = Stroke::new(1.4, fg);
687    let poly = |n: u32, rot: f32, rad: f32| -> Vec<egui::Pos2> {
688        (0..n)
689            .map(|i| {
690                let a = rot + std::f32::consts::TAU * i as f32 / n as f32;
691                c + egui::vec2(a.cos() * rad, a.sin() * rad)
692            })
693            .collect()
694    };
695    // isoceles triangle centred on `ctr`, `depth` from base to tip along `d`, `half` across the base
696    let tri = |ctr: egui::Pos2, d: Dir, depth: f32, half: f32| -> Vec<egui::Pos2> {
697        let u = d.unit();
698        let n = egui::vec2(-u.y, u.x);
699        vec![ctr + u * depth, ctr - u * depth + n * half, ctr - u * depth - n * half]
700    };
701    match g {
702        Glyph::Letter(ch) => {
703            p.text(c, Align2::CENTER_CENTER, ch, FontId::proportional(13.0), fg);
704        }
705        // a classic arrow cursor
706        Glyph::Cursor => {
707            let pts = vec![
708                c + egui::vec2(-3.5, -6.5),
709                c + egui::vec2(-3.5, 5.5),
710                c + egui::vec2(-0.5, 2.5),
711                c + egui::vec2(1.5, 6.5),
712                c + egui::vec2(3.5, 5.5),
713                c + egui::vec2(1.5, 1.8),
714                c + egui::vec2(5.0, 1.5),
715            ];
716            p.add(egui::Shape::convex_polygon(pts, fg, Stroke::NONE));
717        }
718        Glyph::Rect => {
719            p.rect_stroke(
720                egui::Rect::from_center_size(c, egui::vec2(12.0, 9.0)),
721                CornerRadius::ZERO,
722                stroke,
723                StrokeKind::Inside,
724            );
725        }
726        Glyph::Ellipse => {
727            p.circle_stroke(c, r, stroke);
728        }
729        Glyph::Poly(n) => {
730            let pts = poly(n, -std::f32::consts::FRAC_PI_2, r);
731            p.add(egui::Shape::closed_line(pts, stroke));
732        }
733        Glyph::Star => {
734            let outer = poly(5, -std::f32::consts::FRAC_PI_2, r);
735            let inner = poly(5, -std::f32::consts::FRAC_PI_2 + std::f32::consts::TAU / 10.0, r * 0.45);
736            let mut pts = Vec::with_capacity(10);
737            for i in 0..5 {
738                pts.push(outer[i]);
739                pts.push(inner[i]);
740            }
741            p.add(egui::Shape::closed_line(pts, stroke));
742        }
743        Glyph::Line => {
744            p.line_segment([c + egui::vec2(-6.0, 5.0), c + egui::vec2(6.0, -5.0)], stroke);
745        }
746        Glyph::Arrow => {
747            p.line_segment([c + egui::vec2(-6.0, 0.0), c + egui::vec2(4.0, 0.0)], stroke);
748            let head = vec![c + egui::vec2(6.5, 0.0), c + egui::vec2(2.5, -3.2), c + egui::vec2(2.5, 3.2)];
749            p.add(egui::Shape::convex_polygon(head, fg, Stroke::NONE));
750        }
751        // pencil: a slanted body with a tip at the bottom-left
752        Glyph::Pencil => {
753            let body = vec![
754                c + egui::vec2(1.0, -6.0),
755                c + egui::vec2(5.5, -1.5),
756                c + egui::vec2(-2.0, 6.0),
757                c + egui::vec2(-6.0, 6.5),
758                c + egui::vec2(-5.5, 2.5),
759            ];
760            p.add(egui::Shape::convex_polygon(body, fg, Stroke::NONE));
761        }
762        // mask: a circle whose left half is filled (matte in / matte out)
763        Glyph::Mask => {
764            p.circle_stroke(c, r, stroke);
765            let half: Vec<egui::Pos2> = (0..=12)
766                .map(|i| {
767                    let a = std::f32::consts::FRAC_PI_2 + std::f32::consts::PI * i as f32 / 12.0;
768                    c + egui::vec2(a.cos() * r, a.sin() * r)
769                })
770                .collect();
771            p.add(egui::Shape::convex_polygon(half, fg, Stroke::NONE));
772        }
773        Glyph::Zoom => {
774            p.circle_stroke(c + egui::vec2(-1.0, -1.0), 4.5, stroke);
775            p.line_segment([c + egui::vec2(2.2, 2.2), c + egui::vec2(6.0, 6.0)], Stroke::new(1.8, fg));
776        }
777        // razor blade: a rectangular blade with a toothed cutting edge along the bottom
778        Glyph::Razor => {
779            let body = egui::Rect::from_center_size(c + egui::vec2(0.0, -2.0), egui::vec2(12.0, 6.0));
780            p.rect_stroke(body, CornerRadius::same(1), stroke, StrokeKind::Inside);
781            p.line_segment([c + egui::vec2(-6.0, 1.5), c + egui::vec2(6.0, 1.5)], Stroke::new(2.0, fg));
782            for i in 0..4 {
783                let x = -4.5 + i as f32 * 3.0;
784                p.line_segment([c + egui::vec2(x, 1.5), c + egui::vec2(x, 5.0)], Stroke::new(1.0, fg));
785            }
786        }
787        // spacer: two posts with a double-headed arrow pushing them apart
788        Glyph::Spacer => {
789            for x in [-6.5, 6.5] {
790                p.line_segment([c + egui::vec2(x, -6.0), c + egui::vec2(x, 6.0)], stroke);
791            }
792            p.line_segment([c + egui::vec2(-4.5, 0.0), c + egui::vec2(4.5, 0.0)], stroke);
793            for (tip, back) in [(-5.5f32, -2.0f32), (5.5, 2.0)] {
794                let head = vec![c + egui::vec2(tip, 0.0), c + egui::vec2(back, -3.0), c + egui::vec2(back, 3.0)];
795                p.add(egui::Shape::convex_polygon(head, fg, Stroke::NONE));
796            }
797        }
798        // pennant on a pole
799        Glyph::Flag => {
800            p.line_segment([c + egui::vec2(-4.0, -6.5), c + egui::vec2(-4.0, 6.5)], Stroke::new(1.6, fg));
801            let cloth = vec![c + egui::vec2(-4.0, -6.0), c + egui::vec2(5.5, -3.0), c + egui::vec2(-4.0, 0.0)];
802            p.add(egui::Shape::convex_polygon(cloth, fg, Stroke::NONE));
803        }
804        // hourglass: two triangles meeting at the waist, between two plates
805        Glyph::Hourglass => {
806            p.line_segment([c + egui::vec2(-5.0, -6.5), c + egui::vec2(5.0, -6.5)], stroke);
807            p.line_segment([c + egui::vec2(-5.0, 6.5), c + egui::vec2(5.0, 6.5)], stroke);
808            p.add(egui::Shape::convex_polygon(
809                vec![c + egui::vec2(-4.5, -6.0), c + egui::vec2(4.5, -6.0), c],
810                fg,
811                Stroke::NONE,
812            ));
813            p.add(egui::Shape::convex_polygon(
814                vec![c + egui::vec2(-4.5, 6.0), c + egui::vec2(4.5, 6.0), c],
815                fg,
816                Stroke::NONE,
817            ));
818        }
819        // eye: two lids and a pupil (crossed out when hidden)
820        Glyph::Eye | Glyph::EyeOff => {
821            let lid = |up: f32| -> Vec<egui::Pos2> {
822                (0..=16)
823                    .map(|i| {
824                        let x = -7.0 + i as f32 * 14.0 / 16.0;
825                        let k: f32 = 1.0 - (x / 7.0) * (x / 7.0);
826                        c + egui::vec2(x, up * 4.2 * k.max(0.0))
827                    })
828                    .collect()
829            };
830            p.add(egui::Shape::line(lid(-1.0), stroke));
831            p.add(egui::Shape::line(lid(1.0), stroke));
832            p.circle_filled(c, 2.2, fg);
833            if g == Glyph::EyeOff {
834                p.line_segment([c + egui::vec2(-6.5, -6.0), c + egui::vec2(6.5, 6.0)], Stroke::new(1.8, fg));
835            }
836        }
837        Glyph::Diamond => {
838            let pts = vec![
839                c + egui::vec2(0.0, -5.5),
840                c + egui::vec2(4.5, 0.0),
841                c + egui::vec2(0.0, 5.5),
842                c + egui::vec2(-4.5, 0.0),
843            ];
844            p.add(egui::Shape::convex_polygon(pts, fg, Stroke::NONE));
845        }
846        Glyph::Record => {
847            p.circle_filled(c, 5.5, Color32::from_rgb(220, 60, 60));
848        }
849        // microphone: a capsule on a stand
850        Glyph::Mic => {
851            p.rect_filled(
852                egui::Rect::from_center_size(c + egui::vec2(0.0, -2.5), egui::vec2(5.0, 8.0)),
853                CornerRadius::same(2),
854                fg,
855            );
856            p.line_segment([c + egui::vec2(0.0, 3.0), c + egui::vec2(0.0, 6.0)], Stroke::new(1.6, fg));
857            p.line_segment([c + egui::vec2(-3.5, 6.0), c + egui::vec2(3.5, 6.0)], Stroke::new(1.6, fg));
858        }
859        // headphones: a headband over two ear cups
860        Glyph::Headphone => {
861            let band: Vec<egui::Pos2> = (0..=16)
862                .map(|i| {
863                    let a = std::f32::consts::PI + std::f32::consts::PI * i as f32 / 16.0;
864                    c + egui::vec2(a.cos() * 6.0, a.sin() * 6.0 + 1.0)
865                })
866                .collect();
867            p.add(egui::Shape::line(band, stroke));
868            for x in [-6.0, 6.0] {
869                p.rect_filled(
870                    egui::Rect::from_center_size(c + egui::vec2(x, 3.0), egui::vec2(3.5, 6.0)),
871                    CornerRadius::same(1),
872                    fg,
873                );
874            }
875        }
876        // speaker cone, with sound waves or crossed out
877        Glyph::SpeakerOn | Glyph::SpeakerOff => {
878            let cone = vec![
879                c + egui::vec2(-6.0, -2.0),
880                c + egui::vec2(-3.0, -2.0),
881                c + egui::vec2(0.5, -5.5),
882                c + egui::vec2(0.5, 5.5),
883                c + egui::vec2(-3.0, 2.0),
884                c + egui::vec2(-6.0, 2.0),
885            ];
886            p.add(egui::Shape::convex_polygon(cone, fg, Stroke::NONE));
887            if g == Glyph::SpeakerOn {
888                for (i, rad) in [3.0_f32, 5.5].iter().enumerate() {
889                    let arc: Vec<egui::Pos2> = (0..=10)
890                        .map(|k| {
891                            let a = -std::f32::consts::FRAC_PI_3 + 2.0 * std::f32::consts::FRAC_PI_3 * k as f32 / 10.0;
892                            c + egui::vec2(1.5 + a.cos() * rad, a.sin() * rad)
893                        })
894                        .collect();
895                    p.add(egui::Shape::line(arc, Stroke::new(1.3 - i as f32 * 0.2, fg)));
896                }
897            } else {
898                p.line_segment([c + egui::vec2(2.5, -3.5), c + egui::vec2(6.5, 3.5)], Stroke::new(1.6, fg));
899                p.line_segment([c + egui::vec2(6.5, -3.5), c + egui::vec2(2.5, 3.5)], Stroke::new(1.6, fg));
900            }
901        }
902        // stills camera: body, lens and a viewfinder bump
903        Glyph::Camera => {
904            let body = egui::Rect::from_center_size(c + egui::vec2(0.0, 1.0), egui::vec2(13.0, 9.0));
905            p.rect_stroke(body, CornerRadius::same(1), stroke, StrokeKind::Inside);
906            p.rect_filled(
907                egui::Rect::from_center_size(c + egui::vec2(-2.0, -4.5), egui::vec2(5.0, 2.5)),
908                CornerRadius::same(1),
909                fg,
910            );
911            p.circle_stroke(c + egui::vec2(0.0, 1.0), 3.0, stroke);
912        }
913        // film strip: a frame with sprocket holes down both edges
914        Glyph::FilmStrip => {
915            let body = egui::Rect::from_center_size(c, egui::vec2(13.0, 11.0));
916            p.rect_stroke(body, CornerRadius::same(1), stroke, StrokeKind::Inside);
917            for i in 0..3 {
918                let y = -3.5 + i as f32 * 3.5;
919                for x in [-4.8_f32, 4.8] {
920                    p.rect_filled(
921                        egui::Rect::from_center_size(c + egui::vec2(x, y), egui::vec2(2.2, 2.0)),
922                        CornerRadius::ZERO,
923                        fg,
924                    );
925                }
926            }
927        }
928        // node graph: two patch boxes with a cable between them
929        Glyph::Nodes => {
930            let a = egui::Rect::from_center_size(c + egui::vec2(-3.5, -3.5), egui::vec2(7.0, 5.0));
931            let b = egui::Rect::from_center_size(c + egui::vec2(3.5, 3.5), egui::vec2(7.0, 5.0));
932            p.line_segment([a.right_center(), b.left_center()], stroke);
933            p.rect_stroke(a, CornerRadius::same(1), stroke, StrokeKind::Inside);
934            p.rect_stroke(b, CornerRadius::same(1), stroke, StrokeKind::Inside);
935        }
936        // adjustment layer: a stack of sheets
937        Glyph::Layers => {
938            for dy in [-4.0_f32, 0.0, 4.0] {
939                let sheet = egui::Rect::from_center_size(c + egui::vec2(0.0, dy), egui::vec2(12.0, 3.0));
940                p.rect_stroke(sheet, CornerRadius::same(1), stroke, StrokeKind::Inside);
941            }
942        }
943        // horseshoe magnet: a U-shaped body with a pole cap on each leg tip
944        Glyph::Magnet => {
945            let arc_c = c + egui::vec2(0.0, -1.0);
946            let arc_r = 5.0;
947            let arc: Vec<egui::Pos2> = (0..=12)
948                .map(|i| {
949                    let a = std::f32::consts::PI + std::f32::consts::PI * i as f32 / 12.0;
950                    arc_c + egui::vec2(a.cos() * arc_r, a.sin() * arc_r)
951                })
952                .collect();
953            p.add(egui::Shape::line(arc, stroke));
954            p.line_segment([arc_c + egui::vec2(-arc_r, 0.0), arc_c + egui::vec2(-arc_r, 6.5)], stroke);
955            p.line_segment([arc_c + egui::vec2(arc_r, 0.0), arc_c + egui::vec2(arc_r, 6.5)], stroke);
956            for x in [-arc_r, arc_r] {
957                p.rect_filled(
958                    egui::Rect::from_center_size(arc_c + egui::vec2(x, 6.5), egui::vec2(3.0, 2.6)),
959                    CornerRadius::same(1),
960                    fg,
961                );
962            }
963        }
964        // tracking: a shooting target — two rings and four cross ticks
965        Glyph::Target => {
966            p.circle_stroke(c, r, stroke);
967            p.circle_stroke(c, r * 0.4, stroke);
968            for (dx, dy) in [(1.0_f32, 0.0_f32), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] {
969                p.line_segment(
970                    [c + egui::vec2(dx * r, dy * r), c + egui::vec2(dx * (r + 2.5), dy * (r + 2.5))],
971                    stroke,
972                );
973            }
974        }
975        // eighth note: filled notehead, a stem and a flag
976        Glyph::MusicNote => {
977            let head = c + egui::vec2(-2.5, 3.5);
978            p.circle_filled(head, 2.6, fg);
979            let stem_top = c + egui::vec2(2.0, -6.0);
980            p.line_segment([head + egui::vec2(2.4, -0.5), stem_top], Stroke::new(1.6, fg));
981            let flag = vec![stem_top, stem_top + egui::vec2(4.0, 1.5), stem_top + egui::vec2(0.5, 4.5)];
982            p.add(egui::Shape::convex_polygon(flag, fg, Stroke::NONE));
983        }
984        Glyph::Folder => {
985            let body = egui::Rect::from_center_size(c + egui::vec2(0.0, 1.5), egui::vec2(12.0, 8.0));
986            let tab = egui::Rect::from_min_size(body.left_top() - egui::vec2(0.0, 2.5), egui::vec2(5.0, 2.5));
987            p.rect_stroke(tab, CornerRadius::ZERO, stroke, StrokeKind::Inside);
988            p.rect_stroke(body, CornerRadius::ZERO, stroke, StrokeKind::Inside);
989        }
990        Glyph::Cross => {
991            let s = Stroke::new(1.7, fg);
992            p.line_segment([c + egui::vec2(-4.2, -4.2), c + egui::vec2(4.2, 4.2)], s);
993            p.line_segment([c + egui::vec2(4.2, -4.2), c + egui::vec2(-4.2, 4.2)], s);
994        }
995        Glyph::Dot => {
996            p.circle_filled(c, 3.6, fg);
997        }
998        // copy: the sheet you are taking, with its original still behind it
999        Glyph::Copy => {
1000            p.rect_stroke(
1001                egui::Rect::from_min_size(c + egui::vec2(-6.0, -6.5), egui::vec2(8.0, 10.0)),
1002                CornerRadius::same(1),
1003                stroke,
1004                StrokeKind::Inside,
1005            );
1006            p.rect_filled(
1007                egui::Rect::from_min_size(c + egui::vec2(-2.0, -3.0), egui::vec2(8.0, 10.0)),
1008                CornerRadius::same(1),
1009                fg,
1010            );
1011        }
1012        // paste: a clipboard — a board with its spring clip on top
1013        Glyph::Paste => {
1014            p.rect_stroke(
1015                egui::Rect::from_center_size(c + egui::vec2(0.0, 1.0), egui::vec2(11.0, 12.0)),
1016                CornerRadius::same(1),
1017                stroke,
1018                StrokeKind::Inside,
1019            );
1020            p.rect_filled(
1021                egui::Rect::from_center_size(c + egui::vec2(0.0, -5.0), egui::vec2(6.5, 3.5)),
1022                CornerRadius::same(1),
1023                fg,
1024            );
1025        }
1026        Glyph::Tri(d) => {
1027            p.add(egui::Shape::convex_polygon(tri(c, d, 4.0, 4.2), fg, Stroke::NONE));
1028        }
1029        Glyph::Skip(d) => {
1030            let u = d.unit();
1031            for k in [-3.4, 3.4] {
1032                p.add(egui::Shape::convex_polygon(tri(c + u * k, d, 3.4, 4.6), fg, Stroke::NONE));
1033            }
1034        }
1035        // |◀ / ▶| — the bar sits at the far edge, in the direction of travel
1036        Glyph::Jump(d) => {
1037            let u = d.unit();
1038            let n = egui::vec2(-u.y, u.x);
1039            p.line_segment([c + u * 5.5 - n * 5.0, c + u * 5.5 + n * 5.0], Stroke::new(2.0, fg));
1040            p.add(egui::Shape::convex_polygon(tri(c - u * 1.5, d, 3.8, 5.0), fg, Stroke::NONE));
1041        }
1042        Glyph::Play => {
1043            p.add(egui::Shape::convex_polygon(tri(c + egui::vec2(0.8, 0.0), Dir::Right, 5.5, 6.0), fg, Stroke::NONE));
1044        }
1045        Glyph::Pause => {
1046            for x in [-3.0_f32, 3.0] {
1047                p.rect_filled(
1048                    egui::Rect::from_center_size(c + egui::vec2(x, 0.0), egui::vec2(3.0, 11.0)),
1049                    CornerRadius::ZERO,
1050                    fg,
1051                );
1052            }
1053        }
1054        Glyph::Stop => {
1055            p.rect_filled(egui::Rect::from_center_size(c, egui::vec2(10.0, 10.0)), CornerRadius::same(1), fg);
1056        }
1057        // fullscreen: four corner brackets pointing outwards
1058        Glyph::Fullscreen => {
1059            for (sx, sy) in [(-1.0_f32, -1.0_f32), (1.0, -1.0), (-1.0, 1.0), (1.0, 1.0)] {
1060                let corner = c + egui::vec2(sx * 6.5, sy * 5.5);
1061                p.line_segment([corner, corner - egui::vec2(sx * 4.0, 0.0)], stroke);
1062                p.line_segment([corner, corner - egui::vec2(0.0, sy * 3.5)], stroke);
1063            }
1064        }
1065        // pop out: a window frame with an arrow leaving through its top-right corner
1066        Glyph::PopOut => {
1067            p.rect_stroke(
1068                egui::Rect::from_center_size(c + egui::vec2(-1.0, 1.5), egui::vec2(11.0, 10.0)),
1069                CornerRadius::same(1),
1070                stroke,
1071                StrokeKind::Inside,
1072            );
1073            p.line_segment([c + egui::vec2(0.0, -1.0), c + egui::vec2(6.0, -6.0)], stroke);
1074            p.add(egui::Shape::convex_polygon(
1075                vec![c + egui::vec2(6.5, -6.5), c + egui::vec2(1.5, -6.0), c + egui::vec2(6.0, -1.5)],
1076                fg,
1077                Stroke::NONE,
1078            ));
1079        }
1080        // indent / outdent: an arrow shoved against the margin it moves towards
1081        Glyph::Indent(inward) => {
1082            let d = if inward { Dir::Right } else { Dir::Left };
1083            let u = d.unit();
1084            p.line_segment([c + u * 6.0 + egui::vec2(0.0, -5.5), c + u * 6.0 + egui::vec2(0.0, 5.5)], stroke);
1085            p.line_segment([c - u * 6.0, c + u * 1.0], stroke);
1086            p.add(egui::Shape::convex_polygon(tri(c + u * 2.5, d, 2.5, 3.2), fg, Stroke::NONE));
1087        }
1088        // nested sequence: two clip blocks standing on a timeline lane
1089        Glyph::Sequence => {
1090            p.line_segment([c + egui::vec2(-6.5, 5.5), c + egui::vec2(6.5, 5.5)], stroke);
1091            for (x, h) in [(-6.0_f32, 9.0_f32), (0.5, 6.0)] {
1092                p.rect_filled(
1093                    egui::Rect::from_min_size(c + egui::vec2(x, 4.0 - h), egui::vec2(5.5, h)),
1094                    CornerRadius::same(1),
1095                    fg,
1096                );
1097            }
1098        }
1099        // container / slot: a frame box with an inner block
1100        Glyph::Container => {
1101            let outer = egui::Rect::from_center_size(c, egui::vec2(12.0, 10.0));
1102            let inner = egui::Rect::from_center_size(c, egui::vec2(6.0, 4.5));
1103            p.rect_stroke(outer, CornerRadius::same(1), stroke, StrokeKind::Inside);
1104            p.rect_filled(inner, CornerRadius::ZERO, fg);
1105        }
1106        // template: a card with its top-right corner folded over
1107        Glyph::Template => {
1108            let fold = 4.0;
1109            let (l, t, rr, b) = (c.x - 5.0, c.y - 6.0, c.x + 5.0, c.y + 6.0);
1110            p.add(egui::Shape::closed_line(
1111                vec![
1112                    egui::pos2(l, t),
1113                    egui::pos2(rr - fold, t),
1114                    egui::pos2(rr, t + fold),
1115                    egui::pos2(rr, b),
1116                    egui::pos2(l, b),
1117                ],
1118                stroke,
1119            ));
1120            p.add(egui::Shape::line(
1121                vec![egui::pos2(rr - fold, t), egui::pos2(rr - fold, t + fold), egui::pos2(rr, t + fold)],
1122                stroke,
1123            ));
1124        }
1125        // snowflake: three crossed arms, each with a pair of barbs
1126        Glyph::Snowflake => {
1127            let thin = Stroke::new(1.0, fg);
1128            for i in 0..3 {
1129                let a = std::f32::consts::FRAC_PI_2 + std::f32::consts::PI * i as f32 / 3.0;
1130                let arm = egui::vec2(a.cos(), a.sin());
1131                p.line_segment([c - arm * 6.5, c + arm * 6.5], thin);
1132                let barb = egui::vec2(-arm.y, arm.x);
1133                for s in [-1.0_f32, 1.0] {
1134                    let tip = c + arm * (6.5 * s);
1135                    p.line_segment([tip, tip - arm * 2.4 * s + barb * 1.8], thin);
1136                    p.line_segment([tip, tip - arm * 2.4 * s - barb * 1.8], thin);
1137                }
1138            }
1139        }
1140        // movie reel: rim, hub, four spoke holes and a tape tail leaving bottom-right
1141        Glyph::FilmReel => {
1142            p.circle_stroke(c, r, stroke);
1143            p.circle_filled(c, 1.2, fg);
1144            for i in 0..4 {
1145                let a = std::f32::consts::FRAC_PI_2 * i as f32;
1146                p.circle_stroke(c + egui::vec2(a.cos(), a.sin()) * (r * 0.55), 1.3, Stroke::new(1.0, fg));
1147            }
1148            p.line_segment([c + egui::vec2(4.2, 4.2), c + egui::vec2(8.0, 6.0)], stroke);
1149        }
1150        // lightning bolt: a filled zigzag
1151        Glyph::Bolt => {
1152            let pts = vec![
1153                c + egui::vec2(1.5, -6.5),
1154                c + egui::vec2(-3.5, 1.0),
1155                c + egui::vec2(-0.5, 1.0),
1156                c + egui::vec2(-1.5, 6.5),
1157                c + egui::vec2(3.5, -1.0),
1158                c + egui::vec2(0.5, -1.0),
1159            ];
1160            p.add(egui::Shape::closed_line(pts.clone(), Stroke::new(1.0, fg)));
1161            p.add(egui::Shape::convex_polygon(pts, fg, Stroke::NONE)); // concave, but close enough at 12px
1162        }
1163        // import / export: an open tray with an arrow dropping in or rising out
1164        Glyph::ImportArrow | Glyph::ExportArrow => {
1165            p.add(egui::Shape::line(
1166                vec![
1167                    c + egui::vec2(-6.0, 2.0),
1168                    c + egui::vec2(-6.0, 6.0),
1169                    c + egui::vec2(6.0, 6.0),
1170                    c + egui::vec2(6.0, 2.0),
1171                ],
1172                stroke,
1173            ));
1174            let d = if g == Glyph::ImportArrow { Dir::Down } else { Dir::Up };
1175            let tip_y = if g == Glyph::ImportArrow { 3.0 } else { -6.5 };
1176            let tail_y = if g == Glyph::ImportArrow { -6.5 } else { 3.0 };
1177            p.line_segment([c + egui::vec2(0.0, tail_y), c + egui::vec2(0.0, tip_y)], stroke);
1178            p.add(egui::Shape::convex_polygon(tri(c + egui::vec2(0.0, tip_y), d, 2.8, 3.2), fg, Stroke::NONE));
1179        }
1180        // transition: two overlapping frames with a diagonal cut across the overlap
1181        Glyph::Transition => {
1182            let a = egui::Rect::from_center_size(c + egui::vec2(-2.5, -2.0), egui::vec2(9.0, 8.0));
1183            let b = egui::Rect::from_center_size(c + egui::vec2(2.5, 2.0), egui::vec2(9.0, 8.0));
1184            p.rect_stroke(a, CornerRadius::ZERO, stroke, StrokeKind::Inside);
1185            p.rect_stroke(b, CornerRadius::ZERO, stroke, StrokeKind::Inside);
1186            p.line_segment([a.left_bottom() + egui::vec2(2.0, 0.0), a.right_top() + egui::vec2(0.0, 2.0)], stroke);
1187        }
1188        // subtitles: a caption box with two text bars in its lower half
1189        Glyph::Subtitles => {
1190            p.rect_stroke(
1191                egui::Rect::from_center_size(c, egui::vec2(13.0, 10.0)),
1192                CornerRadius::same(2),
1193                stroke,
1194                StrokeKind::Inside,
1195            );
1196            p.line_segment([c + egui::vec2(-4.5, 1.0), c + egui::vec2(2.5, 1.0)], Stroke::new(1.6, fg));
1197            p.line_segment([c + egui::vec2(-4.5, 3.2), c + egui::vec2(4.5, 3.2)], Stroke::new(1.6, fg));
1198        }
1199        // gear: a ring with eight tooth stubs and a hub
1200        Glyph::Gear => {
1201            p.circle_stroke(c, 4.2, stroke);
1202            p.circle_filled(c, 1.4, fg);
1203            for i in 0..8 {
1204                let a = std::f32::consts::FRAC_PI_4 * i as f32;
1205                let u = egui::vec2(a.cos(), a.sin());
1206                p.line_segment([c + u * 4.2, c + u * 6.5], stroke);
1207            }
1208        }
1209        // sliders: three tracks, each with its knob somewhere else
1210        Glyph::Sliders => {
1211            for (i, kx) in [(-1.0_f32, -2.5_f32), (0.0, 3.0), (1.0, -0.5)] {
1212                let y = i * 4.5;
1213                p.line_segment([c + egui::vec2(-6.5, y), c + egui::vec2(6.5, y)], stroke);
1214                p.circle_filled(c + egui::vec2(kx, y), 1.8, fg);
1215            }
1216        }
1217        // wrench: an open jaw (circle with a notch) and a diagonal handle
1218        Glyph::Wrench => {
1219            let jaw = c + egui::vec2(-3.5, -3.5);
1220            p.circle_stroke(jaw, 3.0, stroke);
1221            // notch: paint over the rim towards the top-left, opening the jaw
1222            p.line_segment([jaw, jaw + egui::vec2(-3.5, -3.5)], Stroke::new(2.6, fg));
1223            p.line_segment([jaw + egui::vec2(2.0, 2.0), c + egui::vec2(6.0, 6.0)], Stroke::new(2.2, fg));
1224        }
1225        // clapperboard: the body and a slanted top bar with two hatch strokes
1226        Glyph::Clapperboard => {
1227            let body = egui::Rect::from_center_size(c + egui::vec2(0.0, 2.0), egui::vec2(13.0, 7.5));
1228            p.rect_stroke(body, CornerRadius::same(1), stroke, StrokeKind::Inside);
1229            p.add(egui::Shape::closed_line(
1230                vec![
1231                    body.left_top(),
1232                    body.left_top() + egui::vec2(1.0, -3.8),
1233                    body.right_top() + egui::vec2(0.0, -2.8),
1234                    body.right_top(),
1235                ],
1236                stroke,
1237            ));
1238            for x in [-2.5_f32, 2.0] {
1239                p.line_segment([c + egui::vec2(x, -1.7), c + egui::vec2(x + 2.0, -4.8)], Stroke::new(1.0, fg));
1240            }
1241        }
1242        // waveform: five bars mirrored about the midline
1243        Glyph::Waveform => {
1244            for (i, h) in [3.0_f32, 6.0, 4.0, 6.5, 2.5].iter().enumerate() {
1245                let x = -6.0 + i as f32 * 3.0;
1246                p.line_segment([c + egui::vec2(x, -h / 2.0), c + egui::vec2(x, h / 2.0)], Stroke::new(1.8, fg));
1247            }
1248        }
1249        // value curve: L axes, an easing curve and two square handles
1250        Glyph::CurveIcon => {
1251            let o = c + egui::vec2(-6.0, 6.0);
1252            p.line_segment([o, o + egui::vec2(0.0, -12.0)], stroke);
1253            p.line_segment([o, o + egui::vec2(12.0, 0.0)], stroke);
1254            p.add(egui::Shape::line(
1255                vec![
1256                    o + egui::vec2(1.0, -1.0),
1257                    o + egui::vec2(5.0, -2.5),
1258                    o + egui::vec2(8.0, -6.5),
1259                    o + egui::vec2(11.0, -11.0),
1260                ],
1261                stroke,
1262            ));
1263            for d in [egui::vec2(5.0, -2.5), egui::vec2(8.0, -6.5)] {
1264                p.rect_filled(egui::Rect::from_center_size(o + d, egui::vec2(2.4, 2.4)), CornerRadius::ZERO, fg);
1265            }
1266        }
1267        Glyph::Clock => {
1268            p.circle_stroke(c, r, stroke);
1269            p.line_segment([c, c + egui::vec2(0.0, -4.0)], stroke);
1270            p.line_segment([c, c + egui::vec2(3.0, 1.5)], stroke);
1271        }
1272        Glyph::Notepad => {
1273            p.rect_stroke(
1274                egui::Rect::from_center_size(c, egui::vec2(10.0, 12.0)),
1275                CornerRadius::same(1),
1276                stroke,
1277                StrokeKind::Inside,
1278            );
1279            for dy in [-3.0_f32, 0.0, 3.0] {
1280                p.line_segment([c + egui::vec2(-3.0, dy), c + egui::vec2(3.0, dy)], Stroke::new(1.0, fg));
1281            }
1282        }
1283        // bookmark: a ribbon whose bottom edge is notched into a V
1284        Glyph::Bookmark => {
1285            p.add(egui::Shape::closed_line(
1286                vec![
1287                    c + egui::vec2(-3.5, -6.5),
1288                    c + egui::vec2(3.5, -6.5),
1289                    c + egui::vec2(3.5, 6.5),
1290                    c + egui::vec2(0.0, 3.0),
1291                    c + egui::vec2(-3.5, 6.5),
1292                ],
1293                stroke,
1294            ));
1295        }
1296        // undo / redo: an arc curving over the top, arrowhead at the `Dir::Left` / `Dir::Right` end
1297        Glyph::UndoArrow(d) => {
1298            let sx = if d == Dir::Left { 1.0 } else { -1.0 };
1299            let arc: Vec<egui::Pos2> = (0..=8)
1300                .map(|i| {
1301                    let a = std::f32::consts::PI + std::f32::consts::PI * 0.85 * i as f32 / 8.0;
1302                    c + egui::vec2(a.cos() * 5.0 * sx, a.sin() * 5.0 + 1.5)
1303                })
1304                .collect();
1305            let tip = arc[0];
1306            p.add(egui::Shape::line(arc, stroke));
1307            p.add(egui::Shape::convex_polygon(
1308                vec![
1309                    tip + egui::vec2(-2.5 * sx, 0.5),
1310                    tip + egui::vec2(2.0 * sx, -1.5),
1311                    tip + egui::vec2(1.5 * sx, 3.0),
1312                ],
1313                fg,
1314                Stroke::NONE,
1315            ));
1316        }
1317        // floppy disk: a square with its top-right corner cut, a shutter and a label
1318        Glyph::FloppyDisk => {
1319            let (l, t, rr, b) = (c.x - 6.0, c.y - 6.0, c.x + 6.0, c.y + 6.0);
1320            p.add(egui::Shape::closed_line(
1321                vec![
1322                    egui::pos2(l, t),
1323                    egui::pos2(rr - 2.5, t),
1324                    egui::pos2(rr, t + 2.5),
1325                    egui::pos2(rr, b),
1326                    egui::pos2(l, b),
1327                ],
1328                stroke,
1329            ));
1330            p.rect_filled(
1331                egui::Rect::from_min_size(egui::pos2(l + 2.5, t + 0.7), egui::vec2(5.5, 3.0)),
1332                CornerRadius::ZERO,
1333                fg,
1334            );
1335            p.rect_stroke(
1336                egui::Rect::from_min_size(egui::pos2(l + 2.0, b - 4.5), egui::vec2(8.0, 3.8)),
1337                CornerRadius::ZERO,
1338                Stroke::new(1.0, fg),
1339                StrokeKind::Inside,
1340            );
1341        }
1342        // terminal: a window with a '>' prompt and an underscore cursor
1343        Glyph::Terminal => {
1344            p.rect_stroke(
1345                egui::Rect::from_center_size(c, egui::vec2(13.0, 10.0)),
1346                CornerRadius::same(1),
1347                stroke,
1348                StrokeKind::Inside,
1349            );
1350            p.line_segment([c + egui::vec2(-4.5, -2.5), c + egui::vec2(-2.0, 0.0)], stroke);
1351            p.line_segment([c + egui::vec2(-2.0, 0.0), c + egui::vec2(-4.5, 2.5)], stroke);
1352            p.line_segment([c + egui::vec2(0.0, 2.5), c + egui::vec2(3.5, 2.5)], stroke);
1353        }
1354    }
1355}
1356
1357/// Built-in icon for a menu action (None = text-only). The user's Settings → Appearance → Icons
1358/// override wins over these; abstract actions stay text-only on purpose.
1359pub(crate) fn action_glyph(a: crate::hotkeys::Action) -> Option<Glyph> {
1360    use crate::hotkeys::Action::*;
1361    Some(match a {
1362        NewProject => Glyph::Clapperboard,
1363        OpenFile | OpenProject => Glyph::Folder,
1364        Save | SaveProjectAs => Glyph::FloppyDisk,
1365        ExportVideo | ExportLossless | ExportXml => Glyph::ExportArrow,
1366        ImportMedia => Glyph::FilmReel,
1367        Settings => Glyph::Gear,
1368        Undo => Glyph::UndoArrow(Dir::Left),
1369        Redo => Glyph::UndoArrow(Dir::Right),
1370        PlayPause => Glyph::Play,
1371        Stop => Glyph::Stop,
1372        Split => Glyph::Razor,
1373        AddText => Glyph::Letter('T'),
1374        AddMarker => Glyph::Flag,
1375        Retime => Glyph::Clock,
1376        Fullscreen => Glyph::Fullscreen,
1377        ScreenCapture => Glyph::Camera,
1378        _ => return None,
1379    })
1380}
1381
1382/// Paint `icon` where a plain label would go — no button chrome, no hit area.
1383pub(crate) fn glyph_label(ui: &mut egui::Ui, icon: Glyph, color: Color32) -> egui::Response {
1384    let (rect, r) = ui.allocate_exact_size(egui::vec2(18.0, ui.spacing().interact_size.y), Sense::hover());
1385    draw_glyph(ui.painter(), rect, icon, color);
1386    r
1387}
1388
1389/// The label-colour swatch every label menu shows: a filled round chip. A `Button`, so a caller can
1390/// click it, select it or hang a menu off it.
1391pub(crate) fn color_chip<'a>(color: Color32, selected: bool, palette: &Palette) -> egui::Button<'a> {
1392    let ring = if selected { Stroke::new(2.0, palette.accent) } else { Stroke::new(1.0, palette.border) };
1393    egui::Button::new("").fill(color).stroke(ring).corner_radius(CornerRadius::same(8)).min_size(egui::vec2(15.0, 15.0))
1394}
1395
1396/// Readable text colour on top of the accent fill.
1397fn on_accent(c: Color32) -> Color32 {
1398    let l = 0.299 * c.r() as f32 + 0.587 * c.g() as f32 + 0.114 * c.b() as f32;
1399    if l > 140.0 {
1400        Color32::BLACK
1401    } else {
1402        Color32::WHITE
1403    }
1404}
1405
1406#[cfg(test)]
1407mod tests {
1408    use super::*;
1409    use egui::{Event, Pos2, Rect, Vec2};
1410
1411    struct Harness {
1412        ctx: egui::Context,
1413        state: ToolsState,
1414        snap: bool,
1415        base: egui::Id,
1416        time: f64,
1417        changed: bool,
1418    }
1419
1420    impl Harness {
1421        fn new() -> Self {
1422            let mut h = Self {
1423                ctx: egui::Context::default(),
1424                state: ToolsState::default(),
1425                snap: false,
1426                base: egui::Id::NULL,
1427                time: 0.0,
1428                changed: false,
1429            };
1430            h.frame(vec![]);
1431            h
1432        }
1433        fn frame(&mut self, events: Vec<Event>) {
1434            self.time += 0.05;
1435            let input = egui::RawInput {
1436                screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(900.0, 60.0))),
1437                time: Some(self.time),
1438                events,
1439                ..Default::default()
1440            };
1441            let pal = Palette::new(true, Color32::from_rgb(0, 120, 212));
1442            let Harness { ctx, state, snap, base, changed, .. } = self;
1443            let _ = ctx.run(input, |ctx| {
1444                egui::CentralPanel::default().show(ctx, |ui| {
1445                    *base = ui.id();
1446                    *changed = show(ui, state, &pal, snap);
1447                });
1448            });
1449        }
1450        /// Centre of a strip button by its name, from the previous frame's layout.
1451        fn button(&self, name: &str) -> Pos2 {
1452            self.ctx
1453                .read_response(self.base.with(("tool", name)))
1454                .unwrap_or_else(|| panic!("no button {name}"))
1455                .rect
1456                .center()
1457        }
1458        fn click(&mut self, name: &str) {
1459            let pos = self.button(name);
1460            self.frame(vec![Event::PointerMoved(pos)]);
1461            self.frame(vec![Event::PointerButton {
1462                pos,
1463                button: egui::PointerButton::Primary,
1464                pressed: true,
1465                modifiers: Modifiers::NONE,
1466            }]);
1467            self.frame(vec![Event::PointerButton {
1468                pos,
1469                button: egui::PointerButton::Primary,
1470                pressed: false,
1471                modifiers: Modifiers::NONE,
1472            }]);
1473        }
1474        fn key(&mut self, key: Key) {
1475            self.key_mod(key, Modifiers::NONE);
1476        }
1477        fn key_mod(&mut self, key: Key, modifiers: Modifiers) {
1478            self.frame(vec![
1479                Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
1480                Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
1481            ]);
1482        }
1483    }
1484
1485    /// Every variant (via `Glyph::ALL`), so a new one cannot be added without deciding what it looks
1486    /// like — and without giving it a name for the icon picker.
1487    const ALL_GLYPHS: &[Glyph] = Glyph::ALL;
1488
1489    /// Tessellated vertices produced by `paint`, on a throwaway context.
1490    fn painted(paint: impl Fn(&egui::Painter, Rect)) -> usize {
1491        let ctx = egui::Context::default();
1492        let input = || egui::RawInput {
1493            screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(200.0, 100.0))),
1494            ..Default::default()
1495        };
1496        let mut run = || {
1497            ctx.run(input(), |ctx| {
1498                egui::CentralPanel::default().show(ctx, |ui| {
1499                    let rect = Rect::from_min_size(Pos2::new(20.0, 20.0), Vec2::new(24.0, 22.0));
1500                    ui.allocate_space(rect.size());
1501                    paint(ui.painter(), rect);
1502                });
1503            })
1504        };
1505        run();
1506        let out = run();
1507        ctx.tessellate(out.shapes, 1.0)
1508            .iter()
1509            .map(|p| match &p.primitive {
1510                egui::epaint::Primitive::Mesh(m) => m.vertices.len(),
1511                _ => 0,
1512            })
1513            .sum()
1514    }
1515
1516    #[test]
1517    fn every_glyph_paints_a_picture() {
1518        // the point of a Glyph is that nothing is typed — a variant that paints nothing would be a
1519        // blank button, no better than the tofu box it replaced
1520        let empty = painted(|_, _| {});
1521        for g in ALL_GLYPHS {
1522            let n = painted(|p, rect| draw_glyph(p, rect, *g, Color32::WHITE));
1523            assert!(n > empty, "{g:?} painted nothing ({n} vs {empty} vertices)");
1524        }
1525    }
1526
1527    #[test]
1528    fn strip_lays_out_every_tool() {
1529        let h = Harness::new();
1530        for (_, _, name) in STRIP {
1531            let c = h.button(name);
1532            assert!(c.x > 0.0 && c.x < 900.0, "{name} off-strip at {c:?}");
1533        }
1534    }
1535
1536    #[test]
1537    fn clicking_switches_the_tool() {
1538        let mut h = Harness::new();
1539        assert_eq!(h.state.tool, Tool::Select);
1540        h.click("Ellipse");
1541        assert_eq!(h.state.tool, Tool::Shape(ShapeKind::Ellipse));
1542        assert!(h.changed, "a switch is reported as a change");
1543        h.click("Draw");
1544        assert_eq!(h.state.tool, Tool::Draw);
1545        h.click("Zoom");
1546        assert_eq!(h.state.tool, Tool::Zoom);
1547        h.click("Select");
1548        assert_eq!(h.state.tool, Tool::Select);
1549    }
1550
1551    #[test]
1552    fn clicking_the_active_tool_reports_nothing() {
1553        let mut h = Harness::new();
1554        h.click("Star");
1555        assert_eq!(h.state.tool, Tool::Shape(ShapeKind::Star));
1556        h.click("Star");
1557        assert!(!h.changed, "re-clicking the active tool is not a change");
1558        assert_eq!(h.state.tool, Tool::Shape(ShapeKind::Star));
1559    }
1560
1561    #[test]
1562    fn mask_button_lights_up_for_every_mask_shape() {
1563        assert!(same_tool(Tool::Mask(MaskShape::Rect), Tool::Mask(MaskShape::Path)));
1564        assert!(!same_tool(Tool::Mask(MaskShape::Rect), Tool::Select));
1565        assert!(!same_tool(Tool::Shape(ShapeKind::Rect), Tool::Shape(ShapeKind::Star)));
1566        let mut h = Harness::new();
1567        h.state.tool = Tool::Mask(MaskShape::Path);
1568        h.frame(vec![]);
1569        h.click("Mask");
1570        assert_eq!(h.state.tool, Tool::Mask(MaskShape::Path), "the active mask shape survives a re-click");
1571        h.click("Rectangle");
1572        assert_eq!(h.state.tool, Tool::Shape(ShapeKind::Rect));
1573        h.click("Mask");
1574        assert_eq!(h.state.tool, Tool::Mask(MaskShape::Rect));
1575    }
1576
1577    #[test]
1578    fn single_key_shortcuts_switch_tools() {
1579        let mut h = Harness::new();
1580        h.key(Key::T);
1581        assert_eq!(h.state.tool, Tool::Text);
1582        h.key(Key::D);
1583        assert_eq!(h.state.tool, Tool::Draw);
1584        h.key(Key::V);
1585        assert_eq!(h.state.tool, Tool::Select);
1586        h.key_mod(Key::S, Modifiers::SHIFT);
1587        assert_eq!(h.state.tool, Tool::Shape(ShapeKind::Rect));
1588        h.key_mod(Key::S, Modifiers::SHIFT);
1589        assert_eq!(h.state.tool, Tool::Shape(ShapeKind::Ellipse), "Shift+S steps through the shapes");
1590        h.key(Key::M);
1591        assert_eq!(h.state.tool, Tool::Mask(MaskShape::Rect));
1592        h.key(Key::M);
1593        assert_eq!(h.state.tool, Tool::Mask(MaskShape::Ellipse));
1594    }
1595
1596    #[test]
1597    fn bare_s_toggles_snapping_not_the_tool() {
1598        let mut h = Harness::new();
1599        h.state.tool = Tool::Draw;
1600        assert!(!h.snap);
1601        h.key(Key::S);
1602        assert!(h.snap, "bare S toggles snapping");
1603        assert_eq!(h.state.tool, Tool::Draw, "bare S must not touch the active tool");
1604        assert!(h.changed);
1605        h.key(Key::S);
1606        assert!(!h.snap, "S toggles back off");
1607    }
1608
1609    #[test]
1610    fn modified_keys_are_left_alone() {
1611        let mut h = Harness::new();
1612        h.frame(vec![Event::Key {
1613            key: Key::V,
1614            physical_key: None,
1615            pressed: true,
1616            repeat: false,
1617            modifiers: Modifiers::CTRL,
1618        }]);
1619        assert_eq!(h.state.tool, Tool::Select);
1620        h.state.tool = Tool::Draw;
1621        h.frame(vec![Event::Key {
1622            key: Key::S,
1623            physical_key: None,
1624            pressed: true,
1625            repeat: false,
1626            modifiers: Modifiers::CTRL,
1627        }]);
1628        assert_eq!(h.state.tool, Tool::Draw, "Ctrl+S must stay the save shortcut");
1629        assert!(!h.snap, "Ctrl+S must not toggle snapping either");
1630    }
1631
1632    #[test]
1633    fn hotkeys_are_consumed_once() {
1634        // `show` handles the keys itself, so a second call in the same frame must be a no-op
1635        let mut state = ToolsState::default();
1636        let mut snap = false;
1637        let ctx = egui::Context::default();
1638        let input = egui::RawInput {
1639            screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(900.0, 60.0))),
1640            events: vec![Event::Key {
1641                key: Key::S,
1642                physical_key: None,
1643                pressed: true,
1644                repeat: false,
1645                modifiers: Modifiers::SHIFT,
1646            }],
1647            ..Default::default()
1648        };
1649        let pal = Palette::new(true, Color32::from_rgb(0, 120, 212));
1650        let _ = ctx.run(input, |ctx| {
1651            egui::CentralPanel::default().show(ctx, |ui| {
1652                show(ui, &mut state, &pal, &mut snap);
1653                assert!(handle_hotkeys(ui.ctx(), &mut state).is_none(), "key already consumed");
1654            });
1655        });
1656        assert_eq!(state.tool, Tool::Shape(ShapeKind::Rect));
1657        assert!(!snap, "Shift+S is the shape cycle, not the snap toggle");
1658    }
1659
1660    #[test]
1661    fn tool_hotkey_covers_the_documented_keys() {
1662        assert_eq!(tool_hotkey(Tool::Select), Some(Key::V));
1663        assert_eq!(tool_hotkey(Tool::Text), Some(Key::T));
1664        assert_eq!(tool_hotkey(Tool::Shape(ShapeKind::Star)), None, "shape tools cycle on Shift+S");
1665        assert_eq!(tool_hotkey(Tool::Draw), Some(Key::D));
1666        assert_eq!(tool_hotkey(Tool::Mask(MaskShape::Path)), Some(Key::M));
1667        assert_eq!(tool_hotkey(Tool::Zoom), None);
1668    }
1669
1670    #[test]
1671    fn clicking_the_magnet_toggles_snapping() {
1672        let mut h = Harness::new();
1673        assert!(!h.snap);
1674        let pos = h.ctx.read_response(h.base.with("snap")).unwrap().rect.center();
1675        h.frame(vec![Event::PointerMoved(pos)]);
1676        h.frame(vec![Event::PointerButton {
1677            pos,
1678            button: egui::PointerButton::Primary,
1679            pressed: true,
1680            modifiers: Modifiers::NONE,
1681        }]);
1682        h.frame(vec![Event::PointerButton {
1683            pos,
1684            button: egui::PointerButton::Primary,
1685            pressed: false,
1686            modifiers: Modifiers::NONE,
1687        }]);
1688        assert!(h.snap, "clicking the magnet turns snapping on");
1689        assert!(h.changed);
1690    }
1691
1692    #[test]
1693    fn every_tool_renders_its_style_controls() {
1694        let mut h = Harness::new();
1695        let mut tools: Vec<Tool> = STRIP.iter().map(|(t, ..)| *t).collect();
1696        tools.extend(MaskShape::ALL.map(Tool::Mask));
1697        tools.push(Tool::Shape(ShapeKind::Draw));
1698        for t in tools {
1699            h.state.tool = t;
1700            h.frame(vec![]);
1701            assert_eq!(h.state.tool, t, "{t:?} controls must not change the tool on their own");
1702            assert!(!h.changed, "{t:?} reports no change when nothing is touched");
1703        }
1704    }
1705}