1use 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
29const BAR: Vec2 = Vec2::new(0.0, 54.0);
34
35const 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
41const 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 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#[component]
129fn Control(shell: Shell, face: Face, model: Model) -> Element {
130 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
168fn 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#[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#[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#[component]
290fn Sheet(shell: Shell, bar: Rect, name: String, children: Element) -> Element {
291 let dismiss = use_hook(|| CopyValue::new(shell));
292 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
317const 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
322const SHEET: Vec2 = Vec2::new(116.0, 116.0);
326
327const 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 #[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 #[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}