Skip to main content

blockworx_web/
sidebar.rs

1//! The sidebar: one docked activity bar down the left edge, and the one
2//! panel it opens floating over the diagram (`docs/cad-ui-spec.md` §8).
3//!
4//! Four sections, one address each for everything that is not editing:
5//! Diagrams, Parts and History at the top of the bar, Settings at its foot.
6//! Pressing a section opens its panel; pressing the open one shuts it. The
7//! panel is glass over the diagram — it never reflows the canvas, so the
8//! toolbar never moves — and only *working* dismisses it: a press on the
9//! diagram, a tool pick, or Escape. Nothing inside it ever closes it.
10//!
11//! Which section is open is the shell's, not the kernel's: the session has no
12//! opinion about what is being browsed.
13
14use blockworx_geom::{Rect, pos2};
15use dioxus::prelude::*;
16use dioxus::web::WebEventExt as _;
17
18use crate::control::{Face, Pressable};
19use crate::diagrams::Diagrams;
20use crate::icons;
21use crate::mode::Prefers;
22use crate::prefs::Preferences;
23use crate::settings::Settings;
24use crate::sheet::{History, MUTED, Parts};
25use crate::shell::{Band, Shell, measured};
26
27/// One place on the activity bar.
28#[derive(Clone, Copy, PartialEq, Eq, Debug)]
29pub enum Section {
30    Diagrams,
31    Parts,
32    History,
33    Settings,
34}
35
36impl Section {
37    /// The bar's cells from the top. Settings stands apart at its foot.
38    const TOP: [Section; 3] = [Section::Diagrams, Section::Parts, Section::History];
39
40    #[must_use]
41    pub fn label(self) -> &'static str {
42        match self {
43            Section::Diagrams => "Diagrams",
44            Section::Parts => "Parts",
45            Section::History => "History",
46            Section::Settings => "Settings",
47        }
48    }
49
50    /// The stable spelling a script or a snapshot finds a section by.
51    #[must_use]
52    pub fn name(self) -> &'static str {
53        match self {
54            Section::Diagrams => "diagrams",
55            Section::Parts => "parts",
56            Section::History => "history",
57            Section::Settings => "settings",
58        }
59    }
60
61    fn icon(self) -> icons::Icon {
62        match self {
63            Section::Diagrams => icons::DIAGRAMS,
64            Section::Parts => icons::PARTS,
65            Section::History => icons::HISTORY,
66            Section::Settings => icons::GEAR,
67        }
68    }
69}
70
71/// A section's cell on the bar, and the same cell while its panel is open.
72const CELL: &str = "grid size-target place-items-center rounded-xl text-zinc-600 transition \
73     duration-150 ease-out hover:bg-zinc-950/6 active:scale-90 dark:text-zinc-300 \
74     dark:hover:bg-white/10 [&_svg]:size-5 [&_svg]:stroke-[1.8]";
75const OPEN: &str = "grid size-target place-items-center rounded-xl bg-sky-500/12 text-sky-600 \
76     transition duration-150 ease-out active:scale-90 dark:bg-sky-400/15 dark:text-sky-400 \
77     [&_svg]:size-5 [&_svg]:stroke-[1.8]";
78
79/// The panel: floating glass beside the bar, shadowed and rounded like the
80/// toolbar it never displaces.
81const PANEL: &str = "absolute bottom-3 left-2 top-2 z-30 flex w-[344px] max-w-[calc(100%-1rem)] \
82     flex-col rounded-band bg-white/85 text-zinc-900 shadow-lg ring-1 ring-zinc-950/5 \
83     backdrop-blur-xl transition duration-200 dark:bg-zinc-900/85 dark:text-zinc-100 \
84     dark:ring-white/10";
85
86/// The bar: docked chrome, so flat — no shadow, square corners.
87#[component]
88pub fn ActivityBar(shell: Shell) -> Element {
89    let open = shell.section();
90    rsx! {
91        nav {
92            class: "flex w-15 flex-none flex-col items-center gap-1 border-r border-zinc-950/5 \
93                    bg-white pb-3 pt-2.5 dark:border-white/10 dark:bg-zinc-900",
94            "aria-label": "Sidebar",
95            for section in Section::TOP {
96                Activity { key: "{section:?}", section, open }
97            }
98            div { class: "flex-1" }
99            Activity { section: Section::Settings, open }
100        }
101    }
102}
103
104#[component]
105fn Activity(section: Section, open: Signal<Option<Section>>) -> Element {
106    let showing = open() == Some(section);
107    let mut open = open;
108    rsx! {
109        Pressable {
110            class: if showing { OPEN } else { CELL },
111            title: "{section.label()}",
112            "aria-label": "{section.label()}",
113            "aria-pressed": if showing { "true" } else { "false" },
114            "data-section": "{section.name()}",
115            onpress: move |()| open.set(if showing { None } else { Some(section) }),
116            Face { icon: section.icon() }
117        }
118    }
119}
120
121/// The open section's panel.
122///
123/// What it showed last stays drawn while it fades away, and every fresh open
124/// starts its section afresh: a filter typed into Parts is a transient search,
125/// not a persistent view (§8), so it resets on dismiss.
126#[component]
127pub fn SidebarPanel(shell: Shell, prefs: Signal<Preferences>, prefers: Signal<Prefers>) -> Element {
128    let section = shell.section();
129    let mut shown = use_signal(|| Section::Diagrams);
130    let mut was = use_signal(|| None::<Section>);
131    let mut opens = use_signal(|| 0_u64);
132    let mut element = use_signal(|| None::<web_sys::Element>);
133    let measuring = use_hook(|| CopyValue::new(shell.clone()));
134    use_effect(move || {
135        let now = section();
136        let before = *was.peek();
137        if now == before {
138            return;
139        }
140        was.set(now);
141        if let Some(now) = now {
142            shown.set(now);
143            if before.is_none() {
144                let fresh = *opens.peek() + 1;
145                opens.set(fresh);
146            }
147        }
148        if let Some(panel) = element.peek().as_ref() {
149            covers(&measuring.read(), panel, now);
150        }
151    });
152
153    let open = section();
154    let drawn = open.unwrap_or(*shown.read());
155    let state = if open.is_some() {
156        "ease-out"
157    } else {
158        "pointer-events-none -translate-x-2 opacity-0 ease-in"
159    };
160    let tally = match drawn {
161        Section::Diagrams => Some(format!("{} in this browser", shell.held().read().len())),
162        Section::Parts | Section::History | Section::Settings => None,
163    };
164    rsx! {
165        aside {
166            class: "{PANEL} {state}",
167            "aria-hidden": if open.is_some() { "false" } else { "true" },
168            "aria-label": "{drawn.label()}",
169            "data-open": if open.is_some() { "true" } else { "false" },
170            "data-section": "{drawn.name()}",
171            onmounted: move |event| {
172                if let Some(panel) = event.try_as_web_event() {
173                    covers(&measuring.read(), &panel, *section.peek());
174                    element.set(Some(panel));
175                }
176            },
177            onresize: move |event| {
178                if let Some(entry) = event.try_as_web_event() {
179                    covers(&measuring.read(), &entry.target(), *section.peek());
180                }
181            },
182            div { class: "flex items-baseline justify-between px-4 pb-2 pt-3.5",
183                h2 { class: "text-[15px] font-semibold", "{drawn.label()}" }
184                if let Some(tally) = tally {
185                    span { class: MUTED, "{tally}" }
186                }
187            }
188            div {
189                key: "{opens}-{drawn:?}",
190                class: "min-h-0 flex-1 overflow-y-auto px-2 pb-3",
191                match drawn {
192                    Section::Diagrams => rsx! {
193                        Diagrams { shell: shell.clone() }
194                    },
195                    Section::Parts => rsx! {
196                        Parts { shell: shell.clone() }
197                    },
198                    Section::History => rsx! {
199                        History { shell: shell.clone() }
200                    },
201                    Section::Settings => rsx! {
202                        div { class: "px-2 pt-1",
203                            Settings { shell: shell.clone(), prefs, prefers }
204                        }
205                    },
206                }
207            }
208        }
209    }
210}
211
212/// What the panel covers of the diagram: everything from the canvas's left
213/// edge to its own right, since the sliver beside it is no room a framing may
214/// use. A shut panel covers nothing, so the diagram gets its left edge back
215/// the moment it closes.
216fn covers(shell: &Shell, panel: &web_sys::Element, open: Option<Section>) {
217    let at = match open {
218        Some(_) => measured(panel).map_or(Rect::ZERO, |at| {
219            Rect::from_min_max(pos2(-f32::MAX, at.min.y), at.max)
220        }),
221        None => Rect::ZERO,
222    };
223    shell.covers(Band::Sheet, at);
224}