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 if !cfg!(feature = "devtools") || self.tree.is_empty() {
864 return false;
865 }
866 if self.dt_tabs.iter().any(|t| t.name == name) {
867 self.diag.raise(crate::diag::duplicate_tab(name));
868 return false;
869 }
870 self.dt_tabs.push(TabDecl {
871 name: name.to_string(),
872 label: label.to_string(),
873 slot: slot.map(str::to_string),
874 });
875 true
876 }
877
878 /// Whether the host form of tab `name` is shown this frame — the
879 /// panel is on, docked in this (the main) window, and `name` is the
880 /// tab on show. Read before the content is
881 /// built, from the session's state, which is in place while the
882 /// host's view runs.
883 pub fn devtools_tab_shown(&self, name: &str) -> bool {
884 if !cfg!(feature = "devtools") || self.dt_window || self.env.window.id != WindowId::MAIN {
885 return false;
886 }
887 let s = self.session.state();
888 let d = &s.devtools;
889 d.on && d.dock.docked() && d.custom.as_deref() == Some(name) && d.lists(name)
890 }
891
892 /// The tab on show, by name, when it is a declared one — what the
893 /// Node and Lua drivers read once a frame to call a tab's function
894 /// child. `None` for one of the panel's own,
895 /// for the panel off, popped out, or another window's frame.
896 pub fn devtools_shown_tab(&self) -> Option<String> {
897 if !cfg!(feature = "devtools") || self.dt_window || self.env.window.id != WindowId::MAIN {
898 return None;
899 }
900 let s = self.session.state();
901 let d = &s.devtools;
902 if !(d.on && d.dock.docked()) {
903 return None;
904 }
905 d.custom.clone().filter(|name| d.lists(name))
906 }
907
908 /// Opens the host form's content node: a float anchored to the tab's
909 /// body by key, the body's size, clipped, keyed as the host's own
910 /// child. The caller builds inside and
911 /// closes. The bare door under `Ui::devtools_tab_with`; it does not
912 /// declare, and it does not ask whether the tab is on show.
913 pub fn devtools_tab_open(&mut self, name: &str) {
914 let spec = NodeSpec::column()
915 .float(crate::spec::FloatConfig {
916 anchor: crate::spec::FloatAnchor::Node(tab_body_key(name)),
917 ..Default::default()
918 })
919 .fill()
920 .clip();
921 self.open_keyed(&format!("devtools-tab:{name}"), spec);
922 }
923
924 /// Whether `name` is a slot a declared tab names this frame — what
925 /// counts as *declared* for the `unknown-slot` check whether or not
926 /// the panel mounted it.
927 pub(crate) fn devtools_tab_slot(&self, name: &str) -> bool {
928 self.dt_tabs.iter().any(|t| t.slot.as_deref() == Some(name))
929 }
930
931 /// Mounts the extension form of the tab on show, if it has one: the
932 /// slot is declared at the cursor under the host's origin — so the
933 /// fill's replies reach the host — and filled inside a float anchored to the
934 /// tab's body, the panel's facts as params. Called with the filler in
935 /// hand: from `Ui::finish` in the
936 /// main window before the filler's own finish, and in the panel's
937 /// window after its build. Nothing to do when the tab on show is the
938 /// panel's own or a host form.
939 pub(crate) fn devtools_fill_mount(&mut self, filler: &mut dyn crate::slot::Fill) {
940 if !cfg!(feature = "devtools") || self.tree.is_empty() {
941 return;
942 }
943 let (name, slot, params) = {
944 let s = self.session.state();
945 let d = &s.devtools;
946 if !d.on || !(d.dock.docked() || self.dt_window) {
947 return;
948 }
949 let tabs = if self.dt_window {
950 &d.tabs
951 } else {
952 &self.dt_tabs
953 };
954 let Some(name) = d.custom.as_deref() else {
955 return;
956 };
957 let Some(decl) = tabs.iter().find(|t| t.name == name) else {
958 return;
959 };
960 let Some(slot) = decl.slot.clone() else {
961 return;
962 };
963 (decl.name.clone(), slot, d.facts_params())
964 };
965 let saved = self.origin;
966 self.origin = OriginId::HOST;
967 if let Some(key) = self.begin_slot(&slot) {
968 self.devtools_tab_open(&name);
969 filler.fill(&slot, key, ¶ms, &mut crate::ui::Ui::new(self));
970 self.close();
971 }
972 self.origin = saved;
973 }
974}
975
976impl State {
977 /// The panel's facts as a slot's params: the
978 /// selected, hovered and picked nodes as hex keys (`null` for none),
979 /// the region and the focus by label from the facts rows.
980 fn facts_params(&self) -> Value {
981 let key = |k: Option<Key>| match k {
982 Some(k) => Value::str(format!("{:016x}", k.0)),
983 None => Value::Null,
984 };
985 let row = |name: &str| {
986 self.facts
987 .rows
988 .iter()
989 .find(|(k, _)| *k == name)
990 .map(|(_, v)| Value::str(v.clone()))
991 .unwrap_or(Value::Null)
992 };
993 Value::map([
994 ("selected", key(self.selected)),
995 ("hovered", key(self.hovered_row)),
996 ("picked", key(self.pick_hover)),
997 ("region", row("region")),
998 ("focus", row("focus")),
999 ])
1000 }
1001}
1002
1003// ---------------------------------------------------------------------------
1004// The doors.
1005
1006impl Core {
1007 /// Turns the devtools panel on or off for this session. On,
1008 /// it is drawn where [`Self::set_devtools_dock`] says — beside the
1009 /// host's tree in the main window by default — and its chords are
1010 /// live in every window. `KUI_DEVTOOLS=1` in the environment is the
1011 /// same call made by nobody; `KUI_DEVTOOLS=bottom` (or `left`,
1012 /// `right`, `window`, `off`) also says where.
1013 pub fn set_devtools(&mut self, on: bool) {
1014 // Built without the panel: the door stays and does nothing.
1015 if !cfg!(feature = "devtools") {
1016 return;
1017 }
1018 let mut s = self.session.state();
1019 if s.devtools.on == on {
1020 return;
1021 }
1022 s.devtools.on = on;
1023 if !on {
1024 s.devtools.pick = false;
1025 s.devtools.pick_keep_tab = false;
1026 }
1027 }
1028
1029 /// Opens the panel if `KUI_DEVTOOLS` in the environment asks for it
1030 /// (`1`, `true`, or a placement name), and says whether it did. What
1031 /// the windowed runners call once, before the first frame — the door
1032 /// for a program that was never told about the panel — and what a
1033 /// headless core never reads, so a variable left exported cannot put
1034 /// a dock into a test's tree.
1035 pub fn devtools_from_env(&mut self) -> bool {
1036 let asked = State::from_var(std::env::var("KUI_DEVTOOLS").ok().as_deref());
1037 if asked.on {
1038 self.set_devtools(true);
1039 self.set_devtools_dock(asked.dock);
1040 }
1041 asked.on
1042 }
1043
1044 /// Whether the panel is on.
1045 pub fn devtools(&self) -> bool {
1046 self.session.state().devtools.on
1047 }
1048
1049 /// Where the panel sits; `Ctrl+Shift+D` moves it from there.
1050 pub fn set_devtools_dock(&mut self, dock: Dock) {
1051 self.session.state().devtools.dock = dock;
1052 }
1053
1054 pub fn devtools_dock(&self) -> Dock {
1055 self.session.state().devtools.dock
1056 }
1057
1058 /// Seeds the panel's theme override — what its `T` and `A` chords
1059 /// cycle from. `None` for either leaves the app's own.
1060 pub fn set_devtools_theme(&mut self, base: Option<Appearance>, accent: Option<Color>) {
1061 let mut s = self.session.state();
1062 s.devtools.base = base;
1063 s.devtools.accent = None;
1064 s.devtools.custom_accent = accent;
1065 }
1066
1067 /// Respells the chord that moves the keyboard into the panel and
1068 /// back out — and brings the panel back when it is `off` — from its
1069 /// default `Ctrl+Shift+I`: any [`Accel`] spelling (`"f12"`,
1070 /// `"mod+shift+d"`, `"⌥⌘I"`). The other chords stay `Ctrl+Shift+
1071 /// <letter>`; this is the one an app puts in its own help, and the
1072 /// one whose default an app's keymap may want for itself. A chord
1073 /// the app takes is the app's for good: with `F12` set,
1074 /// `Ctrl+Shift+I` reaches the app's sinks like any other press.
1075 pub fn set_devtools_key(&mut self, key: Accel) {
1076 self.session.state().devtools.inspect_key = key;
1077 }
1078
1079 /// The chord that moves the keyboard into the panel, as set or as
1080 /// it defaults.
1081 pub fn devtools_key(&self) -> Accel {
1082 self.session.state().devtools.inspect_key
1083 }
1084
1085 /// The node the panel's tree tab has selected: what an inspector in a
1086 /// declared tab reads to say which node
1087 /// it is about. Answered from the session, so it is right inside the
1088 /// host's view and inside an extension's fill alike.
1089 pub fn devtools_selected(&self) -> Option<Key> {
1090 self.session.state().devtools.selected
1091 }
1092
1093 /// The tree row under the pointer in whichever window draws the
1094 /// tree — the node the main window outlines.
1095 pub fn devtools_hovered(&self) -> Option<Key> {
1096 self.session.state().devtools.hovered_row
1097 }
1098
1099 /// The node the picker last saw under the pointer, while picking.
1100 pub fn devtools_picked(&self) -> Option<Key> {
1101 self.session.state().devtools.pick_hover
1102 }
1103
1104 /// Selects a node in the panel's tree tab from outside it — an
1105 /// inspector driving the highlight from its side — and reveals it
1106 /// there, as the picker does; `None` clears. The tab does not move:
1107 /// the caller is drawing in one.
1108 pub fn set_devtools_selected(&mut self, key: Option<Key>) {
1109 let mut s = self.session.state();
1110 let d = &mut s.devtools;
1111 d.selected = key;
1112 d.reveal = key;
1113 drop(s);
1114 self.devtools_redraw_others();
1115 }
1116
1117 /// Raises the panel's picker from outside it — an inspector in a
1118 /// declared tab asking "which node?" — or puts it away. Picking happens in
1119 /// the main window, over the app: the
1120 /// node under the pointer is `devtools_picked` while it is up, and
1121 /// the press lands it in `devtools_selected`. Raised while a declared
1122 /// tab is on show, the pick leaves that tab up; raised otherwise — a
1123 /// tab named through [`Self::set_devtools_tab`] but not declared yet
1124 /// included — it is the `Ctrl+Shift+P` pick, which shows the tree
1125 /// tab. A hidden panel comes back docked, as the chord's does.
1126 pub fn set_devtools_pick(&mut self, on: bool) {
1127 if !cfg!(feature = "devtools") {
1128 return;
1129 }
1130 let focus_main = {
1131 let mut s = self.session.state();
1132 let d = &mut s.devtools;
1133 if d.pick == on {
1134 return;
1135 }
1136 d.pick = on;
1137 d.pick_hover = None;
1138 // A declared tab is up when `custom` names one the panel
1139 // lists — not merely when it is set: since F67 a name no frame
1140 // has declared yet is kept there, and the strip falls back to
1141 // the panel's own tab, so a pick raised then is the chord's
1142 // and shows the tree (backlog RG14).
1143 let on_tab = d.custom.as_deref().is_some_and(|n| d.lists(n));
1144 d.pick_keep_tab = on && on_tab;
1145 if on {
1146 if !on_tab {
1147 d.show(Tab::Tree);
1148 }
1149 if d.dock == Dock::Off {
1150 d.dock = Dock::Right;
1151 }
1152 }
1153 on && d.dock == Dock::Window
1154 };
1155 if focus_main {
1156 self.interaction
1157 .window_commands
1158 .push(WindowCommand::Focus(WindowId::MAIN));
1159 }
1160 self.devtools_redraw_others();
1161 }
1162
1163 /// Whether the panel's picker is up.
1164 pub fn devtools_picking(&self) -> bool {
1165 self.session.state().devtools.pick
1166 }
1167
1168 /// Shows the panel's tab named `name` from outside the panel — what
1169 /// the strip's click and `Ctrl+Shift+N` do, for an app with a command
1170 /// that jumps to its own tab. `name` is one of the panel's
1171 /// own (`facts`, `events`, `tree`, in any case — the strip labels
1172 /// them `Facts`, `Events`, `Tree`) or a declared tab's, exactly as
1173 /// the app declared it. A declared
1174 /// name the panel does not list yet is kept and shows once a frame
1175 /// declares it, as a strip click on it would; the return says whether
1176 /// the panel lists it now (it lists a declared tab from the first
1177 /// frame it is on). A hidden panel comes back docked, as the
1178 /// picker's does. The panel's `on` is not touched: that is
1179 /// [`Self::set_devtools`]'s. Edge-triggered — called once a frame it
1180 /// would pin the strip against the user's own clicks.
1181 pub fn set_devtools_tab(&mut self, name: &str) -> bool {
1182 if !cfg!(feature = "devtools") {
1183 return false;
1184 }
1185 let listed = {
1186 let mut s = self.session.state();
1187 let d = &mut s.devtools;
1188 let listed = match Tab::parse(name) {
1189 Some(tab) => {
1190 d.show(tab);
1191 true
1192 }
1193 None => {
1194 d.custom = Some(name.to_string());
1195 d.lists(name)
1196 }
1197 };
1198 if d.dock == Dock::Off {
1199 d.dock = Dock::Right;
1200 }
1201 listed
1202 };
1203 self.devtools_redraw_others();
1204 listed
1205 }
1206
1207 /// The tab the panel is on, by name: one of its own (`facts`,
1208 /// `events`, `tree`) or a declared tab's — what the strip marks,
1209 /// panel on or off, in any window. A declared name the panel stopped
1210 /// listing answers the panel's own tab the strip falls back to.
1211 /// Unlike [`Self::devtools_shown_tab`], which answers only a declared
1212 /// tab on show in the main window for a data binding's function
1213 /// child, this is the selection itself.
1214 pub fn devtools_current_tab(&self) -> String {
1215 let s = self.session.state();
1216 let d = &s.devtools;
1217 match d.shown() {
1218 Shown::Builtin(t) => t.name().to_string(),
1219 Shown::Custom(i) => d.tabs[i].name.clone(),
1220 }
1221 }
1222
1223 /// The key legend the facts tab shows: `(keys, what they do)`.
1224 pub fn set_devtools_legend(&mut self, legend: &[(&str, &str)]) {
1225 self.session.state().devtools.legend = legend
1226 .iter()
1227 .map(|(k, v)| (k.to_string(), v.to_string()))
1228 .collect();
1229 }
1230
1231 /// Whether this core draws the panel's own window — a frame the host
1232 /// builds nothing into.
1233 pub fn devtools_window(&self) -> bool {
1234 self.dt_window
1235 }
1236
1237 /// The app's own theme source: the one in force while no override is,
1238 /// else the one remembered when the override went on — unless the app
1239 /// has set another since, which shows as the source in force not being
1240 /// the override that was applied. That one is the app's now, and it
1241 /// is what the override is lifted back to.
1242 fn app_theme_source(&self) -> ThemeSource {
1243 match self.dt_theme {
1244 Some((app, applied)) if self.theme_source == applied => app,
1245 _ => self.theme_source,
1246 }
1247 }
1248
1249 /// The id of the panel's window while it is open.
1250 fn devtools_window_id(&self) -> Option<WindowId> {
1251 self.session
1252 .state()
1253 .windows
1254 .live()
1255 .into_iter()
1256 .find(|(_, name)| &**name == DEVTOOLS_WINDOW)
1257 .map(|(id, _)| id)
1258 }
1259}
1260
1261// ---------------------------------------------------------------------------
1262// The hooks.
1263
1264impl Core {
1265 /// What the dock leaves of a window `window` big: the
1266 /// viewport a frame begun at that size lays out into, which
1267 /// `viewport()` reports once the frame has begun and a `resize`
1268 /// reports when it changes. It takes the window's size and the dock's
1269 /// state and nothing of the frame, so it answers *before* the first
1270 /// frame too — what a driver's window-size reading hands a host that
1271 /// sizes its model at setup (Node's `KuiWindow.size()`).
1272 pub fn host_area(&self, window: Size) -> Size {
1273 let r = self.devtools_area(window);
1274 Size::new(r.w, r.h)
1275 }
1276
1277 /// Where the current frame laid the host out, in the window's logical
1278 /// px: [`Core::viewport`] with its origin — `x` the pane's width under
1279 /// a left dock, and zero everywhere else, the whole window with the
1280 /// panel off, in a window of its own, or in any window but the main
1281 /// one. The frame's reading, like `viewport()`, so it is zero before
1282 /// the first frame (the pre-frame answer is `host_area`'s size) and it
1283 /// is the rect the quads of `output()` were drawn against: scaled by
1284 /// `scale()` into their physical px, it is what separates the host's
1285 /// quads from the dock's — all but the root's background, which
1286 /// `devtools_configure_root` gives the window as well as the app
1287 /// container, so it fills the whole window beneath the pane. Backlog
1288 /// F92: the origin reached only the Rust
1289 /// runner, through `devtools_inset`, so a Node test could size itself
1290 /// to the host area but not say that nothing of its own left it.
1291 pub fn host_rect(&self) -> Rect {
1292 self.dt_area
1293 }
1294
1295 /// What a docked pane takes off the main window, in the axis it
1296 /// takes it: the side column's width as `(w, 0)`, the bottom strip's
1297 /// height as `(0, h)`, and zero with the panel off, in a window of
1298 /// its own, or asked of any window but the main one. The pane's
1299 /// extent *as the handle left it*, not as the window clamps it — what
1300 /// a driver adds to the app's minimum window size while the panel is
1301 /// docked, so the floor the app declared is a floor on the app and
1302 /// not on the app less the dock (the pomodoro's report, 2026-09-12:
1303 /// a 620×500 minimum with a 340 px dock left the app 280 px, below
1304 /// the tier it was drawn to fit). Read after a frame, since the
1305 /// handle's drag and the placement buttons land in one.
1306 pub fn devtools_inset(&self) -> Size {
1307 if self.env.window.id != WindowId::MAIN {
1308 return Size::ZERO;
1309 }
1310 let s = self.session.state();
1311 let d = &s.devtools;
1312 if !d.on {
1313 return Size::ZERO;
1314 }
1315 match d.dock {
1316 Dock::Left | Dock::Right => Size::new(d.side_w.max(SIDE_MIN_W), 0.0),
1317 Dock::Bottom => Size::new(0.0, d.bottom_h.max(BOTTOM_MIN_H)),
1318 Dock::Window | Dock::Off => Size::ZERO,
1319 }
1320 }
1321
1322 /// The host's viewport for a frame at `viewport`: the
1323 /// window, less the dock when the panel is docked in the main window.
1324 /// The pane keeps its minimum before the app keeps its own, so a
1325 /// window too small for both squeezes the app.
1326 pub(crate) fn devtools_area(&self, viewport: Size) -> Rect {
1327 let window = Rect::new(0.0, 0.0, viewport.w, viewport.h);
1328 if self.env.window.id != WindowId::MAIN {
1329 return window;
1330 }
1331 let s = self.session.state();
1332 let d = &s.devtools;
1333 if !d.on || !d.dock.docked() {
1334 return window;
1335 }
1336 match d.dock {
1337 Dock::Left => {
1338 let w = pane_w(d.side_w, viewport.w);
1339 Rect::new(w, 0.0, (viewport.w - w).max(0.0), viewport.h)
1340 }
1341 Dock::Right => {
1342 let w = pane_w(d.side_w, viewport.w);
1343 Rect::new(0.0, 0.0, (viewport.w - w).max(0.0), viewport.h)
1344 }
1345 Dock::Bottom => {
1346 let h = pane_h(d.bottom_h, viewport.h);
1347 Rect::new(0.0, 0.0, viewport.w, (viewport.h - h).max(0.0))
1348 }
1349 _ => window,
1350 }
1351 }
1352
1353 /// The origin of the host's viewport in window coordinates: nonzero
1354 /// only under a left dock.
1355 #[inline]
1356 pub(crate) fn dt_shift(&self) -> Vec2 {
1357 Vec2::new(self.dt_area.x, self.dt_area.y)
1358 }
1359
1360 /// The coordinates the host is handed, in its own viewport: a drag's point
1361 /// and parent, a layout's rect and parent, a
1362 /// context menu's and a force click's point. A no-op wherever the
1363 /// dock's origin is the window's.
1364 pub(crate) fn devtools_translate(&self, out: &mut [UiEvent]) {
1365 let shift = self.dt_shift();
1366 if shift == Vec2::ZERO {
1367 return;
1368 }
1369 fn shift_xy(v: &mut Value, shift: Vec2) {
1370 if let Value::Map(entries) = v {
1371 for (k, v) in entries.iter_mut() {
1372 match (k.as_str(), &mut *v) {
1373 ("x", Value::Float(x)) => *x -= shift.x as f64,
1374 ("y", Value::Float(y)) => *y -= shift.y as f64,
1375 ("parent", nested @ Value::Map(_)) => shift_xy(nested, shift),
1376 _ => {}
1377 }
1378 }
1379 }
1380 }
1381 for ev in out {
1382 if ev.origin == OriginId::DEVTOOLS {
1383 continue;
1384 }
1385 if matches!(
1386 ev.kind(),
1387 Some("drag" | "layout" | "contextmenu" | "forceclick" | "button")
1388 ) {
1389 shift_xy(&mut ev.payload, shift);
1390 }
1391 }
1392 }
1393
1394 /// The end of `begin_frame`: the overrides for this window, and the
1395 /// wrap of the host's tree in the main one or the
1396 /// deferred root in the panel's own.
1397 /// Puts the host's own menu mode back after an override (see
1398 /// `dt_menus`); nothing to do when there was none.
1399 fn restore_menus(&mut self) {
1400 if let Some((menus, bar)) = self.dt_menus.take() {
1401 self.set_native_menus(menus);
1402 self.set_native_menu_bar(bar);
1403 }
1404 }
1405
1406 pub(crate) fn devtools_begin_frame(&mut self) {
1407 self.dt_app = None;
1408 self.dt_dock = None;
1409 self.dt_window = false;
1410 self.dt_built = None;
1411 // This frame's declarations start empty whatever the panel's state
1412 // and whichever window this is: `after_frame` moves the main
1413 // window's into the session only while the panel is on, and a
1414 // declaration made every frame must not pile up into
1415 // `duplicate-tab` on the second one.
1416 self.dt_tabs.clear();
1417 let main = self.env.window.id == WindowId::MAIN;
1418 let this = if main {
1419 false
1420 } else {
1421 &*self.window_name() == DEVTOOLS_WINDOW
1422 };
1423 let (on, dock, shown, theme, menus, mirror, want_inspect) = {
1424 let s = self.session.state();
1425 let d = &s.devtools;
1426 // The app's own source, for the override to keep the half it
1427 // leaves alone — and the main window's in the panel's own
1428 // window, which has none of its own.
1429 let app = if this {
1430 d.facts.theme_source
1431 } else {
1432 self.app_theme_source()
1433 };
1434 (
1435 d.on,
1436 d.dock,
1437 d.shown(),
1438 d.theme_override(app, &self.env.system),
1439 d.native_menus,
1440 app,
1441 // The panel's need for the node snapshot, derived every
1442 // frame rather than latched (backlog AR38): the tree tab
1443 // showing, or a pick under way. Off, the O(nodes) copy a
1444 // frame stops; the host's own `set_inspect` is apart.
1445 d.on && (d.shown() == Shown::Builtin(Tab::Tree) || d.pick),
1446 )
1447 };
1448 // A menu of the panel's own — a Facts select's — hangs under a
1449 // field this window draws only while it builds the panel: off,
1450 // hidden, or popped into its own window while this is the main
1451 // one, the field is gone and the menu goes with it, or else the
1452 // rows would stay drawn with nobody to answer them (backlog RG2).
1453 // An app's menu is not the panel's to close.
1454 let builds_panel = on && (this || (main && dock.docked()));
1455 if !builds_panel
1456 && self
1457 .menu
1458 .as_ref()
1459 .is_some_and(|m| m.origin == OriginId::DEVTOOLS)
1460 {
1461 self.close_menu();
1462 }
1463 if !on {
1464 if let Some((app, _)) = self.dt_theme.take() {
1465 self.set_theme_source(app);
1466 }
1467 self.restore_menus();
1468 self.dt_inspect = false;
1469 return;
1470 }
1471 // The theme: the override while there is one, and the app's own
1472 // source — remembered from when the override went on — when it
1473 // is taken away again. The panel's own window mirrors the main
1474 // one's app source, since it has none of its own.
1475 match theme {
1476 Some(src) => {
1477 self.dt_theme = Some((mirror, src));
1478 self.set_theme_source(src);
1479 }
1480 None => {
1481 if self.dt_theme.take().is_some() || this {
1482 self.set_theme_source(mirror);
1483 }
1484 }
1485 }
1486 // The menus the same way: the host's own mode — what the driver
1487 // said at launch — remembered when the override goes on, and put
1488 // back when it is lifted (`platform` in the select, or the panel
1489 // off), since nothing else would.
1490 match menus {
1491 Some(m) => {
1492 if self.dt_menus.is_none() {
1493 self.dt_menus = Some((self.native_menus, self.native_menu_bar()));
1494 }
1495 self.set_native_menus(m);
1496 self.set_native_menu_bar(m);
1497 }
1498 None => self.restore_menus(),
1499 }
1500 if this {
1501 // The panel's window: no root until `finish`, so what the host
1502 // builds here builds nothing (every builder door is a no-op
1503 // on an empty tree).
1504 self.dt_window = true;
1505 self.tree.clear();
1506 self.stack.clear();
1507 self.counters.clear();
1508 return;
1509 }
1510 if !main {
1511 return;
1512 }
1513 self.dt_inspect = want_inspect;
1514 if dock.docked() {
1515 self.dt_dock = Some(dock);
1516 self.tree.specs[0] = match dock {
1517 Dock::Bottom => NodeSpec::column(),
1518 _ => NodeSpec::row(),
1519 }
1520 .fill();
1521 // A left dock precedes the app in the root row, so it is built
1522 // now, from the last frame's facts and the tab on show, and
1523 // `finish` skips it.
1524 if dock == Dock::Left {
1525 self.build_panel(Place::Main(dock));
1526 self.dt_built = Some(shown);
1527 }
1528 // By key and not by label: the container is not the host's
1529 // to find through `key_of`.
1530 self.open_with_key(Key::ROOT.str(APP_KEY), NodeSpec::column().fill().clip());
1531 // The host's children are keyed from the root, as if the
1532 // container were not there (ADR 0014's namespace, reused).
1533 self.ns_depth = self.stack.len();
1534 self.ns_key = Key::ROOT;
1535 self.dt_app = Some(self.tree.len() - 1);
1536 }
1537 }
1538
1539 /// `configure_root` while the host's tree is wrapped:
1540 /// what lays out and paints the children goes to the container, what
1541 /// is addressed stays on the root.
1542 pub(crate) fn devtools_configure_root(&mut self, app: usize, spec: NodeSpec) {
1543 let key = self.tree.keys[app];
1544 let mut inner = spec.clone();
1545 inner.layout.width = Sizing::Grow(1.0);
1546 inner.layout.height = Sizing::Grow(1.0);
1547 inner.layout.float = None;
1548 if !(inner.layout.scroll_x || inner.layout.scroll_y) {
1549 inner.layout.clip = true;
1550 }
1551 inner.events = None;
1552 inner.access = None;
1553 // The gradient and the backdrop blur are paint over the children's
1554 // box, so they go with them; the rest of the row is addressed
1555 // (RG115).
1556 inner.interact = spec
1557 .interact
1558 .as_ref()
1559 .filter(|i| i.gradient.is_some() || i.backdrop_blur > 0.0)
1560 .map(|i| {
1561 Box::new(crate::spec::InteractSpec {
1562 gradient: i.gradient.clone(),
1563 backdrop_blur: i.backdrop_blur,
1564 ..Default::default()
1565 })
1566 });
1567 inner.focusable = false;
1568 inner.initial_focus = false;
1569 inner.hoverable = false;
1570 inner.cursor = None;
1571 inner.window = None;
1572 self.ease_spec(key, &mut inner);
1573 self.tree.note(&inner, &crate::tree::NodeContent::Container);
1574 self.tree.specs[app] = inner;
1575
1576 let mut outer = self.tree.specs[0].clone();
1577 outer.style.bg = spec.style.bg;
1578 outer.events = spec.events;
1579 outer.access = spec.access;
1580 outer.focusable = spec.focusable;
1581 outer.initial_focus = spec.initial_focus;
1582 outer.hoverable = spec.hoverable;
1583 outer.disabled = spec.disabled;
1584 outer.cursor = spec.cursor;
1585 outer.window = spec.window;
1586 self.tree.note(&outer, &crate::tree::NodeContent::Container);
1587 self.tree.specs[0] = outer;
1588 }
1589
1590 /// `Ui::finish`, before the menu: closes the app container, builds the
1591 /// panel where it goes, and declares its window when it has one.
1592 pub(crate) fn devtools_finish(&mut self) {
1593 if self.dt_window {
1594 // The deferred root (decision 6): the same push `begin_frame`
1595 // makes, and then the panel is the whole tree.
1596 self.tree.push(
1597 crate::tree::NIL,
1598 Key::ROOT,
1599 OriginId::HOST,
1600 NodeSpec::column().fill(),
1601 crate::tree::NodeContent::Container,
1602 );
1603 self.stack.clear();
1604 self.stack.push(0);
1605 self.counters.clear();
1606 self.counters.push(0);
1607 self.ns_depth = usize::MAX;
1608 self.build_panel(Place::Window);
1609 return;
1610 }
1611 if self.env.window.id != WindowId::MAIN {
1612 return;
1613 }
1614 let (on, dock, shown) = {
1615 let s = self.session.state();
1616 (s.devtools.on, s.devtools.dock, s.devtools.shown())
1617 };
1618 if let Some(_app) = self.dt_app.take() {
1619 // The host's last node closed for it, as `finish_frame` does.
1620 self.stack.truncate(1);
1621 self.counters.truncate(1);
1622 self.ns_depth = usize::MAX;
1623 }
1624 // A left panel is built at `begin_frame`, from the state then, and
1625 // is in the tree for good this frame: turned off, moved or put on
1626 // another tab since — an app's door from its `view` — it is drawn
1627 // as it was, so the next frame is asked for, as the right and
1628 // bottom docks' deferral below asks (backlog RG4). With the idle
1629 // loop quiet, nothing else would draw it.
1630 if let Some(built) = self.dt_built
1631 && (!on || self.dt_dock != Some(dock) || built != shown)
1632 {
1633 self.owe_frame("devtools");
1634 }
1635 if !on {
1636 return;
1637 }
1638 if dock == Dock::Window {
1639 let saved = self.origin;
1640 self.origin = OriginId::DEVTOOLS;
1641 self.declare_window(
1642 DEVTOOLS_WINDOW,
1643 WindowConfig::sized(WINDOW_SIZE.w, WINDOW_SIZE.h),
1644 );
1645 self.origin = saved;
1646 }
1647 if self.dt_built.is_some() {
1648 return;
1649 }
1650 // A docked panel is built into the root this frame began with:
1651 // wrapped for this dock, it goes beside the app container. Turned
1652 // on, or moved to this dock, since `begin_frame` — an app's
1653 // `set_devtools` from its `view` — the root is not laid out for it
1654 // (a column, or a row for another side), and a panel built into it
1655 // anyway sat in the bottom-left corner, 340 wide and half the
1656 // height, until the next frame; so it waits for that frame, and
1657 // asks for it. Undocked, there is nothing to lay out around.
1658 if !dock.docked() || self.dt_dock == Some(dock) {
1659 self.build_panel(Place::Main(dock));
1660 } else {
1661 self.owe_frame("devtools");
1662 }
1663 }
1664
1665 /// Opens the `kui-devtools` node under the current cursor and builds
1666 /// the panel — the dock, the outlines, the picker — inside it, with
1667 /// the session's state taken out for the length of the build.
1668 fn build_panel(&mut self, place: Place) {
1669 let saved_origin = self.origin;
1670 self.origin = OriginId::DEVTOOLS;
1671 // The facts the panel reads: this frame's, from this core, when it
1672 // is the main window; the main window's last, from the session,
1673 // in the panel's own.
1674 let mut state = std::mem::take(&mut self.session.state().devtools);
1675 // The nodes the tree tab lists: this core's last frame in the main
1676 // window, the main window's copy in the panel's own — taken out
1677 // for the build, since the build borrows the core.
1678 let nodes = match place {
1679 Place::Main(dock) => {
1680 // A left dock is built at `begin_frame`, before the host
1681 // has declared this frame's title: it reads the facts the
1682 // last frame left, as the panel's own window does.
1683 if dock != Dock::Left {
1684 state.facts = self.collect_facts(state.inspect_key);
1685 state.tabs.clone_from(&self.dt_tabs);
1686 } else {
1687 // Except the window's size, which is this frame's and
1688 // is what the pane's width is clamped by.
1689 state.facts.viewport = self.viewport;
1690 }
1691 std::mem::take(&mut self.inspected)
1692 }
1693 Place::Window => std::mem::take(&mut state.nodes),
1694 };
1695 #[cfg(feature = "devtools")]
1696 {
1697 let mut ui = crate::ui::Ui::wrap(self);
1698 panel::build(&mut ui, &mut state, &nodes, place);
1699 }
1700 match place {
1701 Place::Main(_) => self.inspected = nodes,
1702 Place::Window => state.nodes = nodes,
1703 }
1704 // What the build asked of the windows.
1705 if std::mem::take(&mut state.focus_window)
1706 && let Some(id) = self.devtools_window_id()
1707 {
1708 self.interaction
1709 .window_commands
1710 .push(WindowCommand::Focus(id));
1711 }
1712 let redraw_main = std::mem::take(&mut state.redraw_main) && self.dt_window;
1713 self.session.state().devtools = state;
1714 self.origin = saved_origin;
1715 if redraw_main {
1716 self.devtools_redraw_others();
1717 }
1718 }
1719
1720 /// The end of `finish_frame` in the main window: the facts and the
1721 /// nodes for the panel's own window, the warnings into the stream, the
1722 /// frame count.
1723 pub(crate) fn devtools_after_frame(&mut self) {
1724 if self.env.window.id != WindowId::MAIN || !self.session.state().devtools.on {
1725 return;
1726 }
1727 let tabs = std::mem::take(&mut self.dt_tabs);
1728 let raised = self.warnings_raised();
1729 let facts = self.collect_facts(self.devtools_key());
1730 let mut s = self.session.state();
1731 let d = &mut s.devtools;
1732 d.frames = self.frame_no;
1733 d.facts = facts;
1734 d.tabs = tabs;
1735 // The panel's own window draws the tree from a copy; docked, the
1736 // tab reads this core's list itself and the copy is not kept.
1737 // The first copy is what the panel's window is waiting for: it
1738 // drew its tree tab before there was one, so it is asked again.
1739 let mut wake_panel = false;
1740 if d.dock == Dock::Window && d.shown() == Shown::Builtin(Tab::Tree) {
1741 wake_panel = d.nodes.is_empty() && !self.inspected.is_empty();
1742 d.nodes = self.inspected.clone();
1743 } else if !d.nodes.is_empty() {
1744 d.nodes = Vec::new();
1745 }
1746 let new_warnings_empty = raised.len() <= d.warnings_seen;
1747 if raised.len() > d.warnings_seen {
1748 let new: Vec<(String, String)> = raised[d.warnings_seen..]
1749 .iter()
1750 .map(|w| (w.code.to_string(), w.message.clone()))
1751 .collect();
1752 d.warnings_seen = raised.len();
1753 let frame = d.frames;
1754 for (code, message) in new {
1755 d.push(Entry {
1756 seq: 0,
1757 frame,
1758 kind: EntryKind::Warning,
1759 window: WindowId::MAIN,
1760 key: Key::ROOT,
1761 label: None,
1762 origin: OriginId::DEVTOOLS,
1763 payload: Value::map([
1764 ("kind", Value::str("warning")),
1765 ("code", Value::str(code)),
1766 ("message", Value::str(message)),
1767 ]),
1768 lines: 0,
1769 });
1770 }
1771 }
1772 // Warnings logged while popped out move the stream there too.
1773 wake_panel |= !new_warnings_empty && d.dock == Dock::Window;
1774 drop(s);
1775 if wake_panel {
1776 self.devtools_redraw_others();
1777 }
1778 }
1779
1780 /// The start of `handle_input`: a chord, or `Escape` while picking, is
1781 /// the panel's and the press goes no further.
1782 pub(crate) fn devtools_intercept(&mut self, ev: &InputEvent) -> bool {
1783 let press = match ev {
1784 InputEvent::KeyDown(press) => press,
1785 // The editor channel's Escape is the same key: a driver sends
1786 // both, and while the picker is up neither is anybody else's.
1787 InputEvent::Key(crate::EditKey::Escape, _) => {
1788 let mut s = self.session.state();
1789 let d = &mut s.devtools;
1790 return d.on && (d.pick || std::mem::take(&mut d.escape_owed));
1791 }
1792 _ => return false,
1793 };
1794 if !self.session.state().devtools.on {
1795 return false;
1796 }
1797 if press.code == KeyCode::Escape {
1798 let mut s = self.session.state();
1799 if s.devtools.pick {
1800 s.devtools.pick = false;
1801 s.devtools.pick_hover = None;
1802 s.devtools.pick_keep_tab = false;
1803 s.devtools.escape_owed = true;
1804 drop(s);
1805 self.devtools_redraw_others();
1806 return true;
1807 }
1808 return false;
1809 }
1810 // Any other press settles the owed half: a driver that never
1811 // sends the editor channel owes nothing.
1812 let inspect = {
1813 let mut s = self.session.state();
1814 s.devtools.escape_owed = false;
1815 s.devtools.inspect_key
1816 };
1817 let Some(what) = chord(press, inspect) else {
1818 return false;
1819 };
1820 self.devtools_act(what);
1821 true
1822 }
1823
1824 /// One action, from a chord or a control, with what it asks of the
1825 /// core done after the state borrow ends.
1826 fn devtools_act(&mut self, what: &str) {
1827 let (acted, focus_main) = {
1828 let mut s = self.session.state();
1829 let acted = s.devtools.act(what);
1830 (acted, std::mem::take(&mut s.devtools.focus_main))
1831 };
1832 if focus_main {
1833 self.interaction
1834 .window_commands
1835 .push(WindowCommand::Focus(WindowId::MAIN));
1836 }
1837 if acted {
1838 self.devtools_redraw_others();
1839 }
1840 }
1841
1842 /// The panel's controls in the batch are acted on and dropped, the
1843 /// panel's window's own `window` events too, and in the panel's
1844 /// window nothing at all is the host's.
1845 pub(crate) fn devtools_consume(&mut self, out: &mut Vec<UiEvent>) {
1846 if out.is_empty() {
1847 return;
1848 }
1849 let on = self.session.state().devtools.on;
1850 if !on && !self.dt_window {
1851 // Off, a panel control can still be in the tree the input
1852 // resolves against — the frame the door owes is not drawn
1853 // yet — and its event is nobody's: not the app's, which
1854 // never declared the origin, and not an action either, since
1855 // the panel it would act on is gone (backlog RG2).
1856 out.retain(|ev| ev.origin != OriginId::DEVTOOLS);
1857 return;
1858 }
1859 let mut actions: Vec<String> = Vec::new();
1860 let mut picks = 0usize;
1861 let mut closed = false;
1862 let mut resize: Option<Vec2> = None;
1863 out.retain(|ev| {
1864 if ev.origin == OriginId::DEVTOOLS {
1865 // A click carries its payload flat; a drag's or a sink's
1866 // rides in `tag`; a select's choice is the menu row's
1867 // `item` (`widgets::select`).
1868 let what = ev
1869 .payload
1870 .get("dt")
1871 .or_else(|| ev.payload.get("tag").and_then(|t| t.get("dt")))
1872 .or_else(|| ev.payload.get("item").and_then(|t| t.get("dt")))
1873 .and_then(Value::as_str);
1874 match what {
1875 Some("picked") => picks += 1,
1876 Some("tree-key") => {
1877 // The tree list's sink: a press (not a release, not
1878 // a repeat of a chord) becomes an action naming
1879 // the key, for the build to move the cursor by.
1880 let s = |k: &str| ev.payload.get(k).and_then(Value::as_str);
1881 // Both modifiers ride every key payload, so each
1882 // is asked on its own: a chord bubbles to the sink
1883 // (ADR 0011) and must not move the cursor.
1884 let held = |k: &str| matches!(ev.payload.get(k), Some(Value::Bool(true)));
1885 let plain = !held("ctrl") && !held("super");
1886 if s("phase") == Some("down")
1887 && plain
1888 && let Some(code) = s("code")
1889 {
1890 actions.push(format!("tree-key:{code}"));
1891 }
1892 }
1893 Some("resize") => {
1894 let f = |k: &str| ev.payload.get(k).and_then(Value::as_float);
1895 if let (Some(x), Some(y)) = (f("x"), f("y")) {
1896 resize = Some(Vec2::new(x as f32, y as f32));
1897 }
1898 }
1899 Some(what) => actions.push(what.to_string()),
1900 None => {}
1901 }
1902 // An edit's `changed` and the like: the build reads the
1903 // field's text; nothing to do here.
1904 return false;
1905 }
1906 if ev.kind() == Some("window") && ev.payload.get_str("name") == Some(DEVTOOLS_WINDOW) {
1907 closed |= ev.payload.get_str("phase") == Some("closed");
1908 return false;
1909 }
1910 !self.dt_window
1911 });
1912 for what in actions {
1913 self.devtools_act(&what);
1914 }
1915 if let Some(p) = resize {
1916 // The handle is dragged in the main window: the pane's extent
1917 // is what the pointer leaves between the edge and itself.
1918 let vp = self.viewport;
1919 let mut s = self.session.state();
1920 let d = &mut s.devtools;
1921 match d.dock {
1922 Dock::Left => d.side_w = pane_w(p.x, vp.w),
1923 Dock::Right => d.side_w = pane_w(vp.w - p.x, vp.w),
1924 Dock::Bottom => d.bottom_h = pane_h(vp.h - p.y, vp.h),
1925 _ => {}
1926 }
1927 }
1928 if picks > 0 {
1929 // The picker's overlay was pressed: the node it was showing is
1930 // the one picked.
1931 let mut s = self.session.state();
1932 let d = &mut s.devtools;
1933 d.pick = false;
1934 if let Some(k) = d.pick_hover.take() {
1935 if std::mem::take(&mut d.pick_keep_tab) {
1936 d.selected = Some(k);
1937 d.reveal = Some(k);
1938 } else {
1939 d.select(k);
1940 }
1941 }
1942 drop(s);
1943 self.devtools_redraw_others();
1944 }
1945 if closed {
1946 // The user closed the panel's window — unless the panel had
1947 // already moved on (a placement button in that very window
1948 // takes it away, and the close that follows is our own).
1949 let mut s = self.session.state();
1950 if s.devtools.dock == Dock::Window {
1951 s.devtools.dock = Dock::Off;
1952 s.devtools.pick = false;
1953 s.devtools.note("dock: off (window closed)");
1954 }
1955 }
1956 }
1957
1958 /// Every event handed to the host, into the stream — after the
1959 /// window stamp, so the row can say which window.
1960 pub(crate) fn devtools_log(&mut self, out: &[UiEvent]) {
1961 if out.is_empty() {
1962 return;
1963 }
1964 let entries: Vec<Entry> = {
1965 let s = self.session.state();
1966 let d = &s.devtools;
1967 if !d.on || d.paused {
1968 return;
1969 }
1970 let frame = d.frames;
1971 out.iter()
1972 .map(|ev| Entry {
1973 seq: 0,
1974 frame,
1975 kind: EntryKind::Event,
1976 window: ev.window,
1977 key: ev.key,
1978 label: if ev.key == Key::ROOT {
1979 Some("root".into())
1980 } else {
1981 self.label_of(ev.key).map(str::to_string)
1982 },
1983 origin: ev.origin,
1984 payload: redact(&ev.payload),
1985 lines: 0,
1986 })
1987 .collect()
1988 };
1989 let mut s = self.session.state();
1990 for e in entries {
1991 s.devtools.push(e);
1992 }
1993 let popped = s.devtools.dock == Dock::Window;
1994 drop(s);
1995 if popped && !self.dt_window {
1996 self.devtools_redraw_others();
1997 }
1998 }
1999
2000 /// Asks the *other* window that shows the panel's effects to draw
2001 /// again: the main window from the panel's own, the
2002 /// panel's own from anywhere else. Coalesced against the last command
2003 /// queued, since a hover storm is many events.
2004 fn devtools_redraw_others(&mut self) {
2005 let target = if self.dt_window {
2006 Some(WindowId::MAIN)
2007 } else {
2008 self.devtools_window_id()
2009 };
2010 let Some(id) = target else {
2011 return;
2012 };
2013 let cmds = &mut self.interaction.window_commands;
2014 if cmds.last() != Some(&WindowCommand::Redraw(id)) {
2015 cmds.push(WindowCommand::Redraw(id));
2016 }
2017 }
2018
2019 /// The status block, read from the doors it comes from. `inspect` is
2020 /// the chord into the dock, handed in because the main window's build
2021 /// collects with the panel's state taken out of the session (and
2022 /// `devtools_key` would read the default).
2023 fn collect_facts(&self, inspect: Accel) -> Facts {
2024 let env = &self.env;
2025 let vp = self.viewport;
2026 let scale = self.scale;
2027 let name = |k: Key| match self.label_of(k) {
2028 _ if k == Key::ROOT => "root".to_string(),
2029 Some(l) => l.to_string(),
2030 None => format!("{:08x}", k.0 as u32),
2031 };
2032 let focus = self.focus;
2033 let focus_visible = self.focus_visible;
2034 let region = self.region();
2035 let inspect = inspect.display();
2036 let mods = self.modifiers();
2037 let windows: Vec<String> = self
2038 .windows()
2039 .iter()
2040 .map(|(id, name)| format!("{name}#{}", id.0))
2041 .collect();
2042 let source = match self.theme_source {
2043 ThemeSource::Derived => "derived",
2044 ThemeSource::DerivedWithAccent(_) => "derived + accent",
2045 ThemeSource::Pinned(_) => "pinned",
2046 };
2047 let opt = |s: Option<String>| s.unwrap_or_else(|| "—".into());
2048 let hex = |c: Color| format!("#{:06x}", c.to_hex() >> 8);
2049 let rows: Vec<(&'static str, String)> = vec![
2050 ("appearance", env.system.appearance.name().into()),
2051 ("motion", env.system.motion.name().into()),
2052 ("locale", opt(env.system.locale.map(|l| l.to_string()))),
2053 ("assistive", env.system.assistive.name().into()),
2054 (
2055 "theme",
2056 format!("{} · {source}", self.theme.appearance.name()),
2057 ),
2058 ("accent", hex(self.theme.accent)),
2059 (
2060 "menus",
2061 if self.native_menus { "native" } else { "drawn" }.into(),
2062 ),
2063 (
2064 "window",
2065 format!(
2066 "#{}{}{}{}{} · {}",
2067 env.window.id.0,
2068 if env.window.custom_chrome {
2069 " custom-chrome"
2070 } else {
2071 ""
2072 },
2073 if env.window.maximized {
2074 " maximized"
2075 } else {
2076 ""
2077 },
2078 if env.window.fullscreen {
2079 " fullscreen"
2080 } else {
2081 ""
2082 },
2083 if env.window.always_on_top {
2084 " on-top"
2085 } else {
2086 ""
2087 },
2088 windows.join(" "),
2089 ),
2090 ),
2091 (
2092 "viewport",
2093 format!(
2094 "{}{}×{} @{scale} · {}",
2095 // The app's, then the window's when the dock has
2096 // taken some of it.
2097 if self.dt_area.w != vp.w || self.dt_area.h != vp.h {
2098 format!("{}×{} of ", self.dt_area.w.round(), self.dt_area.h.round())
2099 } else {
2100 String::new()
2101 },
2102 vp.w.round(),
2103 vp.h.round(),
2104 opt(env.refresh_hz.map(|hz| format!("{hz:.0} Hz"))),
2105 ),
2106 ),
2107 (
2108 "keyboard",
2109 if env.focused {
2110 "this window"
2111 } else {
2112 "elsewhere"
2113 }
2114 .into(),
2115 ),
2116 (
2117 "focus",
2118 match focus.map(name) {
2119 Some(l) if focus_visible => format!("{l} · ring"),
2120 Some(l) => l,
2121 None => "—".into(),
2122 },
2123 ),
2124 (
2125 "region",
2126 match region.map(name) {
2127 Some(l) => format!("{l} · {inspect} leaves"),
2128 None => format!("main · {inspect} enters the dock"),
2129 },
2130 ),
2131 ("modifiers", {
2132 let mut m = Vec::new();
2133 if mods.shift {
2134 m.push("shift");
2135 }
2136 if mods.ctrl {
2137 m.push("ctrl");
2138 }
2139 if mods.alt {
2140 m.push("alt");
2141 }
2142 if mods.super_key {
2143 m.push("super");
2144 }
2145 if m.is_empty() {
2146 "—".into()
2147 } else {
2148 m.join("+")
2149 }
2150 }),
2151 (
2152 "audio",
2153 format!("{} · {} live", env.audio.device.name(), env.audio.live),
2154 ),
2155 ("nodes", format!("{}", self.inspected.len())),
2156 ("fonts", {
2157 // Which installed faces the three generic families are on
2158 // this machine (backlog C32), so a wrong one says so on
2159 // screen.
2160 let [sans, serif, mono] = self.default_font_families();
2161 format!("{sans} · {serif} · {mono}")
2162 }),
2163 ];
2164 let mut origins: Vec<&OriginId> = self.tokens.keys().collect();
2165 origins.sort_by_key(|o| o.0);
2166 let mut tokens = Vec::new();
2167 for origin in origins {
2168 let table = &self.tokens[origin];
2169 for (i, (name, _)) in table.colors().iter().enumerate() {
2170 let (light, dark) = table.halves(i as u16, &self.theme);
2171 tokens.push(TokenFact {
2172 origin: *origin,
2173 name: name.clone(),
2174 kind: crate::tokens::TokenKind::Color,
2175 light,
2176 dark,
2177 resolved: table.resolve_color(i as u16, &self.theme),
2178 length: 0.0,
2179 recipe: table.recipe(i as u16),
2180 });
2181 }
2182 for (name, v) in table.lengths() {
2183 tokens.push(TokenFact {
2184 origin: *origin,
2185 name: name.clone(),
2186 kind: crate::tokens::TokenKind::Length,
2187 light: Color::TRANSPARENT,
2188 dark: Color::TRANSPARENT,
2189 resolved: Color::TRANSPARENT,
2190 length: *v,
2191 recipe: None,
2192 });
2193 }
2194 }
2195 Facts {
2196 title: self
2197 .window_title
2198 .clone()
2199 .unwrap_or_else(|| "kui".to_string()),
2200 rows,
2201 tokens,
2202 theme_source: self.app_theme_source(),
2203 app_appearance: self.app_theme_source().resolve(&env.system).appearance,
2204 viewport: vp,
2205 focus,
2206 focus_visible,
2207 region,
2208 hovered: self.interaction.hovered(),
2209 pressed: self.interaction.pressed_key(),
2210 cursor: self.interaction.cursor(),
2211 }
2212 }
2213}
2214
2215/// A `Value` as one line of data: `{kind: click, n: 3}`.
2216pub fn fmt_value(v: &Value) -> String {
2217 let mut out = String::new();
2218 write_value(v, &mut out);
2219 out
2220}
2221
2222fn write_value(v: &Value, out: &mut String) {
2223 match v {
2224 Value::Null => out.push_str("null"),
2225 Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
2226 Value::Int(i) => out.push_str(&i.to_string()),
2227 Value::Float(f) => {
2228 if f.fract() == 0.0 && f.abs() < 1e9 {
2229 out.push_str(&format!("{f:.0}"));
2230 } else {
2231 out.push_str(&format!("{f:.2}"));
2232 }
2233 }
2234 Value::Str(s) => {
2235 if s.chars().any(|c| c.is_whitespace() || c == ',' || c == '}') || s.is_empty() {
2236 out.push_str(&format!("{s:?}"));
2237 } else {
2238 out.push_str(s);
2239 }
2240 }
2241 Value::List(items) => {
2242 out.push('[');
2243 for (i, item) in items.iter().enumerate() {
2244 if i > 0 {
2245 out.push_str(", ");
2246 }
2247 write_value(item, out);
2248 }
2249 out.push(']');
2250 }
2251 Value::Map(entries) => {
2252 out.push('{');
2253 for (i, (k, v)) in entries.iter().enumerate() {
2254 if i > 0 {
2255 out.push_str(", ");
2256 }
2257 out.push_str(k);
2258 out.push_str(": ");
2259 write_value(v, out);
2260 }
2261 out.push('}');
2262 }
2263 }
2264}
2265
2266/// How many lines `v` takes as an indented tree: a map's entries and a
2267/// list's items are one each, nested ones under theirs; a scalar is one
2268/// line, and a map or list is its entries alone (its own line is the
2269/// row's summary).
2270pub(crate) fn lines(v: &Value) -> u16 {
2271 fn count(v: &Value) -> usize {
2272 match v {
2273 Value::Map(entries) => entries.iter().map(|(_, v)| 1 + nested(v)).sum::<usize>(),
2274 Value::List(items) => items.iter().map(|v| 1 + nested(v)).sum::<usize>(),
2275 _ => 1,
2276 }
2277 }
2278 fn nested(v: &Value) -> usize {
2279 match v {
2280 Value::Map(_) | Value::List(_) => count(v),
2281 _ => 0,
2282 }
2283 }
2284 count(v).min(u16::MAX as usize) as u16
2285}
2286
2287/// A side pane's width for a window `vw` wide: what the handle left,
2288/// within the pane's floor and what the app needs — the floor winning
2289/// when the window has room for neither.
2290fn pane_w(side_w: f32, vw: f32) -> f32 {
2291 side_w.min((vw - APP_MIN).max(SIDE_MIN_W)).max(SIDE_MIN_W)
2292}
2293
2294fn pane_h(bottom_h: f32, vh: f32) -> f32 {
2295 bottom_h
2296 .min((vh - APP_MIN).max(BOTTOM_MIN_H))
2297 .max(BOTTOM_MIN_H)
2298}
2299
2300/// Where a build is drawing the panel.
2301#[derive(Clone, Copy, PartialEq, Eq)]
2302enum Place {
2303 /// The main window: the dock (or nothing, for `Window` and `Off`)
2304 /// plus the outlines and the picker.
2305 Main(Dock),
2306 /// The panel's own window: the panel is the whole tree.
2307 Window,
2308}
2309
2310/// A payload as the stream keeps it: a paste the pasteboard marked
2311/// concealed — a password from a password manager — keeps its
2312/// markers and its length and loses its text, which the events tab would
2313/// otherwise show in plain view and hold for the stream's lifetime.
2314fn redact(payload: &Value) -> Value {
2315 let concealed = payload.get_bool("concealed") == Some(true);
2316 match payload {
2317 Value::Map(fields) if concealed => Value::Map(
2318 fields
2319 .iter()
2320 .map(|(k, v)| match (k.as_str(), v) {
2321 ("text", Value::Str(t)) => (
2322 k.clone(),
2323 Value::str(format!("‹concealed, {} chars›", t.chars().count())),
2324 ),
2325 _ => (k.clone(), v.clone()),
2326 })
2327 .collect(),
2328 ),
2329 _ => payload.clone(),
2330 }
2331}