Skip to main content

kui_core/runtime/devtools/
mod.rs

1#![cfg_attr(not(feature = "devtools"), allow(dead_code))]
2//! The devtools panel, drawn by the core into any app's frame
3//! (`docs/adr/0024-the-devtools-are-the-cores.md`).
4//!
5//! Turned on by [`Core::set_devtools`] — or by `KUI_DEVTOOLS` in the
6//! environment, which the windowed runners read before the first frame
7//! ([`Core::devtools_from_env`]) — the panel sits beside the host's
8//! tree in the main window (`left`, `right`, `bottom`; a handle on the
9//! pane's inner edge resizes it), in a window of its own (`window`), or
10//! nowhere with its chords still live (`off`). It shows what
11//! the runtime is doing while the app runs: a header with the window's
12//! title, the frame counter and an icon strip (the theme base, the accent,
13//! native menus, the placement), and three tabs —
14//!
15//! - **facts**: the latency graph; the status block — what the runtime
16//!   believes right now, each row read from the door it comes from; and
17//!   the key legend a host declared ([`Core::set_devtools_legend`]);
18//! - **events**: every `UiEvent` handed to the host from any window, as a
19//!   virtual list whose rows open into the payload as data, with a
20//!   filter, a pause and a follow toggle — plus every warning the core
21//!   raised and the panel's own notes;
22//! - **tree**: the main window's last frame ([`Core::nodes`]), collapsible,
23//!   filterable, with a picker that finds the node under the pointer from
24//!   the app itself, and an inspector for the selected one.
25//!
26//! The chords are `Ctrl+Shift+<letter>`, a family no app keymap should
27//! use while the panel is on: `T` cycles the base (the app's own → light →
28//! dark), `A` the accent, `M` the menus (the platform's own → native →
29//! drawn), `D` moves the panel
30//! (left → right → bottom → window → off; the header has a button per
31//! placement too), `N` the tab, `C` clears the stream, `I`
32//! moves the keyboard into the panel and back out, `P` picks a node, and
33//! `Escape` leaves the picker. The `I` chord alone is the app's to
34//! respell ([`Core::set_devtools_key`]: `f12`, `mod+shift+d`, any
35//! [`Accel`] spelling), since it is the one an app names in its own
36//! help — the others are the panel's, reached once the keyboard is in.
37//!
38//! **How it is in the frame** (ADR 0024, decisions 2–4). Docked, the main
39//! window's `begin_frame` opens an *app container* under the root, keyed
40//! so the host's children are named as if it were not there; the host's
41//! `configure_root` is split between the two — layout and paint to the
42//! container, everything addressed to the root; `Ui::finish` closes the
43//! container and opens the dock as the root's next child, under
44//! [`OriginId::DEVTOOLS`]. `handle_input` acts on the chords before
45//! routing and on every event of that origin before returning, and logs
46//! what it does return. Everything the panel declares in the main window
47//! is under `Key::ROOT.str("kui-devtools")`. State is the session's, so a
48//! second window can draw the same panel from the same stream.
49
50use std::collections::VecDeque;
51
52use rustc_hash::FxHashSet;
53
54use crate::color::Color;
55use crate::env::{Appearance, SystemEnv};
56use crate::geom::{Rect, Size, Vec2};
57use crate::key::Key;
58use crate::menu::Accel;
59use crate::runtime::inspect::NodeInfo;
60use crate::spec::{NodeSpec, Sizing};
61use crate::theme::{Theme, ThemeSource};
62use crate::tree::OriginId;
63use crate::value::Value;
64use crate::widgets::RowHeights;
65use crate::window::{WindowCommand, WindowConfig, WindowId};
66use crate::{Core, InputEvent, KeyCode, UiEvent};
67
68#[cfg(feature = "devtools")]
69mod icons;
70#[cfg(feature = "devtools")]
71mod panel;
72#[cfg(feature = "devtools")]
73mod stream;
74#[cfg(all(test, feature = "devtools"))]
75mod tests;
76#[cfg(feature = "devtools")]
77mod tree;
78
79/// Width of the side dock, logical px.
80pub const DOCK_SIDE_W: f32 = 340.0;
81/// Height of the bottom dock, logical px.
82pub const DOCK_BOTTOM_H: f32 = 280.0;
83/// The size the popped-out window opens at.
84pub const WINDOW_SIZE: Size = Size { w: 420.0, h: 720.0 };
85/// The name of the panel's own window, when it has one: what
86/// `Core::windows` lists and `Ui::window_name` answers there.
87pub const DEVTOOLS_WINDOW: &str = "kui-devtools";
88/// The label of the node everything the panel declares in the main
89/// window is under, so `key_of(DEVTOOLS_KEY)` finds it — and a tree reader
90/// that wants the app alone skips its subtree.
91pub const DEVTOOLS_KEY: &str = "kui-devtools";
92/// The app container's label (decision 2). Never indexed for `key_of`.
93const APP_KEY: &str = "kui-devtools/app";
94/// How many stream entries are kept.
95pub const STREAM_CAP: usize = 512;
96/// A closed stream row's height, logical px.
97const STREAM_ROW_H: f32 = 18.0;
98/// How many are dropped at once when the cap is hit, so a busy stream is
99/// not rebuilding its row heights on every event.
100const STREAM_EVICT: usize = 64;
101
102/// The accents `Ctrl+Shift+A` walks: kui's own, then four the OS might
103/// report. `None` is the app's own.
104const ACCENTS: [(&str, u32); 5] = [
105    ("kui blue", 0x3b5bd4ff),
106    ("macOS blue", 0x007affff),
107    ("macOS yellow", 0xffc409ff),
108    ("macOS pink", 0xf74f9eff),
109    ("forest", 0x2f7d4fff),
110];
111
112/// Where the panel sits. The header has a button per placement, and
113/// `Ctrl+Shift+D` walks them in [`Dock::ALL`]'s order.
114#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
115pub enum Dock {
116    /// A column on the left of the main window, [`DOCK_SIDE_W`] wide to
117    /// start with; the handle on its edge resizes it.
118    Left,
119    /// The same column on the right.
120    #[default]
121    Right,
122    /// A strip along the bottom, [`DOCK_BOTTOM_H`] tall to start with.
123    Bottom,
124    /// A window of its own, [`DEVTOOLS_WINDOW`] — "undock".
125    Window,
126    /// Hidden: nothing drawn, no window declared, the chords still live
127    /// (`Ctrl+Shift+D` brings it back) — "close".
128    Off,
129}
130
131impl Dock {
132    pub const ALL: [Dock; 5] = [
133        Dock::Left,
134        Dock::Right,
135        Dock::Bottom,
136        Dock::Window,
137        Dock::Off,
138    ];
139
140    pub fn name(self) -> &'static str {
141        match self {
142            Dock::Left => "left",
143            Dock::Right => "right",
144            Dock::Bottom => "bottom",
145            Dock::Window => "window",
146            Dock::Off => "off",
147        }
148    }
149
150    /// A placement by name; `"side"` is the right, the name it had before
151    /// there was a left.
152    pub fn parse(s: &str) -> Option<Dock> {
153        if s == "side" {
154            return Some(Dock::Right);
155        }
156        Self::ALL.into_iter().find(|d| d.name() == s)
157    }
158
159    fn next(self) -> Dock {
160        let i = Self::ALL.iter().position(|d| *d == self).unwrap_or(0);
161        Self::ALL[(i + 1) % Self::ALL.len()]
162    }
163
164    /// Whether the panel is drawn inside the main window.
165    pub fn docked(self) -> bool {
166        matches!(self, Dock::Left | Dock::Right | Dock::Bottom)
167    }
168
169    /// Whether the panel is a column beside the app.
170    pub fn is_side(self) -> bool {
171        matches!(self, Dock::Left | Dock::Right)
172    }
173}
174
175/// The smallest a docked pane can be — dragged, or squeezed by the
176/// window — and the least it leaves the app while the window allows.
177pub const SIDE_MIN_W: f32 = 280.0;
178pub const BOTTOM_MIN_H: f32 = 160.0;
179const APP_MIN: f32 = 160.0;
180
181/// The panel's tabs.
182#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
183pub enum Tab {
184    Facts,
185    #[default]
186    Events,
187    Tree,
188}
189
190/// One tab an app or an extension declared this frame (ADR 0032,
191/// decision 1): its name (the identity), the label the strip shows, and
192/// the slot an extension fills it through — `None` for the host form,
193/// whose content is the host's own subtree, anchored to the body.
194#[derive(Clone, Debug, PartialEq, Eq)]
195pub(crate) struct TabDecl {
196    pub(crate) name: String,
197    pub(crate) label: String,
198    pub(crate) slot: Option<String>,
199}
200
201/// Which tab the panel shows: one of its own three, or a declared one by
202/// index into this frame's list.
203#[derive(Clone, Copy, Debug, PartialEq, Eq)]
204pub(crate) enum Shown {
205    Builtin(Tab),
206    Custom(usize),
207}
208
209impl Tab {
210    const ALL: [Tab; 3] = [Tab::Facts, Tab::Events, Tab::Tree];
211
212    fn name(self) -> &'static str {
213        match self {
214            Tab::Facts => "facts",
215            Tab::Events => "events",
216            Tab::Tree => "tree",
217        }
218    }
219
220    /// What the strip shows and the access tree names: the name with
221    /// its first letter up, as a declared tab's label is written.
222    fn label(self) -> &'static str {
223        match self {
224            Tab::Facts => "Facts",
225            Tab::Events => "Events",
226            Tab::Tree => "Tree",
227        }
228    }
229
230    /// One of the three by its name in any case — `tree`, `Tree`, `TREE`
231    /// — since the strip labels them with a capital and a caller writes
232    /// what it reads there (backlog RG14). A declared tab's name is the
233    /// app's own spelling and is matched exactly.
234    fn parse(s: &str) -> Option<Tab> {
235        Self::ALL
236            .into_iter()
237            .find(|t| t.name().eq_ignore_ascii_case(s))
238    }
239}
240
241/// One entry of the stream.
242pub(crate) struct Entry {
243    /// Its number, ever increasing: what an expanded row is remembered by
244    /// across evictions.
245    seq: u64,
246    /// The main window's frame count when it was logged.
247    frame: u64,
248    kind: EntryKind,
249    window: WindowId,
250    key: Key,
251    /// The node's label when it was logged — the tree that named it is
252    /// gone by the time the row is read.
253    label: Option<String>,
254    origin: OriginId,
255    payload: Value,
256    /// How many lines the payload takes as a tree (`stream::lines`), so a
257    /// row's height is arithmetic and never a measurement.
258    lines: u16,
259}
260
261#[derive(Clone, Copy, Debug, PartialEq, Eq)]
262enum EntryKind {
263    Event,
264    Warning,
265    Note,
266}
267
268/// What the main window's core believed at the end of its last frame,
269/// read by whichever window draws the facts tab. Strings rather than the
270/// facts themselves: the panel prints them, and a second window's core
271/// cannot ask the first's doors.
272/// One declared token as the panel shows it (ADR 0027, decision 7):
273/// which origin declared it, its halves, and what it resolved to this
274/// frame — the value the inspector matches a node's paint against.
275#[derive(Clone, Debug, PartialEq)]
276pub(crate) struct TokenFact {
277    pub(crate) origin: OriginId,
278    pub(crate) name: String,
279    pub(crate) kind: crate::tokens::TokenKind,
280    pub(crate) light: Color,
281    pub(crate) dark: Color,
282    /// This frame's colour, for a colour token.
283    pub(crate) resolved: Color,
284    /// The px, for a length token.
285    pub(crate) length: f32,
286    /// A derived colour token's recipe, as the panel prints it beside the
287    /// hex (ADR 0028): `peach → lift 0.3`.
288    pub(crate) recipe: Option<String>,
289}
290
291#[derive(Clone, Default)]
292pub(crate) struct Facts {
293    title: String,
294    /// `(name, value)` rows of the status block, in order.
295    rows: Vec<(&'static str, String)>,
296    /// Every declared token, the host's first then each extension's,
297    /// each origin in its declaration order.
298    pub(crate) tokens: Vec<TokenFact>,
299    /// The main core's theme source, for the panel's own window to
300    /// mirror when no override is in force — and the base it resolves to,
301    /// so the base toggle's "the app's own" can say which that is.
302    theme_source: ThemeSource,
303    app_appearance: Appearance,
304    /// The viewport the main window's nodes were laid out in — what the
305    /// picker's overlay covers.
306    viewport: Size,
307    /// The frame's focus, hover and region, for the inspector's state rows.
308    focus: Option<Key>,
309    focus_visible: bool,
310    region: Option<Key>,
311    hovered: Option<Key>,
312    pressed: Option<Key>,
313    /// The main window's pointer, for the picker.
314    cursor: Option<Vec2>,
315}
316
317/// The session's devtools state (ADR 0024, decision 5).
318pub(crate) struct State {
319    pub(crate) on: bool,
320    pub(crate) dock: Dock,
321    /// The docked pane's extent — a side column's width, the bottom
322    /// strip's height — as the handle last left it.
323    side_w: f32,
324    bottom_h: f32,
325    tab: Tab,
326    // The overrides. `None` leaves the app's own.
327    base: Option<Appearance>,
328    /// `Some(i)` walks [`ACCENTS`]; with `custom_accent` it is that colour.
329    accent: Option<usize>,
330    custom_accent: Option<Color>,
331    native_menus: Option<bool>,
332    // The stream.
333    stream: VecDeque<Entry>,
334    seq: u64,
335    /// The rows the events tab shows (indices into `stream`, after the
336    /// filter) and their heights — rebuilt when `stream_dirty`.
337    rows: Vec<usize>,
338    heights: RowHeights,
339    expanded: FxHashSet<u64>,
340    stream_dirty: bool,
341    /// The stream grew since the list last followed it.
342    stream_grew: bool,
343    /// The list's travel (`max_offset.y`) the follow last pinned against:
344    /// a travel that differs is content that changed under the pin, an
345    /// offset short of an unchanged travel is the user's wheel.
346    followed_max: f32,
347    paused: bool,
348    follow: bool,
349    stream_filter: String,
350    // The tree.
351    selected: Option<Key>,
352    /// The row under the pointer in whichever window draws the tree, for
353    /// the outline the main window paints.
354    hovered_row: Option<Key>,
355    collapsed: FxHashSet<Key>,
356    tree_filter: String,
357    /// The picker is up (decision 9).
358    pick: bool,
359    /// The picker was raised from a declared tab (`Core::set_devtools_pick`
360    /// while one was on show, ADR 0032): the pick lands in `selected` for
361    /// that tab to read, and the tab stays up rather than the tree tab
362    /// taking over.
363    pick_keep_tab: bool,
364    /// The node the picker last saw under the pointer.
365    pick_hover: Option<Key>,
366    /// Scroll the tree to this node's row on the next build, with the
367    /// rows above it expanded.
368    reveal: Option<Key>,
369    /// Collapse every row with children on the next build.
370    fold_all: bool,
371    /// The row the keyboard is on (backlog D1a): the tree's own cursor,
372    /// moved by the arrows on the list's sink and painted as a ring while
373    /// the list holds focus. None until a key lands on the list.
374    tree_cursor: Option<Key>,
375    /// A key the list's sink heard, for the next build to apply with the
376    /// rows in hand: `up`, `down`, `left`, `right`, `home`, `end`,
377    /// `pageup`, `pagedown`, `enter`, `space`.
378    tree_key: Option<String>,
379    // What the main window wrote for the others.
380    facts: Facts,
381    nodes: Vec<NodeInfo>,
382    /// Frames of the main window so far, and `KUI_SMOKE_FRAMES` when set.
383    frames: u64,
384    smoke_frames: Option<u64>,
385    warnings_seen: usize,
386    legend: Vec<(String, String)>,
387    /// The chord that moves the keyboard into the panel and back out —
388    /// `Ctrl+Shift+I` unless the app respelled it.
389    inspect_key: Accel,
390    /// The tabs declared in the main window's last finished frame (ADR
391    /// 0032): what the strip lists after its own three. Set by the main
392    /// window at the end of its frame; a left dock and the panel's own
393    /// window read it a frame late, as they read the facts.
394    tabs: Vec<TabDecl>,
395    /// The declared tab the panel shows, by name — over `tab` while the
396    /// name is in `tabs`; a name that is gone falls back to `tab`.
397    custom: Option<String>,
398    /// The inspect chord waiting for the next main-window build.
399    toggle_region: bool,
400    /// The picker was left by a raw `Escape` press: the editor channel's
401    /// half of the same press, still to come, is the panel's too.
402    escape_owed: bool,
403    /// The dock wants the keyboard in its own window: `Focus` it once.
404    focus_window: bool,
405    /// The picker was raised from the panel's own window: `Focus` the
406    /// main window once, since that is where the picking happens and
407    /// the panel's window is what the pointer and the keyboard are in.
408    focus_main: bool,
409    /// A build in the panel's own window changed what the main window
410    /// paints (the hovered row's outline): ask it to draw once.
411    redraw_main: bool,
412}
413
414impl Default for State {
415    fn default() -> Self {
416        State {
417            on: false,
418            dock: Dock::default(),
419            side_w: DOCK_SIDE_W,
420            bottom_h: DOCK_BOTTOM_H,
421            tab: Tab::default(),
422            base: None,
423            accent: None,
424            custom_accent: None,
425            native_menus: None,
426            stream: VecDeque::new(),
427            seq: 0,
428            rows: Vec::new(),
429            heights: RowHeights::new(0, STREAM_ROW_H),
430            expanded: FxHashSet::default(),
431            stream_dirty: false,
432            stream_grew: false,
433            followed_max: 0.0,
434            paused: false,
435            follow: true,
436            stream_filter: String::new(),
437            selected: None,
438            hovered_row: None,
439            collapsed: FxHashSet::default(),
440            tree_filter: String::new(),
441            pick: false,
442            pick_keep_tab: false,
443            pick_hover: None,
444            reveal: None,
445            fold_all: false,
446            tree_cursor: None,
447            tree_key: None,
448            facts: Facts::default(),
449            nodes: Vec::new(),
450            frames: 0,
451            smoke_frames: std::env::var("KUI_SMOKE_FRAMES")
452                .ok()
453                .and_then(|s| s.parse().ok()),
454            warnings_seen: 0,
455            legend: Vec::new(),
456            inspect_key: DEFAULT_INSPECT_KEY,
457            tabs: Vec::new(),
458            custom: None,
459            toggle_region: false,
460            escape_owed: false,
461            focus_window: false,
462            focus_main: false,
463            redraw_main: false,
464        }
465    }
466}
467
468impl State {
469    /// What `KUI_DEVTOOLS` asks for: `1`/`true` the panel where it goes by
470    /// default (the right), a placement name the panel there, anything
471    /// else nothing.
472    fn from_var(var: Option<&str>) -> Self {
473        let mut s = State::default();
474        if !cfg!(feature = "devtools") {
475            return s;
476        }
477        if let Some(v) = var {
478            let v = v.trim().to_ascii_lowercase();
479            if v == "1" || v == "true" || v == "on" {
480                s.on = true;
481            } else if let Some(d) = Dock::parse(&v) {
482                s.on = true;
483                s.dock = d;
484            }
485        }
486        s
487    }
488
489    fn base_name(&self) -> &'static str {
490        match self.base {
491            None => "app",
492            Some(Appearance::Light) => "light",
493            Some(_) => "dark",
494        }
495    }
496
497    /// The menu override's name, the select's spelling: `platform` is
498    /// the host's own mode, whatever that is.
499    fn menus_name(&self) -> &'static str {
500        match self.native_menus {
501            None => "platform",
502            Some(true) => "native",
503            Some(false) => "drawn",
504        }
505    }
506
507    fn accent_name(&self) -> String {
508        match (self.accent, self.custom_accent) {
509            (Some(i), _) => ACCENTS[i].0.to_string(),
510            (None, Some(c)) => format!("#{:06x}", c.to_hex() >> 8),
511            (None, None) => "app".to_string(),
512        }
513    }
514
515    /// The theme the overrides add up to, or `None` for the app's own.
516    /// `app` is the app's own source, which the half an override leaves
517    /// alone is read from: "the app's base" is the base *the app* chose,
518    /// not the OS's, so an app that pinned dark on a light desktop stays
519    /// dark under an accent override, and an app with a brand accent
520    /// keeps it under a base override. (The accent half used to go out
521    /// as `DerivedWithAccent`, which follows `env.system`, so a
522    /// pinned-dark app flipped light the moment `Ctrl+Shift+A` was
523    /// pressed — the pomodoro's report of 2026-09-12.)
524    fn theme_override(&self, app: ThemeSource, sys: &SystemEnv) -> Option<ThemeSource> {
525        let accent = self
526            .accent
527            .map(|i| Color::hex(ACCENTS[i].1))
528            .or(self.custom_accent);
529        match (self.base, accent) {
530            (None, None) => None,
531            (None, Some(c)) => Some(match app {
532                // The app follows the OS's base: keep following it.
533                ThemeSource::Derived | ThemeSource::DerivedWithAccent(_) => {
534                    ThemeSource::DerivedWithAccent(c)
535                }
536                // The app pinned a palette: the same palette, recoloured.
537                ThemeSource::Pinned(t) => ThemeSource::Pinned(t.with_accent(c)),
538            }),
539            (Some(base), c) => {
540                // The app's own accent under the chosen base — the OS's
541                // where the app follows the OS, so a host that reports
542                // none still paints kui's blue byte for byte.
543                let own = match app {
544                    ThemeSource::Derived => sys.accent,
545                    ThemeSource::DerivedWithAccent(a) => Some(a),
546                    ThemeSource::Pinned(t) => Some(t.accent),
547                };
548                Some(ThemeSource::Pinned(Theme::derive(base, c.or(own))))
549            }
550        }
551    }
552
553    fn push(&mut self, mut e: Entry) {
554        if self.stream.len() >= STREAM_CAP {
555            self.stream.drain(..STREAM_EVICT);
556            // The rows that went take their open state with them.
557            if let Some(first) = self.stream.front().map(|e| e.seq) {
558                self.expanded.retain(|seq| *seq >= first);
559            }
560        }
561        e.seq = self.seq;
562        self.seq += 1;
563        e.lines = lines(&e.payload);
564        self.stream.push_back(e);
565        self.stream_dirty = true;
566        self.stream_grew = true;
567    }
568
569    fn note(&mut self, text: impl Into<String>) {
570        let frame = self.frames;
571        self.push(Entry {
572            seq: 0,
573            frame,
574            kind: EntryKind::Note,
575            window: WindowId::MAIN,
576            key: Key::ROOT,
577            label: None,
578            origin: OriginId::DEVTOOLS,
579            payload: Value::str(text.into()),
580            lines: 0,
581        });
582    }
583
584    /// One of the panel's actions, by the name its button and its chord
585    /// share. Returns whether `what` was one.
586    fn act(&mut self, what: &str) -> bool {
587        match what {
588            "base" => {
589                self.base = match self.base {
590                    None => Some(Appearance::Light),
591                    Some(Appearance::Light) => Some(Appearance::Dark),
592                    Some(_) => None,
593                };
594                self.note(format!("theme base: {}", self.base_name()));
595            }
596            "accent" => {
597                self.custom_accent = None;
598                self.accent = match self.accent {
599                    None => Some(0),
600                    Some(i) if i + 1 < ACCENTS.len() => Some(i + 1),
601                    Some(_) => None,
602                };
603                self.note(format!("accent: {}", self.accent_name()));
604            }
605            "menus" => {
606                // The select's three, round: the host's own mode (which
607                // `begin_frame` puts back from `dt_menus`), native, drawn.
608                // Not a toggle from a compile-time guess at the host's
609                // mode, which could never return to it (backlog RG12).
610                self.native_menus = match self.native_menus {
611                    None => Some(true),
612                    Some(true) => Some(false),
613                    Some(false) => None,
614                };
615                self.note(format!("menus: {}", self.menus_name()));
616            }
617            "dock" => {
618                self.dock = self.dock.next();
619                self.pick = false;
620                self.note(format!("dock: {}", self.dock.name()));
621            }
622            "inspect" => match self.dock {
623                // Nothing to enter with the panel hidden: it comes back
624                // first, docked.
625                Dock::Off => {
626                    self.dock = Dock::Right;
627                    self.toggle_region = true;
628                }
629                Dock::Window => self.focus_window = true,
630                _ => self.toggle_region = true,
631            },
632            "clear" => {
633                self.stream.clear();
634                self.expanded.clear();
635                self.stream_dirty = true;
636            }
637            "pause" => self.paused = !self.paused,
638            "follow" => {
639                self.follow = !self.follow;
640                self.stream_grew |= self.follow;
641            }
642            "tab" => {
643                // The panel's three, then the declared ones, then round.
644                let n = Tab::ALL.len() + self.tabs.len();
645                let i = match self.shown() {
646                    Shown::Builtin(t) => Tab::ALL.iter().position(|x| *x == t).unwrap_or(0),
647                    Shown::Custom(i) => Tab::ALL.len() + i,
648                };
649                self.select_tab((i + 1) % n);
650            }
651            "pick" => {
652                self.pick = !self.pick;
653                // The chord's pick shows the tree tab and lands there;
654                // a tab's earlier, cancelled pick must not decide otherwise.
655                self.pick_keep_tab = false;
656                if self.pick {
657                    self.show(Tab::Tree);
658                    if self.dock == Dock::Off {
659                        self.dock = Dock::Right;
660                    }
661                    // Picking happens in the main window; raised from
662                    // the panel's own, the keyboard (for Escape) and the
663                    // pointer are both in the wrong one.
664                    self.focus_main = self.dock == Dock::Window;
665                }
666                self.pick_hover = None;
667            }
668            "fold-all" => self.fold_all = true,
669            "unfold-all" => {
670                self.collapsed.clear();
671                self.fold_all = false;
672            }
673            other => {
674                if let Some(name) = other.strip_prefix("tab:custom:") {
675                    if let Some(i) = self.tabs.iter().position(|t| t.name == name) {
676                        self.select_tab(Tab::ALL.len() + i);
677                    }
678                } else if let Some(tab) = other.strip_prefix("tab:").and_then(Tab::parse) {
679                    self.tab = tab;
680                    self.custom = None;
681                } else if let Some(base) = other.strip_prefix("base:") {
682                    // The Facts tab's select: one choice, not the next.
683                    self.base = match base {
684                        "light" => Some(Appearance::Light),
685                        "dark" => Some(Appearance::Dark),
686                        _ => None,
687                    };
688                    self.note(format!("theme base: {}", self.base_name()));
689                } else if let Some(accent) = other.strip_prefix("accent:") {
690                    self.custom_accent = None;
691                    self.accent = accent.parse().ok().filter(|i| *i < ACCENTS.len());
692                    self.note(format!("accent: {}", self.accent_name()));
693                } else if let Some(menus) = other.strip_prefix("menus:") {
694                    self.native_menus = match menus {
695                        "native" => Some(true),
696                        "drawn" => Some(false),
697                        _ => None,
698                    };
699                    self.note(format!("menus: {}", self.menus_name()));
700                } else if let Some(dock) = other.strip_prefix("dock:").and_then(Dock::parse) {
701                    // One of the header's placement buttons.
702                    if dock != self.dock {
703                        self.dock = dock;
704                        self.pick = false;
705                        self.note(format!("dock: {}", dock.name()));
706                    }
707                } else if let Some(key) = other.strip_prefix("node:").and_then(parse_key) {
708                    // A tree row: select it, or unselect the selected one.
709                    self.selected = if self.selected == Some(key) {
710                        None
711                    } else {
712                        Some(key)
713                    };
714                } else if let Some(key) = other.strip_prefix("goto:").and_then(parse_key) {
715                    // The inspector's parent row or a breadcrumb: select
716                    // and bring its row into view.
717                    self.select(key);
718                } else if let Some(key) = other.strip_prefix("fold:").and_then(parse_key) {
719                    if !self.collapsed.remove(&key) {
720                        self.collapsed.insert(key);
721                    }
722                } else if let Some(seq) = other.strip_prefix("row:").and_then(|s| s.parse().ok()) {
723                    if !self.expanded.remove(&seq) {
724                        self.expanded.insert(seq);
725                    }
726                    self.stream_dirty = true;
727                } else if let Some(code) = other.strip_prefix("tree-key:") {
728                    // A key on the tree's list: the build applies it, since
729                    // moving needs the rows and folding needs the children.
730                    self.tree_key = Some(code.to_string());
731                } else {
732                    return false;
733                }
734            }
735        }
736        true
737    }
738
739    /// Selects `key` and asks the next tree build to expand the rows
740    /// above it and scroll to it — what the picker and the inspector's
741    /// links do. The build has the nodes; this has only the key.
742    fn select(&mut self, key: Key) {
743        self.selected = Some(key);
744        self.show(Tab::Tree);
745        self.reveal = Some(key);
746    }
747
748    /// Shows one of the panel's own tabs.
749    fn show(&mut self, tab: Tab) {
750        self.tab = tab;
751        self.custom = None;
752    }
753
754    /// The tab on show: `custom` while it names a declared tab, else
755    /// `tab` (ADR 0032, decision 1).
756    pub(crate) fn shown(&self) -> Shown {
757        match self.custom.as_deref() {
758            Some(name) => match self.tabs.iter().position(|t| t.name == name) {
759                Some(i) => Shown::Custom(i),
760                None => Shown::Builtin(self.tab),
761            },
762            None => Shown::Builtin(self.tab),
763        }
764    }
765
766    /// Whether `name` is a declared tab the panel lists — what `shown`
767    /// falls back on when it is not, and what the laziness rule asks so
768    /// a stale `custom` (a tab the app stopped declaring) shows nothing
769    /// and builds nothing.
770    fn lists(&self, name: &str) -> bool {
771        self.tabs.iter().any(|t| t.name == name)
772    }
773
774    /// Selects the `i`th tab of the strip: the three, then the declared.
775    fn select_tab(&mut self, i: usize) {
776        if i < Tab::ALL.len() {
777            self.show(Tab::ALL[i]);
778        } else if let Some(t) = self.tabs.get(i - Tab::ALL.len()) {
779            self.custom = Some(t.name.clone());
780        }
781    }
782}
783
784fn parse_key(hex: &str) -> Option<Key> {
785    u64::from_str_radix(hex, 16).ok().map(Key)
786}
787
788/// `{ dt: what }`, the payload every control of the panel posts.
789fn action(what: impl Into<String>) -> Value {
790    Value::map([("dt", Value::str(what.into()))])
791}
792
793/// What `Ctrl+Shift+I` parses to: the inspect chord until an app
794/// respells it.
795const DEFAULT_INSPECT_KEY: Accel = Accel {
796    code: KeyCode::Char('i'),
797    mods: crate::input::KeyMods {
798        shift: true,
799        ctrl: true,
800        alt: false,
801        super_key: false,
802    },
803};
804
805/// Whether a press is this chord: the modifiers exactly, and the key by
806/// the layout's character first and the physical position second (ADR
807/// 0002, decision 11) — case-blind for a character, since Shift is part
808/// of the chord and the layout has already applied it.
809fn hits(accel: Accel, press: &crate::input::KeyPress) -> bool {
810    if press.mods != accel.mods {
811        return false;
812    }
813    let same = |code: KeyCode| match (accel.code, code) {
814        (KeyCode::Char(a), KeyCode::Char(b)) => a.eq_ignore_ascii_case(&b),
815        (a, b) => a == b,
816    };
817    same(press.code) || same(press.physical)
818}
819
820/// The panel's chord for a key press, if it is one: the inspect chord
821/// (`Ctrl+Shift+I`, or what the app respelled it to), else `Ctrl+Shift`
822/// and a letter, by the layout's character first and the physical
823/// position second (ADR 0002, decision 11).
824fn chord(press: &crate::input::KeyPress, inspect: Accel) -> Option<&'static str> {
825    if hits(inspect, press) {
826        return Some("inspect");
827    }
828    let m = press.mods;
829    if !m.ctrl || !m.shift || m.alt {
830        return None;
831    }
832    let letter = |c: KeyCode| match c {
833        KeyCode::Char(ch) => Some(ch.to_ascii_lowercase()),
834        _ => None,
835    };
836    match letter(press.code).or_else(|| letter(press.physical))? {
837        't' => Some("base"),
838        'a' => Some("accent"),
839        'm' => Some("menus"),
840        'd' => Some("dock"),
841        'c' => Some("clear"),
842        'n' => Some("tab"),
843        // `I` is the inspect chord's letter only while that is what the
844        // chord is: respelled to `F12`, `Ctrl+Shift+I` is the app's again.
845        'p' => Some("pick"),
846        _ => None,
847    }
848}
849
850/// The key of a declared tab's body (ADR 0032, decision 2): fixed by the
851/// tab's name alone, so the content can anchor to it before it exists —
852/// the host form is built before a right dock's body, after a left one's.
853pub(crate) fn tab_body_key(name: &str) -> Key {
854    Key::ROOT.str(DEVTOOLS_KEY).str("tab").str(name)
855}
856
857impl Core {
858    /// Declares a devtools tab this frame (ADR 0032, decision 1). A name
859    /// declared already this frame warns `duplicate-tab` and keeps the
860    /// first; returns whether this one stood. The bare door under
861    /// `Ui::devtools_tab` / `devtools_tab_with`, for a binding that
862    /// opens the content itself.
863    pub fn devtools_tab_declare(&mut self, name: &str, label: &str, slot: Option<&str>) -> bool {
864        if !cfg!(feature = "devtools") || self.tree.is_empty() {
865            return false;
866        }
867        if self.dt_tabs.iter().any(|t| t.name == name) {
868            self.diag.raise(crate::diag::duplicate_tab(name));
869            return false;
870        }
871        self.dt_tabs.push(TabDecl {
872            name: name.to_string(),
873            label: label.to_string(),
874            slot: slot.map(str::to_string),
875        });
876        true
877    }
878
879    /// Whether the host form of tab `name` is shown this frame — the
880    /// panel is on, docked in this (the main) window, and `name` is the
881    /// tab on show (ADR 0032, decision 3). Read before the content is
882    /// built, from the session's state, which is in place while the
883    /// host's view runs.
884    pub fn devtools_tab_shown(&self, name: &str) -> bool {
885        if !cfg!(feature = "devtools") || self.dt_window || self.env.window.id != WindowId::MAIN {
886            return false;
887        }
888        let s = self.session.state();
889        let d = &s.devtools;
890        d.on && d.dock.docked() && d.custom.as_deref() == Some(name) && d.lists(name)
891    }
892
893    /// The tab on show, by name, when it is a declared one — what the
894    /// Node and Lua drivers read once a frame to call a tab's function
895    /// child (ADR 0032, decision 3). `None` for one of the panel's own,
896    /// for the panel off, popped out, or another window's frame.
897    pub fn devtools_shown_tab(&self) -> Option<String> {
898        if !cfg!(feature = "devtools") || self.dt_window || self.env.window.id != WindowId::MAIN {
899            return None;
900        }
901        let s = self.session.state();
902        let d = &s.devtools;
903        if !(d.on && d.dock.docked()) {
904            return None;
905        }
906        d.custom.clone().filter(|name| d.lists(name))
907    }
908
909    /// Opens the host form's content node: a float anchored to the tab's
910    /// body by key, the body's size, clipped, keyed as the host's own
911    /// child (ADR 0032, decisions 2 and 7). The caller builds inside and
912    /// closes. The bare door under `Ui::devtools_tab_with`; it does not
913    /// declare, and it does not ask whether the tab is on show.
914    pub fn devtools_tab_open(&mut self, name: &str) {
915        let spec = NodeSpec::column()
916            .float(crate::spec::FloatConfig {
917                anchor: crate::spec::FloatAnchor::Node(tab_body_key(name)),
918                ..Default::default()
919            })
920            .fill()
921            .clip();
922        self.open_keyed(&format!("devtools-tab:{name}"), spec);
923    }
924
925    /// Whether `name` is a slot a declared tab names this frame — what
926    /// counts as *declared* for the `unknown-slot` check whether or not
927    /// the panel mounted it (ADR 0032, decision 5).
928    pub(crate) fn devtools_tab_slot(&self, name: &str) -> bool {
929        self.dt_tabs.iter().any(|t| t.slot.as_deref() == Some(name))
930    }
931
932    /// Mounts the extension form of the tab on show, if it has one: the
933    /// slot is declared at the cursor under the host's origin — so the
934    /// fill's replies reach the host, as ADR 0014's rule reads with the
935    /// panel for declarer — and filled inside a float anchored to the
936    /// tab's body, the panel's facts as params (ADR 0032, decisions 2,
937    /// 4 and 5). Called with the filler in hand: from `Ui::finish` in the
938    /// main window before the filler's own finish, and in the panel's
939    /// window after its build. Nothing to do when the tab on show is the
940    /// panel's own or a host form.
941    pub(crate) fn devtools_fill_mount(&mut self, filler: &mut dyn crate::slot::Fill) {
942        if !cfg!(feature = "devtools") || self.tree.is_empty() {
943            return;
944        }
945        let (name, slot, params) = {
946            let s = self.session.state();
947            let d = &s.devtools;
948            if !d.on || !(d.dock.docked() || self.dt_window) {
949                return;
950            }
951            let tabs = if self.dt_window {
952                &d.tabs
953            } else {
954                &self.dt_tabs
955            };
956            let Some(name) = d.custom.as_deref() else {
957                return;
958            };
959            let Some(decl) = tabs.iter().find(|t| t.name == name) else {
960                return;
961            };
962            let Some(slot) = decl.slot.clone() else {
963                return;
964            };
965            (decl.name.clone(), slot, d.facts_params())
966        };
967        let saved = self.origin;
968        self.origin = OriginId::HOST;
969        if let Some(key) = self.begin_slot(&slot) {
970            self.devtools_tab_open(&name);
971            filler.fill(&slot, key, &params, &mut crate::ui::Ui::new(self));
972            self.close();
973        }
974        self.origin = saved;
975    }
976}
977
978impl State {
979    /// The panel's facts as a slot's params (ADR 0032, decision 4): the
980    /// selected, hovered and picked nodes as hex keys (`null` for none),
981    /// the region and the focus by label from the facts rows.
982    fn facts_params(&self) -> Value {
983        let key = |k: Option<Key>| match k {
984            Some(k) => Value::str(format!("{:016x}", k.0)),
985            None => Value::Null,
986        };
987        let row = |name: &str| {
988            self.facts
989                .rows
990                .iter()
991                .find(|(k, _)| *k == name)
992                .map(|(_, v)| Value::str(v.clone()))
993                .unwrap_or(Value::Null)
994        };
995        Value::map([
996            ("selected", key(self.selected)),
997            ("hovered", key(self.hovered_row)),
998            ("picked", key(self.pick_hover)),
999            ("region", row("region")),
1000            ("focus", row("focus")),
1001        ])
1002    }
1003}
1004
1005// ---------------------------------------------------------------------------
1006// The doors.
1007
1008impl Core {
1009    /// Turns the devtools panel on or off for this session (ADR 0024). On,
1010    /// it is drawn where [`Self::set_devtools_dock`] says — beside the
1011    /// host's tree in the main window by default — and its chords are
1012    /// live in every window. `KUI_DEVTOOLS=1` in the environment is the
1013    /// same call made by nobody; `KUI_DEVTOOLS=bottom` (or `left`,
1014    /// `right`, `window`, `off`) also says where.
1015    pub fn set_devtools(&mut self, on: bool) {
1016        // Built without the panel: the door stays and does nothing.
1017        if !cfg!(feature = "devtools") {
1018            return;
1019        }
1020        let mut s = self.session.state();
1021        if s.devtools.on == on {
1022            return;
1023        }
1024        s.devtools.on = on;
1025        if !on {
1026            s.devtools.pick = false;
1027            s.devtools.pick_keep_tab = false;
1028        }
1029    }
1030
1031    /// Opens the panel if `KUI_DEVTOOLS` in the environment asks for it
1032    /// (`1`, `true`, or a placement name), and says whether it did. What
1033    /// the windowed runners call once, before the first frame — the door
1034    /// for a program that was never told about the panel — and what a
1035    /// headless core never reads, so a variable left exported cannot put
1036    /// a dock into a test's tree.
1037    pub fn devtools_from_env(&mut self) -> bool {
1038        let asked = State::from_var(std::env::var("KUI_DEVTOOLS").ok().as_deref());
1039        if asked.on {
1040            self.set_devtools(true);
1041            self.set_devtools_dock(asked.dock);
1042        }
1043        asked.on
1044    }
1045
1046    /// Whether the panel is on.
1047    pub fn devtools(&self) -> bool {
1048        self.session.state().devtools.on
1049    }
1050
1051    /// Where the panel sits; `Ctrl+Shift+D` moves it from there.
1052    pub fn set_devtools_dock(&mut self, dock: Dock) {
1053        self.session.state().devtools.dock = dock;
1054    }
1055
1056    pub fn devtools_dock(&self) -> Dock {
1057        self.session.state().devtools.dock
1058    }
1059
1060    /// Seeds the panel's theme override — what its `T` and `A` chords
1061    /// cycle from. `None` for either leaves the app's own.
1062    pub fn set_devtools_theme(&mut self, base: Option<Appearance>, accent: Option<Color>) {
1063        let mut s = self.session.state();
1064        s.devtools.base = base;
1065        s.devtools.accent = None;
1066        s.devtools.custom_accent = accent;
1067    }
1068
1069    /// Respells the chord that moves the keyboard into the panel and
1070    /// back out — and brings the panel back when it is `off` — from its
1071    /// default `Ctrl+Shift+I`: any [`Accel`] spelling (`"f12"`,
1072    /// `"mod+shift+d"`, `"⌥⌘I"`). The other chords stay `Ctrl+Shift+
1073    /// <letter>`; this is the one an app puts in its own help, and the
1074    /// one whose default an app's keymap may want for itself. A chord
1075    /// the app takes is the app's for good: with `F12` set,
1076    /// `Ctrl+Shift+I` reaches the app's sinks like any other press.
1077    pub fn set_devtools_key(&mut self, key: Accel) {
1078        self.session.state().devtools.inspect_key = key;
1079    }
1080
1081    /// The chord that moves the keyboard into the panel, as set or as
1082    /// it defaults.
1083    pub fn devtools_key(&self) -> Accel {
1084        self.session.state().devtools.inspect_key
1085    }
1086
1087    /// The node the panel's tree tab has selected (ADR 0032, decision
1088    /// 4): what an inspector in a declared tab reads to say which node
1089    /// it is about. Answered from the session, so it is right inside the
1090    /// host's view and inside an extension's fill alike.
1091    pub fn devtools_selected(&self) -> Option<Key> {
1092        self.session.state().devtools.selected
1093    }
1094
1095    /// The tree row under the pointer in whichever window draws the
1096    /// tree — the node the main window outlines.
1097    pub fn devtools_hovered(&self) -> Option<Key> {
1098        self.session.state().devtools.hovered_row
1099    }
1100
1101    /// The node the picker last saw under the pointer, while picking.
1102    pub fn devtools_picked(&self) -> Option<Key> {
1103        self.session.state().devtools.pick_hover
1104    }
1105
1106    /// Selects a node in the panel's tree tab from outside it — an
1107    /// inspector driving the highlight from its side — and reveals it
1108    /// there, as the picker does; `None` clears. The tab does not move:
1109    /// the caller is drawing in one.
1110    pub fn set_devtools_selected(&mut self, key: Option<Key>) {
1111        let mut s = self.session.state();
1112        let d = &mut s.devtools;
1113        d.selected = key;
1114        d.reveal = key;
1115        drop(s);
1116        self.devtools_redraw_others();
1117    }
1118
1119    /// Raises the panel's picker from outside it — an inspector in a
1120    /// declared tab asking "which node?" — or puts it away (ADR 0032,
1121    /// decision 4). Picking happens in the main window, over the app: the
1122    /// node under the pointer is `devtools_picked` while it is up, and
1123    /// the press lands it in `devtools_selected`. Raised while a declared
1124    /// tab is on show, the pick leaves that tab up; raised otherwise — a
1125    /// tab named through [`Self::set_devtools_tab`] but not declared yet
1126    /// included — it is the `Ctrl+Shift+P` pick, which shows the tree
1127    /// tab. A hidden panel comes back docked, as the chord's does.
1128    pub fn set_devtools_pick(&mut self, on: bool) {
1129        if !cfg!(feature = "devtools") {
1130            return;
1131        }
1132        let focus_main = {
1133            let mut s = self.session.state();
1134            let d = &mut s.devtools;
1135            if d.pick == on {
1136                return;
1137            }
1138            d.pick = on;
1139            d.pick_hover = None;
1140            // A declared tab is up when `custom` names one the panel
1141            // lists — not merely when it is set: since F67 a name no frame
1142            // has declared yet is kept there, and the strip falls back to
1143            // the panel's own tab, so a pick raised then is the chord's
1144            // and shows the tree (backlog RG14).
1145            let on_tab = d.custom.as_deref().is_some_and(|n| d.lists(n));
1146            d.pick_keep_tab = on && on_tab;
1147            if on {
1148                if !on_tab {
1149                    d.show(Tab::Tree);
1150                }
1151                if d.dock == Dock::Off {
1152                    d.dock = Dock::Right;
1153                }
1154            }
1155            on && d.dock == Dock::Window
1156        };
1157        if focus_main {
1158            self.interaction
1159                .window_commands
1160                .push(WindowCommand::Focus(WindowId::MAIN));
1161        }
1162        self.devtools_redraw_others();
1163    }
1164
1165    /// Whether the panel's picker is up.
1166    pub fn devtools_picking(&self) -> bool {
1167        self.session.state().devtools.pick
1168    }
1169
1170    /// Shows the panel's tab named `name` from outside the panel — what
1171    /// the strip's click and `Ctrl+Shift+N` do, for an app with a command
1172    /// that jumps to its own tab (ADR 0032). `name` is one of the panel's
1173    /// own (`facts`, `events`, `tree`, in any case — the strip labels
1174    /// them `Facts`, `Events`, `Tree`) or a declared tab's, exactly as
1175    /// the app declared it. A declared
1176    /// name the panel does not list yet is kept and shows once a frame
1177    /// declares it, as a strip click on it would; the return says whether
1178    /// the panel lists it now (it lists a declared tab from the first
1179    /// frame it is on). A hidden panel comes back docked, as the
1180    /// picker's does. The panel's `on` is not touched: that is
1181    /// [`Self::set_devtools`]'s. Edge-triggered — called once a frame it
1182    /// would pin the strip against the user's own clicks.
1183    pub fn set_devtools_tab(&mut self, name: &str) -> bool {
1184        if !cfg!(feature = "devtools") {
1185            return false;
1186        }
1187        let listed = {
1188            let mut s = self.session.state();
1189            let d = &mut s.devtools;
1190            let listed = match Tab::parse(name) {
1191                Some(tab) => {
1192                    d.show(tab);
1193                    true
1194                }
1195                None => {
1196                    d.custom = Some(name.to_string());
1197                    d.lists(name)
1198                }
1199            };
1200            if d.dock == Dock::Off {
1201                d.dock = Dock::Right;
1202            }
1203            listed
1204        };
1205        self.devtools_redraw_others();
1206        listed
1207    }
1208
1209    /// The tab the panel is on, by name: one of its own (`facts`,
1210    /// `events`, `tree`) or a declared tab's — what the strip marks,
1211    /// panel on or off, in any window. A declared name the panel stopped
1212    /// listing answers the panel's own tab the strip falls back to.
1213    /// Unlike [`Self::devtools_shown_tab`], which answers only a declared
1214    /// tab on show in the main window for a data binding's function
1215    /// child, this is the selection itself.
1216    pub fn devtools_current_tab(&self) -> String {
1217        let s = self.session.state();
1218        let d = &s.devtools;
1219        match d.shown() {
1220            Shown::Builtin(t) => t.name().to_string(),
1221            Shown::Custom(i) => d.tabs[i].name.clone(),
1222        }
1223    }
1224
1225    /// The key legend the facts tab shows: `(keys, what they do)`.
1226    pub fn set_devtools_legend(&mut self, legend: &[(&str, &str)]) {
1227        self.session.state().devtools.legend = legend
1228            .iter()
1229            .map(|(k, v)| (k.to_string(), v.to_string()))
1230            .collect();
1231    }
1232
1233    /// Whether this core draws the panel's own window — a frame the host
1234    /// builds nothing into (decision 6).
1235    pub fn devtools_window(&self) -> bool {
1236        self.dt_window
1237    }
1238
1239    /// The app's own theme source: the one in force while no override is,
1240    /// else the one remembered when the override went on — unless the app
1241    /// has set another since, which shows as the source in force not being
1242    /// the override that was applied. That one is the app's now, and it
1243    /// is what the override is lifted back to.
1244    fn app_theme_source(&self) -> ThemeSource {
1245        match self.dt_theme {
1246            Some((app, applied)) if self.theme_source == applied => app,
1247            _ => self.theme_source,
1248        }
1249    }
1250
1251    /// The id of the panel's window while it is open.
1252    fn devtools_window_id(&self) -> Option<WindowId> {
1253        self.session
1254            .state()
1255            .windows
1256            .live()
1257            .into_iter()
1258            .find(|(_, name)| &**name == DEVTOOLS_WINDOW)
1259            .map(|(id, _)| id)
1260    }
1261}
1262
1263// ---------------------------------------------------------------------------
1264// The hooks.
1265
1266impl Core {
1267    /// What the dock leaves of a window `window` big (ADR 0024): the
1268    /// viewport a frame begun at that size lays out into, which
1269    /// `viewport()` reports once the frame has begun and a `resize`
1270    /// reports when it changes. It takes the window's size and the dock's
1271    /// state and nothing of the frame, so it answers *before* the first
1272    /// frame too — what a driver's window-size reading hands a host that
1273    /// sizes its model at setup (Node's `KuiWindow.size()`; backlog F43,
1274    /// where that reading was the window's and `env().viewport` was still
1275    /// 0×0 that early, so no reading said the right number).
1276    pub fn host_area(&self, window: Size) -> Size {
1277        let r = self.devtools_area(window);
1278        Size::new(r.w, r.h)
1279    }
1280
1281    /// Where the current frame laid the host out, in the window's logical
1282    /// px: [`Core::viewport`] with its origin — `x` the pane's width under
1283    /// a left dock, and zero everywhere else, the whole window with the
1284    /// panel off, in a window of its own, or in any window but the main
1285    /// one. The frame's reading, like `viewport()`, so it is zero before
1286    /// the first frame (the pre-frame answer is `host_area`'s size) and it
1287    /// is the rect the quads of `output()` were drawn against: scaled by
1288    /// `scale()` into their physical px, it is what separates the host's
1289    /// quads from the dock's — all but the root's background, which
1290    /// `devtools_configure_root` gives the window as well as the app
1291    /// container, so it fills the whole window beneath the pane. Backlog
1292    /// F92: the origin reached only the Rust
1293    /// runner, through `devtools_inset`, so a Node test could size itself
1294    /// to the host area but not say that nothing of its own left it.
1295    pub fn host_rect(&self) -> Rect {
1296        self.dt_area
1297    }
1298
1299    /// What a docked pane takes off the main window, in the axis it
1300    /// takes it: the side column's width as `(w, 0)`, the bottom strip's
1301    /// height as `(0, h)`, and zero with the panel off, in a window of
1302    /// its own, or asked of any window but the main one. The pane's
1303    /// extent *as the handle left it*, not as the window clamps it — what
1304    /// a driver adds to the app's minimum window size while the panel is
1305    /// docked, so the floor the app declared is a floor on the app and
1306    /// not on the app less the dock (the pomodoro's report, 2026-09-12:
1307    /// a 620×500 minimum with a 340 px dock left the app 280 px, below
1308    /// the tier it was drawn to fit). Read after a frame, since the
1309    /// handle's drag and the placement buttons land in one.
1310    pub fn devtools_inset(&self) -> Size {
1311        if self.env.window.id != WindowId::MAIN {
1312            return Size::ZERO;
1313        }
1314        let s = self.session.state();
1315        let d = &s.devtools;
1316        if !d.on {
1317            return Size::ZERO;
1318        }
1319        match d.dock {
1320            Dock::Left | Dock::Right => Size::new(d.side_w.max(SIDE_MIN_W), 0.0),
1321            Dock::Bottom => Size::new(0.0, d.bottom_h.max(BOTTOM_MIN_H)),
1322            Dock::Window | Dock::Off => Size::ZERO,
1323        }
1324    }
1325
1326    /// The host's viewport for a frame at `viewport` (ADR 0024): the
1327    /// window, less the dock when the panel is docked in the main window.
1328    /// The pane keeps its minimum before the app keeps its own, so a
1329    /// window too small for both squeezes the app.
1330    pub(crate) fn devtools_area(&self, viewport: Size) -> Rect {
1331        let window = Rect::new(0.0, 0.0, viewport.w, viewport.h);
1332        if self.env.window.id != WindowId::MAIN {
1333            return window;
1334        }
1335        let s = self.session.state();
1336        let d = &s.devtools;
1337        if !d.on || !d.dock.docked() {
1338            return window;
1339        }
1340        match d.dock {
1341            Dock::Left => {
1342                let w = pane_w(d.side_w, viewport.w);
1343                Rect::new(w, 0.0, (viewport.w - w).max(0.0), viewport.h)
1344            }
1345            Dock::Right => {
1346                let w = pane_w(d.side_w, viewport.w);
1347                Rect::new(0.0, 0.0, (viewport.w - w).max(0.0), viewport.h)
1348            }
1349            Dock::Bottom => {
1350                let h = pane_h(d.bottom_h, viewport.h);
1351                Rect::new(0.0, 0.0, viewport.w, (viewport.h - h).max(0.0))
1352            }
1353            _ => window,
1354        }
1355    }
1356
1357    /// The origin of the host's viewport in window coordinates: nonzero
1358    /// only under a left dock.
1359    #[inline]
1360    pub(crate) fn dt_shift(&self) -> Vec2 {
1361        Vec2::new(self.dt_area.x, self.dt_area.y)
1362    }
1363
1364    /// The coordinates the host is handed, in its own viewport (ADR
1365    /// 0024): a drag's point and parent, a layout's rect and parent, a
1366    /// context menu's and a force click's point. A no-op wherever the
1367    /// dock's origin is the window's.
1368    pub(crate) fn devtools_translate(&self, out: &mut [UiEvent]) {
1369        let shift = self.dt_shift();
1370        if shift == Vec2::ZERO {
1371            return;
1372        }
1373        fn shift_xy(v: &mut Value, shift: Vec2) {
1374            if let Value::Map(entries) = v {
1375                for (k, v) in entries.iter_mut() {
1376                    match (k.as_str(), &mut *v) {
1377                        ("x", Value::Float(x)) => *x -= shift.x as f64,
1378                        ("y", Value::Float(y)) => *y -= shift.y as f64,
1379                        ("parent", nested @ Value::Map(_)) => shift_xy(nested, shift),
1380                        _ => {}
1381                    }
1382                }
1383            }
1384        }
1385        for ev in out {
1386            if ev.origin == OriginId::DEVTOOLS {
1387                continue;
1388            }
1389            if matches!(
1390                ev.kind(),
1391                Some("drag" | "layout" | "contextmenu" | "forceclick" | "button")
1392            ) {
1393                shift_xy(&mut ev.payload, shift);
1394            }
1395        }
1396    }
1397
1398    /// The end of `begin_frame`: the overrides for this window, and the
1399    /// wrap of the host's tree in the main one (decision 2) or the
1400    /// deferred root in the panel's own (decision 6).
1401    /// Puts the host's own menu mode back after an override (see
1402    /// `dt_menus`); nothing to do when there was none.
1403    fn restore_menus(&mut self) {
1404        if let Some((menus, bar)) = self.dt_menus.take() {
1405            self.set_native_menus(menus);
1406            self.set_native_menu_bar(bar);
1407        }
1408    }
1409
1410    pub(crate) fn devtools_begin_frame(&mut self) {
1411        self.dt_app = None;
1412        self.dt_dock = None;
1413        self.dt_window = false;
1414        self.dt_built = None;
1415        // This frame's declarations start empty whatever the panel's state
1416        // and whichever window this is: `after_frame` moves the main
1417        // window's into the session only while the panel is on, and a
1418        // declaration made every frame must not pile up into
1419        // `duplicate-tab` on the second one.
1420        self.dt_tabs.clear();
1421        let main = self.env.window.id == WindowId::MAIN;
1422        let this = if main {
1423            false
1424        } else {
1425            &*self.window_name() == DEVTOOLS_WINDOW
1426        };
1427        let (on, dock, shown, theme, menus, mirror, want_inspect) = {
1428            let s = self.session.state();
1429            let d = &s.devtools;
1430            // The app's own source, for the override to keep the half it
1431            // leaves alone — and the main window's in the panel's own
1432            // window, which has none of its own.
1433            let app = if this {
1434                d.facts.theme_source
1435            } else {
1436                self.app_theme_source()
1437            };
1438            (
1439                d.on,
1440                d.dock,
1441                d.shown(),
1442                d.theme_override(app, &self.env.system),
1443                d.native_menus,
1444                app,
1445                // The panel's need for the node snapshot, derived every
1446                // frame rather than latched (backlog AR38): the tree tab
1447                // showing, or a pick under way. Off, the O(nodes) copy a
1448                // frame stops; the host's own `set_inspect` is apart.
1449                d.on && (d.shown() == Shown::Builtin(Tab::Tree) || d.pick),
1450            )
1451        };
1452        // A menu of the panel's own — a Facts select's — hangs under a
1453        // field this window draws only while it builds the panel: off,
1454        // hidden, or popped into its own window while this is the main
1455        // one, the field is gone and the menu goes with it, or else the
1456        // rows would stay drawn with nobody to answer them (backlog RG2).
1457        // An app's menu is not the panel's to close.
1458        let builds_panel = on && (this || (main && dock.docked()));
1459        if !builds_panel
1460            && self
1461                .menu
1462                .as_ref()
1463                .is_some_and(|m| m.origin == OriginId::DEVTOOLS)
1464        {
1465            self.close_menu();
1466        }
1467        if !on {
1468            if let Some((app, _)) = self.dt_theme.take() {
1469                self.set_theme_source(app);
1470            }
1471            self.restore_menus();
1472            self.dt_inspect = false;
1473            return;
1474        }
1475        // The theme: the override while there is one, and the app's own
1476        // source — remembered from when the override went on — when it
1477        // is taken away again. The panel's own window mirrors the main
1478        // one's app source, since it has none of its own.
1479        match theme {
1480            Some(src) => {
1481                self.dt_theme = Some((mirror, src));
1482                self.set_theme_source(src);
1483            }
1484            None => {
1485                if self.dt_theme.take().is_some() || this {
1486                    self.set_theme_source(mirror);
1487                }
1488            }
1489        }
1490        // The menus the same way: the host's own mode — what the driver
1491        // said at launch — remembered when the override goes on, and put
1492        // back when it is lifted (`platform` in the select, or the panel
1493        // off), since nothing else would.
1494        match menus {
1495            Some(m) => {
1496                if self.dt_menus.is_none() {
1497                    self.dt_menus = Some((self.native_menus, self.native_menu_bar()));
1498                }
1499                self.set_native_menus(m);
1500                self.set_native_menu_bar(m);
1501            }
1502            None => self.restore_menus(),
1503        }
1504        if this {
1505            // The panel's window: no root until `finish`, so what the host
1506            // builds here builds nothing (every builder door is a no-op
1507            // on an empty tree).
1508            self.dt_window = true;
1509            self.tree.clear();
1510            self.stack.clear();
1511            self.counters.clear();
1512            return;
1513        }
1514        if !main {
1515            return;
1516        }
1517        self.dt_inspect = want_inspect;
1518        if dock.docked() {
1519            self.dt_dock = Some(dock);
1520            self.tree.specs[0] = match dock {
1521                Dock::Bottom => NodeSpec::column(),
1522                _ => NodeSpec::row(),
1523            }
1524            .fill();
1525            // A left dock precedes the app in the root row, so it is built
1526            // now, from the last frame's facts and the tab on show, and
1527            // `finish` skips it.
1528            if dock == Dock::Left {
1529                self.build_panel(Place::Main(dock));
1530                self.dt_built = Some(shown);
1531            }
1532            // By key and not by label: the container is not the host's
1533            // to find through `key_of`.
1534            self.open_with_key(Key::ROOT.str(APP_KEY), NodeSpec::column().fill().clip());
1535            // The host's children are keyed from the root, as if the
1536            // container were not there (ADR 0014's namespace, reused).
1537            self.ns_depth = self.stack.len();
1538            self.ns_key = Key::ROOT;
1539            self.dt_app = Some(self.tree.len() - 1);
1540        }
1541    }
1542
1543    /// `configure_root` while the host's tree is wrapped (decision 2):
1544    /// what lays out and paints the children goes to the container, what
1545    /// is addressed stays on the root.
1546    pub(crate) fn devtools_configure_root(&mut self, app: usize, spec: NodeSpec) {
1547        let key = self.tree.keys[app];
1548        let mut inner = spec.clone();
1549        inner.layout.width = Sizing::Grow(1.0);
1550        inner.layout.height = Sizing::Grow(1.0);
1551        inner.layout.float = None;
1552        if !(inner.layout.scroll_x || inner.layout.scroll_y) {
1553            inner.layout.clip = true;
1554        }
1555        inner.events = None;
1556        inner.access = None;
1557        inner.interact = None;
1558        inner.focusable = false;
1559        inner.initial_focus = false;
1560        inner.hoverable = false;
1561        inner.cursor = None;
1562        inner.window = None;
1563        self.ease_spec(key, &mut inner);
1564        self.tree.note(&inner, &crate::tree::NodeContent::Container);
1565        self.tree.specs[app] = inner;
1566
1567        let mut outer = self.tree.specs[0].clone();
1568        outer.style.bg = spec.style.bg;
1569        outer.events = spec.events;
1570        outer.access = spec.access;
1571        outer.focusable = spec.focusable;
1572        outer.initial_focus = spec.initial_focus;
1573        outer.hoverable = spec.hoverable;
1574        outer.disabled = spec.disabled;
1575        outer.cursor = spec.cursor;
1576        outer.window = spec.window;
1577        self.tree.note(&outer, &crate::tree::NodeContent::Container);
1578        self.tree.specs[0] = outer;
1579    }
1580
1581    /// `Ui::finish`, before the menu: closes the app container, builds the
1582    /// panel where it goes, and declares its window when it has one.
1583    pub(crate) fn devtools_finish(&mut self) {
1584        if self.dt_window {
1585            // The deferred root (decision 6): the same push `begin_frame`
1586            // makes, and then the panel is the whole tree.
1587            self.tree.push(
1588                crate::tree::NIL,
1589                Key::ROOT,
1590                OriginId::HOST,
1591                NodeSpec::column().fill(),
1592                crate::tree::NodeContent::Container,
1593            );
1594            self.stack.clear();
1595            self.stack.push(0);
1596            self.counters.clear();
1597            self.counters.push(0);
1598            self.ns_depth = usize::MAX;
1599            self.build_panel(Place::Window);
1600            return;
1601        }
1602        if self.env.window.id != WindowId::MAIN {
1603            return;
1604        }
1605        let (on, dock, shown) = {
1606            let s = self.session.state();
1607            (s.devtools.on, s.devtools.dock, s.devtools.shown())
1608        };
1609        if let Some(_app) = self.dt_app.take() {
1610            // The host's last node closed for it, as `finish_frame` does.
1611            self.stack.truncate(1);
1612            self.counters.truncate(1);
1613            self.ns_depth = usize::MAX;
1614        }
1615        // A left panel is built at `begin_frame`, from the state then, and
1616        // is in the tree for good this frame: turned off, moved or put on
1617        // another tab since — an app's door from its `view` — it is drawn
1618        // as it was, so the next frame is asked for, as the right and
1619        // bottom docks' deferral below asks (backlog RG4). With the idle
1620        // loop quiet, nothing else would draw it.
1621        if let Some(built) = self.dt_built
1622            && (!on || self.dt_dock != Some(dock) || built != shown)
1623        {
1624            self.owe_frame("devtools");
1625        }
1626        if !on {
1627            return;
1628        }
1629        if dock == Dock::Window {
1630            let saved = self.origin;
1631            self.origin = OriginId::DEVTOOLS;
1632            self.declare_window(
1633                DEVTOOLS_WINDOW,
1634                WindowConfig::sized(WINDOW_SIZE.w, WINDOW_SIZE.h),
1635            );
1636            self.origin = saved;
1637        }
1638        if self.dt_built.is_some() {
1639            return;
1640        }
1641        // A docked panel is built into the root this frame began with:
1642        // wrapped for this dock, it goes beside the app container. Turned
1643        // on, or moved to this dock, since `begin_frame` — an app's
1644        // `set_devtools` from its `view` — the root is not laid out for it
1645        // (a column, or a row for another side), and a panel built into it
1646        // anyway sat in the bottom-left corner, 340 wide and half the
1647        // height, until the next frame; so it waits for that frame, and
1648        // asks for it. Undocked, there is nothing to lay out around.
1649        if !dock.docked() || self.dt_dock == Some(dock) {
1650            self.build_panel(Place::Main(dock));
1651        } else {
1652            self.owe_frame("devtools");
1653        }
1654    }
1655
1656    /// Opens the `kui-devtools` node under the current cursor and builds
1657    /// the panel — the dock, the outlines, the picker — inside it, with
1658    /// the session's state taken out for the length of the build.
1659    fn build_panel(&mut self, place: Place) {
1660        let saved_origin = self.origin;
1661        self.origin = OriginId::DEVTOOLS;
1662        // The facts the panel reads: this frame's, from this core, when it
1663        // is the main window; the main window's last, from the session,
1664        // in the panel's own.
1665        let mut state = std::mem::take(&mut self.session.state().devtools);
1666        // The nodes the tree tab lists: this core's last frame in the main
1667        // window, the main window's copy in the panel's own — taken out
1668        // for the build, since the build borrows the core.
1669        let nodes = match place {
1670            Place::Main(dock) => {
1671                // A left dock is built at `begin_frame`, before the host
1672                // has declared this frame's title: it reads the facts the
1673                // last frame left, as the panel's own window does.
1674                if dock != Dock::Left {
1675                    state.facts = self.collect_facts(state.inspect_key);
1676                    state.tabs.clone_from(&self.dt_tabs);
1677                } else {
1678                    // Except the window's size, which is this frame's and
1679                    // is what the pane's width is clamped by.
1680                    state.facts.viewport = self.viewport;
1681                }
1682                std::mem::take(&mut self.inspected)
1683            }
1684            Place::Window => std::mem::take(&mut state.nodes),
1685        };
1686        #[cfg(feature = "devtools")]
1687        {
1688            let mut ui = crate::ui::Ui::wrap(self);
1689            panel::build(&mut ui, &mut state, &nodes, place);
1690        }
1691        match place {
1692            Place::Main(_) => self.inspected = nodes,
1693            Place::Window => state.nodes = nodes,
1694        }
1695        // What the build asked of the windows.
1696        if std::mem::take(&mut state.focus_window)
1697            && let Some(id) = self.devtools_window_id()
1698        {
1699            self.interaction
1700                .window_commands
1701                .push(WindowCommand::Focus(id));
1702        }
1703        let redraw_main = std::mem::take(&mut state.redraw_main) && self.dt_window;
1704        self.session.state().devtools = state;
1705        self.origin = saved_origin;
1706        if redraw_main {
1707            self.devtools_redraw_others();
1708        }
1709    }
1710
1711    /// The end of `finish_frame` in the main window: the facts and the
1712    /// nodes for the panel's own window, the warnings into the stream, the
1713    /// frame count.
1714    pub(crate) fn devtools_after_frame(&mut self) {
1715        if self.env.window.id != WindowId::MAIN || !self.session.state().devtools.on {
1716            return;
1717        }
1718        let tabs = std::mem::take(&mut self.dt_tabs);
1719        let raised = self.warnings_raised();
1720        let facts = self.collect_facts(self.devtools_key());
1721        let mut s = self.session.state();
1722        let d = &mut s.devtools;
1723        d.frames = self.frame_no;
1724        d.facts = facts;
1725        d.tabs = tabs;
1726        // The panel's own window draws the tree from a copy; docked, the
1727        // tab reads this core's list itself and the copy is not kept.
1728        // The first copy is what the panel's window is waiting for: it
1729        // drew its tree tab before there was one, so it is asked again.
1730        let mut wake_panel = false;
1731        if d.dock == Dock::Window && d.shown() == Shown::Builtin(Tab::Tree) {
1732            wake_panel = d.nodes.is_empty() && !self.inspected.is_empty();
1733            d.nodes = self.inspected.clone();
1734        } else if !d.nodes.is_empty() {
1735            d.nodes = Vec::new();
1736        }
1737        let new_warnings_empty = raised.len() <= d.warnings_seen;
1738        if raised.len() > d.warnings_seen {
1739            let new: Vec<(String, String)> = raised[d.warnings_seen..]
1740                .iter()
1741                .map(|w| (w.code.to_string(), w.message.clone()))
1742                .collect();
1743            d.warnings_seen = raised.len();
1744            let frame = d.frames;
1745            for (code, message) in new {
1746                d.push(Entry {
1747                    seq: 0,
1748                    frame,
1749                    kind: EntryKind::Warning,
1750                    window: WindowId::MAIN,
1751                    key: Key::ROOT,
1752                    label: None,
1753                    origin: OriginId::DEVTOOLS,
1754                    payload: Value::map([
1755                        ("kind", Value::str("warning")),
1756                        ("code", Value::str(code)),
1757                        ("message", Value::str(message)),
1758                    ]),
1759                    lines: 0,
1760                });
1761            }
1762        }
1763        // Warnings logged while popped out move the stream there too.
1764        wake_panel |= !new_warnings_empty && d.dock == Dock::Window;
1765        drop(s);
1766        if wake_panel {
1767            self.devtools_redraw_others();
1768        }
1769    }
1770
1771    /// The start of `handle_input`: a chord, or `Escape` while picking, is
1772    /// the panel's and the press goes no further (decision 4).
1773    pub(crate) fn devtools_intercept(&mut self, ev: &InputEvent) -> bool {
1774        let press = match ev {
1775            InputEvent::KeyDown(press) => press,
1776            // The editor channel's Escape is the same key: a driver sends
1777            // both, and while the picker is up neither is anybody else's.
1778            InputEvent::Key(crate::EditKey::Escape, _) => {
1779                let mut s = self.session.state();
1780                let d = &mut s.devtools;
1781                return d.on && (d.pick || std::mem::take(&mut d.escape_owed));
1782            }
1783            _ => return false,
1784        };
1785        if !self.session.state().devtools.on {
1786            return false;
1787        }
1788        if press.code == KeyCode::Escape {
1789            let mut s = self.session.state();
1790            if s.devtools.pick {
1791                s.devtools.pick = false;
1792                s.devtools.pick_hover = None;
1793                s.devtools.pick_keep_tab = false;
1794                s.devtools.escape_owed = true;
1795                drop(s);
1796                self.devtools_redraw_others();
1797                return true;
1798            }
1799            return false;
1800        }
1801        // Any other press settles the owed half: a driver that never
1802        // sends the editor channel owes nothing.
1803        let inspect = {
1804            let mut s = self.session.state();
1805            s.devtools.escape_owed = false;
1806            s.devtools.inspect_key
1807        };
1808        let Some(what) = chord(press, inspect) else {
1809            return false;
1810        };
1811        self.devtools_act(what);
1812        true
1813    }
1814
1815    /// One action, from a chord or a control, with what it asks of the
1816    /// core done after the state borrow ends.
1817    fn devtools_act(&mut self, what: &str) {
1818        let (acted, focus_main) = {
1819            let mut s = self.session.state();
1820            let acted = s.devtools.act(what);
1821            (acted, std::mem::take(&mut s.devtools.focus_main))
1822        };
1823        if focus_main {
1824            self.interaction
1825                .window_commands
1826                .push(WindowCommand::Focus(WindowId::MAIN));
1827        }
1828        if acted {
1829            self.devtools_redraw_others();
1830        }
1831    }
1832
1833    /// The panel's controls in the batch are acted on and dropped, the
1834    /// panel's window's own `window` events too, and in the panel's
1835    /// window nothing at all is the host's (decision 4).
1836    pub(crate) fn devtools_consume(&mut self, out: &mut Vec<UiEvent>) {
1837        if out.is_empty() {
1838            return;
1839        }
1840        let on = self.session.state().devtools.on;
1841        if !on && !self.dt_window {
1842            // Off, a panel control can still be in the tree the input
1843            // resolves against — the frame the door owes is not drawn
1844            // yet — and its event is nobody's: not the app's, which
1845            // never declared the origin, and not an action either, since
1846            // the panel it would act on is gone (backlog RG2).
1847            out.retain(|ev| ev.origin != OriginId::DEVTOOLS);
1848            return;
1849        }
1850        let mut actions: Vec<String> = Vec::new();
1851        let mut picks = 0usize;
1852        let mut closed = false;
1853        let mut resize: Option<Vec2> = None;
1854        out.retain(|ev| {
1855            if ev.origin == OriginId::DEVTOOLS {
1856                // A click carries its payload flat; a drag's or a sink's
1857                // rides in `tag`; a select's choice is the menu row's
1858                // `item` (`widgets::select`).
1859                let what = ev
1860                    .payload
1861                    .get("dt")
1862                    .or_else(|| ev.payload.get("tag").and_then(|t| t.get("dt")))
1863                    .or_else(|| ev.payload.get("item").and_then(|t| t.get("dt")))
1864                    .and_then(Value::as_str);
1865                match what {
1866                    Some("picked") => picks += 1,
1867                    Some("tree-key") => {
1868                        // The tree list's sink: a press (not a release, not
1869                        // a repeat of a chord) becomes an action naming
1870                        // the key, for the build to move the cursor by.
1871                        let s = |k: &str| ev.payload.get(k).and_then(Value::as_str);
1872                        // Both modifiers ride every key payload, so each
1873                        // is asked on its own: a chord bubbles to the sink
1874                        // (ADR 0011) and must not move the cursor.
1875                        let held = |k: &str| matches!(ev.payload.get(k), Some(Value::Bool(true)));
1876                        let plain = !held("ctrl") && !held("super");
1877                        if s("phase") == Some("down")
1878                            && plain
1879                            && let Some(code) = s("code")
1880                        {
1881                            actions.push(format!("tree-key:{code}"));
1882                        }
1883                    }
1884                    Some("resize") => {
1885                        let f = |k: &str| ev.payload.get(k).and_then(Value::as_float);
1886                        if let (Some(x), Some(y)) = (f("x"), f("y")) {
1887                            resize = Some(Vec2::new(x as f32, y as f32));
1888                        }
1889                    }
1890                    Some(what) => actions.push(what.to_string()),
1891                    None => {}
1892                }
1893                // An edit's `changed` and the like: the build reads the
1894                // field's text; nothing to do here.
1895                return false;
1896            }
1897            if ev.kind() == Some("window") && ev.payload.get_str("name") == Some(DEVTOOLS_WINDOW) {
1898                closed |= ev.payload.get_str("phase") == Some("closed");
1899                return false;
1900            }
1901            !self.dt_window
1902        });
1903        for what in actions {
1904            self.devtools_act(&what);
1905        }
1906        if let Some(p) = resize {
1907            // The handle is dragged in the main window: the pane's extent
1908            // is what the pointer leaves between the edge and itself.
1909            let vp = self.viewport;
1910            let mut s = self.session.state();
1911            let d = &mut s.devtools;
1912            match d.dock {
1913                Dock::Left => d.side_w = pane_w(p.x, vp.w),
1914                Dock::Right => d.side_w = pane_w(vp.w - p.x, vp.w),
1915                Dock::Bottom => d.bottom_h = pane_h(vp.h - p.y, vp.h),
1916                _ => {}
1917            }
1918        }
1919        if picks > 0 {
1920            // The picker's overlay was pressed: the node it was showing is
1921            // the one picked.
1922            let mut s = self.session.state();
1923            let d = &mut s.devtools;
1924            d.pick = false;
1925            if let Some(k) = d.pick_hover.take() {
1926                if std::mem::take(&mut d.pick_keep_tab) {
1927                    d.selected = Some(k);
1928                    d.reveal = Some(k);
1929                } else {
1930                    d.select(k);
1931                }
1932            }
1933            drop(s);
1934            self.devtools_redraw_others();
1935        }
1936        if closed {
1937            // The user closed the panel's window — unless the panel had
1938            // already moved on (a placement button in that very window
1939            // takes it away, and the close that follows is our own).
1940            let mut s = self.session.state();
1941            if s.devtools.dock == Dock::Window {
1942                s.devtools.dock = Dock::Off;
1943                s.devtools.pick = false;
1944                s.devtools.note("dock: off (window closed)");
1945            }
1946        }
1947    }
1948
1949    /// Every event handed to the host, into the stream — after the
1950    /// window stamp, so the row can say which window (decision 4).
1951    pub(crate) fn devtools_log(&mut self, out: &[UiEvent]) {
1952        if out.is_empty() {
1953            return;
1954        }
1955        let entries: Vec<Entry> = {
1956            let s = self.session.state();
1957            let d = &s.devtools;
1958            if !d.on || d.paused {
1959                return;
1960            }
1961            let frame = d.frames;
1962            out.iter()
1963                .map(|ev| Entry {
1964                    seq: 0,
1965                    frame,
1966                    kind: EntryKind::Event,
1967                    window: ev.window,
1968                    key: ev.key,
1969                    label: if ev.key == Key::ROOT {
1970                        Some("root".into())
1971                    } else {
1972                        self.label_of(ev.key).map(str::to_string)
1973                    },
1974                    origin: ev.origin,
1975                    payload: redact(&ev.payload),
1976                    lines: 0,
1977                })
1978                .collect()
1979        };
1980        let mut s = self.session.state();
1981        for e in entries {
1982            s.devtools.push(e);
1983        }
1984        let popped = s.devtools.dock == Dock::Window;
1985        drop(s);
1986        if popped && !self.dt_window {
1987            self.devtools_redraw_others();
1988        }
1989    }
1990
1991    /// Asks the *other* window that shows the panel's effects to draw
1992    /// again (decision 7): the main window from the panel's own, the
1993    /// panel's own from anywhere else. Coalesced against the last command
1994    /// queued, since a hover storm is many events.
1995    fn devtools_redraw_others(&mut self) {
1996        let target = if self.dt_window {
1997            Some(WindowId::MAIN)
1998        } else {
1999            self.devtools_window_id()
2000        };
2001        let Some(id) = target else {
2002            return;
2003        };
2004        let cmds = &mut self.interaction.window_commands;
2005        if cmds.last() != Some(&WindowCommand::Redraw(id)) {
2006            cmds.push(WindowCommand::Redraw(id));
2007        }
2008    }
2009
2010    /// The status block, read from the doors it comes from. `inspect` is
2011    /// the chord into the dock, handed in because the main window's build
2012    /// collects with the panel's state taken out of the session (and
2013    /// `devtools_key` would read the default).
2014    fn collect_facts(&self, inspect: Accel) -> Facts {
2015        let env = &self.env;
2016        let vp = self.viewport;
2017        let scale = self.scale;
2018        let name = |k: Key| match self.label_of(k) {
2019            _ if k == Key::ROOT => "root".to_string(),
2020            Some(l) => l.to_string(),
2021            None => format!("{:08x}", k.0 as u32),
2022        };
2023        let focus = self.focus;
2024        let focus_visible = self.focus_visible;
2025        let region = self.region();
2026        let inspect = inspect.display();
2027        let mods = self.modifiers();
2028        let windows: Vec<String> = self
2029            .windows()
2030            .iter()
2031            .map(|(id, name)| format!("{name}#{}", id.0))
2032            .collect();
2033        let source = match self.theme_source {
2034            ThemeSource::Derived => "derived",
2035            ThemeSource::DerivedWithAccent(_) => "derived + accent",
2036            ThemeSource::Pinned(_) => "pinned",
2037        };
2038        let opt = |s: Option<String>| s.unwrap_or_else(|| "—".into());
2039        let hex = |c: Color| format!("#{:06x}", c.to_hex() >> 8);
2040        let rows: Vec<(&'static str, String)> = vec![
2041            ("appearance", env.system.appearance.name().into()),
2042            ("motion", env.system.motion.name().into()),
2043            ("locale", opt(env.system.locale.map(|l| l.to_string()))),
2044            ("assistive", env.system.assistive.name().into()),
2045            (
2046                "theme",
2047                format!("{} · {source}", self.theme.appearance.name()),
2048            ),
2049            ("accent", hex(self.theme.accent)),
2050            (
2051                "menus",
2052                if self.native_menus { "native" } else { "drawn" }.into(),
2053            ),
2054            (
2055                "window",
2056                format!(
2057                    "#{}{}{}{}{} · {}",
2058                    env.window.id.0,
2059                    if env.window.custom_chrome {
2060                        " custom-chrome"
2061                    } else {
2062                        ""
2063                    },
2064                    if env.window.maximized {
2065                        " maximized"
2066                    } else {
2067                        ""
2068                    },
2069                    if env.window.fullscreen {
2070                        " fullscreen"
2071                    } else {
2072                        ""
2073                    },
2074                    if env.window.always_on_top {
2075                        " on-top"
2076                    } else {
2077                        ""
2078                    },
2079                    windows.join(" "),
2080                ),
2081            ),
2082            (
2083                "viewport",
2084                format!(
2085                    "{}{}×{} @{scale} · {}",
2086                    // The app's, then the window's when the dock has
2087                    // taken some of it.
2088                    if self.dt_area.w != vp.w || self.dt_area.h != vp.h {
2089                        format!("{}×{} of ", self.dt_area.w.round(), self.dt_area.h.round())
2090                    } else {
2091                        String::new()
2092                    },
2093                    vp.w.round(),
2094                    vp.h.round(),
2095                    opt(env.refresh_hz.map(|hz| format!("{hz:.0} Hz"))),
2096                ),
2097            ),
2098            (
2099                "keyboard",
2100                if env.focused {
2101                    "this window"
2102                } else {
2103                    "elsewhere"
2104                }
2105                .into(),
2106            ),
2107            (
2108                "focus",
2109                match focus.map(name) {
2110                    Some(l) if focus_visible => format!("{l} · ring"),
2111                    Some(l) => l,
2112                    None => "—".into(),
2113                },
2114            ),
2115            (
2116                "region",
2117                match region.map(name) {
2118                    Some(l) => format!("{l} · {inspect} leaves"),
2119                    None => format!("main · {inspect} enters the dock"),
2120                },
2121            ),
2122            ("modifiers", {
2123                let mut m = Vec::new();
2124                if mods.shift {
2125                    m.push("shift");
2126                }
2127                if mods.ctrl {
2128                    m.push("ctrl");
2129                }
2130                if mods.alt {
2131                    m.push("alt");
2132                }
2133                if mods.super_key {
2134                    m.push("super");
2135                }
2136                if m.is_empty() {
2137                    "—".into()
2138                } else {
2139                    m.join("+")
2140                }
2141            }),
2142            (
2143                "audio",
2144                format!("{} · {} live", env.audio.device.name(), env.audio.live),
2145            ),
2146            ("nodes", format!("{}", self.inspected.len())),
2147            ("fonts", {
2148                // Which installed faces the three generic families are on
2149                // this machine (backlog C32), so a wrong one says so on
2150                // screen.
2151                let [sans, serif, mono] = self.default_font_families();
2152                format!("{sans} · {serif} · {mono}")
2153            }),
2154        ];
2155        let mut origins: Vec<&OriginId> = self.tokens.keys().collect();
2156        origins.sort_by_key(|o| o.0);
2157        let mut tokens = Vec::new();
2158        for origin in origins {
2159            let table = &self.tokens[origin];
2160            for (i, (name, _)) in table.colors().iter().enumerate() {
2161                let (light, dark) = table.halves(i as u16, &self.theme);
2162                tokens.push(TokenFact {
2163                    origin: *origin,
2164                    name: name.clone(),
2165                    kind: crate::tokens::TokenKind::Color,
2166                    light,
2167                    dark,
2168                    resolved: table.resolve_color(i as u16, &self.theme),
2169                    length: 0.0,
2170                    recipe: table.recipe(i as u16),
2171                });
2172            }
2173            for (name, v) in table.lengths() {
2174                tokens.push(TokenFact {
2175                    origin: *origin,
2176                    name: name.clone(),
2177                    kind: crate::tokens::TokenKind::Length,
2178                    light: Color::TRANSPARENT,
2179                    dark: Color::TRANSPARENT,
2180                    resolved: Color::TRANSPARENT,
2181                    length: *v,
2182                    recipe: None,
2183                });
2184            }
2185        }
2186        Facts {
2187            title: self
2188                .window_title
2189                .clone()
2190                .unwrap_or_else(|| "kui".to_string()),
2191            rows,
2192            tokens,
2193            theme_source: self.app_theme_source(),
2194            app_appearance: self.app_theme_source().resolve(&env.system).appearance,
2195            viewport: vp,
2196            focus,
2197            focus_visible,
2198            region,
2199            hovered: self.interaction.hovered(),
2200            pressed: self.interaction.pressed_key(),
2201            cursor: self.interaction.cursor(),
2202        }
2203    }
2204}
2205
2206/// A `Value` as one line of data: `{kind: click, n: 3}`.
2207pub fn fmt_value(v: &Value) -> String {
2208    let mut out = String::new();
2209    write_value(v, &mut out);
2210    out
2211}
2212
2213fn write_value(v: &Value, out: &mut String) {
2214    match v {
2215        Value::Null => out.push_str("null"),
2216        Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
2217        Value::Int(i) => out.push_str(&i.to_string()),
2218        Value::Float(f) => {
2219            if f.fract() == 0.0 && f.abs() < 1e9 {
2220                out.push_str(&format!("{f:.0}"));
2221            } else {
2222                out.push_str(&format!("{f:.2}"));
2223            }
2224        }
2225        Value::Str(s) => {
2226            if s.chars().any(|c| c.is_whitespace() || c == ',' || c == '}') || s.is_empty() {
2227                out.push_str(&format!("{s:?}"));
2228            } else {
2229                out.push_str(s);
2230            }
2231        }
2232        Value::List(items) => {
2233            out.push('[');
2234            for (i, item) in items.iter().enumerate() {
2235                if i > 0 {
2236                    out.push_str(", ");
2237                }
2238                write_value(item, out);
2239            }
2240            out.push(']');
2241        }
2242        Value::Map(entries) => {
2243            out.push('{');
2244            for (i, (k, v)) in entries.iter().enumerate() {
2245                if i > 0 {
2246                    out.push_str(", ");
2247                }
2248                out.push_str(k);
2249                out.push_str(": ");
2250                write_value(v, out);
2251            }
2252            out.push('}');
2253        }
2254    }
2255}
2256
2257/// How many lines `v` takes as an indented tree: a map's entries and a
2258/// list's items are one each, nested ones under theirs; a scalar is one
2259/// line, and a map or list is its entries alone (its own line is the
2260/// row's summary).
2261pub(crate) fn lines(v: &Value) -> u16 {
2262    fn count(v: &Value) -> usize {
2263        match v {
2264            Value::Map(entries) => entries.iter().map(|(_, v)| 1 + nested(v)).sum::<usize>(),
2265            Value::List(items) => items.iter().map(|v| 1 + nested(v)).sum::<usize>(),
2266            _ => 1,
2267        }
2268    }
2269    fn nested(v: &Value) -> usize {
2270        match v {
2271            Value::Map(_) | Value::List(_) => count(v),
2272            _ => 0,
2273        }
2274    }
2275    count(v).min(u16::MAX as usize) as u16
2276}
2277
2278/// A side pane's width for a window `vw` wide: what the handle left,
2279/// within the pane's floor and what the app needs — the floor winning
2280/// when the window has room for neither.
2281fn pane_w(side_w: f32, vw: f32) -> f32 {
2282    side_w.min((vw - APP_MIN).max(SIDE_MIN_W)).max(SIDE_MIN_W)
2283}
2284
2285fn pane_h(bottom_h: f32, vh: f32) -> f32 {
2286    bottom_h
2287        .min((vh - APP_MIN).max(BOTTOM_MIN_H))
2288        .max(BOTTOM_MIN_H)
2289}
2290
2291/// Where a build is drawing the panel.
2292#[derive(Clone, Copy, PartialEq, Eq)]
2293enum Place {
2294    /// The main window: the dock (or nothing, for `Window` and `Off`)
2295    /// plus the outlines and the picker.
2296    Main(Dock),
2297    /// The panel's own window: the panel is the whole tree.
2298    Window,
2299}
2300
2301/// A payload as the stream keeps it: a paste the pasteboard marked
2302/// concealed (F84) — a password from a password manager — keeps its
2303/// markers and its length and loses its text, which the events tab would
2304/// otherwise show in plain view and hold for the stream's lifetime (RG34).
2305fn redact(payload: &Value) -> Value {
2306    let concealed = payload.get_bool("concealed") == Some(true);
2307    match payload {
2308        Value::Map(fields) if concealed => Value::Map(
2309            fields
2310                .iter()
2311                .map(|(k, v)| match (k.as_str(), v) {
2312                    ("text", Value::Str(t)) => (
2313                        k.clone(),
2314                        Value::str(format!("‹concealed, {} chars›", t.chars().count())),
2315                    ),
2316                    _ => (k.clone(), v.clone()),
2317                })
2318                .collect(),
2319        ),
2320        _ => payload.clone(),
2321    }
2322}