Skip to main content

blockworx_web/
sheet.rs

1//! Parts and History: the two sidebar sections that browse rather than act
2//! (`docs/cad-ui-spec.md` §8).
3//!
4//! **Nothing in them mutates the document.** History changes what is being
5//! looked at; Parts changes what is selected or which level is in context.
6//! Every pick completes a hand-off and leaves the panel open.
7//!
8//! What is typed into either filter, which branches are open and which tree
9//! node Parts is focused on are the shell's, not the kernel's (shell-on-kernel
10//! D7). Which *rows* those choices come to is the kernel's —
11//! [`blockworx_kernel::nav`] — so the two front ends browse one tree.
12
13use std::collections::HashSet;
14
15use blockworx_canvas2d::css_color;
16use blockworx_doc::id::BlockId;
17use blockworx_kernel::nav::{self, Filtered, Index};
18use blockworx_kernel::{Event, NavTree};
19use blockworx_paint::theme::{Role, Theme, accent_role, avatar_role};
20use blockworx_store::doc::Viewing;
21use blockworx_store::history::{Query, Row, said, tag_query};
22use blockworx_store::tags::Tagging;
23use blockworx_tools::tool::Action;
24use dioxus::prelude::*;
25
26use crate::shell::Shell;
27
28/// Horizontal step per tree depth, which the mockup's rows wear as padding.
29const INDENT: f32 = 16.0;
30
31/// How many vocabulary suggestions a tag field offers. The list scrolls, so
32/// it is a cap on how long the vocabulary may get rather than on what fits.
33const SUGGESTIONS: usize = 8;
34
35/// One row of a sidebar list: the mockup's `.row`, at the 44 px target.
36pub const ROW: &str = "flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left \
37     transition hover:bg-zinc-950/5 dark:hover:bg-white/5";
38
39/// The one that is on the canvas.
40pub const CURRENT: &str = "flex w-full items-center gap-3 rounded-xl bg-sky-500/12 px-3 py-2 \
41     text-left transition dark:bg-sky-400/15";
42
43pub const FIELD: &str = "w-full rounded-xl bg-zinc-950/5 px-3 py-2 text-sm outline-none \
44     placeholder:text-zinc-400 focus:outline-2 focus:outline-sky-500 dark:bg-white/5";
45
46pub const MUTED: &str = "text-xs text-zinc-500 dark:text-zinc-400";
47
48// ── History ──────────────────────────────────────────────────────────────
49
50/// The log, newest first, under a day heading each. The rev on the canvas
51/// expands in place into the card that holds every field and the tag editor.
52#[component]
53pub fn History(shell: Shell) -> Element {
54    let chrome = shell.chrome();
55    let mut search = use_signal(String::new);
56    let rows = chrome.read().history.clone();
57    let lens = chrome.read().top_bar.lens.clone();
58    let query = Query::parse(&search());
59    let shown: Vec<&Row> = rows
60        .iter()
61        .rev()
62        .filter(|row| row.rev != lens.head && query.admits(row))
63        .collect();
64    let counted = if query.narrows() {
65        format!("{} of {}", shown.len(), rows.len())
66    } else {
67        rows.len().to_string()
68    };
69    let raise = use_hook(|| CopyValue::new(shell.clone()));
70    let mut day = None;
71    rsx! {
72        div { class: "flex items-center gap-2 px-1 pb-2",
73            input {
74                class: FIELD,
75                value: "{search}",
76                placeholder: "Search history",
77                "aria-label": "Search history",
78                "data-search": "history",
79                oninput: move |event| search.set(event.value()),
80            }
81            span { class: "{MUTED} flex-none", "{counted}" }
82        }
83        // The head is not a row of its own: it is what "Current" stands for,
84        // and two entries for one rev would read as two revs.
85        if !query.narrows() {
86            button {
87                class: if lens.viewing == Viewing::Head { CURRENT } else { ROW },
88                "data-rev": "head",
89                onclick: move |_| {
90                    if lens.viewing != Viewing::Head {
91                        raise.read().say(Event::Action(Action::ViewHead));
92                    }
93                },
94                span { class: "size-[9px] flex-none rounded-full bg-sky-500" }
95                span { class: "min-w-0 flex-1 text-sm", "Current" }
96                span { class: MUTED,
97                    if lens.viewing == Viewing::Head { "editing" } else { "return" }
98                }
99            }
100        }
101        if shown.is_empty() {
102            p { class: "{MUTED} px-3 py-2",
103                "Nothing matches. Try tag:, by:, in:, or # for a rev number."
104            }
105        }
106        for row in shown {
107            {
108                let heading = row.day().filter(|at| Some(at) != day.as_ref());
109                if let Some(at) = &heading {
110                    day = Some(*at);
111                }
112                let viewing = lens.viewing == Viewing::Past(row.rev);
113                rsx! {
114                    if let Some(at) = heading {
115                        p { key: "day-{row.rev.get()}", class: "{MUTED} px-3 pb-1 pt-3 font-medium",
116                            "{at.label(blockworx_store::history::now())}"
117                        }
118                    }
119                    if viewing {
120                        RevCard {
121                            key: "rev-{row.rev.get()}",
122                            shell: shell.clone(),
123                            row: row.clone(),
124                            rows: rows.clone(),
125                            search,
126                        }
127                    } else {
128                        RevRow {
129                            key: "rev-{row.rev.get()}",
130                            shell: shell.clone(),
131                            row: row.clone(),
132                            rows: rows.clone(),
133                            search,
134                        }
135                    }
136                }
137            }
138        }
139    }
140}
141
142/// One commit, in the order a reader scans for it: who, what, where, when.
143#[component]
144fn RevRow(shell: Shell, row: Row, rows: Vec<Row>, search: Signal<String>) -> Element {
145    let theme = shell.theme();
146    let rev = row.rev;
147    let scope = row.scope_names().join(" / ");
148    let tags = row.tags.clone();
149    let inverse = row.is_inverse();
150    let raise = use_hook(|| CopyValue::new(shell));
151    rsx! {
152        div { class: "flex flex-col", "data-row": "{rev.get()}",
153            button {
154                class: ROW,
155                "data-rev": "{rev.get()}",
156                title: "{row.full_when()}",
157                onclick: move |_| raise.read().say(Event::Action(Action::ViewRev(rev))),
158                { avatar(&row, &theme, Disc::Row) }
159                span { class: "min-w-0 flex-1",
160                    span {
161                        class: if inverse {
162                            "block text-sm italic text-zinc-500 dark:text-zinc-400"
163                        } else {
164                            "block text-sm"
165                        },
166                        "{said(&row, &rows)}"
167                    }
168                    if !scope.is_empty() {
169                        // Truncated from the left, so the leaf — the thing the
170                        // act happened to — is what survives a narrow panel.
171                        span {
172                            class: "{MUTED} block truncate",
173                            dir: "rtl",
174                            title: "{scope}",
175                            "{scope}"
176                        }
177                    }
178                }
179                span { class: "flex flex-none flex-col items-end",
180                    span { class: MUTED, "{row.time()}" }
181                    span { class: MUTED, "#{rev.get()}" }
182                }
183            }
184            if !tags.is_empty() {
185                Chips { tags, search }
186            }
187        }
188    }
189}
190
191/// The rev on the canvas, expanded in place: every field in full, and the
192/// tag editor. Tags are annotations on history, not document edits — naming
193/// one authors no rev.
194///
195/// It carries the author's disc like every row under it — a card that
196/// dropped it would read as a different kind of thing — with the instant it
197/// was written under the name, the description in the weight the eye lands
198/// on, and the path small and quiet beneath it.
199#[component]
200fn RevCard(shell: Shell, row: Row, rows: Vec<Row>, search: Signal<String>) -> Element {
201    let theme = shell.theme();
202    let rev = row.rev;
203    let scope = row.scope_names().join(" / ");
204    rsx! {
205        div {
206            class: "flex flex-col gap-2.5 rounded-2xl bg-sky-500/12 p-3 dark:bg-sky-400/15",
207            "data-rev": "{rev.get()}",
208            "aria-current": "true",
209            div { class: "flex items-center gap-2.5",
210                { avatar(&row, &theme, Disc::Card) }
211                span { class: "flex min-w-0 flex-1 flex-col",
212                    span { class: "truncate text-sm font-semibold", "{row.author()}" }
213                    span { class: "{MUTED} truncate", "{row.full_when()}" }
214                }
215                span { class: "{MUTED} flex-none", "#{rev.get()}" }
216            }
217            div { class: "flex flex-col gap-1",
218                p { class: "text-[15px] leading-5", "{said(&row, &rows)}" }
219                if !scope.is_empty() {
220                    // Truncated from the left, as the rows' scope is, so the
221                    // leaf — the thing the act happened to — survives.
222                    p {
223                        class: "truncate text-[11px] text-zinc-500 dark:text-zinc-400",
224                        dir: "rtl",
225                        title: "{scope}",
226                        "{scope}"
227                    }
228                }
229            }
230            span { class: "h-px bg-zinc-950/10 dark:bg-white/15" }
231            TagEditor { shell, row, rows, search }
232        }
233    }
234}
235
236/// A rev's tags, as chips that narrow the search to themselves.
237///
238/// They sit under their own row and are indented past the author's disc, so
239/// they line up with the words they belong to — a chip laid over the row
240/// would cover the disc and read as the next row's.
241#[component]
242fn Chips(tags: Vec<String>, search: Signal<String>) -> Element {
243    rsx! {
244        span { class: "flex flex-wrap gap-1 pb-1.5 pl-[50px] pr-3",
245            for tag in tags {
246                button {
247                    key: "{tag}",
248                    class: "rounded-md bg-zinc-950/6 px-1.5 text-[11px] \
249                            text-zinc-600 dark:bg-white/10 dark:text-zinc-300",
250                    "data-tag": "{tag}",
251                    onclick: {
252                        let tag = tag.clone();
253                        move |event: dioxus::prelude::Event<MouseData>| {
254                            event.stop_propagation();
255                            search.set(tag_query(&tag));
256                        }
257                    },
258                    "{tag}"
259                }
260            }
261        }
262    }
263}
264
265/// The tags on the viewed rev, removable, plus a field that adds one and the
266/// vocabulary the rest of the log already uses.
267#[component]
268fn TagEditor(shell: Shell, row: Row, rows: Vec<Row>, search: Signal<String>) -> Element {
269    let rev = row.rev;
270    let mut draft = use_signal(String::new);
271    let mut asking = use_signal(|| false);
272    let raise = use_hook(|| CopyValue::new(shell));
273    let tag = move |name: String, how: Tagging| {
274        raise
275            .read()
276            .say(Event::Action(Action::TagRev { at: rev, name, how }));
277    };
278    let offered = suggestions(&draft(), &row, &rows);
279    rsx! {
280        div { class: "flex flex-wrap items-center gap-1",
281            for name in row.tags.clone() {
282                span {
283                    key: "{name}",
284                    class: "flex items-center rounded-md bg-zinc-950/6 pl-1.5 text-[11px] \
285                            dark:bg-white/10",
286                    button {
287                        class: "pr-1",
288                        "data-tag": "{name}",
289                        onclick: {
290                            let name = name.clone();
291                            move |_| search.set(tag_query(&name))
292                        },
293                        "{name}"
294                    }
295                    button {
296                        class: "px-1 text-zinc-500",
297                        title: "Remove this tag",
298                        "data-untag": "{name}",
299                        onclick: {
300                            let name = name.clone();
301                            move |_| tag(name.clone(), Tagging::Removed)
302                        },
303                        "\u{00d7}"
304                    }
305                }
306            }
307            span { class: "relative",
308                input {
309                    class: "w-[82px] rounded-md bg-zinc-950/5 px-1.5 py-0.5 text-[11px] outline-none \
310                            focus:outline-2 focus:outline-sky-500 dark:bg-white/5",
311                    value: "{draft}",
312                    placeholder: "Add tag",
313                    "aria-label": "Add tag",
314                    "data-addtag": "true",
315                    onfocusin: move |_| asking.set(true),
316                    onfocusout: move |_| asking.set(false),
317                    oninput: move |event| draft.set(event.value()),
318                    onkeydown: move |event| {
319                        if event.key() == Key::Enter && !draft.peek().trim().is_empty() {
320                            tag(draft.replace(String::new()), Tagging::Added);
321                        }
322                    },
323                }
324                // What the rest of the log is already called, offered the
325                // moment the field is clicked into: the vocabulary is what
326                // keeps a diagram tagged in one spelling rather than five.
327                if asking() && !offered.is_empty() {
328                    span {
329                        class: "absolute left-0 top-full z-50 mt-1 flex max-h-40 min-w-[136px] \
330                                flex-col overflow-y-auto rounded-xl bg-white p-1 shadow-lg \
331                                ring-1 ring-zinc-950/5 dark:bg-zinc-800 dark:ring-white/10",
332                        role: "listbox",
333                        "data-suggests": "true",
334                        for name in offered {
335                            button {
336                                key: "suggest-{name}",
337                                class: "rounded-lg px-2 py-1 text-left text-[11px] text-zinc-700 \
338                                        transition hover:bg-zinc-950/6 dark:text-zinc-200 \
339                                        dark:hover:bg-white/10",
340                                role: "option",
341                                "data-suggest": "{name}",
342                                // Taken on the press rather than the click, so
343                                // the field never loses the focus and takes the
344                                // list away before the pick lands.
345                                onmousedown: {
346                                    let name = name.clone();
347                                    move |event: dioxus::prelude::Event<MouseData>| {
348                                        event.prevent_default();
349                                        draft.set(String::new());
350                                        tag(name.clone(), Tagging::Added);
351                                    }
352                                },
353                                "{name}"
354                            }
355                        }
356                    }
357                }
358            }
359        }
360    }
361}
362
363/// The vocabulary the log already uses, minus what this rev already carries,
364/// so naming things twice two ways is the harder path.
365fn suggestions(draft: &str, row: &Row, rows: &[Row]) -> Vec<String> {
366    let typed = draft.trim().to_lowercase();
367    let mut seen: Vec<String> = Vec::new();
368    for name in rows.iter().flat_map(|at| &at.tags) {
369        if row.tags.contains(name) || seen.contains(name) {
370            continue;
371        }
372        if typed.is_empty() || name.to_lowercase().contains(&typed) {
373            seen.push(name.clone());
374        }
375    }
376    seen.truncate(SUGGESTIONS);
377    seen
378}
379
380/// The author, as the initials disc a name always lands on the same colour
381/// of. An unattributed rev draws nothing and keeps the slot.
382fn avatar(row: &Row, theme: &Theme, size: Disc) -> Element {
383    let by = row.author().to_owned();
384    if by.is_empty() {
385        return rsx! {
386            span { class: "{size.class()} flex-none" }
387        };
388    }
389    let disc = css_color(theme.resolve(avatar_role(&by)));
390    let ink = css_color(theme.resolve(Role::AuthorAvatarText));
391    let initials = row.initials();
392    rsx! {
393        span {
394            class: "{size.class()} grid flex-none place-items-center rounded-full font-medium",
395            style: "background:{disc};color:{ink}",
396            title: "{by}",
397            "{initials}"
398        }
399    }
400}
401
402/// Which list the disc is drawn in: a row of the log, or the card the viewed
403/// rev expands into, which stands a little larger.
404#[derive(Clone, Copy, PartialEq, Eq, Debug)]
405enum Disc {
406    Row,
407    Card,
408}
409
410impl Disc {
411    fn class(self) -> &'static str {
412        match self {
413            Disc::Row => "size-[26px] text-[10px]",
414            Disc::Card => "size-[30px] text-[11px]",
415        }
416    }
417}
418
419// ── Parts ────────────────────────────────────────────────────────────────
420
421/// The block tree. Three mechanisms make a deep tree workable in a narrow
422/// panel, and all three are [`blockworx_kernel::nav`]'s: focus re-roots,
423/// the filter flattens, and selecting on the canvas reveals.
424#[component]
425pub fn Parts(shell: Shell) -> Element {
426    let chrome = shell.chrome();
427    let mut filter = use_signal(String::new);
428    let mut expanded = use_signal(HashSet::<BlockId>::new);
429    let mut focus = use_signal(|| None::<BlockId>);
430    let tree = chrome.read().nav_tree.clone();
431    let selected = tree.selected.clone();
432    let theme = shell.theme();
433
434    // Follow the canvas: a selection or a level the panel has not shown yet
435    // opens the block's ancestors and gives up a focus that would hide it.
436    // Edge-triggered, so it never fights the user's own expand and collapse.
437    let mut revealed = use_signal(Vec::<BlockId>::new);
438    {
439        let signature: Vec<BlockId> = selected
440            .iter()
441            .copied()
442            .chain(tree.path.segments().iter().copied())
443            .collect();
444        if *revealed.peek() != signature {
445            revealed.set(signature);
446            let index = Index::of(&tree, None);
447            let mut open = expanded.peek().clone();
448            for &at in tree.path.segments() {
449                open.insert(at);
450            }
451            if let Some(target) = selected
452                .first()
453                .copied()
454                .or_else(|| tree.path.segments().last().copied())
455            {
456                if focus.peek().is_some_and(|at| !index.under(at, target)) {
457                    focus.set(None);
458                }
459                index.expand_ancestors(blockworx_editor::path::Scope::Root, target, &mut open);
460            }
461            expanded.set(open);
462        }
463    }
464
465    let typed = filter();
466    let (filtered, wanted) = Filtered::of(&typed);
467    let index = Index::of(&tree, wanted);
468    let root = match filtered {
469        // The filter reads the whole document, crossing the focus boundary.
470        Filtered::Yes => blockworx_editor::path::Scope::Root,
471        Filtered::No => nav::rooted_at(&tree, focus()),
472    };
473    let rows = index.rows(&tree, &expanded.read(), root, filtered);
474    let raise = use_hook(|| CopyValue::new(shell.clone()));
475    rsx! {
476        div { class: "px-1 pb-2",
477            input {
478                class: FIELD,
479                value: "{filter}",
480                placeholder: "Search all blocks\u{2026}",
481                "aria-label": "Search all blocks",
482                "data-search": "parts",
483                oninput: move |event| filter.set(event.value()),
484            }
485        }
486        if let Some(at) = focus() {
487            Crumbs { tree: tree.clone(), focus, at }
488        }
489        if rows.is_empty() {
490            p { class: "{MUTED} px-3 py-2",
491                if filtered == Filtered::Yes { "No matches" } else { "No blocks" }
492            }
493        }
494        for row in rows {
495            {
496                let id = row.id;
497                let here = selected.contains(&id);
498                let dot = css_color(
499                    theme.resolve(accent_role(row.accent).unwrap_or(Role::AccentDefault)),
500                );
501                let ancestry = row.ancestors.as_ref().map(|at| at.join("/"));
502                rsx! {
503                    div {
504                        key: "{id}",
505                        class: "flex items-center",
506                        style: "padding-left:{row.depth as f32 * INDENT}px",
507                        button {
508                            class: "grid size-4 flex-none place-items-center text-[9px] \
509                                    text-zinc-500",
510                            "data-twisty": "{id}",
511                            "aria-expanded": if row.open { "true" } else { "false" },
512                            disabled: !row.has_children,
513                            onclick: move |_| {
514                                let mut open = expanded.peek().clone();
515                                if !open.remove(&id) {
516                                    open.insert(id);
517                                }
518                                expanded.set(open);
519                            },
520                            if row.has_children {
521                                if row.open { "\u{25bc}" } else { "\u{25b6}" }
522                            }
523                        }
524                        button {
525                            class: if here { CURRENT } else { ROW },
526                            "data-block": "{id}",
527                            "aria-current": if here { "true" } else { "false" },
528                            onclick: move |event: dioxus::prelude::Event<MouseData>| {
529                                raise.read().say(Event::Action(Action::NavSelect {
530                                    block: id,
531                                    extend: event.modifiers().shift(),
532                                }));
533                            },
534                            span {
535                                class: "size-[6px] flex-none rounded-full",
536                                style: "background:{dot}",
537                            }
538                            span { class: "min-w-0 flex-1",
539                                span { class: "block truncate text-sm", "{row.label}" }
540                                if let Some(path) = ancestry {
541                                    span {
542                                        class: "{MUTED} block truncate",
543                                        dir: "rtl",
544                                        title: "{path}",
545                                        "{path}"
546                                    }
547                                }
548                            }
549                            if let Some(leaves) = row.leaves {
550                                span { class: MUTED, "{leaves}" }
551                            }
552                        }
553                        if row.has_children {
554                            button {
555                                class: "{MUTED} flex-none px-1",
556                                title: "Show only this",
557                                "data-focus": "{id}",
558                                onclick: move |_| focus.set(Some(id)),
559                                "\u{bb}"
560                            }
561                        }
562                    }
563                }
564            }
565        }
566    }
567}
568
569/// The way back out of a focus, one ancestor at a time.
570#[component]
571fn Crumbs(tree: NavTree, focus: Signal<Option<BlockId>>, at: BlockId) -> Element {
572    let index = Index::of(&tree, None);
573    let trail = index.ancestor_ids(blockworx_editor::path::Scope::Root, at);
574    rsx! {
575        nav { class: "flex flex-wrap items-center gap-1 px-3 pb-2 text-xs",
576            button {
577                class: "text-sky-600 dark:text-sky-400",
578                "data-crumb": "root",
579                onclick: move |_| focus.set(None),
580                "Diagram"
581            }
582            for id in trail {
583                span { key: "crumb-{id}", class: MUTED, "/" }
584                button {
585                    class: "text-sky-600 dark:text-sky-400",
586                    "data-crumb": "{id}",
587                    onclick: move |_| focus.set(Some(id)),
588                    "{nav::label_of(&tree, id)}"
589                }
590            }
591            span { class: MUTED, "/" }
592            span { class: "font-medium", "{nav::label_of(&tree, at)}" }
593        }
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    fn row(label: &str, tags: &[&str]) -> Row {
602        Row {
603            rev: blockworx_doc::rev::Rev::ZERO,
604            label: label.to_owned(),
605            touched: 0,
606            written: None,
607            tags: tags.iter().map(|at| (*at).to_owned()).collect(),
608        }
609    }
610
611    /// The vocabulary offered is the rest of the log's, and never a tag this
612    /// rev already carries — suggesting what is already there teaches nothing.
613    #[test]
614    fn suggestions_offer_the_logs_vocabulary_minus_this_revs_own() {
615        let rows = vec![
616            row("Drew it", &["review"]),
617            row("Moved it", &["release", "review"]),
618        ];
619        let carried = row("Named it", &["review"]);
620        assert_eq!(suggestions("", &carried, &rows), vec!["release"]);
621        assert_eq!(suggestions("rel", &carried, &rows), vec!["release"]);
622        assert!(suggestions("zzz", &carried, &rows).is_empty());
623    }
624
625    /// A rev with no tags of its own is offered every distinct name the log
626    /// holds, listed once each.
627    #[test]
628    fn a_name_is_offered_once_however_many_revs_wear_it() {
629        let rows = vec![
630            row("a", &["review"]),
631            row("b", &["review"]),
632            row("c", &["cut"]),
633        ];
634        assert_eq!(
635            suggestions("", &row("d", &[]), &rows),
636            vec!["review", "cut"]
637        );
638    }
639}