blockworx_web/
tool_cluster.rs1use 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
31const RULE: &str = "mx-1.5 h-7 w-px flex-none bg-zinc-950/10 dark:bg-white/15";
33
34const 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
40const 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
47const 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
51const 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 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 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#[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
202fn 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
214fn group_of(tool: ToolName) -> Option<ToolGroup> {
216 BAND_TOOLS
217 .iter()
218 .find(|band| band.tool == tool)
219 .map(|band| band.group)
220}
221
222fn digit(tool: ToolName) -> Option<Chord> {
224 chords(band_command(tool))
225 .find(|chord| chord.modifiers == Modifiers::None)
226 .copied()
227}
228
229fn 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 #[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}