use crate::access::Role;
use crate::color::Color;
use crate::cursor::CursorShape;
use crate::edit::EditOptions;
use crate::geom::{Edges, Vec2};
use crate::key::Key;
use crate::menu::{MenuBar, MenuItem, MenuRole};
use crate::metrics::Metrics;
use crate::spec::{Align, FloatConfig, NodeSpec, Sizing, TextStyle};
use crate::stats::{FrameSample, STATS_CAPACITY};
use crate::theme::Theme;
use crate::tree::OriginId;
use crate::ui::Ui;
use crate::value::Value;
use crate::window::WindowButton;
pub fn latency_hud(ui: &mut Ui<'_>) {
latency_hud_at(ui, Align::End, Align::End);
}
pub fn latency_hud_at(ui: &mut Ui<'_>, x: Align, y: Align) {
let top_inset = if ui.env().window.custom_chrome {
titlebar_height(ui)
} else {
0.0
};
let dx = match x {
Align::Start | Align::SpaceBetween | Align::Baseline => 12.0,
Align::Center | Align::SpaceAround | Align::SpaceEvenly => 0.0,
Align::End => -12.0,
};
let dy = match y {
Align::Start | Align::SpaceBetween | Align::Baseline => 12.0 + top_inset,
Align::Center | Align::SpaceAround | Align::SpaceEvenly => 0.0,
Align::End => -12.0,
};
let t = ui.theme();
ui.with(
NodeSpec::column()
.float(
crate::spec::FloatConfig::viewport()
.inside(x, y)
.offset(dx, dy),
)
.pad(10.0)
.bg(t.bg.with_alpha(0.71))
.radius(8.0)
.border(1.0, t.border_strong.with_alpha(0.5)),
latency_graph,
);
}
pub fn latency_graph(ui: &mut Ui<'_>) {
const GRAPH_H: f32 = 34.0;
const INPUT: Color = Color {
r: 0.45,
g: 0.85,
b: 0.55,
a: 1.0,
};
const VIEW: Color = Color {
r: 0.28,
g: 0.42,
b: 0.88,
a: 1.0,
};
const LAYOUT: Color = Color {
r: 0.60,
g: 0.42,
b: 0.88,
a: 1.0,
};
const RENDER: Color = Color {
r: 0.94,
g: 0.72,
b: 0.35,
a: 1.0,
};
const WAIT: Color = Color {
r: 0.42,
g: 0.45,
b: 0.52,
a: 0.7,
};
const OVER: Color = Color {
r: 0.91,
g: 0.36,
b: 0.36,
a: 1.0,
};
let theme = ui.theme();
let budget_ms = ui.env().frame_budget_ms(); let stats = &ui.core().stats;
let samples: Vec<FrameSample> = stats.iter().collect();
let (avg_work, max_work) = (stats.avg_work(), stats.max_work());
let avg_wait = if samples.is_empty() {
0.0
} else {
samples.iter().map(|s| s.wait_ms).sum::<f32>() / samples.len() as f32
};
ui.with(
NodeSpec::column()
.gap(3.0)
.cross_align(Align::End)
.role(crate::access::Role::None),
|ui| {
let mut label = format!("work {avg_work:.2}ms avg · {max_work:.2}ms max");
if avg_wait > 0.05 {
label.push_str(&format!(" · +{avg_wait:.2}ms vsync"));
}
ui.with(NodeSpec::row().gap(6.0).cross_align(Align::Center), |ui| {
ui.text(&label, TextStyle::new(10.0).color(theme.muted));
let badge = ui.child_key("kui:latency-legend");
let badge_bg =
theme
.muted
.with_alpha(if ui.is_hovered(badge) { 0.31 } else { 0.16 });
ui.with_keyed(
"kui:latency-legend",
NodeSpec::column()
.size(13.0, 13.0)
.center()
.bg(badge_bg)
.radius(6.5)
.hoverable(),
|ui| {
ui.text("?", TextStyle::new(9.0).color(theme.fg));
if ui.is_hovered(badge) {
tooltip_with(ui, |ui| {
ui.with(NodeSpec::column().gap(5.0), |ui| {
for (color, name) in [
(INPUT, "input — events & edits"),
(VIEW, "view — rebuilding the tree"),
(LAYOUT, "layout — sizing & positions"),
(RENDER, "render — encode + submit"),
(WAIT, "vsync wait (not work)"),
(OVER, "cap: work over frame budget"),
] {
ui.with(
NodeSpec::row().gap(7.0).cross_align(Align::Center),
|ui| {
ui.leaf(
NodeSpec::column()
.size(9.0, 9.0)
.bg(color)
.radius(2.0),
);
ui.text(name, TextStyle::new(11.0).color(theme.fg));
},
);
}
});
});
}
},
);
});
ui.with(
NodeSpec::row()
.size(STATS_CAPACITY as f32 * 2.0, GRAPH_H)
.gap(1.0)
.main_align(Align::End)
.cross_align(Align::End)
.bg(theme.sunken.with_alpha(0.6))
.radius(3.0)
.clip(),
|ui| {
let px_per_ms = GRAPH_H / budget_ms;
for s in &samples {
let over = s.work() > budget_ms;
ui.with(
NodeSpec::column()
.width(1.0)
.main_align(Align::End)
.max_height(GRAPH_H),
|ui| {
if over {
ui.leaf(NodeSpec::column().size(1.0, 3.0).bg(OVER));
}
for (ms, color) in [
(s.wait_ms, WAIT),
(s.render_ms, RENDER),
(s.layout_ms, LAYOUT),
(s.view_ms, VIEW),
(s.input_ms, INPUT),
] {
if ms <= 0.0 {
continue;
}
let h = (ms * px_per_ms).max(1.0);
ui.leaf(NodeSpec::column().size(1.0, h).bg(color));
}
},
);
}
},
);
},
);
}
pub fn tooltip(ui: &mut Ui<'_>, text: &str) {
let size = ui.metrics().hint_text;
tooltip_with(ui, |ui| {
ui.text(text, TextStyle::new(size));
});
}
pub fn tooltip_with(ui: &mut Ui<'_>, content: impl FnOnce(&mut Ui<'_>)) {
let spec = tooltip_spec(ui);
ui.with(spec, content);
}
pub(crate) fn hover_hint(ui: &mut Ui<'_>, text: &str) {
let size = ui.metrics().hint_text;
let spec = tooltip_spec(ui).role(crate::access::Role::None);
ui.text_in(spec, text, TextStyle::new(size));
}
pub(crate) fn leaf_hint(ui: &mut Ui<'_>, leaf: Key, text: &str) {
let size = ui.metrics().hint_text;
let mut spec = tooltip_spec(ui).role(crate::access::Role::None);
if let Some(float) = spec.layout.float.as_mut() {
float.anchor = crate::spec::FloatAnchor::Node(leaf);
}
ui.core().open_key(leaf.str("tooltip"), spec);
ui.text(text, TextStyle::new(size));
ui.close();
}
fn tooltip_spec(ui: &Ui<'_>) -> NodeSpec {
let t = ui.theme();
let m = ui.metrics();
NodeSpec::column()
.float(crate::spec::FloatConfig::below().fit())
.pad_xy(m.hint_pad_x, m.hint_pad_y)
.bg(t.raised)
.radius(m.radius)
.border(1.0, t.border_strong)
}
pub fn label(ui: &mut Ui<'_>, text: &str) {
ui.text(text, TextStyle::default());
}
pub fn text_input(ui: &mut Ui<'_>, label: &str, initial: &str) -> Key {
let key = ui.child_key(label);
let t = ui.theme();
let m = ui.metrics();
let border = if ui.is_focused(key) {
t.accent
} else {
t.border
};
ui.text_edit(
label,
initial,
&EditOptions {
multiline: false,
..Default::default()
},
NodeSpec::column()
.grow_width()
.pad_xy(m.field_pad_x, m.field_pad_y)
.bg(t.sunken)
.radius(m.radius)
.border(1.0, border)
.clip()
.label(label),
)
}
pub(crate) fn select_tag() -> Value {
Value::map([("select", Value::Bool(true))])
}
pub fn select(ui: &mut Ui<'_>, label: &str, options: &[&str], current: Option<usize>) -> Key {
let items: Vec<MenuItem> = options.iter().map(|o| MenuItem::new(*o)).collect();
select_items(ui, label, &items, current)
}
pub fn select_items(
ui: &mut Ui<'_>,
label: &str,
items: &[MenuItem],
current: Option<usize>,
) -> Key {
let t = ui.theme();
let m = ui.metrics();
select_with(
ui,
label,
items,
current,
select_spec(&t, &m),
TextStyle::new(m.chrome_text),
)
}
pub fn select_spec(theme: &Theme, m: &Metrics) -> NodeSpec {
NodeSpec::row()
.pad_xy(m.field_pad_x, m.field_pad_y)
.gap(8.0)
.cross_align(Align::Center)
.bg(theme.sunken)
.hover_bg(theme.hover)
.radius(m.radius)
}
pub fn select_with(
ui: &mut Ui<'_>,
label: &str,
items: &[MenuItem],
current: Option<usize>,
spec: NodeSpec,
text: TextStyle,
) -> Key {
let key = ui.child_key(label);
let current = current.filter(|&i| {
let separator = items
.get(i)
.is_some_and(|it| it.role == MenuRole::Separator);
let names_one = i < items.len() && !separator;
if !names_one {
ui.core().warn(crate::diag::select_current_ignored(
key,
label,
i,
items.len(),
separator,
));
}
names_one
});
let t = ui.theme();
let shown = current
.and_then(|i| items.get(i))
.map_or("", |i| i.text())
.to_string();
let open = ui.core().menu().is_some_and(|menu| menu.target == key);
let border = if open || ui.is_focused(key) {
t.accent
} else {
t.border
};
let menu: Vec<MenuItem> = items
.iter()
.enumerate()
.map(|(i, item)| {
let mut row = item.clone().checked(current == Some(i));
row.submenu = Vec::new();
row
})
.collect();
ui.core().declare_select(key, menu);
ui.with_keyed(
label,
spec.border(1.0, border)
.cursor(CursorShape::Pointer)
.on_click(select_tag())
.role(Role::Button)
.label(label)
.description(shown.as_str())
.expanded(open),
|ui| {
ui.text(&shown, text.color(t.fg).nowrap());
ui.text("\u{25BE}", text.color(t.muted));
},
)
}
pub const TITLEBAR_H: f32 = Metrics::comfortable().titlebar_h;
pub fn titlebar_height(ui: &Ui<'_>) -> f32 {
match ui.env().window.native_controls {
Some(r) if r.h > 0.0 => r.h,
_ => ui.metrics().titlebar_h,
}
}
pub fn titlebar(ui: &mut Ui<'_>, title: &str) {
let focused = ui.env().focused;
let title = title.to_string();
titlebar_with(ui, move |ui| {
let t = ui.theme();
let size = ui.metrics().chrome_text;
let color = if focused { t.fg } else { t.faint };
ui.text_in(
NodeSpec::row().fill().cross_align(Align::Center),
&title,
TextStyle::new(size).color(color).ellipsis(),
);
});
}
pub fn titlebar_with(ui: &mut Ui<'_>, content: impl FnOnce(&mut Ui<'_>)) {
let win = ui.env().window;
let h = titlebar_height(ui);
ui.with_keyed(
"kui:titlebar",
NodeSpec::row()
.grow_width()
.height(h)
.cross_align(Align::Center)
.window_drag(),
|ui| {
let inset = win.native_controls.map_or(12.0, |r| r.x + r.w);
ui.leaf(NodeSpec::row().width(inset));
content(ui);
window_buttons(ui);
},
);
}
pub fn window_buttons(ui: &mut Ui<'_>) {
let win = ui.env().window;
if !win.custom_chrome || win.native_controls.is_some() {
return;
}
let h = titlebar_height(ui);
ui.with(NodeSpec::row().grow_height().min_height(h), |ui| {
window_button(ui, WindowButton::Minimize, win.maximized);
window_button(ui, WindowButton::Maximize, win.maximized);
window_button(ui, WindowButton::Close, win.maximized);
});
}
fn window_button(ui: &mut Ui<'_>, button: WindowButton, maximized: bool) {
let label = match button {
WindowButton::Minimize => "kui:win-min",
WindowButton::Maximize => "kui:win-max",
WindowButton::Close => "kui:win-close",
};
let key = ui.child_key(label);
let (hovered, pressed) = (ui.is_hovered(key), ui.is_pressed(key));
let t = ui.theme();
let fg = t.fg;
let (bg, fg) = match button {
WindowButton::Close if pressed => (t.danger.mix(Color::BLACK, 0.15), Color::WHITE),
WindowButton::Close if hovered => (t.danger, Color::WHITE),
_ if pressed => (t.pressed, fg),
_ if hovered => (t.hover, fg),
_ => (Color::TRANSPARENT, fg),
};
ui.with_keyed(
label,
NodeSpec::row()
.width(46.0)
.grow_height()
.center()
.bg(bg)
.window_button(button),
|ui| match button {
WindowButton::Minimize => {
ui.leaf(NodeSpec::row().size(10.0, 1.0).bg(fg));
}
WindowButton::Maximize if maximized => {
ui.with(NodeSpec::column().size(10.0, 10.0), |ui| {
for (x, y) in [(Align::End, Align::Start), (Align::Start, Align::End)] {
ui.leaf(
NodeSpec::column()
.size(7.5, 7.5)
.border(1.0, fg)
.float(crate::spec::FloatConfig::parent().inside(x, y)),
);
}
});
}
WindowButton::Maximize => {
ui.leaf(NodeSpec::column().size(9.0, 9.0).border(1.0, fg));
}
WindowButton::Close => {
const EM: f32 = 23.0;
ui.text_in(
NodeSpec::row().padding(Edges {
b: EM * 0.25,
..Edges::default()
}),
"\u{00d7}",
TextStyle::new(EM).line_height(EM).color(fg),
);
}
},
);
}
pub fn button_spec(theme: &Theme, m: &Metrics) -> NodeSpec {
NodeSpec::row()
.pad_xy(m.control_pad_x, m.control_pad_y)
.bg(theme.accent)
.hover_bg(theme.accent_hover)
.pressed_bg(theme.accent_pressed)
.radius(m.radius)
.center()
}
pub fn button_palette(base: Color) -> (Color, Color, Color) {
(
base,
base.mix(Color::WHITE, 0.09),
base.mix(Color::BLACK, 0.10),
)
}
pub fn readable_on(bg: Color) -> Color {
if bg.luminance() > 0.4 {
Color::BLACK
} else {
Color::WHITE
}
}
pub const BUTTON_TEXT: f32 = Metrics::comfortable().control_text;
pub const BUTTON_DISABLED_OPACITY: f32 = 0.5;
pub fn button(ui: &mut Ui<'_>, text: &str, payload: impl Into<Value>) {
let (theme, m) = (ui.theme(), ui.metrics());
button_with(
ui,
text,
text,
button_spec(&theme, &m).on_click(payload.into()),
None,
);
}
pub fn button_with(ui: &mut Ui<'_>, key: &str, text: &str, spec: NodeSpec, hint: Option<&str>) {
button_body(ui, Ident::Label(key), text, spec, hint);
}
pub fn button_indexed(ui: &mut Ui<'_>, index: u64, text: &str, spec: NodeSpec, hint: Option<&str>) {
button_body(ui, Ident::Index(index), text, spec, hint);
}
enum Ident<'a> {
Label(&'a str),
Index(u64),
}
fn own_hint(spec: &mut NodeSpec, hint: Option<&str>) {
if hint.is_some() && spec.access().tooltip {
spec.access_mut().tooltip = false;
}
}
fn button_body(ui: &mut Ui<'_>, ident: Ident<'_>, text: &str, spec: NodeSpec, hint: Option<&str>) {
let theme = ui.theme();
let spec = if spec.accent {
spec.bg(theme.accent)
.hover_bg(theme.accent_hover)
.pressed_bg(theme.accent_pressed)
} else {
spec
};
let spec = if spec.disabled {
let o = spec.style.opacity * theme.disabled_opacity;
spec.opacity(o)
} else {
spec
};
let spec = if spec.cursor.is_none() && !spec.disabled {
spec.cursor(CursorShape::Pointer)
} else {
spec
};
let mut spec = match hint {
Some(hint) if spec.access().description.is_none() => spec.apply_tooltip(hint),
_ => spec,
};
own_hint(&mut spec, hint);
let label = readable_on(spec.style.bg);
let size = ui.metrics().control_text;
let node = match ident {
Ident::Label(key) => ui.child_key(key),
Ident::Index(i) => ui.child_key_indexed(i),
};
let body = |ui: &mut Ui<'_>| {
ui.text(text, TextStyle::new(size).color(label));
if let Some(hint) = hint
&& ui.is_hovered(node)
{
hover_hint(ui, hint);
}
};
match ident {
Ident::Label(key) => {
ui.with_keyed(key, spec, body);
}
Ident::Index(i) => {
ui.with_indexed(i, spec, body);
}
}
}
pub fn control_box(m: &Metrics) -> f32 {
(m.control_text + 1.0).round()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Toggle {
Checkbox,
Radio,
Switch,
}
impl Toggle {
pub fn role(self) -> Role {
match self {
Toggle::Checkbox => Role::Checkbox,
Toggle::Radio => Role::Radio,
Toggle::Switch => Role::Switch,
}
}
pub fn name(self) -> &'static str {
match self {
Toggle::Checkbox => "checkbox",
Toggle::Radio => "radio",
Toggle::Switch => "switch",
}
}
}
pub fn toggle_spec(m: &Metrics) -> NodeSpec {
NodeSpec::row()
.gap((control_box(m) / 2.0).round())
.cross_align(Align::Center)
}
pub fn checkbox(ui: &mut Ui<'_>, text: &str, checked: bool, payload: impl Into<Value>) -> Key {
let m = ui.metrics();
toggle_with(
ui,
Toggle::Checkbox,
text,
text,
toggle_spec(&m).checked(checked).on_click(payload.into()),
None,
)
}
pub fn radio(ui: &mut Ui<'_>, text: &str, checked: bool, payload: impl Into<Value>) -> Key {
let m = ui.metrics();
toggle_with(
ui,
Toggle::Radio,
text,
text,
toggle_spec(&m).checked(checked).on_click(payload.into()),
None,
)
}
pub fn switch(ui: &mut Ui<'_>, text: &str, on: bool, payload: impl Into<Value>) -> Key {
let m = ui.metrics();
toggle_with(
ui,
Toggle::Switch,
text,
text,
toggle_spec(&m).checked(on).on_click(payload.into()),
None,
)
}
pub fn toggle_with(
ui: &mut Ui<'_>,
kind: Toggle,
key: &str,
text: &str,
spec: NodeSpec,
hint: Option<&str>,
) -> Key {
let t = ui.theme();
let m = ui.metrics();
let node = ui.child_key(key);
let ax = spec.access();
let mixed = ax.mixed && kind == Toggle::Checkbox;
let on = ax.checked || mixed;
let disabled = spec.disabled;
let hovered = !disabled && ui.is_hovered(node);
let mut spec = spec.role(kind.role());
own_hint(&mut spec, hint);
if disabled {
let o = spec.style.opacity * t.disabled_opacity;
spec = spec.opacity(o);
} else if spec.cursor.is_none() {
spec = spec.cursor(CursorShape::Pointer);
}
let b = control_box(&m);
ui.with_keyed(key, spec, |ui| {
let edge = if on || hovered {
t.accent
} else {
t.border_strong
};
match kind {
Toggle::Checkbox | Toggle::Radio => {
let radius = if kind == Toggle::Radio {
b / 2.0
} else {
m.radius_inner.min(b / 4.0)
};
let face = NodeSpec::row()
.size(b, b)
.radius(radius)
.border(1.0, edge)
.bg(if on { t.accent } else { t.sunken })
.center();
ui.with(face, |ui| {
if !on {
return;
}
if kind == Toggle::Radio {
let d = (b * 0.4).round();
ui.leaf(NodeSpec::row().size(d, d).radius(d / 2.0).bg(t.on_accent));
} else if mixed {
ui.leaf(
NodeSpec::row()
.size((b * 0.5).round(), 2.0)
.radius(1.0)
.bg(t.on_accent),
);
} else {
ui.polyline(
&[
Vec2::new(b * 0.26, b * 0.52),
Vec2::new(b * 0.43, b * 0.69),
Vec2::new(b * 0.75, b * 0.33),
],
crate::line::Stroke::new((b / 8.0).max(1.5), t.on_accent),
NodeSpec::default(),
);
}
});
}
Toggle::Switch => {
let track = NodeSpec::row()
.size((b * 1.75).round(), b)
.pad(2.0)
.radius(b / 2.0)
.bg(if on { t.accent } else { t.border_strong })
.main_align(if on { Align::End } else { Align::Start })
.cross_align(Align::Center)
.transition(120.0);
ui.with_keyed("track", track, |ui| {
let k = b - 4.0;
ui.leaf_keyed(
"knob",
NodeSpec::row()
.size(k, k)
.radius(k / 2.0)
.bg(t.on_accent)
.transition(120.0)
.slide(),
);
});
}
}
if !text.is_empty() {
ui.text(text, TextStyle::new(m.control_text).color(t.fg));
}
if let Some(hint) = hint
&& ui.is_hovered(node)
{
tooltip(ui, hint);
}
})
}
pub fn radio_group_spec(m: &Metrics) -> NodeSpec {
NodeSpec::column().gap((control_box(m) / 2.0).round())
}
pub fn radio_group_with(
ui: &mut Ui<'_>,
label: &str,
spec: NodeSpec,
f: impl FnOnce(&mut Ui<'_>),
) -> Key {
let spec = radio_group_open_spec(&ui.metrics(), label, spec);
ui.with_keyed(label, spec, f)
}
pub fn radio_group_open_spec(m: &Metrics, label: &str, spec: NodeSpec) -> NodeSpec {
let mut spec = spec.role(Role::RadioGroup).label(label);
if spec.layout.gap == 0.0 {
spec.layout.gap = radio_group_spec(m).layout.gap;
}
spec
}
pub fn radio_group(
ui: &mut Ui<'_>,
label: &str,
options: &[&str],
current: Option<usize>,
payload: impl Fn(usize) -> Value,
) -> Key {
let m = ui.metrics();
radio_group_with(ui, label, radio_group_spec(&m), |ui| {
for (i, option) in options.iter().enumerate() {
let key = format!("{i}");
toggle_with(
ui,
Toggle::Radio,
&key,
option,
toggle_spec(&m)
.checked(current == Some(i))
.on_click(payload(i)),
None,
);
}
})
}
pub fn slider_spec(m: &Metrics) -> NodeSpec {
let b = control_box(m);
NodeSpec::row()
.size(m.menu_width, b)
.pad_xy(b / 2.0, 0.0)
.cross_align(Align::Center)
}
pub fn slider(
ui: &mut Ui<'_>,
label: &str,
value: f32,
min: f32,
max: f32,
step: f32,
tag: impl Into<Value>,
) -> Key {
let m = ui.metrics();
slider_with(
ui,
label,
slider_spec(&m)
.value_now(value)
.value_min(min)
.value_max(max)
.value_step(step)
.on_change(tag.into()),
None,
)
}
pub fn slider_with(ui: &mut Ui<'_>, label: &str, spec: NodeSpec, hint: Option<&str>) -> Key {
let t = ui.theme();
let m = ui.metrics();
let node = ui.child_key(label);
let ax = spec.access();
let fraction = crate::slider::SliderRange::of(ax).map_or(0.0, |r| {
let now = ax.value_now.map_or(r.min, crate::slider::exact);
((now - r.min) / (r.max - r.min)).clamp(0.0, 1.0) as f32
});
let named = ax.label.is_some();
let disabled = spec.disabled;
let mut spec = spec.role(Role::Slider);
own_hint(&mut spec, hint);
if !named {
spec = spec.label(label);
}
if disabled {
let o = spec.style.opacity * t.disabled_opacity;
spec = spec.opacity(o);
} else if spec.cursor.is_none() {
spec = spec.cursor(CursorShape::Pointer);
}
let b = control_box(&m);
ui.with_keyed(label, spec, |ui| {
let track = NodeSpec::row()
.grow_width()
.height(4.0)
.radius(2.0)
.bg(t.border_strong);
ui.with(track, |ui| {
let fill = NodeSpec::row()
.width(Sizing::Percent(fraction))
.grow_height()
.radius(2.0)
.bg(t.accent);
ui.with(fill, |ui| {
ui.leaf(
NodeSpec::row()
.size(b, b)
.radius(b / 2.0)
.bg(t.on_accent)
.border(1.0, t.border_strong)
.float(
FloatConfig::parent()
.at(Align::End, Align::Center)
.self_at(Align::Center, Align::Center),
),
);
});
});
if let Some(hint) = hint
&& ui.is_hovered(node)
{
tooltip(ui, hint);
}
})
}
pub fn splitter(
ui: &mut Ui<'_>,
label: &str,
dir: crate::spec::Dir,
thickness: f32,
tag: impl Into<Value>,
) -> Key {
let t = ui.theme();
let bar = match dir {
crate::spec::Dir::Row => NodeSpec::column()
.size(thickness, Sizing::GROW)
.cursor(CursorShape::EwResize),
crate::spec::Dir::Column => NodeSpec::column()
.size(Sizing::GROW, thickness)
.cursor(CursorShape::NsResize),
};
ui.leaf_keyed(
label,
bar.bg(t.border)
.hover_bg(t.accent)
.pressed_bg(t.accent)
.on_drag(tag)
.keep_focus(),
)
}
pub const MENU_WIDTH: f32 = Metrics::comfortable().menu_width;
pub const MENU_TEXT: f32 = Metrics::comfortable().chrome_text;
pub const MENU_KEY: &str = "kui.menu";
pub fn context_menu(ui: &mut Ui<'_>, at: Vec2, items: &[MenuItem]) -> Key {
let t = ui.theme();
let m = ui.metrics();
let saved = ui.origin();
ui.set_origin(OriginId::MENU);
let at = at.plus(ui.core().dt_shift());
let root = menu_panel(
ui,
MENU_KEY,
menu_panel_spec(&t, &m)
.float(
FloatConfig::viewport()
.inside(Align::Start, Align::Start)
.offset(at.x, at.y)
.fit(),
)
.modal(Value::str(MENU_KEY))
.label("Menu"),
items,
);
ui.set_origin(saved);
root
}
pub fn menu_panel_spec(t: &Theme, m: &Metrics) -> NodeSpec {
NodeSpec::column()
.role(Role::Menu)
.width(Sizing::Fit)
.min_width(m.menu_width)
.pad(MENU_PANEL_PAD)
.gap(1.0)
.bg(t.raised)
.border(1.0, t.border_strong)
.radius(m.radius)
}
pub fn menu_panel(ui: &mut Ui<'_>, label: &str, spec: NodeSpec, items: &[MenuItem]) -> Key {
menu_level(ui, label, label, spec, items, &[])
}
fn menu_level(
ui: &mut Ui<'_>,
root: &str,
label: &str,
spec: NodeSpec,
items: &[MenuItem],
path: &[usize],
) -> Key {
let t = ui.theme();
let m = ui.metrics();
let ceiling = (ui.viewport().w - 2.0 * MENU_EDGE).max(m.menu_width);
let spec = if spec.layout.max_w >= 0.0 && spec.layout.max_w > ceiling {
spec.max_width(ceiling)
} else {
spec
};
let in_viewport = spec
.layout
.float
.is_some_and(|f| f.anchor == crate::spec::FloatAnchor::Viewport);
let cap = if spec.layout.max_w >= 0.0 {
spec.layout.max_w
} else if let Some(calc) = crate::spec::max_calc(spec.layout.max_w)
&& in_viewport
{
calc.resolve(ui.viewport().w).min(ceiling)
} else {
ceiling
};
let inside = cap - 2.0 * (MENU_PANEL_PAD + 1.0) - 2.0 * m.menu_pad_x;
let accent = t.accent_soft;
let gutter = items.iter().any(|i| i.checked);
let surface = crate::runtime::MenuSurface::of(ui.origin());
let group = |i: usize| format!("{root}/{path:?}/{i}");
if let Some(s) = surface
&& path.is_empty()
{
ui.core().submenu_pass(s, true);
}
if let Some(s) = surface {
for (i, item) in items.iter().enumerate() {
if item.selectable() && ui.is_group_hovered(NodeSpec::hover_group_id(&group(i))) {
let row: Vec<usize> = path.iter().copied().chain([i]).collect();
ui.core().submenu_hovered(s, &row, item.has_submenu());
}
}
}
let open_here = surface.and_then(|s| ui.core().submenu_open_at(s, path));
let mut first_key = None;
let root_key = ui.with_keyed(label, spec, |ui| {
let mut first = path.is_empty();
for (i, item) in items.iter().enumerate() {
if item.role == MenuRole::Separator {
ui.leaf_indexed(
i as u64,
NodeSpec::row()
.grow_width()
.height(1.0)
.bg(t.border)
.role(Role::None),
);
continue;
}
let payload = menu_row_tag(i, path);
let opens = item.has_submenu();
let is_open = opens && open_here == Some(i);
let mut spec = NodeSpec::row()
.role(Role::MenuItem)
.label(item.text())
.grow_width()
.min_width(crate::spec::Bound::Fit)
.pad_xy(m.menu_pad_x, m.menu_pad_y)
.gap(MENU_ROW_GAP)
.radius(m.radius_inner)
.main_align(Align::Start)
.cross_align(Align::Center);
if item.checked {
spec = spec.checked(true);
}
if item.enabled {
spec = spec.on_click(payload).hover_bg(accent).focus_bg(accent);
if surface.is_some() {
spec = spec.hover_group(&group(i));
}
if first {
spec = spec.initial_focus();
first = false;
}
} else {
spec = spec.disabled(true).opacity(t.disabled_opacity);
}
if opens {
spec = spec.expanded(is_open);
if is_open {
spec = spec.bg(accent);
}
}
let tail = if opens {
Some(std::borrow::Cow::Borrowed(MENU_CHEVRON))
} else {
item.accel_label()
};
let row = |ui: &mut Ui<'_>| {
let tail_style = TextStyle::new(m.chrome_text).color(t.muted).nowrap();
let mut label_max = inside;
if gutter {
label_max -= MENU_CHECK_W + MENU_ROW_GAP;
}
let tail_max =
(label_max - 2.0 * MENU_ROW_GAP - MENU_ACCEL_GAP - MENU_LABEL_MIN).max(0.0);
if let Some(tail) = &tail {
let w = ui.measure_text(tail, &tail_style, None).width.min(tail_max);
label_max -= 2.0 * MENU_ROW_GAP + MENU_ACCEL_GAP + w;
}
if gutter {
ui.with(NodeSpec::row().width(MENU_CHECK_W), |ui| {
if item.checked {
ui.text("\u{2713}", TextStyle::new(m.chrome_text).color(t.fg));
}
});
}
ui.text_in(
NodeSpec::row().max_width(label_max.max(0.0)),
item.text(),
TextStyle::new(m.chrome_text)
.color(t.fg)
.nowrap()
.ellipsis(),
);
if let Some(tail) = &tail {
ui.leaf(NodeSpec::row().grow_width().min_width(MENU_ACCEL_GAP));
ui.text_in(
NodeSpec::row().max_width(tail_max),
tail,
tail_style.ellipsis(),
);
}
};
let key = if opens {
let mut key = Key::ROOT;
ui.with_indexed(
i as u64,
NodeSpec::row()
.grow_width()
.min_width(crate::spec::Bound::Fit),
|ui| {
key = ui.with_keyed(MENU_ROW_KEY, spec, row);
if is_open && item.enabled {
let sub: Vec<usize> = path.iter().copied().chain([i]).collect();
menu_level(
ui,
root,
MENU_SUB_KEY,
menu_panel_spec(&t, &m)
.label(item.text())
.hoverable()
.float(
FloatConfig::parent()
.at(Align::End, Align::Start)
.self_at(Align::Start, Align::Start)
.offset(MENU_PANEL_PAD, -MENU_PANEL_PAD)
.fit(),
),
&item.submenu,
&sub,
);
}
},
);
key
} else {
ui.with_indexed(i as u64, spec, row)
};
if item.enabled && first_key.is_none() {
first_key = Some(key);
}
}
});
if let (Some(s), Some(key)) = (surface, first_key)
&& !path.is_empty()
{
ui.core().submenu_drawn(s, path, key);
}
if let Some(s) = surface
&& path.is_empty()
{
ui.core().submenu_pass(s, false);
}
root_key
}
const MENU_PANEL_PAD: f32 = 4.0;
pub const MENU_EDGE: f32 = 8.0;
pub const MENU_LABEL_MIN: f32 = 48.0;
const MENU_ROW_GAP: f32 = 8.0;
pub const MENU_CHEVRON: &str = "\u{203a}";
const MENU_ROW_KEY: &str = "row";
const MENU_SUB_KEY: &str = "sub";
fn menu_row_tag(i: usize, path: &[usize]) -> Value {
let row = ("row", Value::Int(i as i64));
if path.is_empty() {
Value::map([row])
} else {
Value::map([
row,
(
"path",
Value::List(path.iter().map(|&p| Value::Int(p as i64)).collect()),
),
])
}
}
fn menu_title_tag(i: usize) -> Value {
Value::map([("title", Value::Int(i as i64))])
}
pub const MENU_BAR_KEY: &str = "kui.menubar";
const MENU_BAR_PANEL_KEY: &str = "kui.menubar.menu";
const MENU_BAR_TITLE_KEY: &str = "kui.menubar.title";
fn group_name(i: usize) -> String {
format!("{MENU_BAR_KEY}.{i}")
}
pub const MENU_BAR_H: f32 = Metrics::comfortable().menu_bar_h;
pub fn menu_bar(ui: &mut Ui<'_>, bar: MenuBar) {
ui.core().declare_menu_bar(bar);
if ui.core().native_menu_bar() {
return;
}
let Some(bar) = ui.core().menu_bar().cloned() else {
return;
};
if bar.menus.is_empty() {
return;
}
let t = ui.theme();
let m = ui.metrics();
let accent = t.accent_soft;
let mut open = ui.core().menu_bar_open();
let saved = ui.origin();
ui.set_origin(OriginId::MENU_BAR);
let mut spec = NodeSpec::row()
.grow_width()
.height(m.menu_bar_h)
.cross_align(Align::Center)
.pad_xy(4.0, 0.0)
.gap(2.0)
.bg(t.bg)
.role(Role::Menu)
.label("Menu bar");
if open.is_some() {
spec = spec.modal(Value::str(MENU_BAR_KEY));
}
let root = ui.with_keyed(MENU_BAR_KEY, spec, |ui| {
if open.is_some() {
for (i, menu) in bar.menus.iter().enumerate() {
let hovered = ui.is_group_hovered(NodeSpec::hover_group_id(&group_name(i)));
if open != Some(i) && menu.enabled && !menu.items.is_empty() && hovered {
open = Some(i);
ui.core().set_menu_bar_open(open);
}
}
}
for (i, menu) in bar.menus.iter().enumerate() {
let live = menu.enabled && !menu.items.is_empty();
let is_open = open == Some(i);
ui.with_indexed(i as u64, NodeSpec::row(), |ui| {
let mut spec = NodeSpec::row()
.role(Role::MenuItem)
.label(menu.label.as_str())
.pad_xy(m.menu_pad_x, (m.menu_pad_y - 2.0).max(0.0))
.radius(m.radius_inner)
.cross_align(Align::Center);
if live {
spec = spec
.on_click(menu_title_tag(i))
.hover_group(&group_name(i))
.hover_bg(accent)
.focus_bg(accent);
if is_open {
spec = spec.bg(accent);
}
} else {
spec = spec.disabled(true).opacity(t.disabled_opacity);
}
ui.text_in_keyed(
MENU_BAR_TITLE_KEY,
spec,
menu.label.as_str(),
TextStyle::new(m.chrome_text).color(t.fg),
);
if is_open {
menu_panel(
ui,
MENU_BAR_PANEL_KEY,
menu_panel_spec(&t, &m).label(menu.label.as_str()).float(
FloatConfig::parent()
.at(Align::Start, Align::End)
.self_at(Align::Start, Align::Start)
.offset(0.0, 2.0)
.fit(),
),
&menu.items,
);
}
});
}
});
ui.set_origin(saved);
ui.core().set_menu_bar_root(root);
}
const MENU_CHECK_W: f32 = 14.0;
pub const MENU_ACCEL_GAP: f32 = 16.0;
pub fn visible_rows(
offset_y: f32,
vh: f32,
pad_t: f32,
row_h: f32,
rows: usize,
overscan: usize,
) -> std::ops::Range<usize> {
if rows == 0 || row_h <= 0.0 {
return 0..0;
}
let top = offset_y - pad_t;
let first = (top / row_h).floor().max(0.0) as usize;
let last = ((top + vh.max(0.0)) / row_h).ceil().max(0.0) as usize;
let first = first.saturating_sub(overscan).min(rows);
let last = last.saturating_add(overscan).min(rows);
first..last.max(first)
}
pub fn uniform_list(
ui: &mut Ui<'_>,
label: &str,
spec: NodeSpec,
rows: usize,
row_h: f32,
row: impl FnMut(&mut Ui<'_>, usize),
) -> Key {
uniform_list_with(ui, label, spec, rows, row_h, |_| NodeSpec::column(), row)
}
pub fn uniform_list_with(
ui: &mut Ui<'_>,
label: &str,
spec: NodeSpec,
rows: usize,
row_h: f32,
mut row_spec: impl FnMut(usize) -> NodeSpec,
mut row: impl FnMut(&mut Ui<'_>, usize),
) -> Key {
const OVERSCAN: usize = 2;
let key = ui.child_key(label);
let pad_t = spec.layout.padding.t;
let (offset_y, vh, first_frame) = match ui.scroll_geometry(key) {
Some(g) => (g.offset.y, g.rect.h, false),
None => (ui.scroll_offset(key).y, ui.viewport().h, true),
};
let range = visible_rows(offset_y, vh, pad_t, row_h, rows, OVERSCAN);
ui.with_keyed(label, spec.scroll_y().gap(0.0), |ui| {
ui.row_count(rows as u64);
let lead = range.start as f32 * row_h;
if lead > 0.0 {
ui.leaf_keyed("lead", spacer_spec(lead));
}
for i in range.clone() {
let mut spec = row_spec(i).height(row_h);
if spec.layout.width == Sizing::Fit {
spec = spec.grow_width();
}
ui.with_indexed(i as u64, spec, |ui| row(ui, i));
}
let tail = (rows - range.end) as f32 * row_h;
if tail > 0.0 {
ui.leaf_keyed("tail", spacer_spec(tail));
}
});
if first_frame {
ui.owe_frame("list first frame");
}
key
}
pub fn reveal_row(ui: &mut Ui<'_>, label: &str, i: usize, row_h: f32) -> bool {
let key = ui.child_key(label);
let Some(g) = ui.scroll_geometry(key) else {
return false;
};
let y = i as f32 * row_h;
if row_h <= 0.0 || y + row_h > g.content.h + 0.5 {
return false;
}
if g.offset.y <= y && y + row_h <= g.offset.y + g.rect.h {
return false;
}
let to = (y + row_h / 2.0 - g.rect.h / 2.0).max(0.0);
ui.set_scroll(key, Vec2::new(g.offset.x, to));
true
}
pub fn rows_in_view(ui: &mut Ui<'_>, label: &str, row_h: f32) -> usize {
let key = ui.child_key(label);
if row_h <= 0.0 {
return 0;
}
ui.scroll_geometry(key)
.map_or(0, |g| (g.rect.h / row_h).floor().max(0.0) as usize)
}
fn row_spec(h: f32) -> NodeSpec {
NodeSpec::column().grow_width().height(h)
}
fn spacer_spec(h: f32) -> NodeSpec {
NodeSpec::column().grow_width().height(h)
}
#[derive(Clone, Debug)]
pub struct RowHeights {
h: Vec<f32>,
m: Vec<f32>,
u: Vec<u32>,
clean: usize,
seed: f32,
sum: f32,
n: usize,
width: f32,
last: usize,
}
impl RowHeights {
pub fn new(rows: usize, estimate: f32) -> Self {
RowHeights {
h: vec![f32::NAN; rows],
m: vec![0.0],
u: vec![0],
clean: 1,
seed: estimate.max(1.0),
sum: 0.0,
n: 0,
width: f32::NAN,
last: 0,
}
}
pub fn len(&self) -> usize {
self.h.len()
}
pub fn is_empty(&self) -> bool {
self.h.is_empty()
}
pub fn set_len(&mut self, rows: usize) {
if rows == self.h.len() {
return;
}
for i in rows..self.h.len() {
self.forget(i);
}
self.h.resize(rows, f32::NAN);
self.clean = self.clean.min(rows + 1);
}
pub fn clear(&mut self) {
self.h.fill(f32::NAN);
self.sum = 0.0;
self.n = 0;
self.clean = 1;
}
pub fn set(&mut self, i: usize, h: f32) {
if i >= self.h.len() || !h.is_finite() || h < 0.0 {
return;
}
self.forget(i);
self.h[i] = h;
self.sum += h;
self.n += 1;
self.clean = self.clean.min(i + 1);
}
fn forget(&mut self, i: usize) {
let old = self.h[i];
if !old.is_nan() {
self.sum -= old;
self.n -= 1;
self.h[i] = f32::NAN;
self.clean = self.clean.min(i + 1);
}
}
pub fn measured(&self, i: usize) -> Option<f32> {
self.h.get(i).copied().filter(|h| !h.is_nan())
}
pub fn get(&self, i: usize) -> f32 {
self.measured(i).unwrap_or_else(|| self.estimate())
}
pub fn estimate(&self) -> f32 {
if self.n == 0 {
self.seed
} else {
self.sum / self.n as f32
}
}
pub fn width(&self) -> f32 {
self.width
}
pub fn set_width(&mut self, w: f32) -> bool {
if !w.is_finite() || w <= 0.0 || (self.width - w).abs() < 0.5 {
return false;
}
let had = self.n > 0;
self.width = w;
if had {
self.clear();
}
true
}
fn ensure(&mut self, i: usize) {
let want = i.min(self.h.len()) + 1;
if self.clean >= want {
return;
}
self.m.truncate(self.clean);
self.u.truncate(self.clean);
self.m.reserve(want - self.clean);
self.u.reserve(want - self.clean);
let (mut acc, mut est) = (self.m[self.clean - 1], self.u[self.clean - 1]);
for &h in &self.h[self.clean - 1..want - 1] {
if h.is_nan() {
est += 1;
} else {
acc += h;
}
self.m.push(acc);
self.u.push(est);
}
self.clean = want;
}
pub fn offset_of(&mut self, i: usize) -> f32 {
let i = i.min(self.h.len());
self.ensure(i);
self.m[i] + self.u[i] as f32 * self.estimate()
}
pub fn total(&self) -> f32 {
self.sum + (self.h.len() - self.n) as f32 * self.estimate()
}
pub fn row_at(&mut self, y: f32) -> usize {
let rows = self.h.len();
if rows == 0 || y <= 0.0 {
self.last = 0;
return 0;
}
let mut lo = self.last.min(rows - 1);
let mut hi;
if self.offset_of(lo) > y {
hi = lo;
let mut step = 1usize;
while lo > 0 {
lo = lo.saturating_sub(step);
if self.offset_of(lo) <= y {
break;
}
hi = lo;
step *= 2;
}
} else {
hi = (lo + 1).min(rows);
let mut step = 1usize;
while hi < rows && self.offset_of(hi) <= y {
lo = hi;
hi = (hi + step).min(rows);
step *= 2;
}
}
while lo + 1 < hi {
let mid = lo + (hi - lo) / 2;
if self.offset_of(mid) <= y {
lo = mid;
} else {
hi = mid;
}
}
self.last = lo;
lo
}
}
pub fn list(
ui: &mut Ui<'_>,
label: &str,
spec: NodeSpec,
heights: &mut RowHeights,
mut measure: impl FnMut(&mut Ui<'_>, usize, f32) -> f32,
mut row: impl FnMut(&mut Ui<'_>, usize),
) -> Key {
let key = ui.child_key(label);
let mut slice = heights.slice(ListReading::of(ui, key, spec.layout.padding));
loop {
let pending = slice.unmeasured(heights);
if pending.is_empty() {
break;
}
for i in pending {
let h = measure(ui, i, slice.width());
heights.set(i, h);
}
if !slice.reslice(heights) {
break;
}
}
let plan = slice.finish(heights);
if let Some((drawn, target)) = plan.shift {
ui.shift_scroll(key, Vec2::new(0.0, drawn), Vec2::new(0.0, target));
}
ui.with_keyed(label, spec.scroll_y().gap(0.0), |ui| {
ui.row_count(heights.len() as u64);
if plan.lead > 0.0 {
ui.leaf_keyed("lead", spacer_spec(plan.lead));
}
for i in plan.range.clone() {
ui.with_indexed(i as u64, row_spec(heights.get(i)), |ui| row(ui, i));
}
if plan.tail > 0.0 {
ui.leaf_keyed("tail", spacer_spec(plan.tail));
}
});
if plan.first_frame {
ui.owe_frame("list first frame");
}
key
}
#[derive(Clone, Copy, Debug, Default)]
pub struct ListReading {
pub geometry: Option<crate::scroll::ScrollGeometry>,
pub scroll_y: f32,
pub viewport: crate::geom::Size,
pub pad_t: f32,
pub pad_x: f32,
pub overscan: usize,
}
impl ListReading {
pub fn of(ui: &Ui<'_>, key: Key, pad: crate::geom::Edges) -> Self {
ListReading {
geometry: ui.scroll_geometry(key),
scroll_y: ui.scroll_offset(key).y,
viewport: ui.viewport(),
pad_t: pad.t,
pad_x: pad.x(),
overscan: 2,
}
}
}
#[derive(Clone, Debug)]
pub struct ListSlice {
range: std::ops::Range<usize>,
anchor: usize,
into: f32,
top: f32,
top_before: f32,
target: Option<(f32, usize, f32)>,
vh: f32,
width: f32,
overscan: usize,
passes: usize,
first_frame: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ListPlan {
pub range: std::ops::Range<usize>,
pub lead: f32,
pub tail: f32,
pub shift: Option<(f32, f32)>,
pub first_frame: bool,
}
const LIST_PASSES: usize = 4;
impl RowHeights {
pub fn slice(&mut self, reading: ListReading) -> ListSlice {
let mut target_y = None;
let (offset_y, vh, cw, first_frame) = match reading.geometry {
Some(g) => {
let t = reading.scroll_y.clamp(0.0, g.max_offset.y);
if (t - g.offset.y).abs() > 0.5 {
target_y = Some(t);
}
(g.offset.y, g.rect.h, g.rect.w - reading.pad_x, false)
}
None => (
reading.scroll_y,
reading.viewport.h,
reading.viewport.w - reading.pad_x,
true,
),
};
self.set_width(cw);
let top = (offset_y - reading.pad_t).max(0.0);
let anchor = self.row_at(top);
let into = top - self.offset_of(anchor);
let target = target_y.map(|t| {
let t = (t - reading.pad_t).max(0.0);
let row = self.row_at(t);
(t, row, t - self.offset_of(row))
});
let range = visible_range(self, top, vh, reading.overscan);
ListSlice {
range,
anchor,
into,
top,
top_before: top,
target,
vh,
width: cw,
overscan: reading.overscan,
passes: 0,
first_frame,
}
}
}
impl ListSlice {
pub fn width(&self) -> f32 {
self.width
}
pub fn unmeasured(&self, heights: &RowHeights) -> Vec<usize> {
self.range
.clone()
.filter(|&i| heights.measured(i).is_none())
.collect()
}
pub fn reslice(&mut self, heights: &mut RowHeights) -> bool {
self.passes += 1;
self.top = heights.offset_of(self.anchor) + self.into;
let next = visible_range(heights, self.top, self.vh, self.overscan);
if next == self.range {
return false;
}
self.range = next;
self.passes < LIST_PASSES
}
pub fn finish(self, heights: &mut RowHeights) -> ListPlan {
let drawn = self.top - self.top_before;
let target = match self.target {
Some((t, row, into)) => heights.offset_of(row) + into - t,
None => drawn,
};
let lead = heights.offset_of(self.range.start);
let tail = heights.total() - heights.offset_of(self.range.end);
ListPlan {
range: self.range,
lead,
tail,
shift: (drawn.abs() > 0.01 || target.abs() > 0.01).then_some((drawn, target)),
first_frame: self.first_frame,
}
}
}
fn visible_range(
heights: &mut RowHeights,
top: f32,
vh: f32,
overscan: usize,
) -> std::ops::Range<usize> {
let rows = heights.len();
if rows == 0 {
return 0..0;
}
let first = heights.row_at(top).saturating_sub(overscan);
let last = (heights.row_at(top + vh.max(0.0)) + 1 + overscan).min(rows);
first..last.max(first)
}