Skip to main content

blockworx_web/
tool_cluster.rs

1//! The toolbar: eight parameterless tools floating at the bottom centre of
2//! the diagram (`docs/cad-ui-spec.md` §5), in three groups with a rule
3//! between each — what selects, what builds blocks, what annotates the sheet.
4//!
5//! The order is the band's own, which is the order the digit shortcuts run
6//! in, so a cell and the key that arms it cannot disagree. Select sits first
7//! and is visible on every platform; tapping the armed cell returns to it.
8//! The sheet tools are used less often, so they fold into one cell wearing
9//! the one used last, with a caret that opens all four.
10//!
11//! It is floating chrome, so it is shadowed with a large radius — and it
12//! measures itself, since a framing must stay clear of where the bar *is*.
13
14use blockworx_editor::names::{BAND_TOOLS, ToolGroup};
15use blockworx_geom::{Rect, pos2};
16use blockworx_kernel::Event;
17use blockworx_paint::{Chord, Modifiers};
18use blockworx_tools::commands::{band_command, chords};
19use blockworx_tools::names::ToolName;
20use dioxus::prelude::*;
21use dioxus::web::WebEventExt as _;
22use dioxus_primitives::dropdown_menu::{
23    DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
24};
25
26use crate::control::{ARMED, CELL, Face, Press};
27use crate::icons;
28use crate::shell::{Band, Shell, measured};
29use crate::top_bar::MENU_ROW;
30
31/// The rule between two groups.
32const RULE: &str = "mx-1.5 h-7 w-px flex-none bg-zinc-950/10 dark:bg-white/15";
33
34/// The folded cell, which fills as a whole while its tool is armed.
35const FOLDED: &str = "relative flex items-center rounded-cell text-zinc-600 transition \
36     duration-150 ease-out dark:text-zinc-300";
37const FOLDED_ARMED: &str = "relative flex items-center rounded-cell bg-sky-600 text-white \
38     transition duration-150 ease-out dark:bg-sky-500 dark:text-zinc-950";
39
40/// The folded cell's face: the press half, sized like any other cell.
41const FACE: &str = "grid size-13 place-items-center rounded-cell transition duration-150 \
42     ease-out live:hover:bg-zinc-950/6 live:active:scale-90 dead:opacity-30 \
43     dark:live:hover:bg-white/10 [&_svg]:size-6 [&_svg]:stroke-[1.75]";
44const FACE_ARMED: &str = "grid size-13 place-items-center rounded-cell transition duration-150 \
45     ease-out live:active:scale-90 [&_svg]:size-6 [&_svg]:stroke-[1.75]";
46
47/// The caret half, which opens the four.
48const CARET: &str = "grid h-13 w-[22px] place-items-center rounded-cell opacity-70 outline-none \
49     transition hover:opacity-100 [&_svg]:size-3.5 [&_svg]:stroke-[2.2]";
50
51/// The four, rising above the bar.
52const SHEET_MENU: &str = "absolute bottom-full right-0 z-50 mb-3 w-60 rounded-2xl bg-white p-1.5 \
53     text-zinc-900 shadow-lg ring-1 ring-zinc-950/5 dark:bg-zinc-800 dark:text-zinc-100 \
54     dark:ring-white/10 data-[state=closed]:hidden";
55
56const TICK: &str = "grid w-3.5 flex-none place-items-center text-sky-600 dark:text-sky-400";
57
58const KEY: &str = "grid h-5 min-w-5 place-items-center rounded-[5px] bg-zinc-950/5 px-1 \
59     text-[11px] text-zinc-500 dark:bg-white/10 dark:text-zinc-400";
60
61#[component]
62pub fn ToolCluster(shell: Shell) -> Element {
63    let chrome = shell.chrome();
64    let armed = chrome.read().tool;
65    // The sheet tool the folded cell wears once none is armed: the last one
66    // that was. Image arms nothing, so its own pick remembers it instead.
67    let mut worn = use_signal(|| first(ToolGroup::Sheet));
68    use_effect(move || {
69        let tool = chrome.read().tool;
70        if group_of(tool) == Some(ToolGroup::Sheet) && *worn.peek() != tool {
71            worn.set(tool);
72        }
73    });
74    let face = if group_of(armed) == Some(ToolGroup::Sheet) {
75        armed
76    } else {
77        worn()
78    };
79    let mounted = use_hook(|| CopyValue::new(shell.clone()));
80    rsx! {
81        div {
82            class: "absolute inset-x-0 bottom-6 z-10 mx-auto flex w-fit items-center gap-0.5 \
83                    rounded-band bg-white/85 p-1.5 shadow-lg ring-1 ring-zinc-950/5 \
84                    backdrop-blur-xl transition-opacity dark:bg-zinc-900/85 dark:ring-white/10 \
85                    group-data-[viewing=true]:pointer-events-none \
86                    group-data-[viewing=true]:opacity-35",
87            role: "toolbar",
88            "aria-label": "Tools",
89            onmounted: move |event| {
90                if let Some(element) = event.try_as_web_event() {
91                    covers(&mounted.read(), &element);
92                }
93            },
94            onresize: move |event| {
95                if let Some(entry) = event.try_as_web_event() {
96                    covers(&mounted.read(), &entry.target());
97                }
98            },
99            for tool in members(ToolGroup::Selection) {
100                Cell { key: "{tool:?}", shell: shell.clone(), tool, armed }
101            }
102            span { class: RULE }
103            for tool in members(ToolGroup::Block) {
104                Cell { key: "{tool:?}", shell: shell.clone(), tool, armed }
105            }
106            span { class: RULE }
107            SheetTools { shell, armed, face, worn }
108        }
109    }
110}
111
112#[component]
113fn Cell(shell: Shell, tool: ToolName, armed: ToolName) -> Element {
114    rsx! {
115        Press {
116            shell,
117            id: band_command(tool),
118            // Tapping the armed tool returns to Select.
119            raises: band_command(if tool == armed { ToolName::Select } else { tool }),
120            says: tool.label().to_string(),
121            icon: icons::tool(tool),
122            class: if tool == armed { ARMED } else { CELL },
123        }
124    }
125}
126
127/// The sheet tools, folded: a press on the face arms the one it wears, and
128/// the caret — or a long press, which a touch screen raises as a context
129/// menu — opens all four with the keys that arm them.
130#[component]
131fn SheetTools(shell: Shell, armed: ToolName, face: ToolName, worn: Signal<ToolName>) -> Element {
132    let mut open = use_signal(|| false);
133    let chrome = shell.chrome();
134    let rows: Vec<(ToolName, bool)> = members(ToolGroup::Sheet)
135        .map(|tool| {
136            let live = chrome
137                .read()
138                .commands
139                .face(band_command(tool))
140                .is_some_and(crate::chrome::Face::live);
141            (tool, live)
142        })
143        .collect();
144    let token = shell.token();
145    let raise = use_hook(|| CopyValue::new(shell.clone()));
146    let holding = face == armed;
147    rsx! {
148        DropdownMenu {
149            class: if holding { FOLDED_ARMED } else { FOLDED },
150            "data-folded": "sheet",
151            open: Some(open()),
152            on_open_change: move |now: bool| open.set(now),
153            div {
154                oncontextmenu: move |event| {
155                    event.prevent_default();
156                    open.set(true);
157                },
158                Press {
159                    shell,
160                    id: band_command(face),
161                    raises: band_command(if holding { ToolName::Select } else { face }),
162                    says: face.label().to_string(),
163                    icon: icons::tool(face),
164                    class: if holding { FACE_ARMED } else { FACE },
165                }
166            }
167            DropdownMenuTrigger { class: CARET, title: "Sheet tools", "data-cmd": "sheet-tools",
168                Face { icon: icons::CHEVRON_UP }
169            }
170            DropdownMenuContent { class: SHEET_MENU,
171                for (place , (tool , live)) in rows.into_iter().enumerate() {
172                    DropdownMenuItem::<ToolName> {
173                        key: "{tool:?}",
174                        class: MENU_ROW,
175                        "data-cmd": "{band_command(tool).name()}",
176                        value: tool,
177                        index: place,
178                        disabled: !live,
179                        on_select: move |tool: ToolName| {
180                            worn.set(tool);
181                            let shell = raise.read();
182                            shell.works();
183                            shell.say(Event::Command(band_command(tool)));
184                        },
185                        span { class: TICK,
186                            if tool == face {
187                                Face { icon: icons::CHECK }
188                            }
189                        }
190                        Face { icon: icons::tool(tool) }
191                        span { class: "min-w-0 flex-1", "{tool.label()}" }
192                        if let Some(key) = digit(tool) {
193                            kbd { class: KEY, "{crate::chords::spelled(token, key)}" }
194                        }
195                    }
196                }
197            }
198        }
199    }
200}
201
202/// The band's tools in `group`, in the band's order.
203fn members(group: ToolGroup) -> impl Iterator<Item = ToolName> {
204    BAND_TOOLS
205        .iter()
206        .filter(move |band| band.group == group)
207        .map(|band| band.tool)
208}
209
210fn first(group: ToolGroup) -> ToolName {
211    members(group).next().unwrap_or(ToolName::Select)
212}
213
214/// Which group `tool` has a cell in, if it has one at all.
215fn group_of(tool: ToolName) -> Option<ToolGroup> {
216    BAND_TOOLS
217        .iter()
218        .find(|band| band.tool == tool)
219        .map(|band| band.group)
220}
221
222/// The bare digit that arms `tool` — its place on the band.
223fn digit(tool: ToolName) -> Option<Chord> {
224    chords(band_command(tool))
225        .find(|chord| chord.modifiers == Modifiers::None)
226        .copied()
227}
228
229/// What the bar covers of the diagram: its own columns, taken down to the
230/// canvas's bottom edge — the gap beneath it is no room a framing may use.
231fn covers(shell: &Shell, element: &web_sys::Element) {
232    let Some(at) = measured(element) else { return };
233    shell.covers(
234        Band::Toolbar,
235        Rect::from_min_max(at.min, pos2(at.max.x, f32::MAX)),
236    );
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    /// Every sheet tool the folded cell can wear has a digit to show in its
244    /// menu, and they run on from the block tools' in the band's order.
245    #[test]
246    fn the_folded_tools_carry_the_digits_of_their_places() {
247        let folded: Vec<Option<Chord>> = members(ToolGroup::Sheet).map(digit).collect();
248        assert_eq!(folded.len(), 4, "the sheet group changed shape");
249        let before = members(ToolGroup::Selection).count() + members(ToolGroup::Block).count();
250        for (place, key) in folded.into_iter().enumerate() {
251            let Some(key) = key else {
252                panic!("sheet tool {place} has no digit");
253            };
254            assert_eq!(
255                crate::chords::spelled(crate::chords::Token::Control, key),
256                (before + place + 1).to_string(),
257            );
258        }
259    }
260}