Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

features/devtools_tab.rs

//! A tab of the app's own in the core's devtools panel
//! (`docs/adr/0032-a-devtools-tab-mounts-a-slot.md`), in the shape a
//! tree-sitter inspector has. The page is a "source file" whose tokens
//! are coloured by **highlight group** (`@keyword`, `@function`, …) from
//! a small syntax tree this file writes by hand; the panel gains an
//! **Inspector** tab beside facts, events and tree, drawn by this app from
//! its own view with `Ui::devtools_tab_with`, listing that tree. Hover
//! goes both ways: a row of the tab lights the node's tokens in the
//! source, and a token in the source lights its path in the tab — both
//! are the app's own nodes, so both are the app's own `on_hover`. A click
//! on a row selects the token in the panel's tree tab
//! (`set_devtools_selected`), and the tab can raise the panel's picker
//! (`set_devtools_pick`): the pick lands in `devtools_selected`, which the
//! tab reads back as the node it names, with the tab still up. The page
//! has a button of its own that jumps to the tab (`set_devtools_tab`) —
//! an editor's `:syntax_tree` command — and prints which tab the panel
//! is on (`devtools_current_tab`).
//!
//! The closure runs only while the tab is on show — `built` counts the
//! runs, and the page prints it — so a tab nobody looks at costs the
//! declaration and nothing else. Walk to it with Ctrl+Shift+N (facts →
//! events → tree → Inspector), or click it in the strip.
//!
//! Run: cargo run -p kui-native --example devtools_tab [-- --headless]

use kui_devtools::{Drive, Example};
use kui_native::widgets;
use kui_native::{Align, App, Color, Core, Key, NodeSpec, TextStyle, Theme, Ui, UiEvent, Value};

/// One node of the syntax tree: its kind, the highlight group a token
/// carries (`None` for an inner node), the line it starts on, its
/// parent, and — for a token — the text.
struct Node {
    kind: &'static str,
    group: Option<&'static str>,
    line: usize,
    parent: Option<usize>,
    text: Option<&'static str>,
}

const fn tok(
    kind: &'static str,
    group: &'static str,
    line: usize,
    parent: usize,
    text: &'static str,
) -> Node {
    Node {
        kind,
        group: Some(group),
        line,
        parent: Some(parent),
        text: Some(text),
    }
}

const fn inner(kind: &'static str, line: usize, parent: usize) -> Node {
    Node {
        kind,
        group: None,
        line,
        parent: Some(parent),
        text: None,
    }
}

/// The tree, in preorder, over the six lines below — what a parser would
/// have said about them. Tokens appear in source order within a line.
#[rustfmt::skip]
const TREE: &[Node] = &[
    Node { kind: "source_file", group: None, line: 0, parent: None, text: None }, // 0
    inner("function_item", 0, 0),                                                 // 1
    tok("fn", "keyword", 0, 1, "fn"),                                             // 2
    tok("identifier", "function", 0, 1, "main"),                                  // 3
    inner("parameters", 0, 1),                                                    // 4
    tok("(", "punctuation", 0, 4, "("),                                           // 5
    tok(")", "punctuation", 0, 4, ")"),                                           // 6
    inner("block", 0, 1),                                                         // 7
    tok("{", "punctuation", 0, 7, "{"),                                           // 8
    inner("let_declaration", 1, 7),                                               // 9
    tok("let", "keyword", 1, 9, "let"),                                           // 10
    tok("identifier", "variable", 1, 9, "tree"),                                  // 11
    tok("=", "operator", 1, 9, "="),                                              // 12
    inner("call_expression", 1, 9),                                               // 13
    tok("identifier", "function", 1, 13, "parse"),                                // 14
    inner("arguments", 1, 13),                                                    // 15
    tok("(", "punctuation", 1, 15, "("),                                          // 16
    tok("identifier", "variable", 1, 15, "source"),                               // 17
    tok(")", "punctuation", 1, 15, ")"),                                          // 18
    tok(";", "punctuation", 1, 9, ";"),                                           // 19
    inner("for_expression", 2, 7),                                                // 20
    tok("for", "keyword", 2, 20, "for"),                                          // 21
    tok("identifier", "variable", 2, 20, "node"),                                 // 22
    tok("in", "keyword", 2, 20, "in"),                                            // 23
    inner("call_expression", 2, 20),                                              // 24
    inner("field_expression", 2, 24),                                             // 25
    tok("identifier", "variable", 2, 25, "tree"),                                 // 26
    tok(".", "punctuation", 2, 25, "."),                                          // 27
    tok("field_identifier", "function", 2, 25, "walk"),                           // 28
    inner("arguments", 2, 24),                                                    // 29
    tok("(", "punctuation", 2, 29, "("),                                          // 30
    tok(")", "punctuation", 2, 29, ")"),                                          // 31
    inner("block", 2, 20),                                                        // 32
    tok("{", "punctuation", 2, 32, "{"),                                          // 33
    inner("expression_statement", 3, 32),                                         // 34
    inner("macro_invocation", 3, 34),                                             // 35
    tok("identifier", "macro", 3, 35, "println"),                                 // 36
    tok("!", "macro", 3, 35, "!"),                                                // 37
    inner("token_tree", 3, 35),                                                   // 38
    tok("(", "punctuation", 3, 38, "("),                                          // 39
    tok("string_literal", "string", 3, 38, "\"{node:?}\""),                       // 40
    tok(")", "punctuation", 3, 38, ")"),                                          // 41
    tok(";", "punctuation", 3, 34, ";"),                                          // 42
    tok("}", "punctuation", 4, 32, "}"),                                          // 43
    tok("}", "punctuation", 5, 7, "}"),                                           // 44
];

/// How far each line is indented, in spaces.
const INDENT: [usize; 6] = [0, 4, 4, 8, 4, 0];

/// The colour a highlight group paints with, from the theme's roles.
fn group_color(t: &Theme, group: &str) -> Color {
    match group {
        "keyword" => t.accent,
        "function" => t.success,
        "string" => t.warning,
        "macro" => t.danger,
        "punctuation" | "operator" => t.muted,
        _ => t.fg,
    }
}

/// Whether `node` is `ancestor` or under it.
fn under(node: usize, ancestor: usize) -> bool {
    let mut n = Some(node);
    while let Some(i) = n {
        if i == ancestor {
            return true;
        }
        n = TREE[i].parent;
    }
    false
}

fn depth(i: usize) -> usize {
    let mut d = 0;
    let mut n = TREE[i].parent;
    while let Some(p) = n {
        d += 1;
        n = TREE[p].parent;
    }
    d
}

/// The node's path from the root, `source_file › function_item › …`.
fn path(i: usize) -> String {
    let mut names = vec![TREE[i].kind];
    let mut n = TREE[i].parent;
    while let Some(p) = n {
        names.push(TREE[p].kind);
        n = TREE[p].parent;
    }
    names.reverse();
    names.join(" › ")
}

#[derive(Default)]
struct Page {
    /// How many times the tab's closure ran: the laziness, on screen.
    built: u32,
    /// The tab row under the pointer: its node's tokens light up in the
    /// source.
    hover_row: Option<usize>,
    /// The source token under the pointer: its path lights up in the tab.
    hover_tok: Option<usize>,
    /// The token the tab last scrolled to, so a hover reveals its row once
    /// and the list stays where the user put it afterwards.
    revealed: Option<usize>,
    /// A row the tab clicked: select its token in the panel's tree on the
    /// next view (the door is the core's, reached from the view).
    reveal: Option<usize>,
    /// The tab asked for the picker, applied the same way.
    pick: bool,
    /// The page's button asked for the tab, applied the same way.
    show_tab: bool,
}

impl Page {
    /// The token a source-side key names, back from the panel: what
    /// `devtools_selected` / `devtools_picked` mean in the tree's own
    /// terms.
    fn node_of(ui: &mut Ui<'_>, key: Option<Key>) -> Option<usize> {
        let key = key?;
        (0..TREE.len())
            .find(|i| TREE[*i].text.is_some() && ui.key_of(&format!("tok:{i}")) == Some(key))
    }
}

impl App for Page {
    fn view(&mut self, ui: &mut Ui<'_>) {
        let t = ui.theme();
        if let Some(i) = self.reveal.take()
            && let Some(k) = ui.key_of(&format!("tok:{i}"))
        {
            ui.core().set_devtools_selected(Some(k));
        }
        if std::mem::take(&mut self.pick) {
            ui.core().set_devtools_pick(true);
        }
        if std::mem::take(&mut self.show_tab) {
            ui.core().set_devtools_tab("inspector");
        }
        let current_tab = ui.devtools_current_tab();
        // What the panel holds, in the tree's terms — read before the
        // page is built, so the tab and the source agree on it.
        let selected = Self::node_of(ui, ui.devtools_selected());
        let picked = Self::node_of(ui, ui.devtools_picked());
        let hover_row = self.hover_row;
        let hover_tok = self.hover_tok;
        // A token lights up when the tab row over it is the token itself
        // or an ancestor, or when the panel selected or is picking it.
        let lit = |i: usize| {
            hover_row.is_some_and(|r| under(i, r)) || selected == Some(i) || picked == Some(i)
        };

        ui.with(
            NodeSpec::column()
                .fill()
                .pad(16.0)
                .gap(4.0),
            |ui| {
                ui.text(
                    "the source, coloured by highlight group — hover a token to see its node in the Inspector",
                    TextStyle::new(12.0).color(t.muted),
                );
                for (line, indent) in INDENT.iter().enumerate() {
                    ui.with(
                        NodeSpec::row()
                            .grow_width()
                            .cross_align(Align::Center),
                        |ui| {
                            ui.leaf(
                                NodeSpec::row().width(8.0 * *indent as f32));
                            let mut first = true;
                            for (i, n) in TREE.iter().enumerate() {
                                let Some(text) = n.text else { continue };
                                if n.line != line {
                                    continue;
                                }
                                // A space before a token that is a word, or
                                // before `=` and `{`: the spacing a formatter
                                // leaves.
                                let word = text.chars().next().is_some_and(|c| c.is_alphanumeric());
                                if !first && (word || matches!(text, "=" | "{")) {
                                    ui.leaf(NodeSpec::row().width(7.0));
                                }
                                first = false;
                                let on = lit(i);
                                let tag = Value::map([
                                    ("kind", Value::str("tok")),
                                    ("id", Value::Int(i as i64)),
                                ]);
                                ui.text_in_keyed(&format!("tok:{i}"), NodeSpec::row()
                                        .pad_xy(1.0, 2.0)
                                        .radius(3.0)
                                        .bg(if on { t.accent_soft } else { Color::TRANSPARENT })
                                        .hover_bg(t.hover)
                                        .on_hover(tag.clone())
                                        .on_click(tag)
                                        .label(n.kind), text,
                                            TextStyle::new(14.0)
                                                .mono()
                                                .color(group_color(&t, n.group.unwrap_or(""))));
                            }
                        },
                    );
                }
                ui.text(
                    &match hover_tok.or(selected) {
                        Some(i) => {
                            format!("{} · @{}", path(i), TREE[i].group.unwrap_or("none"))
                        }
                        None => "hover a token, or pick one from the Inspector".into(),
                    },
                    TextStyle::new(12.0).color(t.muted),
                );
                ui.with(
                    NodeSpec::row().gap(8.0).cross_align(Align::Center),
                    |ui| {
                        widgets::button(
                            ui,
                            "open the Inspector",
                            Value::map([("kind", Value::str("show-tab"))]),
                        );
                        ui.text(
                            &format!(
                                "the panel is on `{current_tab}` · Inspector built {} time(s) — only while its tab is on show",
                                self.built
                            ),
                            TextStyle::new(11.0).color(t.faint),
                        );
                    },
                );
            },
        );

        // The tab: declared every frame, drawn only while it is on show.
        let built = &mut self.built;
        let revealed = &mut self.revealed;
        ui.devtools_tab_with("inspector", "Inspector", |ui| {
            *built += 1;
            let picking = ui.devtools_picking();
            // A token hovered in the source scrolls the tab to its row,
            // once per token: `reveal` reads last frame's layout, which
            // is why it is asked here and not where the hover arrived.
            if let Some(i) = hover_tok
                && *revealed != Some(i)
                && let Some(k) = ui.key_of(&format!("node:{i}"))
            {
                ui.reveal(k);
                *revealed = Some(i);
            }
            ui.with(
                NodeSpec::column()
                    .fill()
                    .gap(4.0),
                |ui| {
                    ui.with(
                        NodeSpec::row().gap(6.0).cross_align(Align::Center),
                        |ui| {
                            widgets::button(
                                ui,
                                if picking {
                                    "picking… (Escape leaves)"
                                } else {
                                    "pick a node"
                                },
                                Value::map([("kind", Value::str("pick"))]),
                            );
                            ui.text(
                                &match (selected, picked) {
                                    (_, Some(p)) => format!("picking {}", TREE[p].kind),
                                    (Some(s), None) => format!("selected {}", TREE[s].kind),
                                    (None, None) => "nothing selected in the panel".into(),
                                },
                                TextStyle::new(11.0).color(t.muted),
                            );
                        },
                    );
                    ui.text(
                        "the syntax tree — hover a row to light its tokens, click to select it in the panel's tree",
                        TextStyle::new(11.0).color(t.muted),
                    );
                    ui.with(
                        NodeSpec::column()
                            .fill()
                            .scroll_y(),
                        |ui| {
                            for (i, n) in TREE.iter().enumerate() {
                                // A row is lit when the source token under the
                                // pointer is it or under it, or when the panel
                                // selected or is picking it.
                                let on_path = hover_tok.is_some_and(|k| under(k, i));
                                let on = on_path || selected == Some(i) || picked == Some(i);
                                let tag = Value::map([
                                    ("kind", Value::str("row")),
                                    ("id", Value::Int(i as i64)),
                                ]);
                                ui.with_keyed(
                                    &format!("node:{i}"),
                                    NodeSpec::row()
                                        .grow_width()
                                        .pad_xy(6.0, 2.0)
                                        .gap(6.0)
                                        .radius(3.0)
                                        .cross_align(Align::Center)
                                        .bg(if on { t.accent_soft } else { Color::TRANSPARENT })
                                        .hover_bg(t.hover)
                                        .on_hover(tag.clone())
                                        .on_click(tag)
                                        .label(n.kind),
                                    |ui| {
                                        ui.leaf(
                                            NodeSpec::row()
                                                .width(10.0 * depth(i) as f32));
                                        let one_line = |size: f32| {
                                            TextStyle::new(size).mono().wrap(kui_native::TextWrap::None)
                                        };
                                        ui.text(
                                            n.kind,
                                            one_line(12.0).color(if n.text.is_some() { t.fg } else { t.muted }),
                                        );
                                        if let Some(g) = n.group {
                                            ui.text(&format!("@{g}"), one_line(11.0).color(group_color(&t, g)));
                                        }
                                        if let Some(text) = n.text {
                                            ui.text(text, one_line(11.0).color(t.faint));
                                        }
                                        ui.leaf(NodeSpec::row().grow_width());
                                        ui.text(
                                            &format!("{}", n.line + 1),
                                            TextStyle::new(10.0).color(t.faint),
                                        );
                                    },
                                );
                            }
                        },
                    );
                },
            );
        });
    }

    fn on_event(&mut self, ev: UiEvent) {
        let phase = ev.payload.get_str("phase");
        // A hover carries its node's tag under `tag`; a click is the tag.
        let tag = ev.payload.get("tag").unwrap_or(&ev.payload);
        let id = tag.get_int("id").map(|i| i as usize);
        match (tag.get_str("kind"), phase) {
            (Some("tok"), Some("enter")) => self.hover_tok = id,
            (Some("tok"), Some("leave")) => {
                if self.hover_tok == id {
                    self.hover_tok = None;
                }
            }
            (Some("row"), Some("enter")) => self.hover_row = id,
            (Some("row"), Some("leave")) => {
                if self.hover_row == id {
                    self.hover_row = None;
                }
            }
            // A click on a row or a token: select it in the panel's tree.
            (Some("row") | Some("tok"), None) => self.reveal = id,
            (Some("pick"), None) => self.pick = true,
            (Some("show-tab"), None) => self.show_tab = true,
            _ => {}
        }
    }
}

impl Example for Page {
    const KEYS: &'static [(&'static str, &'static str)] = &[
        (
            "Ctrl+Shift+N",
            "the next tab — facts, events, tree, Inspector",
        ),
        (
            "Ctrl+Shift+P",
            "pick a token; the Inspector reads it back as its node",
        ),
    ];

    fn window(&self) -> kui_devtools::Window {
        kui_devtools::Window::default().size(760.0, 480.0)
    }

    /// The tab is declared and not built while another is up; on show it
    /// is built once a frame, over the panel's body; hovering a row lights
    /// the node's tokens in the source and hovering a token lights its
    /// path in the tab; a row's click selects the token in the panel's
    /// tree; the tab's picker lands a pick in `selected` with the tab up;
    /// the page's button jumps to the tab from the app's side.
    fn headless(&mut self, core: &mut Core) -> Result<(), String> {
        // The bare core the drive gets has no panel: on, docked right, as
        // the harness's window would have it.
        core.set_devtools(true);
        core.set_devtools_dock(kui_native::DevtoolsDock::Right);
        core.set_inspect(true);
        let mut d = Drive::new(core, 760.0, 480.0);
        d.frame(self);
        d.frame(self);
        d.check(
            self.built == 0,
            "the tab is declared, not built, while events is up",
        )?;
        let listed = d.key_of("kui-devtools/tab-custom:inspector").is_some();
        d.check(listed, "and the strip lists it")?;
        let chord = |d: &mut Drive<'_>, app: &mut Page| {
            d.key(app, "n", kui_native::KeyMods::NONE.with_ctrl().with_shift());
        };
        chord(&mut d, self); // tree
        chord(&mut d, self); // Inspector
        d.frame(self);
        d.check(self.built == 1, "on show, the closure ran once")?;
        d.frame(self);
        d.check(self.built == 2, "and once a frame")?;
        // The body is not interactive, so `rect_of` (the hit list) has no
        // rect for it: the inspect snapshot has every node's.
        let body_key = d
            .key_of("kui-devtools/tab/inspector")
            .ok_or("no tab body")?;
        let body = d
            .core
            .nodes()
            .iter()
            .find(|n| n.key == body_key)
            .map(|n| n.rect)
            .ok_or("no tab body rect")?;
        let row = d.key_of("node:9").ok_or("no let_declaration row")?;
        let r = d.rect_of(row).ok_or("no row rect")?;
        d.check(
            r.x >= body.x && r.x + r.w <= body.x + body.w && r.y >= body.y,
            "the tab's content is laid out over the panel's body",
        )?;
        let lit_of = |d: &mut Drive<'_>, prefix: &str| -> Vec<usize> {
            let nodes = d.core.nodes();
            (0..TREE.len())
                .filter(|i| {
                    d.core.key_of(&format!("{prefix}:{i}")).is_some_and(|k| {
                        nodes
                            .iter()
                            .any(|n| n.key == k && n.bg != Color::TRANSPARENT)
                    })
                })
                .collect()
        };

        // Hover the `let_declaration` row: its tokens light up on line 2,
        // nothing else does.
        d.hover(self, row);
        d.frame(self);
        d.check(self.hover_row == Some(9), "hovering a row names its node")?;
        let lit = lit_of(&mut d, "tok");
        d.check(
            lit == [10, 11, 12, 14, 16, 17, 18, 19],
            "and the node's tokens are lit in the source, no others",
        )?;

        // Hover a token in the source: its path lights up in the tab.
        let walk = d.key_of("tok:28").ok_or("no walk token")?;
        d.hover(self, walk);
        d.frame(self);
        d.check(
            self.hover_tok == Some(28) && self.hover_row.is_none(),
            "hovering a token names it, and the row's hover ended",
        )?;
        let lit_rows = lit_of(&mut d, "node");
        d.check(
            lit_rows == [0, 1, 7, 20, 24, 25, 28],
            "and its path is lit in the tab, root to leaf",
        )?;

        // The hover scrolled the tab to the token's row; a frame settles
        // that, and a click on the row selects its token in the panel's
        // tree.
        d.frame(self);
        let walk_row = d.key_of("node:28").ok_or("no walk row")?;
        d.click_key(self, walk_row);
        d.frame(self);
        d.frame(self);
        d.check(
            d.core.devtools_selected() == Some(walk),
            "a row's click selected its token in the panel's tree",
        )?;

        // The picker, raised from the tab: over the app, the tab stays,
        // and the pick reads back as the node it names.
        let pick = d.key_of("pick a node").ok_or("no pick button")?;
        d.click_key(self, pick);
        d.frame(self);
        d.check(d.core.devtools_picking(), "the tab raised the picker")?;
        d.frame(self);
        let up = d.key_of("kui-devtools/tab/inspector").is_some();
        d.check(up, "and stayed up while picking")?;
        let string = d.key_of("tok:40").ok_or("no string token")?;
        d.hover(self, string);
        d.frame(self);
        d.check(
            d.core.devtools_picked() == Some(string),
            "the token is under the picker",
        )?;
        let r = d.rect_of(string).ok_or("no rect")?;
        d.click(self, r.x + 4.0, r.y + r.h / 2.0);
        d.frame(self);
        d.check(
            !d.core.devtools_picking() && d.core.devtools_selected() == Some(string),
            "the press picked the token into `selected`",
        )?;
        let up = d.key_of("kui-devtools/tab/inspector").is_some();
        d.check(up, "and the tab is still the one on show")?;
        chord(&mut d, self); // facts
        let runs = self.built;
        d.frame(self);
        d.check(self.built == runs, "another tab up: the closure rests")?;
        d.check(
            d.core.devtools_current_tab() == "facts",
            "and the panel says which it is on",
        )?;
        // The page's own button jumps to the tab: the app's command, not
        // the strip's click.
        let open = d.key_of("open the Inspector").ok_or("no open button")?;
        d.click_key(self, open);
        d.frame(self);
        d.check(
            d.core.devtools_current_tab() == "inspector",
            "the button selected the tab from the app's side",
        )?;
        d.check(
            self.built == runs + 1,
            "and the same view built it, the door being read before the tab",
        )?;
        let up = d.key_of("kui-devtools/tab/inspector").is_some();
        d.check(up, "over the panel's body")?;
        Ok(())
    }
}

kui_devtools::main!(Page::default());