Skip to main content

blockworx_web/
overlay.rs

1//! The selection overlay, anchored to what is selected, and the two pickers
2//! it opens (`docs/cad-ui-spec.md` §3).
3//!
4//! The most important component in the application, and the most
5//! input-agnostic: identical with mouse, trackpad, touch and pen. Which verbs
6//! it carries, in what order, where the row ends and the overflow begins, and
7//! where the bar stands are all [`blockworx_kernel::bar`]'s — so this bar and
8//! the desktop's are one bar drawn twice.
9//!
10//! It is glass floating over the middle of the diagram, so it takes nothing
11//! off the safe region: a framing may pass under it, since glass is not a
12//! wall.
13
14use blockworx_canvas2d::css_color;
15use blockworx_geom::{Rect, Vec2};
16use blockworx_kernel::bar::{self, Bar};
17use blockworx_kernel::chrome::accent_display_role;
18use blockworx_kernel::{Event, Overlay as Model};
19use blockworx_tools::commands::{ACCENTS, CommandId, PIN_DIRS, in_overlay};
20use blockworx_tools::tool::Action;
21use dioxus::prelude::*;
22use dioxus::web::WebEventExt as _;
23
24use crate::chrome::Face;
25use crate::control::{Face as Glyph, Press, Pressable, TAP};
26use crate::icons;
27use crate::shell::{Picker, Shell};
28
29/// What the bar measures out to before it has measured itself. The DOM tells
30/// us the real size on the frame after, and the placement re-runs; this only
31/// decides where the first paint lands, so it is the bar's own metrics rather
32/// than a guess: a 44 px row with 5 px of margin either side.
33const BAR: Vec2 = Vec2::new(0.0, 54.0);
34
35/// One cell of the bar.
36const CELL: &str = "relative grid h-target min-w-target place-items-center rounded-[11px] \
37     px-1.5 text-zinc-700 transition duration-150 ease-out live:hover:bg-zinc-950/6 \
38     live:active:scale-90 dead:opacity-30 dark:text-zinc-200 \
39     dark:live:hover:bg-white/10 [&_svg]:size-5 [&_svg]:stroke-[1.8]";
40
41/// The bar's own shell: floating chrome, so a shadow and a large radius.
42const GLASS: &str = "pointer-events-auto absolute z-24 flex items-center gap-0.5 rounded-[14px] \
43     bg-white/95 p-[5px] shadow-lg ring-1 ring-zinc-950/5 backdrop-blur-xl \
44     dark:bg-zinc-800/95 dark:ring-white/10";
45
46#[component]
47pub fn SelectionOverlay(shell: Shell) -> Element {
48    let chrome = shell.chrome();
49    let safe = shell.safe();
50    // The DOM measures itself, so the bar is placed from the width it drew
51    // at last time; the first paint of a new set of verbs uses its height
52    // alone, which is all the vertical fit test reads.
53    //
54    // Hooked before anything can return: Dioxus matches hooks by position,
55    // so a frame with nothing selected must still take this slot.
56    let mut measured = use_signal(|| BAR);
57    let Some(model) = chrome.read().overlay.clone() else {
58        return rsx! {};
59    };
60    let selection = model.selection.screen;
61    if !safe().intersects(selection) {
62        return rsx! {};
63    }
64    let offered: Vec<Face> = chrome
65        .read()
66        .commands
67        .drawn()
68        .filter(|face| in_overlay(face.id))
69        .cloned()
70        .collect();
71    let count = model.selection.count;
72    let bar = Bar::of(offered, count, |face: &Face| face.precedence);
73    let Some(at) = bar::place(selection, measured(), safe()) else {
74        return rsx! {};
75    };
76    rsx! {
77        div {
78            class: GLASS,
79            style: "left:{at.min.x}px;top:{at.min.y}px",
80            role: "toolbar",
81            "aria-label": "Selection",
82            "data-overlay": "true",
83            onmounted: move |event| sized(&mut measured, &event),
84            onresize: move |event| {
85                let size = event
86                    .try_as_web_event()
87                    .and_then(|entry| crate::shell::measured(&entry.target()))
88                    .map(|at| at.size());
89                if let Some(size) = size
90                    && *measured.peek() != size
91                {
92                    measured.set(size);
93                }
94            },
95            if count > 1 {
96                span {
97                    class: "mr-1 flex h-[26px] items-center whitespace-nowrap border-r \
98                            border-zinc-950/10 pl-1.5 pr-2.5 text-xs text-zinc-500 \
99                            dark:border-white/15 dark:text-zinc-400",
100                    "{count} selected"
101                }
102            }
103            if let Bar::Row(_) = bar {
104                for face in bar.row() {
105                    Control {
106                        key: "{face.id.name()}",
107                        shell: shell.clone(),
108                        face: face.clone(),
109                        model: model.clone(),
110                    }
111                }
112                if !bar.overflow().is_empty() {
113                    Overflow { shell: shell.clone(), rest: bar.overflow().to_vec() }
114                }
115            } else {
116                span { class: "px-3 text-[13px] text-zinc-500 dark:text-zinc-400",
117                    "{bar::nothing_to_say(count)}"
118                }
119            }
120        }
121        Pickers { shell, bar: at, model }
122    }
123}
124
125/// One verb. The two that open a picker wear the reading the picker will show
126/// — the accent's own colour, the pins' shared direction — so what a press
127/// leads to is legible before it is pressed.
128#[component]
129fn Control(shell: Shell, face: Face, model: Model) -> Element {
130    // The accent cell is the one control whose face is the document's own
131    // colour rather than a glyph, so its swatch rides over the button.
132    if face.id == CommandId::Accent {
133        return rsx! {
134            span { class: "relative",
135                Press {
136                    shell,
137                    id: CommandId::Accent,
138                    says: face.label.clone(),
139                    icon: icons::PAINT,
140                    class: CELL,
141                }
142                if let Some(swatch) = model.swatch {
143                    span {
144                        class: "pointer-events-none absolute inset-x-2 bottom-1.5 h-1 \
145                                rounded-full",
146                        style: "background:{css_color(swatch)}",
147                    }
148                }
149            }
150        };
151    }
152    let says = if face.id == CommandId::PinType {
153        pin_dir_hover(model.pin_dir)
154    } else {
155        face.label.clone()
156    };
157    rsx! {
158        Press {
159            shell,
160            id: face.id,
161            says,
162            icon: icons::overlay(face.id),
163            class: CELL,
164        }
165    }
166}
167
168/// What the I/O control says, which is where the state lives: one glyph in
169/// every state, so the cell is the same shape whatever the pins are facing.
170fn pin_dir_hover(dir: Option<blockworx_doc::values::PinDir>) -> String {
171    let says = match dir {
172        Some(blockworx_doc::values::PinDir::Input) => "currently Input",
173        Some(blockworx_doc::values::PinDir::Output) => "currently Output",
174        Some(blockworx_doc::values::PinDir::InOut) => "currently Input Output",
175        None => "these pins face different ways",
176    };
177    format!("Direction \u{2014} {says}")
178}
179
180/// The verbs past the row's fill, behind one control that opens them as a
181/// menu. Order stays stable regardless of which verbs overflowed.
182#[component]
183fn Overflow(shell: Shell, rest: Vec<Face>) -> Element {
184    use dioxus_primitives::dropdown_menu::{
185        DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
186    };
187    let token = shell.token();
188    let raise = use_hook(|| CopyValue::new(shell));
189    rsx! {
190        DropdownMenu { class: "relative",
191            DropdownMenuTrigger {
192                class: CELL,
193                title: "{rest.len()} more",
194                "data-cmd": "more",
195                Glyph { icon: icons::MORE }
196            }
197            DropdownMenuContent { class: crate::top_bar::MENU,
198                for (place , face) in rest.into_iter().enumerate() {
199                    DropdownMenuItem::<CommandId> {
200                        key: "{face.id.name()}",
201                        class: crate::top_bar::MENU_ROW,
202                        title: crate::chords::hinted(token, &face.label, face.id),
203                        "data-cmd": "{face.id.name()}",
204                        value: face.id,
205                        index: place,
206                        disabled: !face.live(),
207                        on_select: move |id| raise.read().say(Event::Command(id)),
208                        Glyph { icon: icons::overlay(face.id) }
209                        "{face.label}"
210                    }
211                }
212            }
213        }
214    }
215}
216
217/// Whichever picker the bar's controls opened, anchored to the bar's own top
218/// right and clamped into the same room the bar is, so a bar near the top of
219/// the diagram does not push its picker off the page.
220#[component]
221fn Pickers(shell: Shell, bar: Rect, model: Model) -> Element {
222    let picker = shell.picker();
223    let Some(open) = picker.read().clone() else {
224        return rsx! {};
225    };
226    let theme = shell.theme();
227    match open {
228        Picker::Accent(target) => rsx! {
229            Sheet { shell: shell.clone(), bar, name: "accent",
230                for (id , label , value) in ACCENTS {
231                    {
232                        let colour = css_color(theme.resolve(accent_display_role(target, value)));
233                        let taken = value == model.accent;
234                        rsx! {
235                            Pressable {
236                                key: "{id.name()}",
237                                class: if taken {
238                                    "size-7 rounded ring-2 ring-zinc-900 dark:ring-zinc-100"
239                                } else {
240                                    "size-7 rounded ring-1 ring-zinc-950/15 dark:ring-white/20"
241                                },
242                                style: "background:{colour}",
243                                title: "{label}",
244                                "data-cmd": "{id.name()}",
245                                "aria-pressed": if taken { "true" } else { "false" },
246                                onpress: {
247                                    let shell = shell.clone();
248                                    move |()| {
249                                        shell.picked(Some(
250                                            Action::SetRole { target, role: value }.into(),
251                                        ));
252                                    }
253                                },
254                            }
255                        }
256                    }
257                }
258            }
259        },
260        Picker::PinType(pins) => rsx! {
261            Sheet { shell: shell.clone(), bar, name: "io",
262                for (id , label , kind) in PIN_DIRS {
263                    Pressable {
264                        key: "{id.name()}",
265                        class: if model.pin_dir == Some(kind) { TAKEN } else { TAP },
266                        title: "{label}",
267                        "data-cmd": "{id.name()}",
268                        "aria-pressed": if model.pin_dir == Some(kind) { "true" } else { "false" },
269                        onpress: {
270                            let shell = shell.clone();
271                            let pins = pins.clone();
272                            move |()| {
273                                shell.picked(Some(
274                                    Action::SetPinsKind { pins: pins.clone(), kind }.into(),
275                                ));
276                            }
277                        },
278                        Glyph { icon: icons::pin_dir(kind) }
279                    }
280                }
281            }
282        },
283    }
284}
285
286/// A picker's own sheet: the grid, and the backdrop that dismisses it. The
287/// backdrop is what makes a click away a cancel without a frame's delay —
288/// the press that opened the picker is over by the time it exists.
289#[component]
290fn Sheet(shell: Shell, bar: Rect, name: String, children: Element) -> Element {
291    let dismiss = use_hook(|| CopyValue::new(shell));
292    // Above the bar's top right, growing up and to the left.
293    let at = bar::clamp(
294        Rect::from_min_size(
295            blockworx_geom::pos2(bar.max.x - SHEET.x, bar.min.y - SHEET.y - GAP),
296            SHEET,
297        ),
298        dismiss.read().safe()(),
299    );
300    rsx! {
301        div {
302            class: "fixed inset-0 z-40",
303            "data-backdrop": "{name}",
304            onclick: move |_| dismiss.read().picked(None),
305        }
306        div {
307            class: "absolute z-50 grid grid-cols-3 gap-1 rounded-2xl bg-white p-2 shadow-lg \
308                    ring-1 ring-zinc-950/5 dark:bg-zinc-800 dark:ring-white/10",
309            style: "left:{at.min.x}px;top:{at.min.y}px",
310            role: "group",
311            "data-picker": "{name}",
312            {children}
313        }
314    }
315}
316
317/// The direction the pins already face, which is the one cell drawn filled.
318const TAKEN: &str = "relative grid size-target place-items-center rounded-xl bg-sky-600 \
319     text-white transition duration-150 ease-out dark:bg-sky-500 dark:text-zinc-950 \
320     [&_svg]:size-5 [&_svg]:stroke-[1.8]";
321
322/// How big a picker's sheet is: three swatches and their gaps, plus the
323/// sheet's own padding. Both pickers fit inside it — the I/O row is three
324/// cells wide and one tall.
325const SHEET: Vec2 = Vec2::new(116.0, 116.0);
326
327/// The air between the bar's top edge and the picker above it.
328const GAP: f32 = 15.0;
329
330fn sized(measured: &mut Signal<Vec2>, event: &dioxus::prelude::Event<MountedData>) {
331    use dioxus::web::WebEventExt as _;
332    let Some(at) = event
333        .try_as_web_event()
334        .and_then(|element| crate::shell::measured(&element))
335    else {
336        return;
337    };
338    if *measured.peek() != at.size() {
339        measured.set(at.size());
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use blockworx_doc::values::PinDir;
346
347    use super::*;
348
349    /// One glyph in every state, so the state has to be in the words.
350    #[test]
351    fn the_io_control_says_which_way_the_pins_face() {
352        assert_eq!(
353            pin_dir_hover(Some(PinDir::Input)),
354            "Direction \u{2014} currently Input",
355        );
356        assert_eq!(
357            pin_dir_hover(None),
358            "Direction \u{2014} these pins face different ways",
359        );
360    }
361
362    /// A sheet anchored above a bar at the top of the diagram is moved down
363    /// into the room rather than off the page — which is the clamp the
364    /// desktop's picker never had.
365    #[test]
366    fn a_picker_over_a_bar_at_the_top_is_clamped_into_the_room() {
367        let region = Rect::from_min_max(
368            blockworx_geom::pos2(0.0, 54.0),
369            blockworx_geom::pos2(800.0, 600.0),
370        );
371        let bar = Rect::from_min_size(blockworx_geom::pos2(300.0, 60.0), Vec2::new(220.0, 54.0));
372        let wanted = Rect::from_min_size(
373            blockworx_geom::pos2(bar.max.x - SHEET.x, bar.min.y - SHEET.y - GAP),
374            SHEET,
375        );
376        assert!(
377            wanted.min.y < region.min.y,
378            "precondition: unclamped, the picker is off the top of the diagram",
379        );
380        assert!(region.contains_rect(bar::clamp(wanted, region)));
381    }
382}