use super::icons::{self, Icon};
use super::{DEVTOOLS_KEY, Dock, Place, Shown, State, Tab, action, stream, tree};
use crate::color::Color;
use crate::env::Appearance;
use crate::menu::MenuItem;
use crate::runtime::inspect::NodeInfo;
use crate::spec::TextStyle;
use crate::spec::{Align, Min, NodeSpec};
use crate::theme::Theme;
use crate::tree::OriginId;
use crate::ui::Ui;
use crate::widgets;
pub(super) fn build(ui: &mut Ui<'_>, st: &mut State, nodes: &[NodeInfo], place: Place) {
let t = ink(ui.theme());
if st.shown() != Shown::Builtin(Tab::Tree) {
st.hovered_row = None;
}
match place {
Place::Window => {
ui.window_title(&format!("kui devtools — {}", st.facts.title));
let spec = NodeSpec::column().fill();
panel(ui, st, nodes, &t, place, spec);
}
Place::Main(dock) if dock.docked() => {
let vp = st.facts.viewport;
let spec = if dock.is_side() {
NodeSpec::row()
.width(super::pane_w(st.side_w, vp.w))
.min_width(super::SIDE_MIN_W)
.grow_height()
} else {
NodeSpec::column()
.grow_width()
.height(super::pane_h(st.bottom_h, vp.h))
.min_height(super::BOTTOM_MIN_H)
}
.focus_region()
.label("Devtools");
panel(ui, st, nodes, &t, place, spec);
}
Place::Main(_) => {
ui.with_keyed(DEVTOOLS_KEY, NodeSpec::row().size(0.0, 0.0), |ui| {
tree::overlays(ui, st, nodes, &t, place)
});
}
}
}
pub(super) fn ink(t: Theme) -> Theme {
let ink = t.ink_for(t.accent);
if ink == t.accent {
t
} else {
t.with_accent(ink)
}
}
fn panel(
ui: &mut Ui<'_>,
st: &mut State,
nodes: &[NodeInfo],
t: &Theme,
place: Place,
spec: NodeSpec,
) {
let dock = ui.open_keyed(
DEVTOOLS_KEY,
spec.bg(t.surface).border(1.0, t.border).clip(),
);
if std::mem::take(&mut st.toggle_region) && matches!(place, Place::Main(_)) {
ui.focus_region(if ui.region() == Some(dock) {
None
} else {
Some(dock)
});
}
let docked = match place {
Place::Main(d) if d.docked() => Some(d),
_ => None,
};
if matches!(docked, Some(Dock::Right | Dock::Bottom)) {
handle(ui, t, docked.unwrap());
}
ui.open(NodeSpec::column().fill().pad(10.0).gap(8.0).clip());
fixed(ui, |ui| header(ui, st, t));
fixed(ui, |ui| tabs(ui, st, t));
match st.shown() {
Shown::Custom(i) => tab_body(ui, st, i, place),
Shown::Builtin(Tab::Facts) => {
ui.with(NodeSpec::column().fill().gap(10.0).scroll_y(), |ui| {
if matches!(place, Place::Main(_)) {
fixed(ui, widgets::latency_graph);
}
facts(ui, st, t);
fixed(ui, |ui| tokens(ui, st, t));
fixed(ui, |ui| legend(ui, st, t));
});
}
Shown::Builtin(Tab::Events) => stream::events_tab(ui, st, t),
Shown::Builtin(Tab::Tree) => tree::tree_tab(ui, st, nodes, t, place),
}
if matches!(place, Place::Main(_)) {
tree::overlays(ui, st, nodes, t, place);
}
ui.close();
if docked == Some(Dock::Left) {
handle(ui, t, Dock::Left);
}
ui.close();
}
fn tab_body(ui: &mut Ui<'_>, st: &State, i: usize, place: Place) {
let decl = &st.tabs[i];
let key = super::tab_body_key(&decl.name);
ui.open_with_key(
key,
&format!("{DEVTOOLS_KEY}/tab/{}", decl.name),
NodeSpec::column().fill().clip().label(decl.label.as_str()),
);
if place == Place::Window && decl.slot.is_none() {
let t = ink(ui.theme());
ui.text(
"docked only — this tab is drawn from the app's own tree",
TextStyle::new(12.0).color(t.muted),
);
}
ui.close();
}
fn handle(ui: &mut Ui<'_>, t: &Theme, dock: Dock) {
let spec = if dock.is_side() {
NodeSpec::row()
.width(6.0)
.grow_height()
.cursor(crate::cursor::CursorShape::EwResize)
} else {
NodeSpec::row()
.grow_width()
.height(6.0)
.cursor(crate::cursor::CursorShape::NsResize)
};
ui.leaf_keyed(
"kui-devtools/resize",
spec.hover_bg(t.accent_soft)
.pressed_bg(t.accent)
.on_drag(action("resize"))
.role(crate::access::Role::None)
.label("resize the panel"),
);
}
fn header(ui: &mut Ui<'_>, st: &State, t: &Theme) {
ui.with(
NodeSpec::row()
.grow_width()
.cross_align(Align::Center)
.gap(6.0),
|ui| {
ui.text(
&st.facts.title,
TextStyle::new(13.0).color(t.fg).nowrap().ellipsis(),
);
let frames = match st.smoke_frames {
Some(n) => format!("{} / {n}", st.frames),
None => format!("{}", st.frames),
};
ui.text(&frames, TextStyle::new(11.0).color(t.faint).mono());
ui.leaf(NodeSpec::row().grow_width());
let walk = "Ctrl+Shift+D walks left → right → bottom → window → off";
ui.with(
NodeSpec::row()
.gap(6.0)
.cross_align(Align::Center)
.role(crate::access::Role::RadioGroup)
.label("Dock"),
|ui| {
for dock in Dock::ALL {
let (glyph, what) = match dock {
Dock::Left => (Icon::DockLeft, "dock on the left"),
Dock::Right => (Icon::DockRight, "dock on the right"),
Dock::Bottom => (Icon::DockBottom, "dock at the bottom"),
Dock::Window => (Icon::DockWindow, "undock into a window"),
Dock::Off => continue,
};
icon_lit(
ui,
t,
&format!("dock:{}", dock.name()),
glyph,
Some(dock == st.dock),
&format!("{what} · {walk}"),
);
}
},
);
icon_lit(
ui,
t,
&format!("dock:{}", Dock::Off.name()),
Icon::Close,
None,
&format!("close · Ctrl+Shift+D brings it back · {walk}"),
);
},
);
}
fn tabs(ui: &mut Ui<'_>, st: &State, t: &Theme) {
ui.with(
NodeSpec::row()
.grow_width()
.gap(2.0)
.cross_gap(2.0)
.wrap()
.cross_align(Align::Center)
.role(crate::access::Role::TabList)
.label("Devtools"),
|ui| {
let shown = st.shown();
let own = Tab::ALL.iter().map(|tab| {
(
tab.name().to_string(),
tab.label().to_string(),
Shown::Builtin(*tab),
)
});
let declared = st.tabs.iter().enumerate().map(|(i, d)| {
(
format!("custom:{}", d.name),
d.label.clone(),
Shown::Custom(i),
)
});
for (id, label, which) in own.chain(declared) {
let on = which == shown;
ui.text_in_keyed(
&format!("kui-devtools/tab-{id}"),
NodeSpec::row()
.pad_xy(10.0, 4.0)
.radius_top(5.0)
.bg(if on { t.sunken } else { t.surface })
.hover_bg(if on { t.sunken } else { t.hover })
.on_click(action(format!("tab:{id}")))
.role(crate::access::Role::Tab)
.selected(on)
.label(label.as_str())
.apply_tooltip("Ctrl+Shift+N · the next tab"),
&label,
TextStyle::new(11.0)
.color(if on { t.fg } else { t.muted })
.nowrap(),
);
}
},
);
}
fn facts(ui: &mut Ui<'_>, st: &State, t: &Theme) {
ui.with(
NodeSpec::table().grow_width().min_height(Min::FIT).gap(3.0),
|ui| {
for (k, v) in &st.facts.rows {
ui.with(
NodeSpec::row()
.grow_width()
.gap(8.0)
.cross_align(Align::Center),
|ui| {
ui.text(k, TextStyle::new(11.0).color(t.muted));
ui.text_in(
NodeSpec::row().grow_width(),
v,
TextStyle::new(11.0).color(t.fg).mono().nowrap(),
);
match *k {
"theme" => override_select(ui, st, "base"),
"accent" => override_select(ui, st, "accent"),
"menus" => override_select(ui, st, "menus"),
_ => {}
}
},
);
}
},
);
}
fn override_select(ui: &mut Ui<'_>, st: &State, what: &str) {
let (items, current): (Vec<MenuItem>, usize) = match what {
"base" => {
let own = format!("app ({})", st.facts.app_appearance.name());
let items = vec![
MenuItem::new(own).id(action("base:app")),
MenuItem::new("light").id(action("base:light")),
MenuItem::new("dark").id(action("base:dark")),
];
let current = match st.base {
None => 0,
Some(Appearance::Light) => 1,
Some(_) => 2,
};
(items, current)
}
"accent" => {
let mut items = vec![MenuItem::new("app").id(action("accent:app"))];
items.extend(
super::ACCENTS
.iter()
.enumerate()
.map(|(i, (name, _))| MenuItem::new(*name).id(action(format!("accent:{i}")))),
);
(items, st.accent.map_or(0, |i| i + 1))
}
_ => {
let items = vec![
MenuItem::new("platform").id(action("menus:default")),
MenuItem::new("native").id(action("menus:native")),
MenuItem::new("drawn").id(action("menus:drawn")),
];
let current = match st.native_menus {
None => 0,
Some(true) => 1,
Some(false) => 2,
};
(items, current)
}
};
let t = ink(ui.theme());
widgets::select_with(
ui,
&format!("kui-devtools/{what}"),
&items,
Some(current),
NodeSpec::row()
.pad_xy(6.0, 1.0)
.gap(4.0)
.cross_align(Align::Center)
.bg(t.sunken)
.hover_bg(t.hover)
.radius(4.0),
TextStyle::new(11.0),
);
}
fn tokens(ui: &mut Ui<'_>, st: &State, t: &Theme) {
if st.facts.tokens.is_empty() {
return;
}
let dark = t.is_dark();
ui.with(NodeSpec::column().grow_width().gap(2.0), |ui| {
let mut last_origin = None;
let mut open = false;
for tok in &st.facts.tokens {
if last_origin != Some(tok.origin) {
if open {
ui.close();
}
last_origin = Some(tok.origin);
let who = if tok.origin == OriginId::HOST {
"tokens".to_string()
} else {
format!("tokens · origin {}", tok.origin.0)
};
ui.text(&who, TextStyle::new(11.0).color(t.muted));
ui.open(NodeSpec::table().grow_width().gap(2.0));
open = true;
}
ui.with(
NodeSpec::row()
.grow_width()
.gap(6.0)
.cross_align(Align::Center)
.padding(crate::geom::Edges {
l: 8.0,
r: 0.0,
t: 0.0,
b: 0.0,
}),
|ui| {
ui.with(NodeSpec::row().gap(6.0), |ui| {
if tok.kind == crate::tokens::TokenKind::Color {
let (first, second) = if dark {
(tok.dark, tok.light)
} else {
(tok.light, tok.dark)
};
swatch(ui, first, t);
if tok.light != tok.dark {
swatch(ui, second, t);
}
}
});
ui.text(&tok.name, TextStyle::new(11.0).color(t.fg).nowrap());
let value = match tok.kind {
crate::tokens::TokenKind::Color => {
if tok.light != tok.dark {
format!(
"#{:08x} · #{:08x}",
if dark { tok.dark } else { tok.light }.to_hex(),
if dark { tok.light } else { tok.dark }.to_hex()
)
} else {
format!("#{:08x}", tok.resolved.to_hex())
}
}
crate::tokens::TokenKind::Length => format!("{:.0} px", tok.length),
};
ui.text(&value, TextStyle::new(11.0).color(t.muted).mono().nowrap());
if let Some(recipe) = &tok.recipe {
ui.text(recipe, TextStyle::new(11.0).color(t.faint).nowrap());
}
},
);
}
if open {
ui.close();
}
});
}
fn swatch(ui: &mut Ui<'_>, c: Color, t: &Theme) {
ui.leaf(
NodeSpec::row()
.size(12.0, 12.0)
.radius(2.0)
.bg(c)
.border(1.0, t.border),
);
}
fn legend(ui: &mut Ui<'_>, st: &State, t: &Theme) {
if st.legend.is_empty() {
return;
}
ui.with(NodeSpec::table().grow_width().gap(2.0), |ui| {
for (keys, what) in &st.legend {
ui.with(NodeSpec::row().grow_width().gap(8.0), |ui| {
ui.text(keys, TextStyle::new(11.0).color(t.accent));
ui.text(what, TextStyle::new(11.0).color(t.muted));
});
}
});
}
pub(super) fn fixed(ui: &mut Ui<'_>, f: impl FnOnce(&mut Ui<'_>)) {
ui.with(NodeSpec::column().grow_width().min_height(Min::FIT), f);
}
fn icon_lit(ui: &mut Ui<'_>, t: &Theme, what: &str, glyph: Icon, radio: Option<bool>, hint: &str) {
let label = format!("kui-devtools/{what}");
let key = ui.child_key(&label);
let on = radio == Some(true);
let mut spec = NodeSpec::row();
if let Some(on) = radio {
spec = spec.role(crate::access::Role::Radio).checked(on);
}
ui.with_keyed(
&label,
spec.size(22.0, 22.0)
.center()
.radius(4.0)
.bg(if on { t.accent_soft } else { t.surface })
.hover_bg(if on { t.accent_soft } else { t.hover })
.pressed_bg(t.pressed)
.on_click(action(what))
.cursor(crate::cursor::CursorShape::Pointer)
.label(hint.split(" · ").next().unwrap_or(what))
.apply_tooltip(hint),
|ui| {
let color = if on { t.accent } else { t.muted };
icons::draw(
ui,
glyph,
color,
color.with_alpha(if on { 0.9 } else { 0.3 }),
);
if ui.is_hovered(key) {
widgets::hover_hint(ui, hint);
}
},
);
}
pub(super) fn small_button(
ui: &mut Ui<'_>,
t: &Theme,
what: &str,
text: &str,
hint: &str,
on: bool,
) {
let label = format!("kui-devtools/{what}");
let key = ui.child_key(&label);
ui.with_keyed(
&label,
NodeSpec::row()
.pad_xy(7.0, 1.0)
.radius(4.0)
.bg(if on { t.accent_soft } else { t.raised })
.hover_bg(if on { t.accent_soft } else { t.hover })
.pressed_bg(t.pressed)
.border(1.0, if on { t.accent } else { t.border })
.on_click(action(what))
.cursor(crate::cursor::CursorShape::Pointer)
.label(text)
.apply_tooltip(hint),
|ui| {
ui.text(
text,
TextStyle::new(10.0).color(if on { t.accent } else { t.fg }),
);
if ui.is_hovered(key) {
widgets::hover_hint(ui, hint);
}
},
);
}
pub(super) fn small_button_iconed(
ui: &mut Ui<'_>,
t: &Theme,
what: &str,
glyph: Icon,
text: &str,
hint: &str,
on: bool,
) {
let label = format!("kui-devtools/{what}");
let key = ui.child_key(&label);
ui.with_keyed(
&label,
NodeSpec::row()
.pad_xy(6.0, 1.0)
.gap(3.0)
.radius(4.0)
.cross_align(Align::Center)
.bg(if on { t.accent_soft } else { t.raised })
.hover_bg(if on { t.accent_soft } else { t.hover })
.pressed_bg(t.pressed)
.border(1.0, if on { t.accent } else { t.border })
.on_click(action(what))
.cursor(crate::cursor::CursorShape::Pointer)
.label(text)
.apply_tooltip(hint),
|ui| {
let color = if on { t.accent } else { t.fg };
icons::draw(ui, glyph, color, color.with_alpha(0.35));
ui.text(text, TextStyle::new(10.0).color(color));
if ui.is_hovered(key) {
widgets::hover_hint(ui, hint);
}
},
);
}
pub(super) fn filter_field(ui: &mut Ui<'_>, t: &Theme, what: &str, name: &str) -> String {
let label = format!("kui-devtools/{what}");
let key = ui.child_key(&label);
let focused = ui.is_focused(key);
ui.text(name, TextStyle::new(11.0).color(t.muted));
ui.text_edit(
&label,
"",
&crate::edit::EditOptions {
style: TextStyle::new(11.0).mono(),
multiline: false,
..Default::default()
},
NodeSpec::column()
.grow_width()
.pad_xy(6.0, 1.0)
.bg(t.sunken)
.radius(4.0)
.border(1.0, if focused { t.accent } else { t.border })
.clip()
.label(name),
);
ui.edit_text(key).unwrap_or_default()
}