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