Skip to main content

blockworx_web/
top_bar.rs

1//! The top strip: the breadcrumb on the left, mode in the centre, undo, redo
2//! and fit on the right, and never anything else (`docs/cad-ui-spec.md`
3//! §2.0). Documents, parts, history and settings live in the sidebar.
4//!
5//! It is docked chrome, so it is flat — no shadow, square corners — and the
6//! one thing that changes about it is its colour: while an earlier rev is on
7//! the canvas the whole strip washes amber, which is one of the three
8//! redundant signals §6.2 asks for. That state is a fact of the root element
9//! (`.viewing`), read here through Tailwind's group variants so the bar, the
10//! toolbar and the diagram cannot disagree about it.
11
12use 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
28/// How long a click on the document's own segment waits to see whether a
29/// second one is coming. Both gestures live on one word — the document sits
30/// at the head of the breadcrumb and is renamed where it stands — so they are
31/// told apart in time, and a rename never navigates on its way to the box.
32const 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/// Undo and redo. Both are always on the strip and disable when empty, and
74/// each names what one press would cost the log — the same sentence the
75/// desktop's does, because it is the same function.
76#[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/// The document's state as the dot beside its name reports it. Hover-only,
119/// and it always carries words — a coloured dot on its own teaches nothing.
120#[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
132/// The one resolver for what the dot shows, so its colour and its words
133/// cannot disagree — here, and on the Diagrams section's own card.
134pub 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
152/// How a segment of the breadcrumb is drawn.
153const 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
158/// The level the canvas is actually standing on, and the document itself:
159/// full ink.
160const 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/// The edit-context breadcrumb, rooted at the document.
164///
165/// Which segments it draws is the kernel's rule, not this element's: the
166/// collapse keeps the root and the current level and hides the middle behind
167/// one ellipsis that lists what it hid.
168#[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/// The middle the collapse hid, listed rather than merely announced.
231#[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/// The document's own segment: at the top level a press opens the Diagrams
271/// section, below it a press goes back up to the top, and — on a double click
272/// — it becomes the box that types over the name.
273#[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    // The Diagrams section's Rename opens the box the double click opens:
286    // one gesture, so one place it can be typed.
287    use_effect({
288        let name = name.clone();
289        // Mounted over a document that has just opened, the box must not
290        // answer again the ask the one before it already answered.
291        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    // Clicking away is a commit rather than a cancel: the box opened on the
302    // document's own name, so the worst it can settle on is the name it
303    // started with.
304    let ends = use_callback({
305        let was = name.clone();
306        move |typed: Option<String>| {
307            // Taking the box away blurs it, and that blur ends it a second
308            // time — while the first is still being rendered.
309            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                // Asked for from the sidebar, the box mounts while the press
329                // that asked still holds the focus.
330                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/// The centre slot: empty unless a mode is active. While an earlier rev is on
396/// the canvas it names the rev, steps between neighbours, and offers the one
397/// way back — no new object appears, the strip you already look at changes
398/// state.
399#[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
458/// How a menu's own sheet is drawn: floating chrome, so a shadow and a large
459/// radius, against the docked strip's flat square.
460pub 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
464/// One row of one.
465pub 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]";