Skip to main content

logicaffeine_web/ui/components/
tree.rs

1//! The Studio's tree: the file browser's folders and files and the Logic column's Syntax Tree
2//! are both drawn by it, so they read alike.
3//!
4//! [`Tree`] holds the geometry, the lines' snapping to the device-pixel grid and the chevron's
5//! look; [`TreeNode`] draws one node — the line it hangs from, its row, its parts while it is open.
6//! Each user walks its own data and gives every node its row's content.
7//!
8//! A tree answers the keyboard as the WAI-ARIA tree pattern says: it is one tab stop, on the selected
9//! row (or the row its user marks when none is), the arrows walk the rows a reader sees, Home and End
10//! reach the ends, Right opens a node and then enters it, Left closes it and then climbs to its parent,
11//! and Enter or Space press the row. [`next_row`] decides each key from the rows' shapes alone.
12
13use dioxus::prelude::*;
14use crate::ui::components::chevron::{Chevron, CHEVRON_STYLE};
15use crate::ui::hooks::{use_device_pixel_grid, PixelGrid};
16
17/// The tree's geometry is one step. Its first line stands `--tree-step` from the tree's edge, every
18/// level's line one step further in, and every branch runs one step, from its parent's line to its
19/// own; a row's first glyph (a node's chevron, else the first thing in its row) sits `--tree-gap`
20/// past the end of its branch. Every length snaps to whole device pixels (`--dp`, one device pixel
21/// in CSS pixels, is set from the page's device pixel ratio), and the tree's content starts on a
22/// device pixel wherever the layout puts the tree (`--dp-nudge-x`/`-y`, under one device pixel, pad
23/// it onto the grid), so each line covers whole screen pixels at any zoom and all read at one
24/// weight, `--tree-hair`: about 1.5px and never under two device pixels. A tree sets how far its
25/// content stands from its top and bottom with `--tree-pad-top` and `--tree-pad-bottom` (the Studio's
26/// inset by default), each held to whole device pixels.
27pub const TREE_STYLE: &str = r#"
28[data-tree] {
29    --dp: 1px;
30    --dp-nudge-x: 0px;
31    --dp-nudge-y: 0px;
32    --tree-step: round(nearest, 18px, var(--dp));
33    --tree-gap: round(nearest, 4px, var(--dp));
34    --tree-row: round(nearest, 22px, var(--dp));
35    --tree-hair: max(calc(2 * var(--dp)), round(nearest, 1.5px, var(--dp)));
36    --tree-mid: round(down, calc((var(--tree-row) - var(--tree-hair)) / 2), var(--dp));
37    --twistie: 12px;
38    --twistie-gap: 4px;
39    --tree-line: rgba(255, 255, 255, 0.22);
40    padding:
41        calc(round(nearest, var(--tree-pad-top, var(--studio-inset, 4px)), var(--dp)) + var(--dp-nudge-y))
42        0
43        round(nearest, var(--tree-pad-bottom, var(--studio-inset, 4px)), var(--dp))
44        var(--dp-nudge-x);
45}
46
47.tree-node {
48    position: relative;
49    -webkit-user-select: none;
50    user-select: none;
51}
52
53/* VS Code's explorer: 22px rows in its workbench font; a row at `--depth` starts its first glyph
54   (`--depth` + 1) steps and a gap in. */
55.tree-item {
56    position: relative;
57    display: flex;
58    align-items: center;
59    height: var(--tree-row);
60    padding: 0 var(--studio-inset, 8px) 0 calc(var(--tree-step) * (var(--depth) + 1) + var(--tree-gap));
61    font-family: -apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI", "Ubuntu", "Droid Sans", sans-serif;
62    font-size: 13px;
63    line-height: var(--tree-row);
64    color: rgba(255, 255, 255, 0.8);
65    transition: background 0.1s ease;
66}
67
68.tree-item.pressable {
69    cursor: pointer;
70}
71
72.tree-item:hover {
73    background: rgba(255, 255, 255, 0.04);
74}
75
76/* Under a finger every row is a finger tall, in the file tree and the Syntax Tree alike; the lines
77   and branches follow the row. */
78@media (pointer: coarse) {
79    [data-tree] {
80        --tree-row: round(nearest, 44px, var(--dp));
81    }
82}
83
84/* A row the keyboard reached shows a ring inside its edges; a row pressed with a pointer shows none. */
85.tree-item:focus {
86    outline: none;
87}
88
89.tree-item:focus-visible {
90    outline: 1px solid rgba(0, 212, 255, 0.6);
91    outline-offset: -1px;
92}
93
94.tree-item.selected {
95    background: rgba(0, 212, 255, 0.12);
96    color: #00d4ff;
97}
98
99/* A node's chevron cell, the first glyph of its row. The chevron fills it, so an open node's
100   chevron points down from the cell's left edge. */
101.tree-item .twistie {
102    flex: none;
103    width: var(--twistie);
104    margin-right: var(--twistie-gap);
105    height: var(--tree-row);
106    display: flex;
107    align-items: center;
108    justify-content: center;
109}
110
111/* Branches. A child at `--depth` hangs from the line `--depth` steps in; its branch leaves that
112   line at the row's middle and runs one step, to the line its own children hang from. The first
113   child's line starts a row up, at its parent's branch (the corner where the branch turns down),
114   and the last child's line stops at its own branch. */
115.tree-line,
116.tree-branch {
117    position: absolute;
118    left: calc(var(--tree-step) * var(--depth));
119    background: var(--tree-line);
120}
121
122.tree-line {
123    top: var(--line-top, 0px);
124    bottom: 0;
125    width: var(--tree-hair);
126}
127
128.tree-children > .tree-node:first-child > .tree-line {
129    --line-top: calc(var(--tree-mid) - var(--tree-row));
130}
131
132.tree-line.last {
133    bottom: auto;
134    height: calc(var(--tree-mid) + var(--tree-hair) - var(--line-top, 0px));
135}
136
137.tree-branch {
138    top: var(--tree-mid);
139    width: var(--tree-step);
140    height: var(--tree-hair);
141}
142"#;
143
144/// Which tree this is: the name its parts carry beside the shared ones.
145#[derive(Clone, Copy, PartialEq)]
146struct Kind(&'static str);
147
148/// A tree, its nodes drawn inside it. `kind` names it and its parts: the tree is the element of
149/// class `{kind}` (marked `data-tree`), and each node, row and open node's parts carry
150/// `{kind}-node`, `{kind}-item` and `{kind}-children` beside the shared classes. The file
151/// browser's tree is `file-tree`.
152#[component]
153pub fn Tree(
154    kind: &'static str,
155    /// The tree's name, for a screen reader.
156    #[props(default)]
157    label: &'static str,
158    children: Element,
159) -> Element {
160    use_context_provider(|| Kind(kind));
161    let (grid, on_mounted) = use_device_pixel_grid();
162    let PixelGrid { device_px, nudge_x, nudge_y } = grid();
163
164    rsx! {
165        div {
166            class: "{kind}",
167            "data-tree": "{kind}",
168            role: "tree",
169            "aria-label": (!label.is_empty()).then_some(label),
170            style: "--dp: {device_px}px; --dp-nudge-x: {nudge_x}px; --dp-nudge-y: {nudge_y}px;",
171            onmounted: move |event| on_mounted.call(event),
172            style { "{TREE_STYLE}" }
173            style { "{CHEVRON_STYLE}" }
174            {children}
175        }
176    }
177}
178
179/// One node of a [`Tree`]: the line it hangs from (below the top level), its row — its branch, its
180/// chevron if it has parts, then `row` — and its parts, `children`, while it is open.
181#[component]
182pub fn TreeNode(
183    /// How many levels below the top it sits.
184    depth: usize,
185    /// The last of its siblings: its line stops at its own branch.
186    last: bool,
187    /// Whether it has parts to open; its row then leads with the chevron.
188    #[props(default)]
189    expandable: bool,
190    /// Whether its parts are shown.
191    #[props(default)]
192    open: bool,
193    #[props(default)]
194    selected: bool,
195    /// The row the tree's one tab stop falls on when no row is selected: its first.
196    #[props(default)]
197    stop: bool,
198    /// The row's tooltip.
199    #[props(default)]
200    title: Option<String>,
201    /// What pressing the row does; a row with none is not pressable.
202    #[props(default)]
203    on_press: Option<EventHandler<()>>,
204    /// The row's content after its chevron.
205    row: Element,
206    children: Element,
207) -> Element {
208    let Kind(kind) = use_context::<Kind>();
209    let row_class = format!(
210        "tree-item {kind}-item{}{}",
211        if selected { " selected" } else { "" },
212        if on_press.is_some() { " pressable" } else { "" }
213    );
214
215    rsx! {
216        div { class: "tree-node {kind}-node", style: "--depth: {depth};",
217            if depth > 0 {
218                span { class: if last { "tree-line last" } else { "tree-line" } }
219            }
220            div {
221                class: "{row_class}",
222                role: "treeitem",
223                "aria-level": "{depth + 1}",
224                "aria-selected": if selected { "true" } else { "false" },
225                "aria-expanded": expandable.then_some(if open { "true" } else { "false" }),
226                tabindex: if selected || stop { "0" } else { "-1" },
227                title: title,
228                onclick: move |_| {
229                    if let Some(press) = on_press {
230                        press.call(());
231                    }
232                },
233                onkeydown: move |evt: KeyboardEvent| {
234                    let Some(key) = tree_key(&evt.key().to_string()) else {
235                        return;
236                    };
237                    evt.prevent_default();
238                    if walk(&evt, key) == TreeMove::Press {
239                        if let Some(press) = on_press {
240                            press.call(());
241                        }
242                    }
243                },
244                if depth > 0 {
245                    span { class: "tree-branch" }
246                }
247                if expandable {
248                    span { class: "twistie",
249                        Chevron { open }
250                    }
251                }
252                {row}
253            }
254            if expandable && open {
255                div { class: "tree-children {kind}-children", role: "group", {children} }
256            }
257        }
258    }
259}
260
261/// A key the tree answers.
262#[derive(Clone, Copy, Debug, PartialEq, Eq)]
263pub enum TreeKey {
264    Down,
265    Up,
266    Home,
267    End,
268    Right,
269    Left,
270    /// Enter or Space: what pressing the row does.
271    Press,
272}
273
274/// The key a keyboard event names, if the tree answers it.
275pub fn tree_key(key: &str) -> Option<TreeKey> {
276    Some(match key {
277        "ArrowDown" => TreeKey::Down,
278        "ArrowUp" => TreeKey::Up,
279        "Home" => TreeKey::Home,
280        "End" => TreeKey::End,
281        "ArrowRight" => TreeKey::Right,
282        "ArrowLeft" => TreeKey::Left,
283        "Enter" | " " => TreeKey::Press,
284        _ => return None,
285    })
286}
287
288/// A row as the keys see it.
289#[derive(Clone, Copy, Debug, PartialEq, Eq)]
290pub struct RowShape {
291    pub depth: usize,
292    pub expandable: bool,
293    pub open: bool,
294}
295
296/// What a key does from a row.
297#[derive(Clone, Copy, Debug, PartialEq, Eq)]
298pub enum TreeMove {
299    /// Focus the row at this index of the rows a reader sees.
300    Focus(usize),
301    /// Press the row: open or close a node, open a file.
302    Press,
303    Stay,
304}
305
306/// What `key` does from row `at` of `rows`, the rows a reader sees in order.
307pub fn next_row(rows: &[RowShape], at: usize, key: TreeKey) -> TreeMove {
308    let Some(row) = rows.get(at) else {
309        return TreeMove::Stay;
310    };
311    let focus = |to: Option<usize>| match to {
312        Some(to) if to != at && to < rows.len() => TreeMove::Focus(to),
313        _ => TreeMove::Stay,
314    };
315    match key {
316        TreeKey::Down => focus(Some(at + 1)),
317        TreeKey::Up => focus(at.checked_sub(1)),
318        TreeKey::Home => focus(Some(0)),
319        TreeKey::End => focus(rows.len().checked_sub(1)),
320        TreeKey::Right if row.expandable && !row.open => TreeMove::Press,
321        TreeKey::Right if row.expandable => focus(Some(at + 1).filter(|&next| rows.get(next).is_some_and(|r| r.depth == row.depth + 1))),
322        TreeKey::Right => TreeMove::Stay,
323        TreeKey::Left if row.expandable && row.open => TreeMove::Press,
324        TreeKey::Left => focus(row.depth.checked_sub(1).and_then(|parent| rows[..at].iter().rposition(|r| r.depth == parent))),
325        TreeKey::Press => TreeMove::Press,
326    }
327}
328
329/// Answer `key` on the row the event came from, among the rows of its tree the page draws: move the
330/// focus where [`next_row`] says, or say the row is to be pressed.
331#[cfg(target_arch = "wasm32")]
332fn walk(evt: &KeyboardEvent, key: TreeKey) -> TreeMove {
333    use wasm_bindgen::JsCast;
334    let row = evt
335        .data()
336        .downcast::<web_sys::KeyboardEvent>()
337        .and_then(|e| e.target())
338        .and_then(|target| target.dyn_into::<web_sys::Element>().ok())
339        .and_then(|element| element.closest(".tree-item").ok().flatten());
340    let Some(row) = row else {
341        return TreeMove::Stay;
342    };
343    let Some(list) = row.closest("[data-tree]").ok().flatten().and_then(|tree| tree.query_selector_all(".tree-item").ok()) else {
344        return TreeMove::Stay;
345    };
346    let rows: Vec<web_sys::Element> = (0..list.length()).filter_map(|i| list.item(i)?.dyn_into().ok()).collect();
347    let shapes: Vec<RowShape> = rows
348        .iter()
349        .map(|r| RowShape {
350            depth: r.get_attribute("aria-level").and_then(|l| l.parse::<usize>().ok()).unwrap_or(1).saturating_sub(1),
351            expandable: r.has_attribute("aria-expanded"),
352            open: r.get_attribute("aria-expanded").as_deref() == Some("true"),
353        })
354        .collect();
355    let Some(at) = rows.iter().position(|r| *r == row) else {
356        return TreeMove::Stay;
357    };
358    let step = next_row(&shapes, at, key);
359    if let TreeMove::Focus(to) = step {
360        if let Some(target) = rows.get(to).and_then(|r| r.dyn_ref::<web_sys::HtmlElement>()) {
361            let _ = target.focus();
362        }
363    }
364    step
365}
366
367/// Off the browser no key reaches a row.
368#[cfg(not(target_arch = "wasm32"))]
369fn walk(_evt: &KeyboardEvent, key: TreeKey) -> TreeMove {
370    if key == TreeKey::Press {
371        TreeMove::Press
372    } else {
373        TreeMove::Stay
374    }
375}
376
377#[cfg(test)]
378#[path = "../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/components/tree/tests.rs"]
379mod tests;
380
381#[cfg(test)]
382#[path = "../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/components/tree/keys_tests.rs"]
383mod keys_tests;