1use core::time::Duration;
13
14use blockworx_kernel::{Crumb, Liveness, Locked, TopBar as Bar};
15use blockworx_store::doc::{At, Renaming, TimeStep, Viewing};
16use blockworx_tools::commands::{CommandId, Effect};
17use blockworx_tools::history::{Direction, consequence};
18use blockworx_tools::tool::Action;
19use dioxus::prelude::*;
20use dioxus::web::WebEventExt as _;
21use wasm_bindgen::JsCast as _;
22
23use crate::control::{Face, Press, Pressable, Pressing, TAP};
24use crate::icons;
25use crate::shell::{Shell, after};
26use crate::sidebar::Section;
27
28const DOUBLE_CLICK: Duration = Duration::from_millis(300);
33
34#[component]
35pub fn TopBar(shell: Shell) -> Element {
36 let chrome = shell.chrome();
37 let liveness = chrome.read().top_bar.liveness;
38 rsx! {
39 header {
40 class: "flex h-bar shrink-0 items-center gap-1 px-3 transition-colors \
41 bg-white dark:bg-zinc-900 \
42 group-data-[viewing=true]:bg-amber-100 \
43 dark:group-data-[viewing=true]:bg-amber-950",
44 div { class: "flex min-w-0 flex-none items-center gap-0.5",
45 LivenessDot { liveness }
46 Breadcrumb { shell: shell.clone() }
47 }
48 div { class: "flex min-w-0 flex-1 items-center justify-center",
49 TimeMachine { shell: shell.clone() }
50 }
51 div { class: "flex flex-none items-center gap-0.5",
52 Tape { shell: shell.clone() }
53 span { class: "mx-1.5 h-5 w-px bg-zinc-950/10 dark:bg-white/15" }
54 Press {
55 shell: shell.clone(),
56 id: CommandId::GoUp,
57 says: "Go up a level",
58 icon: icons::LEVEL_UP,
59 class: TAP,
60 }
61 Press {
62 shell,
63 id: CommandId::FitView,
64 says: "Zoom to fit",
65 icon: icons::FIT,
66 class: TAP,
67 }
68 }
69 }
70 }
71}
72
73#[component]
77fn Tape(shell: Shell) -> Element {
78 let chrome = shell.chrome();
79 let (steps, viewing) = {
80 let read = chrome.read();
81 (read.top_bar.steps.clone(), read.top_bar.lens.viewing)
82 };
83 let offered = |id| {
84 chrome
85 .read()
86 .commands
87 .face(id)
88 .is_some_and(|face| face.live())
89 };
90 rsx! {
91 Press {
92 shell: shell.clone(),
93 id: CommandId::Undo,
94 says: consequence(
95 Direction::Back,
96 steps.undo.as_ref(),
97 offered(CommandId::Undo).into(),
98 viewing,
99 ),
100 icon: icons::UNDO,
101 class: TAP,
102 }
103 Press {
104 shell,
105 id: CommandId::Redo,
106 says: consequence(
107 Direction::Forward,
108 steps.redo.as_ref(),
109 offered(CommandId::Redo).into(),
110 viewing,
111 ),
112 icon: icons::REDO,
113 class: TAP,
114 }
115 }
116}
117
118#[component]
121fn LivenessDot(liveness: Liveness) -> Element {
122 let (tint, says) = reading(liveness);
123 rsx! {
124 span {
125 class: "mx-1.5 size-2 flex-none rounded-full {tint}",
126 title: "{says}",
127 role: "status",
128 }
129 }
130}
131
132pub fn reading(liveness: Liveness) -> (&'static str, &'static str) {
135 match liveness {
136 Liveness::Recorded => ("bg-emerald-500", "All changes recorded"),
137 Liveness::ReadOnly(Locked::Lens) => (
138 "bg-red-500",
139 "Read-only \u{2014} an earlier rev is on the canvas",
140 ),
141 Liveness::ReadOnly(Locked::Container) => (
142 "bg-red-500",
143 "Read-only \u{2014} this document was opened without a write lock",
144 ),
145 Liveness::Scratch => (
146 "bg-zinc-400",
147 "Scratch session \u{2014} not yet saved to disk",
148 ),
149 }
150}
151
152const SEGMENT: &str = "max-w-[200px] truncate rounded-lg px-2.5 py-1.5 text-sm \
154 text-zinc-600 transition enabled:hover:bg-zinc-950/6 enabled:hover:text-zinc-900 \
155 disabled:opacity-100 dark:text-zinc-400 dark:enabled:hover:bg-white/10 \
156 dark:enabled:hover:text-zinc-100";
157
158const HERE: &str = "max-w-[200px] truncate rounded-lg px-2.5 py-1.5 text-sm font-medium \
161 text-zinc-900 transition hover:bg-zinc-950/6 dark:text-zinc-100 dark:hover:bg-white/10";
162
163#[component]
169fn Breadcrumb(shell: Shell) -> Element {
170 let chrome = shell.chrome();
171 let bar = chrome.read().top_bar.clone();
172 let here = bar.scope.here();
173 let scope = bar.scope.clone();
174 let rose = use_hook(|| CopyValue::new(shell.clone()));
175 rsx! {
176 nav { class: "flex min-w-0 items-center gap-px", "aria-label": "Edit context",
177 for shown in scope.collapsed() {
178 match shown {
179 Crumb::Root => rsx! {
180 DocumentName { key: "{here}-root", shell: shell.clone(), bar: bar.clone() }
181 },
182 Crumb::Level(depth) => {
183 let name = bar.scope.names[depth - 1].clone();
184 let scope = bar.scope.clone();
185 rsx! {
186 Separator { key: "sep-{depth}" }
187 if depth == here {
188 span {
189 key: "at-{depth}",
190 class: "max-w-[200px] truncate rounded-lg px-2.5 py-1.5 text-sm font-medium \
191 text-zinc-900 dark:text-zinc-100",
192 "aria-current": "page",
193 "{name}"
194 }
195 } else {
196 button {
197 key: "to-{depth}",
198 class: SEGMENT,
199 title: "Go to {name}",
200 onclick: move |_| {
201 rose.read().say(blockworx_kernel::Event::Action(scope.up_to(depth)));
202 },
203 "{name}"
204 }
205 }
206 }
207 }
208 Crumb::Elided(hidden) => rsx! {
209 Separator { key: "sep-{here}" }
210 Elision {
211 shell: shell.clone(),
212 names: bar.scope.names.clone(),
213 path: bar.scope.clone(),
214 hidden,
215 }
216 },
217 }
218 }
219 }
220 }
221}
222
223#[component]
224fn Separator() -> Element {
225 rsx! {
226 span { class: "flex-none text-xs text-zinc-400 dark:text-zinc-600", "aria-hidden": "true", "/" }
227 }
228}
229
230#[component]
232fn Elision(
233 shell: Shell,
234 names: Vec<String>,
235 path: blockworx_kernel::ScopePath,
236 hidden: Vec<usize>,
237) -> Element {
238 use dioxus_primitives::dropdown_menu::{
239 DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
240 };
241 let shell = use_hook(|| CopyValue::new(shell));
242 rsx! {
243 DropdownMenu {
244 DropdownMenuTrigger {
245 class: "{SEGMENT} tracking-widest",
246 title: "The levels between",
247 "\u{00b7}\u{00b7}\u{00b7}"
248 }
249 DropdownMenuContent { class: MENU,
250 for (place , depth) in hidden.into_iter().enumerate() {
251 DropdownMenuItem::<usize> {
252 key: "{depth}",
253 class: MENU_ROW,
254 value: depth,
255 index: place,
256 on_select: {
257 let path = path.clone();
258 move |depth: usize| {
259 shell.read().say(blockworx_kernel::Event::Action(path.up_to(depth)));
260 }
261 },
262 "{names[depth - 1]}"
263 }
264 }
265 }
266 }
267 }
268}
269
270#[component]
274fn DocumentName(shell: Shell, bar: Bar) -> Element {
275 let name = bar.name.clone();
276 let renaming = bar.renaming;
277 let standing = bar.scope.here() == 0;
278 let scope = bar.scope.clone();
279 let mut draft = use_signal(|| None::<String>);
280 let mut rising = use_signal(|| 0_u64);
281 let asked = shell.renaming();
282 let section = shell.section();
283 let shell = use_hook(|| CopyValue::new(shell));
284
285 use_effect({
288 let name = name.clone();
289 let mut answered = use_signal(|| *asked.peek());
292 move || {
293 let asked = asked();
294 if asked != *answered.peek() {
295 answered.set(asked);
296 draft.set(Some(name.clone()));
297 }
298 }
299 });
300
301 let ends = use_callback({
305 let was = name.clone();
306 move |typed: Option<String>| {
307 if draft.peek().is_none() {
310 return;
311 }
312 draft.set(None);
313 let Some(to) = typed else { return };
314 if to != was {
315 shell.read().raises(Effect::RenameDocument(to).into());
316 }
317 }
318 });
319 let typed = move || draft.peek().clone();
320
321 if let Some(typing) = draft.read().clone() {
322 return rsx! {
323 input {
324 class: "w-40 rounded-lg bg-white px-2.5 py-1.5 text-sm font-medium outline-2 \
325 outline-sky-500 dark:bg-zinc-800",
326 value: "{typing}",
327 autofocus: true,
328 onmounted: move |event| {
331 let Some(field) = event
332 .try_as_web_event()
333 .and_then(|element| element.dyn_into::<web_sys::HtmlElement>().ok())
334 else {
335 return;
336 };
337 after(Duration::ZERO, move || {
338 let _ = field.focus();
339 });
340 },
341 "aria-label": "Document name",
342 oninput: move |event| draft.set(Some(event.value())),
343 onblur: move |_| ends.call(typed()),
344 onkeydown: move |event| match event.key() {
345 Key::Enter => ends.call(typed()),
346 Key::Escape => ends.call(None),
347 _ => {}
348 },
349 }
350 };
351 }
352
353 let says = match (standing, renaming) {
354 (true, Renaming::Offered) => "Diagrams \u{2014} double-click to rename",
355 (true, Renaming::Withheld) => "Diagrams",
356 (false, Renaming::Offered) => "Back to the top level \u{2014} double-click to rename",
357 (false, Renaming::Withheld) => "Back to the top level",
358 };
359 rsx! {
360 button {
361 class: HERE,
362 title: "{says}",
363 "data-crumb": "root",
364 onclick: move |_| {
365 let taken = rising() + 1;
366 rising.set(taken);
367 let scope = scope.clone();
368 let mut section = section;
369 after(
370 DOUBLE_CLICK,
371 move || {
372 if *rising.peek() != taken || draft.peek().is_some() {
373 return;
374 }
375 if standing {
376 section.set(Some(Section::Diagrams));
377 } else {
378 shell.read().say(blockworx_kernel::Event::Action(scope.up_to(0)));
379 }
380 },
381 );
382 },
383 ondoubleclick: move |_| {
384 if renaming == Renaming::Offered {
385 let taken = rising() + 1;
386 rising.set(taken);
387 draft.set(Some(name.clone()));
388 }
389 },
390 "{bar.name}"
391 }
392 }
393}
394
395#[component]
400fn TimeMachine(shell: Shell) -> Element {
401 let chrome = shell.chrome();
402 let lens = chrome.read().top_bar.lens.clone();
403 let Viewing::Past(at) = lens.viewing else {
404 return rsx! {};
405 };
406 let headline = if lens.age.is_empty() {
407 format!("Rev {}", at.get())
408 } else {
409 format!("Rev {} \u{00b7} {}", at.get(), lens.age)
410 };
411 let shell = use_hook(|| CopyValue::new(shell));
412 let stepped = move |step: TimeStep| {
413 let action = match lens.viewing.stepped(lens.head, step) {
414 Some(At::Rev(rev)) => Some(Action::ViewRev(rev)),
415 Some(At::Current) => Some(Action::ViewHead),
416 None => None,
417 };
418 if let Some(action) = action {
419 shell.read().say(blockworx_kernel::Event::Action(action));
420 }
421 };
422 let walks = |step| lens.viewing.stepped(lens.head, step).is_none();
423 rsx! {
424 div {
425 class: "flex items-center gap-2 text-sm font-medium text-amber-700 dark:text-amber-300",
426 role: "status",
427 span { class: "truncate", "{headline}" }
428 Pressable {
429 class: "{TAP} size-9 rounded-[11px] text-amber-700 dark:text-amber-300",
430 title: "Older rev",
431 "data-step": "older",
432 pressing: if walks(TimeStep::Back) { Pressing::Dead } else { Pressing::Live },
433 onpress: move |()| stepped(TimeStep::Back),
434 Face { icon: icons::STEP_OLDER }
435 }
436 Pressable {
437 class: "{TAP} size-9 rounded-[11px] text-amber-700 dark:text-amber-300",
438 title: "Newer rev",
439 "data-step": "newer",
440 pressing: if walks(TimeStep::Forward) { Pressing::Dead } else { Pressing::Live },
441 onpress: move |()| stepped(TimeStep::Forward),
442 Face { icon: icons::STEP_NEWER }
443 }
444 button {
445 class: "h-9 min-w-22 rounded-full bg-amber-500 px-4 text-sm font-medium \
446 text-zinc-950 transition hover:bg-amber-400",
447 title: "Return to current (Escape)",
448 "data-step": "return",
449 onclick: move |_| shell.read().say(
450 blockworx_kernel::Event::Action(Action::ViewHead),
451 ),
452 "Return"
453 }
454 }
455 }
456}
457
458pub const MENU: &str = "absolute left-0 top-full z-50 mt-2 min-w-52 rounded-2xl bg-white p-1.5 \
461 shadow-lg ring-1 ring-zinc-950/5 dark:bg-zinc-800 dark:ring-white/10 \
462 data-[state=closed]:hidden";
463
464pub const MENU_ROW: &str = "flex w-full items-center gap-2.5 \
466 rounded-lg px-2.5 py-2 text-left text-sm transition hover:bg-zinc-950/6 focus:bg-zinc-950/6 focus:outline-none \
467 data-[disabled=true]:opacity-40 dark:hover:bg-white/10 dark:focus:bg-white/10 \
468 [&_svg]:size-[18px] [&_svg]:flex-none [&_svg]:stroke-[1.7]";