#![cfg_attr(not(feature = "devtools"), allow(dead_code))]
use std::collections::VecDeque;
use rustc_hash::FxHashSet;
use crate::color::Color;
use crate::env::{Appearance, SystemEnv};
use crate::geom::{Rect, Size, Vec2};
use crate::key::Key;
use crate::menu::Accel;
use crate::runtime::inspect::NodeInfo;
use crate::spec::{NodeSpec, Sizing};
use crate::theme::{Theme, ThemeSource};
use crate::tree::OriginId;
use crate::value::Value;
use crate::widgets::RowHeights;
use crate::window::{WindowCommand, WindowConfig, WindowId};
use crate::{Core, InputEvent, KeyCode, UiEvent};
#[cfg(feature = "devtools")]
mod icons;
#[cfg(feature = "devtools")]
mod panel;
#[cfg(feature = "devtools")]
mod stream;
#[cfg(all(test, feature = "devtools"))]
mod tests;
#[cfg(feature = "devtools")]
mod tree;
pub const DOCK_SIDE_W: f32 = 340.0;
pub const DOCK_BOTTOM_H: f32 = 280.0;
pub const WINDOW_SIZE: Size = Size { w: 420.0, h: 720.0 };
pub const DEVTOOLS_WINDOW: &str = "kui-devtools";
pub const DEVTOOLS_KEY: &str = "kui-devtools";
const APP_KEY: &str = "kui-devtools/app";
pub const STREAM_CAP: usize = 512;
const STREAM_ROW_H: f32 = 18.0;
const STREAM_EVICT: usize = 64;
const ACCENTS: [(&str, u32); 5] = [
("kui blue", 0x3b5bd4ff),
("macOS blue", 0x007affff),
("macOS yellow", 0xffc409ff),
("macOS pink", 0xf74f9eff),
("forest", 0x2f7d4fff),
];
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Dock {
Left,
#[default]
Right,
Bottom,
Window,
Off,
}
impl Dock {
pub const ALL: [Dock; 5] = [
Dock::Left,
Dock::Right,
Dock::Bottom,
Dock::Window,
Dock::Off,
];
pub fn name(self) -> &'static str {
match self {
Dock::Left => "left",
Dock::Right => "right",
Dock::Bottom => "bottom",
Dock::Window => "window",
Dock::Off => "off",
}
}
pub fn parse(s: &str) -> Option<Dock> {
if s == "side" {
return Some(Dock::Right);
}
Self::ALL.into_iter().find(|d| d.name() == s)
}
fn next(self) -> Dock {
let i = Self::ALL.iter().position(|d| *d == self).unwrap_or(0);
Self::ALL[(i + 1) % Self::ALL.len()]
}
pub fn docked(self) -> bool {
matches!(self, Dock::Left | Dock::Right | Dock::Bottom)
}
pub fn is_side(self) -> bool {
matches!(self, Dock::Left | Dock::Right)
}
}
pub const SIDE_MIN_W: f32 = 280.0;
pub const BOTTOM_MIN_H: f32 = 160.0;
const APP_MIN: f32 = 160.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Tab {
Facts,
#[default]
Events,
Tree,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct TabDecl {
pub(crate) name: String,
pub(crate) label: String,
pub(crate) slot: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Shown {
Builtin(Tab),
Custom(usize),
}
impl Tab {
const ALL: [Tab; 3] = [Tab::Facts, Tab::Events, Tab::Tree];
fn name(self) -> &'static str {
match self {
Tab::Facts => "facts",
Tab::Events => "events",
Tab::Tree => "tree",
}
}
fn label(self) -> &'static str {
match self {
Tab::Facts => "Facts",
Tab::Events => "Events",
Tab::Tree => "Tree",
}
}
fn parse(s: &str) -> Option<Tab> {
Self::ALL
.into_iter()
.find(|t| t.name().eq_ignore_ascii_case(s))
}
}
pub(crate) struct Entry {
seq: u64,
frame: u64,
kind: EntryKind,
window: WindowId,
key: Key,
label: Option<String>,
origin: OriginId,
payload: Value,
lines: u16,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum EntryKind {
Event,
Warning,
Note,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct TokenFact {
pub(crate) origin: OriginId,
pub(crate) name: String,
pub(crate) kind: crate::tokens::TokenKind,
pub(crate) light: Color,
pub(crate) dark: Color,
pub(crate) resolved: Color,
pub(crate) length: f32,
pub(crate) recipe: Option<String>,
}
#[derive(Clone, Default)]
pub(crate) struct Facts {
title: String,
rows: Vec<(&'static str, String)>,
pub(crate) tokens: Vec<TokenFact>,
theme_source: ThemeSource,
app_appearance: Appearance,
viewport: Size,
focus: Option<Key>,
focus_visible: bool,
region: Option<Key>,
hovered: Option<Key>,
pressed: Option<Key>,
cursor: Option<Vec2>,
}
pub(crate) struct State {
pub(crate) on: bool,
pub(crate) dock: Dock,
side_w: f32,
bottom_h: f32,
tab: Tab,
base: Option<Appearance>,
accent: Option<usize>,
custom_accent: Option<Color>,
native_menus: Option<bool>,
stream: VecDeque<Entry>,
seq: u64,
rows: Vec<usize>,
heights: RowHeights,
expanded: FxHashSet<u64>,
stream_dirty: bool,
stream_grew: bool,
followed_max: f32,
paused: bool,
follow: bool,
stream_filter: String,
selected: Option<Key>,
hovered_row: Option<Key>,
collapsed: FxHashSet<Key>,
tree_filter: String,
pick: bool,
pick_keep_tab: bool,
pick_hover: Option<Key>,
reveal: Option<Key>,
fold_all: bool,
tree_cursor: Option<Key>,
tree_key: Option<String>,
facts: Facts,
nodes: Vec<NodeInfo>,
frames: u64,
smoke_frames: Option<u64>,
warnings_seen: usize,
legend: Vec<(String, String)>,
inspect_key: Accel,
tabs: Vec<TabDecl>,
custom: Option<String>,
toggle_region: bool,
escape_owed: bool,
focus_window: bool,
focus_main: bool,
redraw_main: bool,
}
impl Default for State {
fn default() -> Self {
State {
on: false,
dock: Dock::default(),
side_w: DOCK_SIDE_W,
bottom_h: DOCK_BOTTOM_H,
tab: Tab::default(),
base: None,
accent: None,
custom_accent: None,
native_menus: None,
stream: VecDeque::new(),
seq: 0,
rows: Vec::new(),
heights: RowHeights::new(0, STREAM_ROW_H),
expanded: FxHashSet::default(),
stream_dirty: false,
stream_grew: false,
followed_max: 0.0,
paused: false,
follow: true,
stream_filter: String::new(),
selected: None,
hovered_row: None,
collapsed: FxHashSet::default(),
tree_filter: String::new(),
pick: false,
pick_keep_tab: false,
pick_hover: None,
reveal: None,
fold_all: false,
tree_cursor: None,
tree_key: None,
facts: Facts::default(),
nodes: Vec::new(),
frames: 0,
smoke_frames: std::env::var("KUI_SMOKE_FRAMES")
.ok()
.and_then(|s| s.parse().ok()),
warnings_seen: 0,
legend: Vec::new(),
inspect_key: DEFAULT_INSPECT_KEY,
tabs: Vec::new(),
custom: None,
toggle_region: false,
escape_owed: false,
focus_window: false,
focus_main: false,
redraw_main: false,
}
}
}
impl State {
fn from_var(var: Option<&str>) -> Self {
let mut s = State::default();
if !cfg!(feature = "devtools") {
return s;
}
if let Some(v) = var {
let v = v.trim().to_ascii_lowercase();
if v == "1" || v == "true" || v == "on" {
s.on = true;
} else if let Some(d) = Dock::parse(&v) {
s.on = true;
s.dock = d;
}
}
s
}
fn base_name(&self) -> &'static str {
match self.base {
None => "app",
Some(Appearance::Light) => "light",
Some(_) => "dark",
}
}
fn menus_name(&self) -> &'static str {
match self.native_menus {
None => "platform",
Some(true) => "native",
Some(false) => "drawn",
}
}
fn accent_name(&self) -> String {
match (self.accent, self.custom_accent) {
(Some(i), _) => ACCENTS[i].0.to_string(),
(None, Some(c)) => format!("#{:06x}", c.to_hex() >> 8),
(None, None) => "app".to_string(),
}
}
fn theme_override(&self, app: ThemeSource, sys: &SystemEnv) -> Option<ThemeSource> {
let accent = self
.accent
.map(|i| Color::hex(ACCENTS[i].1))
.or(self.custom_accent);
match (self.base, accent) {
(None, None) => None,
(None, Some(c)) => Some(match app {
ThemeSource::Derived | ThemeSource::DerivedWithAccent(_) => {
ThemeSource::DerivedWithAccent(c)
}
ThemeSource::Pinned(t) => ThemeSource::Pinned(t.with_accent(c)),
}),
(Some(base), c) => {
let own = match app {
ThemeSource::Derived => sys.accent,
ThemeSource::DerivedWithAccent(a) => Some(a),
ThemeSource::Pinned(t) => Some(t.accent),
};
Some(ThemeSource::Pinned(Theme::derive(base, c.or(own))))
}
}
}
fn push(&mut self, mut e: Entry) {
if self.stream.len() >= STREAM_CAP {
self.stream.drain(..STREAM_EVICT);
if let Some(first) = self.stream.front().map(|e| e.seq) {
self.expanded.retain(|seq| *seq >= first);
}
}
e.seq = self.seq;
self.seq += 1;
e.lines = lines(&e.payload);
self.stream.push_back(e);
self.stream_dirty = true;
self.stream_grew = true;
}
fn note(&mut self, text: impl Into<String>) {
let frame = self.frames;
self.push(Entry {
seq: 0,
frame,
kind: EntryKind::Note,
window: WindowId::MAIN,
key: Key::ROOT,
label: None,
origin: OriginId::DEVTOOLS,
payload: Value::str(text.into()),
lines: 0,
});
}
fn act(&mut self, what: &str) -> bool {
match what {
"base" => {
self.base = match self.base {
None => Some(Appearance::Light),
Some(Appearance::Light) => Some(Appearance::Dark),
Some(_) => None,
};
self.note(format!("theme base: {}", self.base_name()));
}
"accent" => {
self.custom_accent = None;
self.accent = match self.accent {
None => Some(0),
Some(i) if i + 1 < ACCENTS.len() => Some(i + 1),
Some(_) => None,
};
self.note(format!("accent: {}", self.accent_name()));
}
"menus" => {
self.native_menus = match self.native_menus {
None => Some(true),
Some(true) => Some(false),
Some(false) => None,
};
self.note(format!("menus: {}", self.menus_name()));
}
"dock" => {
self.dock = self.dock.next();
self.pick = false;
self.note(format!("dock: {}", self.dock.name()));
}
"inspect" => match self.dock {
Dock::Off => {
self.dock = Dock::Right;
self.toggle_region = true;
}
Dock::Window => self.focus_window = true,
_ => self.toggle_region = true,
},
"clear" => {
self.stream.clear();
self.expanded.clear();
self.stream_dirty = true;
}
"pause" => self.paused = !self.paused,
"follow" => {
self.follow = !self.follow;
self.stream_grew |= self.follow;
}
"tab" => {
let n = Tab::ALL.len() + self.tabs.len();
let i = match self.shown() {
Shown::Builtin(t) => Tab::ALL.iter().position(|x| *x == t).unwrap_or(0),
Shown::Custom(i) => Tab::ALL.len() + i,
};
self.select_tab((i + 1) % n);
}
"pick" => {
self.pick = !self.pick;
self.pick_keep_tab = false;
if self.pick {
self.show(Tab::Tree);
if self.dock == Dock::Off {
self.dock = Dock::Right;
}
self.focus_main = self.dock == Dock::Window;
}
self.pick_hover = None;
}
"fold-all" => self.fold_all = true,
"unfold-all" => {
self.collapsed.clear();
self.fold_all = false;
}
other => {
if let Some(name) = other.strip_prefix("tab:custom:") {
if let Some(i) = self.tabs.iter().position(|t| t.name == name) {
self.select_tab(Tab::ALL.len() + i);
}
} else if let Some(tab) = other.strip_prefix("tab:").and_then(Tab::parse) {
self.tab = tab;
self.custom = None;
} else if let Some(base) = other.strip_prefix("base:") {
self.base = match base {
"light" => Some(Appearance::Light),
"dark" => Some(Appearance::Dark),
_ => None,
};
self.note(format!("theme base: {}", self.base_name()));
} else if let Some(accent) = other.strip_prefix("accent:") {
self.custom_accent = None;
self.accent = accent.parse().ok().filter(|i| *i < ACCENTS.len());
self.note(format!("accent: {}", self.accent_name()));
} else if let Some(menus) = other.strip_prefix("menus:") {
self.native_menus = match menus {
"native" => Some(true),
"drawn" => Some(false),
_ => None,
};
self.note(format!("menus: {}", self.menus_name()));
} else if let Some(dock) = other.strip_prefix("dock:").and_then(Dock::parse) {
if dock != self.dock {
self.dock = dock;
self.pick = false;
self.note(format!("dock: {}", dock.name()));
}
} else if let Some(key) = other.strip_prefix("node:").and_then(parse_key) {
self.selected = if self.selected == Some(key) {
None
} else {
Some(key)
};
} else if let Some(key) = other.strip_prefix("goto:").and_then(parse_key) {
self.select(key);
} else if let Some(key) = other.strip_prefix("fold:").and_then(parse_key) {
if !self.collapsed.remove(&key) {
self.collapsed.insert(key);
}
} else if let Some(seq) = other.strip_prefix("row:").and_then(|s| s.parse().ok()) {
if !self.expanded.remove(&seq) {
self.expanded.insert(seq);
}
self.stream_dirty = true;
} else if let Some(code) = other.strip_prefix("tree-key:") {
self.tree_key = Some(code.to_string());
} else {
return false;
}
}
}
true
}
fn select(&mut self, key: Key) {
self.selected = Some(key);
self.show(Tab::Tree);
self.reveal = Some(key);
}
fn show(&mut self, tab: Tab) {
self.tab = tab;
self.custom = None;
}
pub(crate) fn shown(&self) -> Shown {
match self.custom.as_deref() {
Some(name) => match self.tabs.iter().position(|t| t.name == name) {
Some(i) => Shown::Custom(i),
None => Shown::Builtin(self.tab),
},
None => Shown::Builtin(self.tab),
}
}
fn lists(&self, name: &str) -> bool {
self.tabs.iter().any(|t| t.name == name)
}
fn select_tab(&mut self, i: usize) {
if i < Tab::ALL.len() {
self.show(Tab::ALL[i]);
} else if let Some(t) = self.tabs.get(i - Tab::ALL.len()) {
self.custom = Some(t.name.clone());
}
}
}
fn parse_key(hex: &str) -> Option<Key> {
u64::from_str_radix(hex, 16).ok().map(Key)
}
fn action(what: impl Into<String>) -> Value {
Value::map([("dt", Value::str(what.into()))])
}
const DEFAULT_INSPECT_KEY: Accel = Accel {
code: KeyCode::Char('i'),
mods: crate::input::KeyMods {
shift: true,
ctrl: true,
alt: false,
super_key: false,
},
};
fn hits(accel: Accel, press: &crate::input::KeyPress) -> bool {
if press.mods != accel.mods {
return false;
}
let same = |code: KeyCode| match (accel.code, code) {
(KeyCode::Char(a), KeyCode::Char(b)) => a.eq_ignore_ascii_case(&b),
(a, b) => a == b,
};
same(press.code) || same(press.physical)
}
fn chord(press: &crate::input::KeyPress, inspect: Accel) -> Option<&'static str> {
if hits(inspect, press) {
return Some("inspect");
}
let m = press.mods;
if !m.ctrl || !m.shift || m.alt {
return None;
}
let letter = |c: KeyCode| match c {
KeyCode::Char(ch) => Some(ch.to_ascii_lowercase()),
_ => None,
};
match letter(press.code).or_else(|| letter(press.physical))? {
't' => Some("base"),
'a' => Some("accent"),
'm' => Some("menus"),
'd' => Some("dock"),
'c' => Some("clear"),
'n' => Some("tab"),
'p' => Some("pick"),
_ => None,
}
}
pub(crate) fn tab_body_key(name: &str) -> Key {
Key::ROOT.str(DEVTOOLS_KEY).str("tab").str(name)
}
impl Core {
pub fn devtools_tab_declare(&mut self, name: &str, label: &str, slot: Option<&str>) -> bool {
if !cfg!(feature = "devtools") || self.tree.is_empty() {
return false;
}
if self.dt_tabs.iter().any(|t| t.name == name) {
self.diag.raise(crate::diag::duplicate_tab(name));
return false;
}
self.dt_tabs.push(TabDecl {
name: name.to_string(),
label: label.to_string(),
slot: slot.map(str::to_string),
});
true
}
pub fn devtools_tab_shown(&self, name: &str) -> bool {
if !cfg!(feature = "devtools") || self.dt_window || self.env.window.id != WindowId::MAIN {
return false;
}
let s = self.session.state();
let d = &s.devtools;
d.on && d.dock.docked() && d.custom.as_deref() == Some(name) && d.lists(name)
}
pub fn devtools_shown_tab(&self) -> Option<String> {
if !cfg!(feature = "devtools") || self.dt_window || self.env.window.id != WindowId::MAIN {
return None;
}
let s = self.session.state();
let d = &s.devtools;
if !(d.on && d.dock.docked()) {
return None;
}
d.custom.clone().filter(|name| d.lists(name))
}
pub fn devtools_tab_open(&mut self, name: &str) {
let spec = NodeSpec::column()
.float(crate::spec::FloatConfig {
anchor: crate::spec::FloatAnchor::Node(tab_body_key(name)),
..Default::default()
})
.fill()
.clip();
self.open_keyed(&format!("devtools-tab:{name}"), spec);
}
pub(crate) fn devtools_tab_slot(&self, name: &str) -> bool {
self.dt_tabs.iter().any(|t| t.slot.as_deref() == Some(name))
}
pub(crate) fn devtools_fill_mount(&mut self, filler: &mut dyn crate::slot::Fill) {
if !cfg!(feature = "devtools") || self.tree.is_empty() {
return;
}
let (name, slot, params) = {
let s = self.session.state();
let d = &s.devtools;
if !d.on || !(d.dock.docked() || self.dt_window) {
return;
}
let tabs = if self.dt_window {
&d.tabs
} else {
&self.dt_tabs
};
let Some(name) = d.custom.as_deref() else {
return;
};
let Some(decl) = tabs.iter().find(|t| t.name == name) else {
return;
};
let Some(slot) = decl.slot.clone() else {
return;
};
(decl.name.clone(), slot, d.facts_params())
};
let saved = self.origin;
self.origin = OriginId::HOST;
if let Some(key) = self.begin_slot(&slot) {
self.devtools_tab_open(&name);
filler.fill(&slot, key, ¶ms, &mut crate::ui::Ui::new(self));
self.close();
}
self.origin = saved;
}
}
impl State {
fn facts_params(&self) -> Value {
let key = |k: Option<Key>| match k {
Some(k) => Value::str(format!("{:016x}", k.0)),
None => Value::Null,
};
let row = |name: &str| {
self.facts
.rows
.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| Value::str(v.clone()))
.unwrap_or(Value::Null)
};
Value::map([
("selected", key(self.selected)),
("hovered", key(self.hovered_row)),
("picked", key(self.pick_hover)),
("region", row("region")),
("focus", row("focus")),
])
}
}
impl Core {
pub fn set_devtools(&mut self, on: bool) {
if !cfg!(feature = "devtools") {
return;
}
let mut s = self.session.state();
if s.devtools.on == on {
return;
}
s.devtools.on = on;
if !on {
s.devtools.pick = false;
s.devtools.pick_keep_tab = false;
}
}
pub fn devtools_from_env(&mut self) -> bool {
let asked = State::from_var(std::env::var("KUI_DEVTOOLS").ok().as_deref());
if asked.on {
self.set_devtools(true);
self.set_devtools_dock(asked.dock);
}
asked.on
}
pub fn devtools(&self) -> bool {
self.session.state().devtools.on
}
pub fn set_devtools_dock(&mut self, dock: Dock) {
self.session.state().devtools.dock = dock;
}
pub fn devtools_dock(&self) -> Dock {
self.session.state().devtools.dock
}
pub fn set_devtools_theme(&mut self, base: Option<Appearance>, accent: Option<Color>) {
let mut s = self.session.state();
s.devtools.base = base;
s.devtools.accent = None;
s.devtools.custom_accent = accent;
}
pub fn set_devtools_key(&mut self, key: Accel) {
self.session.state().devtools.inspect_key = key;
}
pub fn devtools_key(&self) -> Accel {
self.session.state().devtools.inspect_key
}
pub fn devtools_selected(&self) -> Option<Key> {
self.session.state().devtools.selected
}
pub fn devtools_hovered(&self) -> Option<Key> {
self.session.state().devtools.hovered_row
}
pub fn devtools_picked(&self) -> Option<Key> {
self.session.state().devtools.pick_hover
}
pub fn set_devtools_selected(&mut self, key: Option<Key>) {
let mut s = self.session.state();
let d = &mut s.devtools;
d.selected = key;
d.reveal = key;
drop(s);
self.devtools_redraw_others();
}
pub fn set_devtools_pick(&mut self, on: bool) {
if !cfg!(feature = "devtools") {
return;
}
let focus_main = {
let mut s = self.session.state();
let d = &mut s.devtools;
if d.pick == on {
return;
}
d.pick = on;
d.pick_hover = None;
let on_tab = d.custom.as_deref().is_some_and(|n| d.lists(n));
d.pick_keep_tab = on && on_tab;
if on {
if !on_tab {
d.show(Tab::Tree);
}
if d.dock == Dock::Off {
d.dock = Dock::Right;
}
}
on && d.dock == Dock::Window
};
if focus_main {
self.interaction
.window_commands
.push(WindowCommand::Focus(WindowId::MAIN));
}
self.devtools_redraw_others();
}
pub fn devtools_picking(&self) -> bool {
self.session.state().devtools.pick
}
pub fn set_devtools_tab(&mut self, name: &str) -> bool {
if !cfg!(feature = "devtools") {
return false;
}
let listed = {
let mut s = self.session.state();
let d = &mut s.devtools;
let listed = match Tab::parse(name) {
Some(tab) => {
d.show(tab);
true
}
None => {
d.custom = Some(name.to_string());
d.lists(name)
}
};
if d.dock == Dock::Off {
d.dock = Dock::Right;
}
listed
};
self.devtools_redraw_others();
listed
}
pub fn devtools_current_tab(&self) -> String {
let s = self.session.state();
let d = &s.devtools;
match d.shown() {
Shown::Builtin(t) => t.name().to_string(),
Shown::Custom(i) => d.tabs[i].name.clone(),
}
}
pub fn set_devtools_legend(&mut self, legend: &[(&str, &str)]) {
self.session.state().devtools.legend = legend
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
}
pub fn devtools_window(&self) -> bool {
self.dt_window
}
fn app_theme_source(&self) -> ThemeSource {
match self.dt_theme {
Some((app, applied)) if self.theme_source == applied => app,
_ => self.theme_source,
}
}
fn devtools_window_id(&self) -> Option<WindowId> {
self.session
.state()
.windows
.live()
.into_iter()
.find(|(_, name)| &**name == DEVTOOLS_WINDOW)
.map(|(id, _)| id)
}
}
impl Core {
pub fn host_area(&self, window: Size) -> Size {
let r = self.devtools_area(window);
Size::new(r.w, r.h)
}
pub fn host_rect(&self) -> Rect {
self.dt_area
}
pub fn devtools_inset(&self) -> Size {
if self.env.window.id != WindowId::MAIN {
return Size::ZERO;
}
let s = self.session.state();
let d = &s.devtools;
if !d.on {
return Size::ZERO;
}
match d.dock {
Dock::Left | Dock::Right => Size::new(d.side_w.max(SIDE_MIN_W), 0.0),
Dock::Bottom => Size::new(0.0, d.bottom_h.max(BOTTOM_MIN_H)),
Dock::Window | Dock::Off => Size::ZERO,
}
}
pub(crate) fn devtools_area(&self, viewport: Size) -> Rect {
let window = Rect::new(0.0, 0.0, viewport.w, viewport.h);
if self.env.window.id != WindowId::MAIN {
return window;
}
let s = self.session.state();
let d = &s.devtools;
if !d.on || !d.dock.docked() {
return window;
}
match d.dock {
Dock::Left => {
let w = pane_w(d.side_w, viewport.w);
Rect::new(w, 0.0, (viewport.w - w).max(0.0), viewport.h)
}
Dock::Right => {
let w = pane_w(d.side_w, viewport.w);
Rect::new(0.0, 0.0, (viewport.w - w).max(0.0), viewport.h)
}
Dock::Bottom => {
let h = pane_h(d.bottom_h, viewport.h);
Rect::new(0.0, 0.0, viewport.w, (viewport.h - h).max(0.0))
}
_ => window,
}
}
#[inline]
pub(crate) fn dt_shift(&self) -> Vec2 {
Vec2::new(self.dt_area.x, self.dt_area.y)
}
pub(crate) fn devtools_translate(&self, out: &mut [UiEvent]) {
let shift = self.dt_shift();
if shift == Vec2::ZERO {
return;
}
fn shift_xy(v: &mut Value, shift: Vec2) {
if let Value::Map(entries) = v {
for (k, v) in entries.iter_mut() {
match (k.as_str(), &mut *v) {
("x", Value::Float(x)) => *x -= shift.x as f64,
("y", Value::Float(y)) => *y -= shift.y as f64,
("parent", nested @ Value::Map(_)) => shift_xy(nested, shift),
_ => {}
}
}
}
}
for ev in out {
if ev.origin == OriginId::DEVTOOLS {
continue;
}
if matches!(
ev.kind(),
Some("drag" | "layout" | "contextmenu" | "forceclick" | "button")
) {
shift_xy(&mut ev.payload, shift);
}
}
}
fn restore_menus(&mut self) {
if let Some((menus, bar)) = self.dt_menus.take() {
self.set_native_menus(menus);
self.set_native_menu_bar(bar);
}
}
pub(crate) fn devtools_begin_frame(&mut self) {
self.dt_app = None;
self.dt_dock = None;
self.dt_window = false;
self.dt_built = None;
self.dt_tabs.clear();
let main = self.env.window.id == WindowId::MAIN;
let this = if main {
false
} else {
&*self.window_name() == DEVTOOLS_WINDOW
};
let (on, dock, shown, theme, menus, mirror, want_inspect) = {
let s = self.session.state();
let d = &s.devtools;
let app = if this {
d.facts.theme_source
} else {
self.app_theme_source()
};
(
d.on,
d.dock,
d.shown(),
d.theme_override(app, &self.env.system),
d.native_menus,
app,
d.on && (d.shown() == Shown::Builtin(Tab::Tree) || d.pick),
)
};
let builds_panel = on && (this || (main && dock.docked()));
if !builds_panel
&& self
.menu
.as_ref()
.is_some_and(|m| m.origin == OriginId::DEVTOOLS)
{
self.close_menu();
}
if !on {
if let Some((app, _)) = self.dt_theme.take() {
self.set_theme_source(app);
}
self.restore_menus();
self.dt_inspect = false;
return;
}
match theme {
Some(src) => {
self.dt_theme = Some((mirror, src));
self.set_theme_source(src);
}
None => {
if self.dt_theme.take().is_some() || this {
self.set_theme_source(mirror);
}
}
}
match menus {
Some(m) => {
if self.dt_menus.is_none() {
self.dt_menus = Some((self.native_menus, self.native_menu_bar()));
}
self.set_native_menus(m);
self.set_native_menu_bar(m);
}
None => self.restore_menus(),
}
if this {
self.dt_window = true;
self.tree.clear();
self.stack.clear();
self.counters.clear();
return;
}
if !main {
return;
}
self.dt_inspect = want_inspect;
if dock.docked() {
self.dt_dock = Some(dock);
self.tree.specs[0] = match dock {
Dock::Bottom => NodeSpec::column(),
_ => NodeSpec::row(),
}
.fill();
if dock == Dock::Left {
self.build_panel(Place::Main(dock));
self.dt_built = Some(shown);
}
self.open_with_key(Key::ROOT.str(APP_KEY), NodeSpec::column().fill().clip());
self.ns_depth = self.stack.len();
self.ns_key = Key::ROOT;
self.dt_app = Some(self.tree.len() - 1);
}
}
pub(crate) fn devtools_configure_root(&mut self, app: usize, spec: NodeSpec) {
let key = self.tree.keys[app];
let mut inner = spec.clone();
inner.layout.width = Sizing::Grow(1.0);
inner.layout.height = Sizing::Grow(1.0);
inner.layout.float = None;
if !(inner.layout.scroll_x || inner.layout.scroll_y) {
inner.layout.clip = true;
}
inner.events = None;
inner.access = None;
inner.interact = spec
.interact
.as_ref()
.filter(|i| i.gradient.is_some() || i.backdrop_blur > 0.0)
.map(|i| {
Box::new(crate::spec::InteractSpec {
gradient: i.gradient.clone(),
backdrop_blur: i.backdrop_blur,
..Default::default()
})
});
inner.focusable = false;
inner.initial_focus = false;
inner.hoverable = false;
inner.cursor = None;
inner.window = None;
self.ease_spec(key, &mut inner);
self.tree.note(&inner, &crate::tree::NodeContent::Container);
self.tree.specs[app] = inner;
let mut outer = self.tree.specs[0].clone();
outer.style.bg = spec.style.bg;
outer.events = spec.events;
outer.access = spec.access;
outer.focusable = spec.focusable;
outer.initial_focus = spec.initial_focus;
outer.hoverable = spec.hoverable;
outer.disabled = spec.disabled;
outer.cursor = spec.cursor;
outer.window = spec.window;
self.tree.note(&outer, &crate::tree::NodeContent::Container);
self.tree.specs[0] = outer;
}
pub(crate) fn devtools_finish(&mut self) {
if self.dt_window {
self.tree.push(
crate::tree::NIL,
Key::ROOT,
OriginId::HOST,
NodeSpec::column().fill(),
crate::tree::NodeContent::Container,
);
self.stack.clear();
self.stack.push(0);
self.counters.clear();
self.counters.push(0);
self.ns_depth = usize::MAX;
self.build_panel(Place::Window);
return;
}
if self.env.window.id != WindowId::MAIN {
return;
}
let (on, dock, shown) = {
let s = self.session.state();
(s.devtools.on, s.devtools.dock, s.devtools.shown())
};
if let Some(_app) = self.dt_app.take() {
self.stack.truncate(1);
self.counters.truncate(1);
self.ns_depth = usize::MAX;
}
if let Some(built) = self.dt_built
&& (!on || self.dt_dock != Some(dock) || built != shown)
{
self.owe_frame("devtools");
}
if !on {
return;
}
if dock == Dock::Window {
let saved = self.origin;
self.origin = OriginId::DEVTOOLS;
self.declare_window(
DEVTOOLS_WINDOW,
WindowConfig::sized(WINDOW_SIZE.w, WINDOW_SIZE.h),
);
self.origin = saved;
}
if self.dt_built.is_some() {
return;
}
if !dock.docked() || self.dt_dock == Some(dock) {
self.build_panel(Place::Main(dock));
} else {
self.owe_frame("devtools");
}
}
fn build_panel(&mut self, place: Place) {
let saved_origin = self.origin;
self.origin = OriginId::DEVTOOLS;
let mut state = std::mem::take(&mut self.session.state().devtools);
let nodes = match place {
Place::Main(dock) => {
if dock != Dock::Left {
state.facts = self.collect_facts(state.inspect_key);
state.tabs.clone_from(&self.dt_tabs);
} else {
state.facts.viewport = self.viewport;
}
std::mem::take(&mut self.inspected)
}
Place::Window => std::mem::take(&mut state.nodes),
};
#[cfg(feature = "devtools")]
{
let mut ui = crate::ui::Ui::wrap(self);
panel::build(&mut ui, &mut state, &nodes, place);
}
match place {
Place::Main(_) => self.inspected = nodes,
Place::Window => state.nodes = nodes,
}
if std::mem::take(&mut state.focus_window)
&& let Some(id) = self.devtools_window_id()
{
self.interaction
.window_commands
.push(WindowCommand::Focus(id));
}
let redraw_main = std::mem::take(&mut state.redraw_main) && self.dt_window;
self.session.state().devtools = state;
self.origin = saved_origin;
if redraw_main {
self.devtools_redraw_others();
}
}
pub(crate) fn devtools_after_frame(&mut self) {
if self.env.window.id != WindowId::MAIN || !self.session.state().devtools.on {
return;
}
let tabs = std::mem::take(&mut self.dt_tabs);
let raised = self.warnings_raised();
let facts = self.collect_facts(self.devtools_key());
let mut s = self.session.state();
let d = &mut s.devtools;
d.frames = self.frame_no;
d.facts = facts;
d.tabs = tabs;
let mut wake_panel = false;
if d.dock == Dock::Window && d.shown() == Shown::Builtin(Tab::Tree) {
wake_panel = d.nodes.is_empty() && !self.inspected.is_empty();
d.nodes = self.inspected.clone();
} else if !d.nodes.is_empty() {
d.nodes = Vec::new();
}
let new_warnings_empty = raised.len() <= d.warnings_seen;
if raised.len() > d.warnings_seen {
let new: Vec<(String, String)> = raised[d.warnings_seen..]
.iter()
.map(|w| (w.code.to_string(), w.message.clone()))
.collect();
d.warnings_seen = raised.len();
let frame = d.frames;
for (code, message) in new {
d.push(Entry {
seq: 0,
frame,
kind: EntryKind::Warning,
window: WindowId::MAIN,
key: Key::ROOT,
label: None,
origin: OriginId::DEVTOOLS,
payload: Value::map([
("kind", Value::str("warning")),
("code", Value::str(code)),
("message", Value::str(message)),
]),
lines: 0,
});
}
}
wake_panel |= !new_warnings_empty && d.dock == Dock::Window;
drop(s);
if wake_panel {
self.devtools_redraw_others();
}
}
pub(crate) fn devtools_intercept(&mut self, ev: &InputEvent) -> bool {
let press = match ev {
InputEvent::KeyDown(press) => press,
InputEvent::Key(crate::EditKey::Escape, _) => {
let mut s = self.session.state();
let d = &mut s.devtools;
return d.on && (d.pick || std::mem::take(&mut d.escape_owed));
}
_ => return false,
};
if !self.session.state().devtools.on {
return false;
}
if press.code == KeyCode::Escape {
let mut s = self.session.state();
if s.devtools.pick {
s.devtools.pick = false;
s.devtools.pick_hover = None;
s.devtools.pick_keep_tab = false;
s.devtools.escape_owed = true;
drop(s);
self.devtools_redraw_others();
return true;
}
return false;
}
let inspect = {
let mut s = self.session.state();
s.devtools.escape_owed = false;
s.devtools.inspect_key
};
let Some(what) = chord(press, inspect) else {
return false;
};
self.devtools_act(what);
true
}
fn devtools_act(&mut self, what: &str) {
let (acted, focus_main) = {
let mut s = self.session.state();
let acted = s.devtools.act(what);
(acted, std::mem::take(&mut s.devtools.focus_main))
};
if focus_main {
self.interaction
.window_commands
.push(WindowCommand::Focus(WindowId::MAIN));
}
if acted {
self.devtools_redraw_others();
}
}
pub(crate) fn devtools_consume(&mut self, out: &mut Vec<UiEvent>) {
if out.is_empty() {
return;
}
let on = self.session.state().devtools.on;
if !on && !self.dt_window {
out.retain(|ev| ev.origin != OriginId::DEVTOOLS);
return;
}
let mut actions: Vec<String> = Vec::new();
let mut picks = 0usize;
let mut closed = false;
let mut resize: Option<Vec2> = None;
out.retain(|ev| {
if ev.origin == OriginId::DEVTOOLS {
let what = ev
.payload
.get("dt")
.or_else(|| ev.payload.get("tag").and_then(|t| t.get("dt")))
.or_else(|| ev.payload.get("item").and_then(|t| t.get("dt")))
.and_then(Value::as_str);
match what {
Some("picked") => picks += 1,
Some("tree-key") => {
let s = |k: &str| ev.payload.get(k).and_then(Value::as_str);
let held = |k: &str| matches!(ev.payload.get(k), Some(Value::Bool(true)));
let plain = !held("ctrl") && !held("super");
if s("phase") == Some("down")
&& plain
&& let Some(code) = s("code")
{
actions.push(format!("tree-key:{code}"));
}
}
Some("resize") => {
let f = |k: &str| ev.payload.get(k).and_then(Value::as_float);
if let (Some(x), Some(y)) = (f("x"), f("y")) {
resize = Some(Vec2::new(x as f32, y as f32));
}
}
Some(what) => actions.push(what.to_string()),
None => {}
}
return false;
}
if ev.kind() == Some("window") && ev.payload.get_str("name") == Some(DEVTOOLS_WINDOW) {
closed |= ev.payload.get_str("phase") == Some("closed");
return false;
}
!self.dt_window
});
for what in actions {
self.devtools_act(&what);
}
if let Some(p) = resize {
let vp = self.viewport;
let mut s = self.session.state();
let d = &mut s.devtools;
match d.dock {
Dock::Left => d.side_w = pane_w(p.x, vp.w),
Dock::Right => d.side_w = pane_w(vp.w - p.x, vp.w),
Dock::Bottom => d.bottom_h = pane_h(vp.h - p.y, vp.h),
_ => {}
}
}
if picks > 0 {
let mut s = self.session.state();
let d = &mut s.devtools;
d.pick = false;
if let Some(k) = d.pick_hover.take() {
if std::mem::take(&mut d.pick_keep_tab) {
d.selected = Some(k);
d.reveal = Some(k);
} else {
d.select(k);
}
}
drop(s);
self.devtools_redraw_others();
}
if closed {
let mut s = self.session.state();
if s.devtools.dock == Dock::Window {
s.devtools.dock = Dock::Off;
s.devtools.pick = false;
s.devtools.note("dock: off (window closed)");
}
}
}
pub(crate) fn devtools_log(&mut self, out: &[UiEvent]) {
if out.is_empty() {
return;
}
let entries: Vec<Entry> = {
let s = self.session.state();
let d = &s.devtools;
if !d.on || d.paused {
return;
}
let frame = d.frames;
out.iter()
.map(|ev| Entry {
seq: 0,
frame,
kind: EntryKind::Event,
window: ev.window,
key: ev.key,
label: if ev.key == Key::ROOT {
Some("root".into())
} else {
self.label_of(ev.key).map(str::to_string)
},
origin: ev.origin,
payload: redact(&ev.payload),
lines: 0,
})
.collect()
};
let mut s = self.session.state();
for e in entries {
s.devtools.push(e);
}
let popped = s.devtools.dock == Dock::Window;
drop(s);
if popped && !self.dt_window {
self.devtools_redraw_others();
}
}
fn devtools_redraw_others(&mut self) {
let target = if self.dt_window {
Some(WindowId::MAIN)
} else {
self.devtools_window_id()
};
let Some(id) = target else {
return;
};
let cmds = &mut self.interaction.window_commands;
if cmds.last() != Some(&WindowCommand::Redraw(id)) {
cmds.push(WindowCommand::Redraw(id));
}
}
fn collect_facts(&self, inspect: Accel) -> Facts {
let env = &self.env;
let vp = self.viewport;
let scale = self.scale;
let name = |k: Key| match self.label_of(k) {
_ if k == Key::ROOT => "root".to_string(),
Some(l) => l.to_string(),
None => format!("{:08x}", k.0 as u32),
};
let focus = self.focus;
let focus_visible = self.focus_visible;
let region = self.region();
let inspect = inspect.display();
let mods = self.modifiers();
let windows: Vec<String> = self
.windows()
.iter()
.map(|(id, name)| format!("{name}#{}", id.0))
.collect();
let source = match self.theme_source {
ThemeSource::Derived => "derived",
ThemeSource::DerivedWithAccent(_) => "derived + accent",
ThemeSource::Pinned(_) => "pinned",
};
let opt = |s: Option<String>| s.unwrap_or_else(|| "—".into());
let hex = |c: Color| format!("#{:06x}", c.to_hex() >> 8);
let rows: Vec<(&'static str, String)> = vec![
("appearance", env.system.appearance.name().into()),
("motion", env.system.motion.name().into()),
("locale", opt(env.system.locale.map(|l| l.to_string()))),
("assistive", env.system.assistive.name().into()),
(
"theme",
format!("{} · {source}", self.theme.appearance.name()),
),
("accent", hex(self.theme.accent)),
(
"menus",
if self.native_menus { "native" } else { "drawn" }.into(),
),
(
"window",
format!(
"#{}{}{}{}{} · {}",
env.window.id.0,
if env.window.custom_chrome {
" custom-chrome"
} else {
""
},
if env.window.maximized {
" maximized"
} else {
""
},
if env.window.fullscreen {
" fullscreen"
} else {
""
},
if env.window.always_on_top {
" on-top"
} else {
""
},
windows.join(" "),
),
),
(
"viewport",
format!(
"{}{}×{} @{scale} · {}",
if self.dt_area.w != vp.w || self.dt_area.h != vp.h {
format!("{}×{} of ", self.dt_area.w.round(), self.dt_area.h.round())
} else {
String::new()
},
vp.w.round(),
vp.h.round(),
opt(env.refresh_hz.map(|hz| format!("{hz:.0} Hz"))),
),
),
(
"keyboard",
if env.focused {
"this window"
} else {
"elsewhere"
}
.into(),
),
(
"focus",
match focus.map(name) {
Some(l) if focus_visible => format!("{l} · ring"),
Some(l) => l,
None => "—".into(),
},
),
(
"region",
match region.map(name) {
Some(l) => format!("{l} · {inspect} leaves"),
None => format!("main · {inspect} enters the dock"),
},
),
("modifiers", {
let mut m = Vec::new();
if mods.shift {
m.push("shift");
}
if mods.ctrl {
m.push("ctrl");
}
if mods.alt {
m.push("alt");
}
if mods.super_key {
m.push("super");
}
if m.is_empty() {
"—".into()
} else {
m.join("+")
}
}),
(
"audio",
format!("{} · {} live", env.audio.device.name(), env.audio.live),
),
("nodes", format!("{}", self.inspected.len())),
("fonts", {
let [sans, serif, mono] = self.default_font_families();
format!("{sans} · {serif} · {mono}")
}),
];
let mut origins: Vec<&OriginId> = self.tokens.keys().collect();
origins.sort_by_key(|o| o.0);
let mut tokens = Vec::new();
for origin in origins {
let table = &self.tokens[origin];
for (i, (name, _)) in table.colors().iter().enumerate() {
let (light, dark) = table.halves(i as u16, &self.theme);
tokens.push(TokenFact {
origin: *origin,
name: name.clone(),
kind: crate::tokens::TokenKind::Color,
light,
dark,
resolved: table.resolve_color(i as u16, &self.theme),
length: 0.0,
recipe: table.recipe(i as u16),
});
}
for (name, v) in table.lengths() {
tokens.push(TokenFact {
origin: *origin,
name: name.clone(),
kind: crate::tokens::TokenKind::Length,
light: Color::TRANSPARENT,
dark: Color::TRANSPARENT,
resolved: Color::TRANSPARENT,
length: *v,
recipe: None,
});
}
}
Facts {
title: self
.window_title
.clone()
.unwrap_or_else(|| "kui".to_string()),
rows,
tokens,
theme_source: self.app_theme_source(),
app_appearance: self.app_theme_source().resolve(&env.system).appearance,
viewport: vp,
focus,
focus_visible,
region,
hovered: self.interaction.hovered(),
pressed: self.interaction.pressed_key(),
cursor: self.interaction.cursor(),
}
}
}
pub fn fmt_value(v: &Value) -> String {
let mut out = String::new();
write_value(v, &mut out);
out
}
fn write_value(v: &Value, out: &mut String) {
match v {
Value::Null => out.push_str("null"),
Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
Value::Int(i) => out.push_str(&i.to_string()),
Value::Float(f) => {
if f.fract() == 0.0 && f.abs() < 1e9 {
out.push_str(&format!("{f:.0}"));
} else {
out.push_str(&format!("{f:.2}"));
}
}
Value::Str(s) => {
if s.chars().any(|c| c.is_whitespace() || c == ',' || c == '}') || s.is_empty() {
out.push_str(&format!("{s:?}"));
} else {
out.push_str(s);
}
}
Value::List(items) => {
out.push('[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
write_value(item, out);
}
out.push(']');
}
Value::Map(entries) => {
out.push('{');
for (i, (k, v)) in entries.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
out.push_str(k);
out.push_str(": ");
write_value(v, out);
}
out.push('}');
}
}
}
pub(crate) fn lines(v: &Value) -> u16 {
fn count(v: &Value) -> usize {
match v {
Value::Map(entries) => entries.iter().map(|(_, v)| 1 + nested(v)).sum::<usize>(),
Value::List(items) => items.iter().map(|v| 1 + nested(v)).sum::<usize>(),
_ => 1,
}
}
fn nested(v: &Value) -> usize {
match v {
Value::Map(_) | Value::List(_) => count(v),
_ => 0,
}
}
count(v).min(u16::MAX as usize) as u16
}
fn pane_w(side_w: f32, vw: f32) -> f32 {
side_w.min((vw - APP_MIN).max(SIDE_MIN_W)).max(SIDE_MIN_W)
}
fn pane_h(bottom_h: f32, vh: f32) -> f32 {
bottom_h
.min((vh - APP_MIN).max(BOTTOM_MIN_H))
.max(BOTTOM_MIN_H)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Place {
Main(Dock),
Window,
}
fn redact(payload: &Value) -> Value {
let concealed = payload.get_bool("concealed") == Some(true);
match payload {
Value::Map(fields) if concealed => Value::Map(
fields
.iter()
.map(|(k, v)| match (k.as_str(), v) {
("text", Value::Str(t)) => (
k.clone(),
Value::str(format!("‹concealed, {} chars›", t.chars().count())),
),
_ => (k.clone(), v.clone()),
})
.collect(),
),
_ => payload.clone(),
}
}