kui_core/widgets.rs
1//! Stock widgets built from the primitives: buttons, toggles, text input,
2//! select, slider, splitter, tooltips, menus, a titlebar and virtual lists.
3//!
4//! Every widget here is a plain function over a [`Ui`] that opens ordinary
5//! nodes with ordinary [`NodeSpec`]s; there is no widget trait and no
6//! retained object. State lives in the core by key (focus, hover, an edit
7//! buffer, a scroll offset), and the app's model is the only other state.
8//! A custom widget follows the same pattern, and the `*_spec` functions
9//! ([`button_spec`], [`toggle_spec`], [`slider_spec`], [`menu_panel_spec`])
10//! are the starting points for one that should look like the stock set.
11//!
12//! ```rust
13//! use kui_core::{Core, NodeSpec, Size, Value, widgets};
14//!
15//! let mut core = Core::new();
16//! let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
17//! ui.configure_root(NodeSpec::column().fill().pad(12.0).gap(8.0));
18//!
19//! widgets::label(&mut ui, "Settings");
20//! let name = widgets::text_input(&mut ui, "name", "Ada");
21//! widgets::checkbox(&mut ui, "Dark mode", true, "toggle-dark");
22//! widgets::slider(&mut ui, "volume", 40.0, 0.0, 100.0, 1.0, "volume");
23//! widgets::button(&mut ui, "Save", Value::str("save"));
24//!
25//! assert_eq!(ui.edit_text(name).as_deref(), Some("Ada"));
26//! ui.finish();
27//! ```
28//!
29//! Each control posts the payload it was given as a
30//! [`UiEvent`](crate::input::UiEvent) when it is used, and the view redraws
31//! from its model; a checkbox does not flip itself.
32
33use crate::access::Role;
34use crate::color::Color;
35use crate::cursor::CursorShape;
36use crate::edit::EditOptions;
37use crate::geom::{Edges, Vec2};
38use crate::key::Key;
39use crate::menu::{MenuBar, MenuItem, MenuRole};
40use crate::metrics::Metrics;
41use crate::spec::{Align, FloatConfig, NodeSpec, Sizing, TextStyle};
42use crate::stats::{FrameSample, STATS_CAPACITY};
43use crate::theme::Theme;
44use crate::tree::OriginId;
45use crate::ui::Ui;
46use crate::value::Value;
47use crate::window::WindowButton;
48
49/// Floating latency HUD: `latency_graph` in a translucent panel pinned to a
50/// viewport corner, above all content and out of layout flow. Call anywhere
51/// in the view; pick the corner with `latency_hud_at`.
52pub fn latency_hud(ui: &mut Ui<'_>) {
53 latency_hud_at(ui, Align::End, Align::End);
54}
55
56pub fn latency_hud_at(ui: &mut Ui<'_>, x: Align, y: Align) {
57 // Under custom chrome the top of the viewport is the app's titlebar;
58 // keep the HUD below it.
59 let top_inset = if ui.env().window.custom_chrome {
60 titlebar_height(ui)
61 } else {
62 0.0
63 };
64 // The same attach points a float takes, which place the spreads and
65 // `Baseline` as the start or the centre (`layout::align_factor`).
66 let dx = match x {
67 Align::Start | Align::SpaceBetween | Align::Baseline => 12.0,
68 Align::Center | Align::SpaceAround | Align::SpaceEvenly => 0.0,
69 Align::End => -12.0,
70 };
71 let dy = match y {
72 Align::Start | Align::SpaceBetween | Align::Baseline => 12.0 + top_inset,
73 Align::Center | Align::SpaceAround | Align::SpaceEvenly => 0.0,
74 Align::End => -12.0,
75 };
76 // Translucent over whatever the app is painting, so the panel takes
77 // the theme's backmost surface and its strong border at the alphas
78 // the HUD has always used.
79 let t = ui.theme();
80 ui.with(
81 NodeSpec::column()
82 .float(
83 crate::spec::FloatConfig::viewport()
84 .inside(x, y)
85 .offset(dx, dy),
86 )
87 .pad(10.0)
88 .bg(t.bg.with_alpha(0.71))
89 .radius(8.0)
90 .border(1.0, t.border_strong.with_alpha(0.5)),
91 latency_graph,
92 );
93}
94
95/// Frame-latency graph: the last ~120 frames as stacked per-phase bars
96/// (input / view / layout / render, bottom to top) against the display's
97/// frame budget (`env.refresh_hz`, 120 Hz fallback) — a bar that blows the
98/// budget turns red. Feed `core.stats` (and `core.env`) from your frame
99/// driver (the built-in runner does this automatically).
100pub fn latency_graph(ui: &mut Ui<'_>) {
101 const GRAPH_H: f32 = 34.0;
102 const INPUT: Color = Color {
103 r: 0.45,
104 g: 0.85,
105 b: 0.55,
106 a: 1.0,
107 };
108 const VIEW: Color = Color {
109 r: 0.28,
110 g: 0.42,
111 b: 0.88,
112 a: 1.0,
113 };
114 const LAYOUT: Color = Color {
115 r: 0.60,
116 g: 0.42,
117 b: 0.88,
118 a: 1.0,
119 };
120 const RENDER: Color = Color {
121 r: 0.94,
122 g: 0.72,
123 b: 0.35,
124 a: 1.0,
125 };
126 const WAIT: Color = Color {
127 r: 0.42,
128 g: 0.45,
129 b: 0.52,
130 a: 0.7,
131 };
132 const OVER: Color = Color {
133 r: 0.91,
134 g: 0.36,
135 b: 0.36,
136 a: 1.0,
137 };
138
139 let theme = ui.theme();
140 let budget_ms = ui.env().frame_budget_ms(); // full graph height
141 let stats = &ui.core().stats;
142 let samples: Vec<FrameSample> = stats.iter().collect();
143 let (avg_work, max_work) = (stats.avg_work(), stats.max_work());
144 let avg_wait = if samples.is_empty() {
145 0.0
146 } else {
147 samples.iter().map(|s| s.wait_ms).sum::<f32>() / samples.len() as f32
148 };
149
150 // A development overlay, not app content: kept out of the access tree
151 // so a screen reader does not read frame timings between the controls.
152 ui.with(
153 NodeSpec::column()
154 .gap(3.0)
155 .cross_align(Align::End)
156 .role(crate::access::Role::None),
157 |ui| {
158 let mut label = format!("work {avg_work:.2}ms avg · {max_work:.2}ms max");
159 if avg_wait > 0.05 {
160 label.push_str(&format!(" · +{avg_wait:.2}ms vsync"));
161 }
162 ui.with(NodeSpec::row().gap(6.0).cross_align(Align::Center), |ui| {
163 ui.text(&label, TextStyle::new(10.0).color(theme.muted));
164 // "?" badge: hover for the color legend. Also the dynamic-float
165 // showcase — in the default bottom-right HUD the tooltip has no
166 // room below or to the right, so it flips above and slides left.
167 let badge = ui.child_key("kui:latency-legend");
168 let badge_bg =
169 theme
170 .muted
171 .with_alpha(if ui.is_hovered(badge) { 0.31 } else { 0.16 });
172 ui.with_keyed(
173 "kui:latency-legend",
174 NodeSpec::column()
175 .size(13.0, 13.0)
176 .center()
177 .bg(badge_bg)
178 .radius(6.5)
179 .hoverable(),
180 |ui| {
181 ui.text("?", TextStyle::new(9.0).color(theme.fg));
182 if ui.is_hovered(badge) {
183 tooltip_with(ui, |ui| {
184 ui.with(NodeSpec::column().gap(5.0), |ui| {
185 for (color, name) in [
186 (INPUT, "input — events & edits"),
187 (VIEW, "view — rebuilding the tree"),
188 (LAYOUT, "layout — sizing & positions"),
189 (RENDER, "render — encode + submit"),
190 (WAIT, "vsync wait (not work)"),
191 (OVER, "cap: work over frame budget"),
192 ] {
193 ui.with(
194 NodeSpec::row().gap(7.0).cross_align(Align::Center),
195 |ui| {
196 ui.leaf(
197 NodeSpec::column()
198 .size(9.0, 9.0)
199 .bg(color)
200 .radius(2.0),
201 );
202 ui.text(name, TextStyle::new(11.0).color(theme.fg));
203 },
204 );
205 }
206 });
207 });
208 }
209 },
210 );
211 });
212 ui.with(
213 NodeSpec::row()
214 .size(STATS_CAPACITY as f32 * 2.0, GRAPH_H)
215 .gap(1.0)
216 .main_align(Align::End)
217 .cross_align(Align::End)
218 .bg(theme.sunken.with_alpha(0.6))
219 .radius(3.0)
220 .clip(),
221 |ui| {
222 let px_per_ms = GRAPH_H / budget_ms;
223 for s in &samples {
224 // Phases keep their colors even over budget — a spike
225 // you can't attribute is a spike you can't fix. Work
226 // (not vsync pacing) over budget gets a red cap.
227 let over = s.work() > budget_ms;
228 ui.with(
229 NodeSpec::column()
230 .width(1.0)
231 .main_align(Align::End)
232 .max_height(GRAPH_H),
233 |ui| {
234 if over {
235 ui.leaf(NodeSpec::column().size(1.0, 3.0).bg(OVER));
236 }
237 // Column children run top->bottom; push in
238 // reverse so input sits at the bottom.
239 for (ms, color) in [
240 (s.wait_ms, WAIT),
241 (s.render_ms, RENDER),
242 (s.layout_ms, LAYOUT),
243 (s.view_ms, VIEW),
244 (s.input_ms, INPUT),
245 ] {
246 if ms <= 0.0 {
247 continue;
248 }
249 let h = (ms * px_per_ms).max(1.0);
250 ui.leaf(NodeSpec::column().size(1.0, h).bg(color));
251 }
252 },
253 );
254 }
255 },
256 );
257 },
258 );
259}
260
261/// Small floating label hanging below the node it's declared inside. The
262/// placement is dynamic (`FloatConfig::fit`): it flips above when the
263/// viewport bottom is too close and slides sideways off window edges.
264/// Typical use: `if ui.is_hovered(key) { widgets::tooltip(ui, "..."); }`
265pub fn tooltip(ui: &mut Ui<'_>, text: &str) {
266 let size = ui.metrics().hint_text;
267 tooltip_with(ui, |ui| {
268 ui.text(text, TextStyle::new(size));
269 });
270}
271
272/// [`tooltip`] chrome around arbitrary content (legends, shortcut hints, …).
273pub fn tooltip_with(ui: &mut Ui<'_>, content: impl FnOnce(&mut Ui<'_>)) {
274 let spec = tooltip_spec(ui);
275 ui.with(spec, content);
276}
277
278/// The hint the `tooltip` prop floats under a hovered node, and the stock
279/// button under a hovered button: [`tooltip`]'s chrome and text, kept out
280/// of the access tree (`Role::None`). The prop has already set the same
281/// string as the node's description, which is where a reader hears it;
282/// as content it would be read twice under a group and, under a control
283/// named from its content, become part of the *name* whenever the pointer
284/// crossed it. The `tooltip` element keeps its text, since
285/// it is drawn with no description behind it.
286pub(crate) fn hover_hint(ui: &mut Ui<'_>, text: &str) {
287 let size = ui.metrics().hint_text;
288 let spec = tooltip_spec(ui).role(crate::access::Role::None);
289 ui.text_in(spec, text, TextStyle::new(size));
290}
291
292fn tooltip_spec(ui: &Ui<'_>) -> NodeSpec {
293 let t = ui.theme();
294 let m = ui.metrics();
295 NodeSpec::column()
296 .float(crate::spec::FloatConfig::below().fit())
297 .pad_xy(m.hint_pad_x, m.hint_pad_y)
298 .bg(t.raised)
299 .radius(m.radius)
300 .border(1.0, t.border_strong)
301}
302
303/// A line of text in the default style: `ui.text(text, TextStyle::default())`.
304pub fn label(ui: &mut Ui<'_>, text: &str) {
305 ui.text(text, TextStyle::default());
306}
307
308/// Single-line text input with chrome (background, focus ring).
309/// Read the value with `ui.edit_text(key)`; "changed"/"submit" events arrive
310/// in `on_event` with this key. The `label` is the key and the accessible
311/// name both (`"search"`, `"name"`), so a screen reader has something to
312/// announce; use `ui.text_edit` with `NodeSpec::label` when they differ.
313pub fn text_input(ui: &mut Ui<'_>, label: &str, initial: &str) -> Key {
314 let key = ui.child_key(label);
315 let t = ui.theme();
316 let m = ui.metrics();
317 let border = if ui.is_focused(key) {
318 t.accent
319 } else {
320 t.border
321 };
322 ui.text_edit(
323 label,
324 initial,
325 &EditOptions {
326 multiline: false,
327 ..Default::default()
328 },
329 NodeSpec::column()
330 .grow_width()
331 .pad_xy(m.field_pad_x, m.field_pad_y)
332 .bg(t.sunken)
333 .radius(m.radius)
334 .border(1.0, border)
335 .clip()
336 .label(label),
337 )
338}
339
340/// What a select's trigger posts when it is clicked; the core takes it
341/// back and opens the menu (`Core::consume_select_events`).
342pub(crate) fn select_tag() -> Value {
343 Value::map([("select", Value::Bool(true))])
344}
345
346/// A choice among a few named options: a field that shows the one in
347/// force and, clicked, drops a menu of them all with the current one
348/// checked. `options` are the labels, `current` the index in force (or
349/// none). Keyed by `label`, which is the accessible name too.
350///
351/// The menu is the core's own — the same one a right-click opens
352/// (`Core::open_menu`): drawn in the frame, or the platform's where the
353/// host shows menus itself, dismissed by Escape or a press outside, its
354/// rows walked by the arrows. So the app holds no open state; what it
355/// hears is the choice, as the `menu` event a menu row posts, on this
356/// key: `{kind: "menu", role: "custom", item: <the option>}`. A view
357/// that then draws the select with the new `current` is the whole loop.
358///
359/// [`select_items`] is the same field over [`MenuItem`]s, for an option
360/// that posts an `id` of its own rather than its label.
361pub fn select(ui: &mut Ui<'_>, label: &str, options: &[&str], current: Option<usize>) -> Key {
362 let items: Vec<MenuItem> = options.iter().map(|o| MenuItem::new(*o)).collect();
363 select_items(ui, label, &items, current)
364}
365
366/// [`select`] over items the caller built: their labels are the rows,
367/// their `id`s what a choice posts, and the `current`th is drawn checked
368/// whatever the item said. A separator is a separator here too.
369pub fn select_items(
370 ui: &mut Ui<'_>,
371 label: &str,
372 items: &[MenuItem],
373 current: Option<usize>,
374) -> Key {
375 let t = ui.theme();
376 let m = ui.metrics();
377 select_with(
378 ui,
379 label,
380 items,
381 current,
382 select_spec(&t, &m),
383 TextStyle::new(m.chrome_text),
384 )
385}
386
387/// The stock select field's spec: a sunken field with the stock radius
388/// and padding, as [`button_spec`] is the stock button's. What
389/// [`select_with`] is handed by [`select_items`]; a caller with a spec
390/// of its own starts here and adds to it.
391pub fn select_spec(theme: &Theme, m: &Metrics) -> NodeSpec {
392 NodeSpec::row()
393 .pad_xy(m.field_pad_x, m.field_pad_y)
394 .gap(8.0)
395 .cross_align(Align::Center)
396 .bg(theme.sunken)
397 .hover_bg(theme.hover)
398 .radius(m.radius)
399}
400
401/// [`select_items`] with its spec and text style in the caller's hands —
402/// a compact field in a dense panel — the way [`button_with`] takes the
403/// button's. The border, the click, the role and the disclosure are
404/// added here whatever `spec` said.
405///
406/// A `current` that names no option (past the end, or a separator) is
407/// none, with a `select-current-ignored` warning on the field: the field
408/// is described by nothing and no row is checked.
409pub fn select_with(
410 ui: &mut Ui<'_>,
411 label: &str,
412 items: &[MenuItem],
413 current: Option<usize>,
414 spec: NodeSpec,
415 text: TextStyle,
416) -> Key {
417 let key = ui.child_key(label);
418 let current = current.filter(|&i| {
419 let separator = items
420 .get(i)
421 .is_some_and(|it| it.role == MenuRole::Separator);
422 let names_one = i < items.len() && !separator;
423 if !names_one {
424 ui.core().warn(crate::diag::select_current_ignored(
425 key,
426 label,
427 i,
428 items.len(),
429 separator,
430 ));
431 }
432 names_one
433 });
434 let t = ui.theme();
435 let shown = current
436 .and_then(|i| items.get(i))
437 .map_or("", |i| i.text())
438 .to_string();
439 let open = ui.core().menu().is_some_and(|menu| menu.target == key);
440 let border = if open || ui.is_focused(key) {
441 t.accent
442 } else {
443 t.border
444 };
445 let menu: Vec<MenuItem> = items
446 .iter()
447 .enumerate()
448 .map(|(i, item)| item.clone().checked(current == Some(i)))
449 .collect();
450 ui.core().declare_select(key, menu);
451 ui.with_keyed(
452 label,
453 spec.border(1.0, border)
454 .cursor(CursorShape::Pointer)
455 .on_click(select_tag())
456 // A button named by the field, described by the choice: what
457 // a reader says of a pop-up button, in the two slots a button
458 // has (`value` is a slider's and an editor's).
459 .role(Role::Button)
460 .label(label)
461 .description(shown.as_str())
462 .expanded(open),
463 |ui| {
464 ui.text(&shown, text.color(t.fg).nowrap());
465 // The disclosure: a small triangle, the mark every platform's
466 // pop-up field carries.
467 ui.text("\u{25BE}", text.color(t.muted));
468 },
469 )
470}
471
472/// Default titlebar height, logical px, where the strip is the app's
473/// alone. Follows platform conventions (as measured by gpui): 32 on
474/// Windows (the native caption height), 34 elsewhere. The stock
475/// [`Metrics`] carries the same number as `titlebar_h`, and the titlebar
476/// draws from *that*, so an app that set its own metrics lays out against
477/// `ui.metrics().titlebar_h` rather than this constant — and where the OS
478/// keeps controls of its own over the strip, against [`titlebar_height`].
479pub const TITLEBAR_H: f32 = Metrics::comfortable().titlebar_h;
480
481/// The height the titlebar strip draws at — what an app laying out its
482/// own strip, or something under it, should read instead of
483/// `ui.metrics().titlebar_h`. Where the OS keeps controls of its own over
484/// the strip (`env.window.native_controls`: the macOS traffic lights under
485/// custom chrome) the strip is the OS's own titlebar, as tall as the
486/// keep-out rect says that titlebar is, so the strip's content centres on
487/// the buttons the OS centred in it. Everywhere else the strip is the app's alone and
488/// `Metrics::titlebar_h` is its height. A keep-out with no height (a host
489/// that reported a width only) falls back to the metric.
490pub fn titlebar_height(ui: &Ui<'_>) -> f32 {
491 match ui.env().window.native_controls {
492 Some(r) if r.h > 0.0 => r.h,
493 _ => ui.metrics().titlebar_h,
494 }
495}
496
497/// A cross-platform titlebar: a full-width drag strip with the window title
498/// left-aligned next to the window controls. Reads `env.window` and adapts
499/// by itself — under macOS custom chrome it insets past the native traffic
500/// lights and draws no buttons; under custom chrome elsewhere it appends
501/// minimize/maximize/close; under native decorations it is just a drag
502/// strip (no duplicate buttons).
503///
504/// Typical use, as the first child of a full-height root:
505/// `widgets::titlebar(ui, "my app")`.
506pub fn titlebar(ui: &mut Ui<'_>, title: &str) {
507 let focused = ui.env().focused;
508 let title = title.to_string();
509 titlebar_with(ui, move |ui| {
510 // A background window's title recedes; the OS does the same.
511 let t = ui.theme();
512 let size = ui.metrics().chrome_text;
513 let color = if focused { t.fg } else { t.faint };
514 ui.text_in(
515 NodeSpec::row().fill().cross_align(Align::Center),
516 &title,
517 TextStyle::new(size).color(color).ellipsis(),
518 );
519 });
520}
521
522/// Titlebar with custom content (tabs, a search box, …) between the
523/// platform inset and the window buttons. The whole strip is a drag
524/// handle; interactive children declared inside it sit on top and win
525/// hit-testing, so buttons in a titlebar just work.
526pub fn titlebar_with(ui: &mut Ui<'_>, content: impl FnOnce(&mut Ui<'_>)) {
527 let win = ui.env().window;
528 let h = titlebar_height(ui);
529 ui.with_keyed(
530 "kui:titlebar",
531 NodeSpec::row()
532 .grow_width()
533 .height(h)
534 .cross_align(Align::Center)
535 .window_drag(),
536 |ui| {
537 // Keep clear of controls the OS draws over our content (the
538 // reported rect already includes the trailing gap); without
539 // them, a plain leading margin.
540 let inset = win.native_controls.map_or(12.0, |r| r.x + r.w);
541 ui.leaf(NodeSpec::row().width(inset));
542 content(ui);
543 window_buttons(ui);
544 },
545 );
546}
547
548/// The minimize/maximize/close cluster. Renders nothing when the OS already
549/// provides controls (native decorations, or macOS traffic lights), so it
550/// is always safe to call. It grows to the height it is given — the
551/// strip's, in [`titlebar_with`] — and is a titlebar tall where nothing
552/// gives it one, since a grow child adds nothing to a fit parent's
553/// height.
554pub fn window_buttons(ui: &mut Ui<'_>) {
555 let win = ui.env().window;
556 if !win.custom_chrome || win.native_controls.is_some() {
557 return;
558 }
559 let h = titlebar_height(ui);
560 ui.with(NodeSpec::row().grow_height().min_height(h), |ui| {
561 window_button(ui, WindowButton::Minimize, win.maximized);
562 window_button(ui, WindowButton::Maximize, win.maximized);
563 window_button(ui, WindowButton::Close, win.maximized);
564 });
565}
566
567fn window_button(ui: &mut Ui<'_>, button: WindowButton, maximized: bool) {
568 let label = match button {
569 WindowButton::Minimize => "kui:win-min",
570 WindowButton::Maximize => "kui:win-max",
571 WindowButton::Close => "kui:win-close",
572 };
573 let key = ui.child_key(label);
574 let (hovered, pressed) = (ui.is_hovered(key), ui.is_pressed(key));
575 let t = ui.theme();
576 let fg = t.fg;
577 // Close is the one button that keeps a colour of its own on both
578 // bases — it is the platform's signal, not the palette's — but it is
579 // the theme's danger rather than a second red.
580 let (bg, fg) = match button {
581 WindowButton::Close if pressed => (t.danger.mix(Color::BLACK, 0.15), Color::WHITE),
582 WindowButton::Close if hovered => (t.danger, Color::WHITE),
583 _ if pressed => (t.pressed, fg),
584 _ if hovered => (t.hover, fg),
585 _ => (Color::TRANSPARENT, fg),
586 };
587 ui.with_keyed(
588 label,
589 NodeSpec::row()
590 .width(46.0)
591 .grow_height()
592 .center()
593 .bg(bg)
594 .window_button(button),
595 |ui| match button {
596 WindowButton::Minimize => {
597 ui.leaf(NodeSpec::row().size(10.0, 1.0).bg(fg));
598 }
599 WindowButton::Maximize if maximized => {
600 // Restore: two offset outlines.
601 ui.with(NodeSpec::column().size(10.0, 10.0), |ui| {
602 for (x, y) in [(Align::End, Align::Start), (Align::Start, Align::End)] {
603 ui.leaf(
604 NodeSpec::column()
605 .size(7.5, 7.5)
606 .border(1.0, fg)
607 .float(crate::spec::FloatConfig::parent().inside(x, y)),
608 );
609 }
610 });
611 }
612 WindowButton::Maximize => {
613 ui.leaf(NodeSpec::column().size(9.0, 9.0).border(1.0, fg));
614 }
615 WindowButton::Close => {
616 // The multiplication sign inks only about 0.42 em, so it
617 // needs a far larger em than the 9-10px bar and box beside
618 // it to read as the same size. It also rides the math axis,
619 // which sits a little under the middle of the line box, so
620 // the bottom padding lifts it back onto the button center.
621 const EM: f32 = 23.0;
622 ui.text_in(
623 NodeSpec::row().padding(Edges {
624 b: EM * 0.25,
625 ..Edges::default()
626 }),
627 "\u{00d7}",
628 TextStyle::new(EM).line_height(EM).color(fg),
629 );
630 }
631 },
632 );
633}
634
635/// The standard button's spec: hover and pressed backgrounds are declared
636/// on the node and resolved by the core, so every binding's button is this
637/// same data. Add the label as a child.
638///
639/// The three backgrounds are the theme's accent trio (`accent`,
640/// `accent_hover`, `accent_pressed`): the OS's accent where the host
641/// reports one, the app's where it set or pinned one, and kui's blue
642/// otherwise. Takes the theme and the metrics rather than reading them, so
643/// `widgets::button_spec(&ui.theme(), &ui.metrics())` is the idiom. The
644/// derivation for any other base colour is [`button_palette`].
645pub fn button_spec(theme: &Theme, m: &Metrics) -> NodeSpec {
646 NodeSpec::row()
647 .pad_xy(m.control_pad_x, m.control_pad_y)
648 .bg(theme.accent)
649 .hover_bg(theme.accent_hover)
650 .pressed_bg(theme.accent_pressed)
651 .radius(m.radius)
652 .center()
653}
654
655/// A button's three backgrounds from one base colour: the base, a hover a
656/// step toward white, a pressed a step toward black. The steps are the
657/// distances the stock button's own trio sits at, so an accent-painted
658/// button reads as the same control in a different colour.
659///
660/// Public because "a button in *this* colour" is the same question with a
661/// different answer, and the arithmetic should not be re-guessed per app.
662pub fn button_palette(base: Color) -> (Color, Color, Color) {
663 (
664 base,
665 base.mix(Color::WHITE, 0.09),
666 base.mix(Color::BLACK, 0.10),
667 )
668}
669
670/// Black or white, whichever a reader can see on `bg`.
671///
672/// The split is at `Color::luminance` 0.4 rather than at the midpoint:
673/// white text needs a darker background than black text needs a light one,
674/// and the accents that land near the line (macOS's yellow at 0.72, its
675/// orange at 0.44) come out the way the platform paints them. It is the
676/// stock button's answer, not a general contrast checker — a palette that
677/// cares should say what its label colour is.
678pub fn readable_on(bg: Color) -> Color {
679 if bg.luminance() > 0.4 {
680 Color::BLACK
681 } else {
682 Color::WHITE
683 }
684}
685
686/// The stock button's text size — [`Metrics::default`]'s `control_text`;
687/// the widget itself reads `ui.metrics()`.
688pub const BUTTON_TEXT: f32 = Metrics::comfortable().control_text;
689/// What a disabled stock button's opacity is multiplied by. The core makes
690/// it inert and drops its hover and pressed backgrounds, and nothing else
691/// would show a sighted user the state a reader is told.
692pub const BUTTON_DISABLED_OPACITY: f32 = 0.5;
693
694/// A push button showing `text`, keyed by it; a click posts `payload` as
695/// a [`UiEvent`](crate::input::UiEvent) on the button's key.
696///
697/// ```rust
698/// # use kui_core::{Core, NodeSpec, Size, widgets};
699/// # let mut core = Core::new();
700/// # let mut ui = core.frame(Size::new(200.0, 100.0), 1.0);
701/// widgets::button(&mut ui, "Save", "save");
702/// // The same button with its spec in hand: a tooltip and a stable key.
703/// let (t, m) = (ui.theme(), ui.metrics());
704/// widgets::button_with(&mut ui, "save-2", "Save", widgets::button_spec(&t, &m).on_click("save"), Some("Ctrl+S"));
705/// # ui.finish();
706/// ```
707///
708/// A label that changes re-keys the node (a new node, so it loses keyboard
709/// focus and a screen reader's cursor); declare such a button with
710/// [`button_with`] and a key of its own. The pointer over it is the hand
711/// (`CursorShape::Pointer`): the core implies no shape from an `on_click`,
712/// and the stock button is the one place the hand is declared for you.
713pub fn button(ui: &mut Ui<'_>, text: &str, payload: impl Into<Value>) {
714 let (theme, m) = (ui.theme(), ui.metrics());
715 button_with(
716 ui,
717 text,
718 text,
719 button_spec(&theme, &m).on_click(payload.into()),
720 None,
721 );
722}
723
724/// [`button`] with its spec in the caller's hands: `spec` is [`button_spec`]
725/// plus what the caller declared on it — the `on_click`, and the rows the
726/// stock button admits in every binding (`schema::BUTTON_ROWS_JSX`): a
727/// `label` when the text is not the name, a `description`, `disabled`,
728/// and the hover tracking and description a `tooltip` sets, whose float
729/// is `hint` — drawn under the button while it is hovered, as every
730/// binding's `tooltip` prop floats one. Keyed by `key`, so a label that
731/// changes need not re-key the node. A disabled button is dimmed
732/// ([`BUTTON_DISABLED_OPACITY`]) as well as inert.
733///
734/// This is what `<button>`, `button { }` and `kui_button_with` lower to,
735/// so a binding cannot end up with a button of its own.
736pub fn button_with(ui: &mut Ui<'_>, key: &str, text: &str, spec: NodeSpec, hint: Option<&str>) {
737 button_body(ui, Ident::Label(key), text, spec, hint);
738}
739
740/// [`button_with`] keyed by a data index rather than a label — a row of a
741/// virtual list (`Ui::open_indexed`), so the button keeps its focus, its
742/// hover and its tweens as the built range slides and the same text on
743/// two rows is two nodes. What `<button index>` and
744/// `button { index = }` lower to.
745pub fn button_indexed(ui: &mut Ui<'_>, index: u64, text: &str, spec: NodeSpec, hint: Option<&str>) {
746 button_body(ui, Ident::Index(index), text, spec, hint);
747}
748
749/// How a button is keyed: by the label its `key` declares, or by the
750/// data index its `index` declares.
751enum Ident<'a> {
752 Label(&'a str),
753 Index(u64),
754}
755
756/// A widget that takes a `hint` floats it itself, so a spec that also
757/// declared [`NodeSpec::tooltip`] does not float a second one.
758fn own_hint(spec: &mut NodeSpec, hint: Option<&str>) {
759 if hint.is_some() && spec.access().tooltip {
760 spec.access_mut().tooltip = false;
761 }
762}
763
764fn button_body(ui: &mut Ui<'_>, ident: Ident<'_>, text: &str, spec: NodeSpec, hint: Option<&str>) {
765 let theme = ui.theme();
766 // `accent` asks for the whole family, not just the background the
767 // core would substitute for any node: a button whose hover and pressed
768 // shades stayed put would flash under a yellow accent. On a stock
769 // spec it changes nothing — `button_spec` paints from the theme's
770 // trio already (AR41) — and on a spec whose caller set its own `bg`
771 // it is the ask to take the theme's instead. The family is the
772 // *theme's*, and the theme always has one, so there is no
773 // gate here: kui's blue is the accent nobody chose.
774 let spec = if spec.accent {
775 spec.bg(theme.accent)
776 .hover_bg(theme.accent_hover)
777 .pressed_bg(theme.accent_pressed)
778 } else {
779 spec
780 };
781 let spec = if spec.disabled {
782 let o = spec.style.opacity * theme.disabled_opacity;
783 spec.opacity(o)
784 } else {
785 spec
786 };
787 // The hand is declared, never derived from the `on_click`
788 // (`crate::cursor`), and the stock button is where it is declared: a
789 // caller's own `cursor` stands, and an inert button is the arrow — the
790 // click it refuses is not one to point at.
791 let spec = if spec.cursor.is_none() && !spec.disabled {
792 spec.cursor(CursorShape::Pointer)
793 } else {
794 spec
795 };
796 // The hint floats out of the access tree (`hover_hint`), so it is
797 // heard only as the description: a caller that passed one without
798 // `apply_tooltip` on the spec still has it said. A declared
799 // description stands, as it does over the prop.
800 let mut spec = match hint {
801 Some(hint) if spec.access().description.is_none() => spec.apply_tooltip(hint),
802 _ => spec,
803 };
804 own_hint(&mut spec, hint);
805 // Whatever the background ended up being: white on the stock blue as
806 // it has always been, black on an accent light enough to need it.
807 let label = readable_on(spec.style.bg);
808 let size = ui.metrics().control_text;
809 let node = match ident {
810 Ident::Label(key) => ui.child_key(key),
811 Ident::Index(i) => ui.child_key_indexed(i),
812 };
813 let body = |ui: &mut Ui<'_>| {
814 ui.text(text, TextStyle::new(size).color(label));
815 if let Some(hint) = hint
816 && ui.is_hovered(node)
817 {
818 hover_hint(ui, hint);
819 }
820 };
821 match ident {
822 Ident::Label(key) => {
823 ui.with_keyed(key, spec, body);
824 }
825 Ident::Index(i) => {
826 ui.with_indexed(i, spec, body);
827 }
828 }
829}
830
831// -- Stock controls ---------------------------------------------------------
832// The stock controls over the roles: checkbox, radio, switch
833// and slider, composed over the roles the core already reads. The state is
834// the app's and rides on the spec — `checked`, `mixed`, `value_now` — so a
835// control is drawn from what the view declared this frame, and a toggle's
836// press is its `on_click` like any button's. One definition per control:
837// every binding's element lowers to the `*_with` here.
838
839/// The side of a stock control's box — a checkbox, a radio's circle, a
840/// switch's height, a slider's thumb — from the metrics' control text, so
841/// `compact` and `scaled` move it with the stock button: 16 px at the
842/// comfortable density, 14 at the compact one.
843pub fn control_box(m: &Metrics) -> f32 {
844 (m.control_text + 1.0).round()
845}
846
847/// Which toggle a [`toggle_with`] draws.
848#[derive(Clone, Copy, Debug, PartialEq, Eq)]
849pub enum Toggle {
850 Checkbox,
851 Radio,
852 Switch,
853}
854
855impl Toggle {
856 /// The role it declares, whatever the spec said.
857 pub fn role(self) -> Role {
858 match self {
859 Toggle::Checkbox => Role::Checkbox,
860 Toggle::Radio => Role::Radio,
861 Toggle::Switch => Role::Switch,
862 }
863 }
864
865 /// The element's name in every binding.
866 pub fn name(self) -> &'static str {
867 match self {
868 Toggle::Checkbox => "checkbox",
869 Toggle::Radio => "radio",
870 Toggle::Switch => "switch",
871 }
872 }
873}
874
875/// A stock toggle's spec — the row its indicator and label sit in — as
876/// [`button_spec`] is the button's. A caller with a spec of its own starts
877/// here and adds the state (`checked`, `mixed`), the `on_click` and the
878/// access rows to it.
879pub fn toggle_spec(m: &Metrics) -> NodeSpec {
880 NodeSpec::row()
881 .gap((control_box(m) / 2.0).round())
882 .cross_align(Align::Center)
883}
884
885/// A checkbox labelled `text`, keyed by it, drawn from `checked`; a press
886/// — pointer, Space, Enter or assistive technology — posts `payload`,
887/// and the view flips its model and draws it again.
888pub fn checkbox(ui: &mut Ui<'_>, text: &str, checked: bool, payload: impl Into<Value>) -> Key {
889 let m = ui.metrics();
890 toggle_with(
891 ui,
892 Toggle::Checkbox,
893 text,
894 text,
895 toggle_spec(&m).checked(checked).on_click(payload.into()),
896 None,
897 )
898}
899
900/// A radio labelled `text`, keyed by it; see [`checkbox`]. Radios belong in
901/// a [`radio_group_with`], whose arrows move the choice.
902pub fn radio(ui: &mut Ui<'_>, text: &str, checked: bool, payload: impl Into<Value>) -> Key {
903 let m = ui.metrics();
904 toggle_with(
905 ui,
906 Toggle::Radio,
907 text,
908 text,
909 toggle_spec(&m).checked(checked).on_click(payload.into()),
910 None,
911 )
912}
913
914/// A switch labelled `text`, keyed by it; see [`checkbox`].
915pub fn switch(ui: &mut Ui<'_>, text: &str, on: bool, payload: impl Into<Value>) -> Key {
916 let m = ui.metrics();
917 toggle_with(
918 ui,
919 Toggle::Switch,
920 text,
921 text,
922 toggle_spec(&m).checked(on).on_click(payload.into()),
923 None,
924 )
925}
926
927/// A toggle with its spec in the caller's hands, the way [`button_with`]
928/// takes the button's: `spec` is [`toggle_spec`] plus the state and the
929/// rows the element admits — `checked`, `mixed` (a checkbox's third
930/// state), `on_click`, `label`, `description`, `disabled`, and `hint`, the
931/// tooltip drawn while it is hovered. The role is `kind`'s whatever the
932/// spec said. Keyed by `key`; an empty `text` draws the indicator alone,
933/// which then wants a `label`. A disabled toggle is dimmed as well as
934/// inert. This is what `<checkbox>`, `<radio>`, `<switch>` and their Lua
935/// and C doors lower to.
936pub fn toggle_with(
937 ui: &mut Ui<'_>,
938 kind: Toggle,
939 key: &str,
940 text: &str,
941 spec: NodeSpec,
942 hint: Option<&str>,
943) -> Key {
944 let t = ui.theme();
945 let m = ui.metrics();
946 let node = ui.child_key(key);
947 let ax = spec.access();
948 let mixed = ax.mixed && kind == Toggle::Checkbox;
949 let on = ax.checked || mixed;
950 let disabled = spec.disabled;
951 let hovered = !disabled && ui.is_hovered(node);
952 let mut spec = spec.role(kind.role());
953 own_hint(&mut spec, hint);
954 if disabled {
955 let o = spec.style.opacity * t.disabled_opacity;
956 spec = spec.opacity(o);
957 } else if spec.cursor.is_none() {
958 spec = spec.cursor(CursorShape::Pointer);
959 }
960 let b = control_box(&m);
961 ui.with_keyed(key, spec, |ui| {
962 let edge = if on || hovered {
963 t.accent
964 } else {
965 t.border_strong
966 };
967 match kind {
968 Toggle::Checkbox | Toggle::Radio => {
969 let radius = if kind == Toggle::Radio {
970 b / 2.0
971 } else {
972 m.radius_inner.min(b / 4.0)
973 };
974 let face = NodeSpec::row()
975 .size(b, b)
976 .radius(radius)
977 .border(1.0, edge)
978 .bg(if on { t.accent } else { t.sunken })
979 .center();
980 ui.with(face, |ui| {
981 if !on {
982 return;
983 }
984 if kind == Toggle::Radio {
985 let d = (b * 0.4).round();
986 ui.leaf(NodeSpec::row().size(d, d).radius(d / 2.0).bg(t.on_accent));
987 } else if mixed {
988 ui.leaf(
989 NodeSpec::row()
990 .size((b * 0.5).round(), 2.0)
991 .radius(1.0)
992 .bg(t.on_accent),
993 );
994 } else {
995 // Drawn, not a glyph: the same mark at every size
996 // and in every font.
997 ui.polyline(
998 &[
999 Vec2::new(b * 0.26, b * 0.52),
1000 Vec2::new(b * 0.43, b * 0.69),
1001 Vec2::new(b * 0.75, b * 0.33),
1002 ],
1003 crate::line::Stroke::new((b / 8.0).max(1.5), t.on_accent),
1004 NodeSpec::default(),
1005 );
1006 }
1007 });
1008 }
1009 Toggle::Switch => {
1010 let track = NodeSpec::row()
1011 .size((b * 1.75).round(), b)
1012 .pad(2.0)
1013 .radius(b / 2.0)
1014 .bg(if on { t.accent } else { t.border_strong })
1015 .main_align(if on { Align::End } else { Align::Start })
1016 .cross_align(Align::Center)
1017 .transition(120.0);
1018 ui.with_keyed("track", track, |ui| {
1019 let k = b - 4.0;
1020 ui.leaf_keyed(
1021 "knob",
1022 NodeSpec::row()
1023 .size(k, k)
1024 .radius(k / 2.0)
1025 .bg(t.on_accent)
1026 .transition(120.0)
1027 .slide(),
1028 );
1029 });
1030 }
1031 }
1032 if !text.is_empty() {
1033 ui.text(text, TextStyle::new(m.control_text).color(t.fg));
1034 }
1035 if let Some(hint) = hint
1036 && ui.is_hovered(node)
1037 {
1038 tooltip(ui, hint);
1039 }
1040 })
1041}
1042
1043/// The stock radio group's spec: a column of radios. What
1044/// [`radio_group_with`] is handed by [`radio_group`].
1045pub fn radio_group_spec(m: &Metrics) -> NodeSpec {
1046 NodeSpec::column().gap((control_box(m) / 2.0).round())
1047}
1048
1049/// A radio group named `label`: one Tab stop whose arrows, Home and End
1050/// move the choice among the radios `f` declares and press the one they
1051/// land on, so a group of radios whose
1052/// payloads each set the choice answers the keyboard with no more code.
1053/// The role and the name are the group's whatever `spec` said; a `row`
1054/// spec lays the radios out across, and its arrows run across with it. A
1055/// spec with no gap takes [`radio_group_spec`]'s, so a binding that built
1056/// the spec from its rows — where `dir="row"` starts one from nothing —
1057/// gets the stock spacing without restating it.
1058pub fn radio_group_with(
1059 ui: &mut Ui<'_>,
1060 label: &str,
1061 spec: NodeSpec,
1062 f: impl FnOnce(&mut Ui<'_>),
1063) -> Key {
1064 let spec = radio_group_open_spec(&ui.metrics(), label, spec);
1065 ui.with_keyed(label, spec, f)
1066}
1067
1068/// The spec a radio group named `label` opens with: `spec` with the
1069/// group's role and name, and the stock gap where it has none. What
1070/// [`radio_group_with`] opens, and what C's `kui_radio_group_open` does,
1071/// whose radios are declared between it and `kui_close`.
1072pub fn radio_group_open_spec(m: &Metrics, label: &str, spec: NodeSpec) -> NodeSpec {
1073 let mut spec = spec.role(Role::RadioGroup).label(label);
1074 if spec.layout.gap == 0.0 {
1075 spec.layout.gap = radio_group_spec(m).layout.gap;
1076 }
1077 spec
1078}
1079
1080/// A radio group over named options: `current` is the one in force, and a
1081/// choice posts `payload(i)`. Each radio is keyed by its index, so two
1082/// options with one label are two radios.
1083pub fn radio_group(
1084 ui: &mut Ui<'_>,
1085 label: &str,
1086 options: &[&str],
1087 current: Option<usize>,
1088 payload: impl Fn(usize) -> Value,
1089) -> Key {
1090 let m = ui.metrics();
1091 radio_group_with(ui, label, radio_group_spec(&m), |ui| {
1092 for (i, option) in options.iter().enumerate() {
1093 let key = format!("{i}");
1094 toggle_with(
1095 ui,
1096 Toggle::Radio,
1097 &key,
1098 option,
1099 toggle_spec(&m)
1100 .checked(current == Some(i))
1101 .on_click(payload(i)),
1102 None,
1103 );
1104 }
1105 })
1106}
1107
1108/// The stock slider's spec: a row as wide as a menu and as tall as its
1109/// thumb, padded by half the thumb on either side so the thumb's centre
1110/// is under the pointer at both ends — the content box is the track the
1111/// core reads a press along. A caller sizing its
1112/// own slider changes the width and keeps the padding.
1113pub fn slider_spec(m: &Metrics) -> NodeSpec {
1114 let b = control_box(m);
1115 NodeSpec::row()
1116 .size(m.menu_width, b)
1117 .pad_xy(b / 2.0, 0.0)
1118 .cross_align(Align::Center)
1119}
1120
1121/// A slider named `label` over `min..=max`, at `value`, moving by `step`.
1122/// Its changes arrive as `{kind: "change", value, phase, tag}` with `tag`
1123/// — from the pointer, the arrows, the Page keys, Home / End and
1124/// assistive technology alike — and the view stores `value` and draws the
1125/// slider again at it.
1126pub fn slider(
1127 ui: &mut Ui<'_>,
1128 label: &str,
1129 value: f32,
1130 min: f32,
1131 max: f32,
1132 step: f32,
1133 tag: impl Into<Value>,
1134) -> Key {
1135 let m = ui.metrics();
1136 slider_with(
1137 ui,
1138 label,
1139 slider_spec(&m)
1140 .value_now(value)
1141 .value_min(min)
1142 .value_max(max)
1143 .value_step(step)
1144 .on_change(tag.into()),
1145 None,
1146 )
1147}
1148
1149/// A slider with its spec in the caller's hands: [`slider_spec`] plus the
1150/// value rows (`value_now`, `value_min`, `value_max`, `value_step`,
1151/// `value_text`), `on_change`, `description`, `disabled`, a width, and
1152/// `hint`, the tooltip drawn while it is hovered. Keyed by `label`, which
1153/// is its accessible name unless the spec carries a `label` of its own.
1154/// The role is the slider's whatever the spec said. What `<slider>` and
1155/// its Lua and C doors lower to.
1156pub fn slider_with(ui: &mut Ui<'_>, label: &str, spec: NodeSpec, hint: Option<&str>) -> Key {
1157 let t = ui.theme();
1158 let m = ui.metrics();
1159 let node = ui.child_key(label);
1160 let ax = spec.access();
1161 let fraction = crate::slider::SliderRange::of(ax).map_or(0.0, |r| {
1162 let now = ax.value_now.map_or(r.min, crate::slider::exact);
1163 ((now - r.min) / (r.max - r.min)).clamp(0.0, 1.0) as f32
1164 });
1165 let named = ax.label.is_some();
1166 let disabled = spec.disabled;
1167 let mut spec = spec.role(Role::Slider);
1168 own_hint(&mut spec, hint);
1169 if !named {
1170 spec = spec.label(label);
1171 }
1172 if disabled {
1173 let o = spec.style.opacity * t.disabled_opacity;
1174 spec = spec.opacity(o);
1175 } else if spec.cursor.is_none() {
1176 spec = spec.cursor(CursorShape::Pointer);
1177 }
1178 let b = control_box(&m);
1179 ui.with_keyed(label, spec, |ui| {
1180 let track = NodeSpec::row()
1181 .grow_width()
1182 .height(4.0)
1183 .radius(2.0)
1184 .bg(t.border_strong);
1185 ui.with(track, |ui| {
1186 let fill = NodeSpec::row()
1187 .width(Sizing::Percent(fraction))
1188 .grow_height()
1189 .radius(2.0)
1190 .bg(t.accent);
1191 ui.with(fill, |ui| {
1192 // Hung off the fill's end, so it sits where the value is
1193 // with no arithmetic of the view's.
1194 ui.leaf(
1195 NodeSpec::row()
1196 .size(b, b)
1197 .radius(b / 2.0)
1198 .bg(t.on_accent)
1199 .border(1.0, t.border_strong)
1200 .float(
1201 FloatConfig::parent()
1202 .at(Align::End, Align::Center)
1203 .self_at(Align::Center, Align::Center),
1204 ),
1205 );
1206 });
1207 });
1208 if let Some(hint) = hint
1209 && ui.is_hovered(node)
1210 {
1211 tooltip(ui, hint);
1212 }
1213 })
1214}
1215
1216// -- Splitter ---------------------------------------------------------------
1217
1218/// A divider between two panes that the pointer drags:
1219/// `thickness` px across, growing along the rest of its parent, in the
1220/// theme's border colour and its accent while hovered or held, with the
1221/// resize arrows, and `tag` as its `on_drag`. `dir` is the parent's: in a
1222/// `Dir::Row` the panes sit side by side and the bar stands between them;
1223/// in a `Dir::Column` it lies across. A press on it leaves the keyboard
1224/// where it was (`keep_focus`), as a divider beside an editor should.
1225///
1226/// The split is the app's: `ev.drag()` on the tag's event, and
1227/// `Drag::ratio()` is the pointer's place across the parent — `.x` for a
1228/// row's split, `.y` for a column's — which is the new fraction as it is.
1229/// Returns the bar's key.
1230pub fn splitter(
1231 ui: &mut Ui<'_>,
1232 label: &str,
1233 dir: crate::spec::Dir,
1234 thickness: f32,
1235 tag: impl Into<Value>,
1236) -> Key {
1237 let t = ui.theme();
1238 let bar = match dir {
1239 crate::spec::Dir::Row => NodeSpec::column()
1240 .size(thickness, Sizing::GROW)
1241 .cursor(CursorShape::EwResize),
1242 crate::spec::Dir::Column => NodeSpec::column()
1243 .size(Sizing::GROW, thickness)
1244 .cursor(CursorShape::NsResize),
1245 };
1246 ui.leaf_keyed(
1247 label,
1248 bar.bg(t.border)
1249 .hover_bg(t.accent)
1250 .pressed_bg(t.accent)
1251 .on_drag(tag)
1252 .keep_focus(),
1253 )
1254}
1255
1256// -- Context menus ----------------------------------------------------------
1257// The menu every app was writing for itself. It is
1258// exported rather than hidden inside the core's automatic path, and the
1259// automatic path calls exactly this — so an app that answers its own
1260// `onContextMenu` to add two items of its own gets the layout, the
1261// keyboard, the dismissal and the access rows without rewriting them, and
1262// the corpus tests one menu rather than two.
1263
1264/// Menu chrome, in one place so a native renderer's absence still looks
1265/// deliberate rather than improvised.
1266pub const MENU_WIDTH: f32 = Metrics::comfortable().menu_width;
1267pub const MENU_TEXT: f32 = Metrics::comfortable().chrome_text;
1268/// The reserved label the stock menu is keyed under. A menu the core
1269/// opened is found by key, not by guessing at payloads, so an app is free
1270/// to post whatever it likes from its own items.
1271pub const MENU_KEY: &str = "kui.menu";
1272
1273/// Draws a context menu at `at` (logical viewport px) and returns the key
1274/// of its root. A float anchored to the viewport rather than to a parent,
1275/// because a context menu belongs at the pointer and not under whatever
1276/// node happens to enclose it; `fit` is what keeps it in the window, which
1277/// for a menu near the bottom edge means flipping above the point.
1278///
1279/// It declares `modal`, so a press outside it or Escape emits a `dismiss`
1280/// event on it rather than through a dismissal rule of its own; the caller
1281/// closes it when that dismissal arrives. The rows are `menuItem`s under a
1282/// `menu`, which is what makes the arrow keys work and what a screen reader
1283/// reads.
1284///
1285/// Each chosen row posts the item's `id`, or its label when it declares
1286/// none. A `Separator` posts nothing and takes no focus.
1287pub fn context_menu(ui: &mut Ui<'_>, at: Vec2, items: &[MenuItem]) -> Key {
1288 let t = ui.theme();
1289 let m = ui.metrics();
1290 // The menu's nodes are the core's, not the host's: opened under their
1291 // own origin, so the core takes their events back by it.
1292 let saved = ui.origin();
1293 ui.set_origin(OriginId::MENU);
1294 // The menu floats against the window, not the host area (a menu the
1295 // platform showed would not stop at a dock's edge either, and the
1296 // devtools' own select opens one inside the dock): the host's point
1297 // becomes the window's.
1298 let at = at.plus(ui.core().dt_shift());
1299 let root = menu_panel(
1300 ui,
1301 MENU_KEY,
1302 menu_panel_spec(&t, &m)
1303 .float(
1304 FloatConfig::viewport()
1305 // Top-left of the menu at the top-left of the
1306 // viewport, then offset to the point: the placement
1307 // every context menu has, with `fit` flipping it up
1308 // or clamping it in when the point is near an edge.
1309 .inside(Align::Start, Align::Start)
1310 .offset(at.x, at.y)
1311 .fit(),
1312 )
1313 .modal(Value::str(MENU_KEY))
1314 .label("Menu"),
1315 items,
1316 );
1317 ui.set_origin(saved);
1318 root
1319}
1320
1321/// The panel every menu is: a fixed-width column of rows, in the palette
1322/// the stock menu paints. What the caller adds is where it goes and what
1323/// scope it belongs to — a context menu floats at the pointer and declares
1324/// its own `modal`; the menu bar's drops out of its title and lives inside
1325/// the bar's. Takes the palette and the metrics rather than reading them,
1326/// because a caller that has a `Ui` in one hand cannot lend it to this and
1327/// to `menu_panel` in the same expression; `let t = ui.theme();` first is
1328/// the idiom.
1329pub fn menu_panel_spec(t: &Theme, m: &Metrics) -> NodeSpec {
1330 NodeSpec::column()
1331 .role(Role::Menu)
1332 .width(m.menu_width)
1333 .pad(4.0)
1334 .gap(1.0)
1335 .bg(t.raised)
1336 .border(1.0, t.border_strong)
1337 .radius(m.radius)
1338}
1339
1340/// Builds the rows of one menu into `spec`, keyed under `label`, and
1341/// reports the keys they took. The one place a menu's rows are drawn:
1342/// both menus kui has are this function with a different container.
1343pub fn menu_panel(ui: &mut Ui<'_>, label: &str, spec: NodeSpec, items: &[MenuItem]) -> Key {
1344 let t = ui.theme();
1345 let m = ui.metrics();
1346 // A wash rather than a fill, so a row's label stays readable on both
1347 // bases without the view guessing a frame ahead of the core — see
1348 // `Theme::accent_soft`.
1349 let accent = t.accent_soft;
1350 // A gutter for the checkmarks, and only where a row has one: a menu of
1351 // plain commands is not indented for a column nothing uses, and one
1352 // with a setting in it keeps every label on the same left edge whether
1353 // the setting is on or off.
1354 let gutter = items.iter().any(|i| i.checked);
1355 ui.with_keyed(label, spec, |ui| {
1356 let mut first = true;
1357 for (i, item) in items.iter().enumerate() {
1358 if item.role == MenuRole::Separator {
1359 ui.leaf_indexed(
1360 i as u64,
1361 NodeSpec::row()
1362 .grow_width()
1363 .height(1.0)
1364 .bg(t.border)
1365 // Not a row anything reads out: a divider is
1366 // paint, and a screen reader hearing "separator"
1367 // between every pair of items is noise.
1368 .role(Role::None),
1369 );
1370 continue;
1371 }
1372 // The row posts which item it is; the core takes the event back
1373 // by origin, performs the item, and what the app hears is the
1374 // item's own `id` on the node the menu was about.
1375 let payload = menu_row_tag(i);
1376 let mut spec = NodeSpec::row()
1377 .role(Role::MenuItem)
1378 .label(item.text())
1379 .grow_width()
1380 .pad_xy(m.menu_pad_x, m.menu_pad_y)
1381 .gap(8.0)
1382 .radius(m.radius_inner)
1383 .main_align(Align::Start)
1384 .cross_align(Align::Center);
1385 if item.checked {
1386 // The gutter's checkmark is paint; this is the same fact for
1387 // a screen reader, which reads a row that carries one as
1388 // checked rather than as "✓ Wrap".
1389 spec = spec.checked(true);
1390 }
1391 if item.enabled {
1392 spec = spec.on_click(payload).hover_bg(accent).focus_bg(accent);
1393 // The first row that can take focus is where the modal opens:
1394 // a menu whose keyboard starts nowhere makes the arrow keys
1395 // feel like they missed.
1396 if first {
1397 spec = spec.initial_focus();
1398 first = false;
1399 }
1400 } else {
1401 spec = spec.disabled(true).opacity(t.disabled_opacity);
1402 }
1403 ui.with_indexed(i as u64, spec, |ui| {
1404 if gutter {
1405 ui.with(NodeSpec::row().width(MENU_CHECK_W), |ui| {
1406 if item.checked {
1407 ui.text("\u{2713}", TextStyle::new(m.chrome_text).color(t.fg));
1408 }
1409 });
1410 }
1411 ui.text(item.text(), TextStyle::new(m.chrome_text).color(t.fg));
1412 if let Some(accel) = item.accel_text() {
1413 // Pushed to the right edge by a grow spacer, so the label
1414 // stays where the eye expects it whatever the
1415 // accelerator is.
1416 ui.leaf(NodeSpec::row().grow_width());
1417 ui.text(accel, TextStyle::new(m.chrome_text).color(t.muted));
1418 }
1419 });
1420 }
1421 })
1422}
1423
1424/// What a menu row's click carries: its index in the menu's items, for
1425/// the core to read back (`Core::menu_row_of`). The title of a menu-bar
1426/// menu carries its index the same way, under `title`.
1427fn menu_row_tag(i: usize) -> Value {
1428 Value::map([("row", Value::Int(i as i64))])
1429}
1430
1431fn menu_title_tag(i: usize) -> Value {
1432 Value::map([("title", Value::Int(i as i64))])
1433}
1434
1435/// The reserved label the drawn menu bar is keyed under, the way
1436/// [`MENU_KEY`] is the open menu's.
1437pub const MENU_BAR_KEY: &str = "kui.menubar";
1438/// The label its dropped menu is keyed under, beside the open title.
1439const MENU_BAR_PANEL_KEY: &str = "kui.menubar.menu";
1440/// The label each title is keyed under, inside its own wrapper.
1441const MENU_BAR_TITLE_KEY: &str = "kui.menubar.title";
1442
1443/// The hover group a title and its menu share, so the widget can ask
1444/// whether the pointer is on the `i`th title without knowing its key.
1445fn group_name(i: usize) -> String {
1446 format!("{MENU_BAR_KEY}.{i}")
1447}
1448/// The bar's height, logical px — a little under a titlebar's, which is
1449/// what every platform that draws one in the window does.
1450pub const MENU_BAR_H: f32 = Metrics::comfortable().menu_bar_h;
1451
1452/// The application menu: `bar` is what the app's menu *is*, and calling
1453/// this is where its titles go when they have to be drawn in the window.
1454///
1455/// One call and not two, because the declaration and the placement are one
1456/// decision. **It draws nothing where the platform owns the bar** — macOS,
1457/// where the driver hands this same declaration to `NSApp` — so the call
1458/// still says what the menu is and the strip simply is not there; that is
1459/// the contract [`window_buttons`] has under native decorations, and it is
1460/// what makes one view portable. An empty `bar` takes the menu away.
1461///
1462/// Declared every frame, and diffed: an unchanged menu costs a comparison
1463/// and rebuilds nothing.
1464///
1465/// Everything below a title is the stock menu: the same rows, roles,
1466/// accelerators and access tree the context menu draws, through the same
1467/// [`menu_panel`]. What is the bar's own is the scope — while a menu is
1468/// open the *bar* is the frame's modal, not the dropdown, so hovering
1469/// across the titles moves the open menu the way a menu bar does, a press
1470/// on the open title closes it, and Escape or a press in the app below
1471/// dismisses it as any modal is dismissed.
1472///
1473/// Typical use, as the first child of a full-height root, under the
1474/// titlebar if there is one:
1475/// `widgets::menu_bar(ui, self.menu());`
1476pub fn menu_bar(ui: &mut Ui<'_>, bar: MenuBar) {
1477 // Declaring it is this call's first half, and drawing it the second:
1478 // where the platform owns the bar there is no second half, and the
1479 // frame has still said what the app's menu is.
1480 ui.core().declare_menu_bar(bar);
1481 if ui.core().native_menu_bar() {
1482 return;
1483 }
1484 let Some(bar) = ui.core().menu_bar().cloned() else {
1485 return;
1486 };
1487 if bar.menus.is_empty() {
1488 return;
1489 }
1490 let t = ui.theme();
1491 let m = ui.metrics();
1492 let accent = t.accent_soft;
1493 let mut open = ui.core().menu_bar_open();
1494 // The bar's nodes are the core's, opened under their own origin (see
1495 // `OriginId::MENU_BAR`), so the core takes their events back by it.
1496 let saved = ui.origin();
1497 ui.set_origin(OriginId::MENU_BAR);
1498 let mut spec = NodeSpec::row()
1499 .grow_width()
1500 .height(m.menu_bar_h)
1501 .cross_align(Align::Center)
1502 .pad_xy(4.0, 0.0)
1503 .gap(2.0)
1504 .bg(t.bg)
1505 .role(Role::Menu)
1506 .label("Menu bar");
1507 if open.is_some() {
1508 // The bar and not the dropdown is the modal while a menu is open:
1509 // the titles have to stay live for the hover to walk them, and the
1510 // app below has to be as inert as it is under any other menu.
1511 spec = spec.modal(Value::str(MENU_BAR_KEY));
1512 }
1513 let root = ui.with_keyed(MENU_BAR_KEY, spec, |ui| {
1514 // Hovering another title while a menu is open moves the open menu
1515 // to it, which is what a menu bar does everywhere. Resolved before
1516 // anything is built, so the frame that notices the hover is the
1517 // frame that draws the new menu and not the one after it — and
1518 // asked by *group* rather than by key, since a title's key is
1519 // inside a wrapper this loop has not opened yet.
1520 if open.is_some() {
1521 for (i, menu) in bar.menus.iter().enumerate() {
1522 let hovered = ui.is_group_hovered(NodeSpec::hover_group_id(&group_name(i)));
1523 if open != Some(i) && menu.enabled && !menu.items.is_empty() && hovered {
1524 open = Some(i);
1525 ui.core().set_menu_bar_open(open);
1526 }
1527 }
1528 }
1529 for (i, menu) in bar.menus.iter().enumerate() {
1530 let live = menu.enabled && !menu.items.is_empty();
1531 let is_open = open == Some(i);
1532 // A wrapper the menu drops out of, so the panel is a *sibling*
1533 // of the title and not a child of it: a `menuItem` is a
1534 // name-from-content role, and a menu nested inside one would be
1535 // read as part of its name and never reached on its own.
1536 ui.with_indexed(i as u64, NodeSpec::row(), |ui| {
1537 let mut spec = NodeSpec::row()
1538 .role(Role::MenuItem)
1539 .label(menu.label.as_str())
1540 // Two px shorter than a row's, so the bar's height and
1541 // not the title's padding decides the strip.
1542 .pad_xy(m.menu_pad_x, (m.menu_pad_y - 2.0).max(0.0))
1543 .radius(m.radius_inner)
1544 .cross_align(Align::Center);
1545 if live {
1546 // Which title this is: the core takes the event back
1547 // by origin and opens or closes the `i`th menu.
1548 spec = spec
1549 .on_click(menu_title_tag(i))
1550 .hover_group(&group_name(i))
1551 .hover_bg(accent)
1552 .focus_bg(accent);
1553 if is_open {
1554 spec = spec.bg(accent);
1555 }
1556 } else {
1557 spec = spec.disabled(true).opacity(t.disabled_opacity);
1558 }
1559 ui.text_in_keyed(
1560 MENU_BAR_TITLE_KEY,
1561 spec,
1562 menu.label.as_str(),
1563 TextStyle::new(m.chrome_text).color(t.fg),
1564 );
1565 if is_open {
1566 // Out of the title's bottom-left corner, and `fit` to
1567 // slide back in at the right-hand end of the bar.
1568 menu_panel(
1569 ui,
1570 MENU_BAR_PANEL_KEY,
1571 menu_panel_spec(&t, &m).label(menu.label.as_str()).float(
1572 FloatConfig::parent()
1573 .at(Align::Start, Align::End)
1574 .self_at(Align::Start, Align::Start)
1575 .offset(0.0, 2.0)
1576 .fit(),
1577 ),
1578 &menu.items,
1579 );
1580 }
1581 });
1582 }
1583 });
1584 ui.set_origin(saved);
1585 ui.core().set_menu_bar_root(root);
1586}
1587
1588/// The checkmark gutter's width, logical px.
1589const MENU_CHECK_W: f32 = 14.0;
1590
1591// -- Virtual lists ----------------------------------------------------------
1592// The core culls glyphs by viewport but builds every child a view declares,
1593// so a ten-thousand-row log costs ten thousand rows of build and layout on
1594// every frame — most of a 120 Hz budget spent on rows nobody can see. A view
1595// that knows the container's height and offset can declare a screenful and
1596// two spacers instead. `Core::scroll_geometry` is that knowledge; this is
1597// the arithmetic, for the case where every row is the same height.
1598
1599/// The half-open range of rows a container of `rows` rows, each `row_h`
1600/// logical px tall, has any reason to build — those crossing the visible
1601/// band, plus `overscan` on each side — given the geometry of the frame
1602/// before. Pure arithmetic, exposed for views that build their own
1603/// container instead of using [`uniform_list`].
1604///
1605/// `vh` is the container's height, and `pad_t` the padding above the first
1606/// row. `None` geometry means no layout has resolved the container yet:
1607/// the caller decides what the first frame builds.
1608pub fn visible_rows(
1609 offset_y: f32,
1610 vh: f32,
1611 pad_t: f32,
1612 row_h: f32,
1613 rows: usize,
1614 overscan: usize,
1615) -> std::ops::Range<usize> {
1616 if rows == 0 || row_h <= 0.0 {
1617 return 0..0;
1618 }
1619 // Flow coordinates: row i spans [i*row_h, (i+1)*row_h), and layout puts
1620 // the flow's origin at pad_t - offset_y inside the container's box, so
1621 // the visible window is [offset_y - pad_t, that + vh).
1622 let top = offset_y - pad_t;
1623 let first = (top / row_h).floor().max(0.0) as usize;
1624 let last = ((top + vh.max(0.0)) / row_h).ceil().max(0.0) as usize;
1625 let first = first.saturating_sub(overscan).min(rows);
1626 let last = last.saturating_add(overscan).min(rows);
1627 first..last.max(first)
1628}
1629
1630/// A vertically scrolling column of `rows` uniform rows that builds only the
1631/// visible ones. `row(ui, i)` declares row `i`; it must come out exactly
1632/// `row_h` logical px tall, since that is the arithmetic placing every row
1633/// above and below it.
1634///
1635/// The container is `spec` forced to a scrolling column with no gap — put
1636/// the spacing inside `row_h` (a row that pads itself) rather than in a
1637/// `gap`, so one number describes the stride. Rows are opened with
1638/// [`Ui::open_indexed`] at their *data* index, so a row keeps its key, and
1639/// with it its hover, focus, edit buffer and tweens, as the built range
1640/// slides over it. Above and below sit two empty spacers holding the space
1641/// of the rows not built, so the content height, the scrollbar and
1642/// `set_scroll` all behave as if the whole list were there.
1643///
1644/// The geometry it slices by is the previous frame's, so the first frame —
1645/// before any layout has resolved the container — slices by the viewport
1646/// height instead and asks for one more frame; a resize is one frame late
1647/// and covered by the two rows of overscan. Returns the container's key,
1648/// for `set_scroll` (`Vec2::new(0.0, i as f32 * row_h)` scrolls row `i` to
1649/// the top, which is how you reach a row that is not built — `reveal` of an
1650/// unbuilt row finds nothing).
1651pub fn uniform_list(
1652 ui: &mut Ui<'_>,
1653 label: &str,
1654 spec: NodeSpec,
1655 rows: usize,
1656 row_h: f32,
1657 row: impl FnMut(&mut Ui<'_>, usize),
1658) -> Key {
1659 uniform_list_with(ui, label, spec, rows, row_h, |_| NodeSpec::column(), row)
1660}
1661
1662/// [`uniform_list`] with each row's own node spelled by `row_spec(i)` —
1663/// the click, the zebra stripe, the hover background, the role a row
1664/// carries — where the plain form's rows are bare and the callback nests
1665/// a second node inside each to carry them. The height is
1666/// forced to `row_h`, the stride the arithmetic assumes, and a width the
1667/// spec leaves `fit` grows across the list.
1668pub fn uniform_list_with(
1669 ui: &mut Ui<'_>,
1670 label: &str,
1671 spec: NodeSpec,
1672 rows: usize,
1673 row_h: f32,
1674 mut row_spec: impl FnMut(usize) -> NodeSpec,
1675 mut row: impl FnMut(&mut Ui<'_>, usize),
1676) -> Key {
1677 const OVERSCAN: usize = 2;
1678
1679 let key = ui.child_key(label);
1680 let pad_t = spec.layout.padding.t;
1681 // Both numbers from the same frame: the geometry's offset is clamped to
1682 // that frame's travel, so a `set_scroll(key, huge)` between frames
1683 // slices the end of the list instead of a megabyte past it.
1684 let (offset_y, vh, first_frame) = match ui.scroll_geometry(key) {
1685 Some(g) => (g.offset.y, g.rect.h, false),
1686 // Nothing laid out yet: the container cannot be taller than the
1687 // window in the ordinary case, so a screenful is a safe over-build
1688 // for one frame.
1689 None => (ui.scroll_offset(key).y, ui.viewport().h, true),
1690 };
1691 let range = visible_rows(offset_y, vh, pad_t, row_h, rows, OVERSCAN);
1692
1693 ui.with_keyed(label, spec.scroll_y().gap(0.0), |ui| {
1694 // The whole list's size, built or not: what Select All inside a
1695 // `selectable` list spans.
1696 ui.row_count(rows as u64);
1697 // Keyed, not auto-keyed: an auto key is a sibling index, and the
1698 // rows already occupy that namespace at their data indices — an
1699 // auto-keyed spacer next to a built row 0 would be row 0's key.
1700 let lead = range.start as f32 * row_h;
1701 if lead > 0.0 {
1702 ui.leaf_keyed("lead", spacer_spec(lead));
1703 }
1704 for i in range.clone() {
1705 let mut spec = row_spec(i).height(row_h);
1706 if spec.layout.width == Sizing::Fit {
1707 spec = spec.grow_width();
1708 }
1709 ui.with_indexed(i as u64, spec, |ui| row(ui, i));
1710 }
1711 let tail = (rows - range.end) as f32 * row_h;
1712 if tail > 0.0 {
1713 ui.leaf_keyed("tail", spacer_spec(tail));
1714 }
1715 });
1716
1717 // Sliced by a screenful's guess, with no layout of its own yet: the
1718 // next frame slices by its geometry. kui's ask, not the app's, so a
1719 // trace names it for what it is.
1720 if first_frame {
1721 ui.owe_frame("list first frame");
1722 }
1723 key
1724}
1725
1726/// Scrolls the [`uniform_list`] labelled `label` so row `i` shows, when
1727/// it does not already: to the middle of the list, so a jump lands with
1728/// rows on both sides of it. Call it before the list is
1729/// declared, in the same parent — the frame that scrolls then slices its
1730/// rows by the offset it scrolls to, instead of a frame late. Returns
1731/// whether it scrolled. The first frame, before the list has laid out,
1732/// has no geometry and scrolls nothing; the row arithmetic assumes the
1733/// list's rows start at its content top and fill its box, as they do
1734/// without padding. A row past the list's content — an index past its
1735/// end — scrolls nothing and answers false, as does a `row_h` that is
1736/// not positive.
1737///
1738/// `Ui::reveal` cannot do this for a row that is not built, and a
1739/// virtual list builds only what shows.
1740pub fn reveal_row(ui: &mut Ui<'_>, label: &str, i: usize, row_h: f32) -> bool {
1741 let key = ui.child_key(label);
1742 let Some(g) = ui.scroll_geometry(key) else {
1743 return false;
1744 };
1745 let y = i as f32 * row_h;
1746 if row_h <= 0.0 || y + row_h > g.content.h + 0.5 {
1747 return false;
1748 }
1749 if g.offset.y <= y && y + row_h <= g.offset.y + g.rect.h {
1750 return false;
1751 }
1752 let to = (y + row_h / 2.0 - g.rect.h / 2.0).max(0.0);
1753 ui.set_scroll(key, Vec2::new(g.offset.x, to));
1754 true
1755}
1756
1757/// How many whole rows of `row_h` the [`uniform_list`] labelled `label`
1758/// shows as of the last layout — a PageDown's stride. 0 before it has
1759/// laid out, and for a `row_h` that is not positive.
1760pub fn rows_in_view(ui: &mut Ui<'_>, label: &str, row_h: f32) -> usize {
1761 let key = ui.child_key(label);
1762 if row_h <= 0.0 {
1763 return 0;
1764 }
1765 ui.scroll_geometry(key)
1766 .map_or(0, |g| (g.rect.h / row_h).floor().max(0.0) as usize)
1767}
1768
1769/// Each row's own node in the variable-height `list`: the wrapper the
1770/// callback builds inside, sized to the height the arithmetic assumes.
1771fn row_spec(h: f32) -> NodeSpec {
1772 NodeSpec::column().grow_width().height(h)
1773}
1774
1775fn spacer_spec(h: f32) -> NodeSpec {
1776 NodeSpec::column().grow_width().height(h)
1777}
1778
1779// -- Variable-height virtual lists ------------------------------------------
1780// `uniform_list` takes one stride and every row must come out that tall,
1781// which is the log viewer, the data table and the chat history whose rows are
1782// one line. A row that wraps, a card with an image, a message that is
1783// sometimes three lines: none of those have a stride, and the three things
1784// the uniform arithmetic does with `i * row_h` — the lead spacer, the search
1785// from an offset to the first visible row, and "scroll to row i" — have no
1786// closed form without one. Prefix sums are the closed form, and
1787// `RowHeights` is where they live.
1788//
1789// Heights come from the caller, measured only for the rows the frame needs:
1790// `measure_text` gives layout's own number for a text row (wrap, max_lines
1791// and the shaping cache included), so a row measured and then drawn shapes
1792// once. Everything not measured yet stands at an estimate, and the estimate
1793// is the mean of what has been measured — which means it *moves*, and moving
1794// it changes the height of every row above the window as well as below.
1795// That is what the anchor is for.
1796
1797/// The heights a [`list`] slices by: a measured number per row
1798/// where one is known, an estimate everywhere else, and the prefix sums over
1799/// both.
1800///
1801/// The app owns it and hands the same one back every frame — a widget
1802/// composed from primitives keeps no state of its own, which is what keeps
1803/// it reachable from a scripting frontend. Rebuild it (or [`Self::clear`])
1804/// when the rows themselves change.
1805#[derive(Clone, Debug)]
1806pub struct RowHeights {
1807 /// One per row; `f32::NAN` for a row nothing has measured yet.
1808 h: Vec<f32>,
1809 /// The prefix sums, split so that the estimate is applied at the query
1810 /// rather than baked in: `m[i]` is the measured height in rows `0..i`
1811 /// and `u[i]` how many of those rows have none. A moving mean then costs
1812 /// nothing to fold in — which matters, because every measurement moves
1813 /// it, and a mean baked into the sums would dirty all of them.
1814 m: Vec<f32>,
1815 u: Vec<u32>,
1816 /// How many entries of `m` / `u` are valid, counting from 0. Filled
1817 /// on demand and only as far as a query asks, so a list scrolled to row
1818 /// 30 never sums the 9,970 below it; a measurement at row `i` truncates
1819 /// this to `i + 1`, since nothing at or below `i` changed.
1820 clean: usize,
1821 /// What the caller guessed before anything was measured.
1822 seed: f32,
1823 /// Running mean of the measured rows — the estimate for the rest.
1824 sum: f32,
1825 n: usize,
1826 /// The content width the cached heights were measured at. A different
1827 /// one rewraps every row, so it drops them all.
1828 width: f32,
1829 /// Where the last search landed. Scrolling is local, so the next one
1830 /// gallops out from here instead of bisecting the whole list — which is
1831 /// what keeps the lazy `ensure` above from being filled past what is
1832 /// being looked at, and what a bisection from 0..len would defeat by
1833 /// probing the middle every time.
1834 last: usize,
1835}
1836
1837impl RowHeights {
1838 /// `rows` rows, none measured, each standing at `estimate` logical px
1839 /// until it is. The estimate only has to be the right order of
1840 /// magnitude: it decides how wrong the scrollbar is before the list has
1841 /// been scrolled through, and nothing else.
1842 pub fn new(rows: usize, estimate: f32) -> Self {
1843 RowHeights {
1844 h: vec![f32::NAN; rows],
1845 m: vec![0.0],
1846 u: vec![0],
1847 clean: 1,
1848 seed: estimate.max(1.0),
1849 sum: 0.0,
1850 n: 0,
1851 width: f32::NAN,
1852 last: 0,
1853 }
1854 }
1855
1856 pub fn len(&self) -> usize {
1857 self.h.len()
1858 }
1859
1860 pub fn is_empty(&self) -> bool {
1861 self.h.is_empty()
1862 }
1863
1864 /// Grows or shrinks to `rows`, keeping what is still in range — rows
1865 /// appended to a log keep every height already measured, and cost
1866 /// nothing until something asks about them. A list whose rows *changed*
1867 /// rather than grew wants [`Self::clear`].
1868 pub fn set_len(&mut self, rows: usize) {
1869 if rows == self.h.len() {
1870 return;
1871 }
1872 for i in rows..self.h.len() {
1873 self.forget(i);
1874 }
1875 self.h.resize(rows, f32::NAN);
1876 self.clean = self.clean.min(rows + 1);
1877 }
1878
1879 /// Forgets every measurement, keeping the length and the seed — the call
1880 /// for a list whose contents changed under the same indices.
1881 pub fn clear(&mut self) {
1882 self.h.fill(f32::NAN);
1883 self.sum = 0.0;
1884 self.n = 0;
1885 self.clean = 1;
1886 }
1887
1888 /// Records row `i`'s height. Rows measured this way are what the
1889 /// estimate for the others is the mean of.
1890 pub fn set(&mut self, i: usize, h: f32) {
1891 if i >= self.h.len() || !h.is_finite() || h < 0.0 {
1892 return;
1893 }
1894 self.forget(i);
1895 self.h[i] = h;
1896 self.sum += h;
1897 self.n += 1;
1898 // Everything up to and including row `i`'s own top is unchanged.
1899 self.clean = self.clean.min(i + 1);
1900 }
1901
1902 fn forget(&mut self, i: usize) {
1903 let old = self.h[i];
1904 if !old.is_nan() {
1905 self.sum -= old;
1906 self.n -= 1;
1907 self.h[i] = f32::NAN;
1908 self.clean = self.clean.min(i + 1);
1909 }
1910 }
1911
1912 /// Row `i`'s height as it was measured, or `None` for one standing at
1913 /// the estimate.
1914 pub fn measured(&self, i: usize) -> Option<f32> {
1915 self.h.get(i).copied().filter(|h| !h.is_nan())
1916 }
1917
1918 /// Row `i`'s height: measured, or the estimate.
1919 pub fn get(&self, i: usize) -> f32 {
1920 self.measured(i).unwrap_or_else(|| self.estimate())
1921 }
1922
1923 /// What an unmeasured row stands at: the mean of the measured ones, or
1924 /// the caller's seed before there are any.
1925 pub fn estimate(&self) -> f32 {
1926 if self.n == 0 {
1927 self.seed
1928 } else {
1929 self.sum / self.n as f32
1930 }
1931 }
1932
1933 /// The width the measurements were taken at, or `NaN` before any.
1934 pub fn width(&self) -> f32 {
1935 self.width
1936 }
1937
1938 /// Declares the content width the next measurements are for. A width
1939 /// that differs from the cached one drops every height — the rows wrap
1940 /// differently now — and returns true. [`list`] calls this from
1941 /// the container's own laid-out box.
1942 pub fn set_width(&mut self, w: f32) -> bool {
1943 if !w.is_finite() || w <= 0.0 || (self.width - w).abs() < 0.5 {
1944 return false;
1945 }
1946 let had = self.n > 0;
1947 self.width = w;
1948 if had {
1949 self.clear();
1950 }
1951 true
1952 }
1953
1954 /// Fills the prefix sums up to `i` if they do not reach it yet.
1955 fn ensure(&mut self, i: usize) {
1956 let want = i.min(self.h.len()) + 1;
1957 if self.clean >= want {
1958 return;
1959 }
1960 self.m.truncate(self.clean);
1961 self.u.truncate(self.clean);
1962 self.m.reserve(want - self.clean);
1963 self.u.reserve(want - self.clean);
1964 let (mut acc, mut est) = (self.m[self.clean - 1], self.u[self.clean - 1]);
1965 for &h in &self.h[self.clean - 1..want - 1] {
1966 if h.is_nan() {
1967 est += 1;
1968 } else {
1969 acc += h;
1970 }
1971 self.m.push(acc);
1972 self.u.push(est);
1973 }
1974 self.clean = want;
1975 }
1976
1977 /// The top of row `i` in content coordinates — the height of everything
1978 /// above it. `offset_of(len())` is the whole list's height.
1979 pub fn offset_of(&mut self, i: usize) -> f32 {
1980 let i = i.min(self.h.len());
1981 self.ensure(i);
1982 self.m[i] + self.u[i] as f32 * self.estimate()
1983 }
1984
1985 /// The list's total height, measured and estimated together — what the
1986 /// two spacers and the scrollbar are made of. Kept as it goes, so the
1987 /// tail spacer costs nothing however long the list is.
1988 pub fn total(&self) -> f32 {
1989 self.sum + (self.h.len() - self.n) as f32 * self.estimate()
1990 }
1991
1992 /// The row `y` (content coordinates) lands in: the last row whose top is
1993 /// at or above it, clamped to the list. The binary search that replaces
1994 /// `y / row_h`.
1995 pub fn row_at(&mut self, y: f32) -> usize {
1996 let rows = self.h.len();
1997 if rows == 0 || y <= 0.0 {
1998 self.last = 0;
1999 return 0;
2000 }
2001 // `offset_of` is non-decreasing, so what is wanted is the last row
2002 // whose top is at or below `y`. Row 0's top is 0, so it always
2003 // qualifies and the bracket below always closes.
2004 let mut lo = self.last.min(rows - 1);
2005 let mut hi;
2006 if self.offset_of(lo) > y {
2007 hi = lo;
2008 let mut step = 1usize;
2009 while lo > 0 {
2010 lo = lo.saturating_sub(step);
2011 if self.offset_of(lo) <= y {
2012 break;
2013 }
2014 hi = lo;
2015 step *= 2;
2016 }
2017 } else {
2018 hi = (lo + 1).min(rows);
2019 let mut step = 1usize;
2020 while hi < rows && self.offset_of(hi) <= y {
2021 lo = hi;
2022 hi = (hi + step).min(rows);
2023 step *= 2;
2024 }
2025 }
2026 while lo + 1 < hi {
2027 let mid = lo + (hi - lo) / 2;
2028 if self.offset_of(mid) <= y {
2029 lo = mid;
2030 } else {
2031 hi = mid;
2032 }
2033 }
2034 self.last = lo;
2035 lo
2036 }
2037}
2038
2039/// A vertically scrolling column of rows of *different* heights that builds
2040/// only the visible ones — [`uniform_list`] where no single stride
2041/// describes the list.
2042///
2043/// `measure(ui, i, width)` returns row `i`'s height at that content width,
2044/// and is called only for rows the frame is about to build that `heights`
2045/// has no number for; `ui.measure_text(.., Some(width))` is layout's own
2046/// answer for a text row, wrap and all, and shapes through the same cache
2047/// the row's draw will hit. What it returns is the height the row *gets*:
2048/// each row's node is fixed to it, so the arithmetic above and below can
2049/// never disagree with the layout, the way `uniform_list`'s stride cannot.
2050/// A row that would rather size itself has to say what that size is here.
2051///
2052/// `row(ui, i)` declares row `i` inside that node, exactly as
2053/// `uniform_list`'s does, and rows are opened with [`Ui::open_indexed`] at
2054/// their data index, so a row keeps its hover, focus, edit buffer and tweens
2055/// as the built range slides over it.
2056///
2057/// **What it does that the uniform one never has to:** every row not yet
2058/// measured stands at the mean of the ones that are, so measuring the rows
2059/// this frame builds changes the height of every row it does not — the ones
2060/// above the window included. Left alone that slides the content out from
2061/// under the pointer on the frame it learns anything. So the widget takes
2062/// the row the window starts in and how far into it, measures, and then puts
2063/// that pair back: `Core::set_scroll` from inside a view lands on the frame
2064/// being built (the positions pass reads the store after the view has run),
2065/// so the corrected frame is the only one ever seen. What does move is the
2066/// scrollbar, which is the honest thing to move — the list really did just
2067/// learn it is a different length.
2068///
2069/// Returns the container's key, for `set_scroll` — and "scroll to row `i`"
2070/// is `set_scroll(key, Vec2::new(0.0, heights.offset_of(i)))`, exact for a
2071/// measured row and converging over a frame or two for one that is not.
2072pub fn list(
2073 ui: &mut Ui<'_>,
2074 label: &str,
2075 spec: NodeSpec,
2076 heights: &mut RowHeights,
2077 mut measure: impl FnMut(&mut Ui<'_>, usize, f32) -> f32,
2078 mut row: impl FnMut(&mut Ui<'_>, usize),
2079) -> Key {
2080 let key = ui.child_key(label);
2081 let mut slice = heights.slice(ListReading::of(ui, key, spec.layout.padding));
2082 loop {
2083 let pending = slice.unmeasured(heights);
2084 if pending.is_empty() {
2085 break;
2086 }
2087 for i in pending {
2088 let h = measure(ui, i, slice.width());
2089 heights.set(i, h);
2090 }
2091 if !slice.reslice(heights) {
2092 break;
2093 }
2094 }
2095 let plan = slice.finish(heights);
2096 // A write from inside a view lands on the frame being built: the
2097 // positions pass reads the store after the view has run. So the frame
2098 // that learned the rows are a different size is drawn already
2099 // corrected, and the uncorrected one is never seen. A shift, not a
2100 // `set_scroll`: the correction moves the coordinates under the
2101 // content, so it is never eased on a container with a `transition`,
2102 // and mid-glide it moves the leg with it rather than ending the leg
2103 // where the content stands (RG18).
2104 if let Some((drawn, target)) = plan.shift {
2105 ui.shift_scroll(key, Vec2::new(0.0, drawn), Vec2::new(0.0, target));
2106 }
2107
2108 ui.with_keyed(label, spec.scroll_y().gap(0.0), |ui| {
2109 ui.row_count(heights.len() as u64);
2110 if plan.lead > 0.0 {
2111 ui.leaf_keyed("lead", spacer_spec(plan.lead));
2112 }
2113 for i in plan.range.clone() {
2114 ui.with_indexed(i as u64, row_spec(heights.get(i)), |ui| row(ui, i));
2115 }
2116 if plan.tail > 0.0 {
2117 ui.leaf_keyed("tail", spacer_spec(plan.tail));
2118 }
2119 });
2120
2121 // As `uniform_list`'s first frame.
2122 if plan.first_frame {
2123 ui.owe_frame("list first frame");
2124 }
2125 key
2126}
2127
2128/// What a variable-height list reads before it slices: the container's
2129/// last layout, where its scroll is going, the window, and the padding
2130/// its rows sit inside. [`list`] takes it from the frame
2131/// ([`Self::of`]); a binding builds it from the same readings its view
2132/// already has (`scrollGeometry`, `scrollOffset`, the viewport), so the
2133/// arithmetic after it is this module's in every language.
2134#[derive(Clone, Copy, Debug, Default)]
2135pub struct ListReading {
2136 /// `scroll_geometry` of the container, `None` before a layout has
2137 /// resolved it — the first frame.
2138 pub geometry: Option<crate::scroll::ScrollGeometry>,
2139 /// `scroll_offset(key).y`: the retained offset, which is where an
2140 /// eased leg is going when it differs from `geometry.offset`.
2141 pub scroll_y: f32,
2142 /// The window, logical px: what the first frame slices by.
2143 pub viewport: crate::geom::Size,
2144 /// The container's top padding and its horizontal padding together.
2145 pub pad_t: f32,
2146 pub pad_x: f32,
2147 /// Rows built past each end of the window; 2 unless a view says.
2148 pub overscan: usize,
2149}
2150
2151impl ListReading {
2152 /// The reading for the container `key`, with `pad` its padding, from
2153 /// the frame being built.
2154 pub fn of(ui: &Ui<'_>, key: Key, pad: crate::geom::Edges) -> Self {
2155 ListReading {
2156 geometry: ui.scroll_geometry(key),
2157 scroll_y: ui.scroll_offset(key).y,
2158 viewport: ui.viewport(),
2159 pad_t: pad.t,
2160 pad_x: pad.x(),
2161 overscan: 2,
2162 }
2163 }
2164}
2165
2166/// One frame's slicing of a variable-height list, between the reading and
2167/// the rows: which rows to measure, and — once they are — where the window
2168/// lands and what to build. Made by [`RowHeights::slice`]; see [`list`]
2169/// for the loop that drives it, which every binding's port repeats.
2170#[derive(Clone, Debug)]
2171pub struct ListSlice {
2172 range: std::ops::Range<usize>,
2173 /// The row the window starts in, and how far into it: the pair the
2174 /// correction puts back where it was.
2175 anchor: usize,
2176 into: f32,
2177 top: f32,
2178 /// What the passes move `top` away from. The correction is for a
2179 /// *measurement* moving the numbers — not for the clamp to zero, which
2180 /// on a list shorter than its box (offset 0, padding 6) makes `top +
2181 /// pad_t` differ from the offset every frame, and a correction every
2182 /// frame is a frame requested every frame.
2183 top_before: f32,
2184 /// Where an eased leg (F80) is going, when that is somewhere other
2185 /// than where the content is drawn: a second anchor, so the row under
2186 /// the target stays the target however the measurements move the rows
2187 /// between the two (RG18). The target, its row, and how far into it.
2188 target: Option<(f32, usize, f32)>,
2189 vh: f32,
2190 width: f32,
2191 overscan: usize,
2192 passes: usize,
2193 first_frame: bool,
2194}
2195
2196/// What a [`ListSlice`] comes to: the rows to build, the two spacers'
2197/// heights, and the scroll correction the frame needs (see [`list`]).
2198#[derive(Clone, Debug, PartialEq)]
2199pub struct ListPlan {
2200 pub range: std::ops::Range<usize>,
2201 pub lead: f32,
2202 pub tail: f32,
2203 /// `(drawn, target)` on y, for `Ui::shift_scroll`, when measuring moved
2204 /// the rows; `None` when nothing needs correcting.
2205 pub shift: Option<(f32, f32)>,
2206 /// Sliced by the window, not a layout: the frame after it has to run.
2207 pub first_frame: bool,
2208}
2209
2210/// Measuring changes the heights the range was sliced from, which can widen
2211/// it; four passes is far more than a screenful ever needs and bounds the
2212/// work whatever the measurements do.
2213const LIST_PASSES: usize = 4;
2214
2215impl RowHeights {
2216 /// Starts a frame's slicing from `reading`: the content width (a new one
2217 /// drops every height, since the rows rewrap), the anchors, and the
2218 /// first range.
2219 pub fn slice(&mut self, reading: ListReading) -> ListSlice {
2220 let mut target_y = None;
2221 let (offset_y, vh, cw, first_frame) = match reading.geometry {
2222 Some(g) => {
2223 let t = reading.scroll_y.clamp(0.0, g.max_offset.y);
2224 if (t - g.offset.y).abs() > 0.5 {
2225 target_y = Some(t);
2226 }
2227 (g.offset.y, g.rect.h, g.rect.w - reading.pad_x, false)
2228 }
2229 // Nothing laid out yet: a screenful of the viewport is a safe
2230 // over-build for one frame, and the width is its width.
2231 None => (
2232 reading.scroll_y,
2233 reading.viewport.h,
2234 reading.viewport.w - reading.pad_x,
2235 true,
2236 ),
2237 };
2238 // A resize rewraps every row, so the cache is void; the frame after
2239 // it measures a screenful again.
2240 self.set_width(cw);
2241 let top = (offset_y - reading.pad_t).max(0.0);
2242 let anchor = self.row_at(top);
2243 let into = top - self.offset_of(anchor);
2244 let target = target_y.map(|t| {
2245 let t = (t - reading.pad_t).max(0.0);
2246 let row = self.row_at(t);
2247 (t, row, t - self.offset_of(row))
2248 });
2249 let range = visible_range(self, top, vh, reading.overscan);
2250 ListSlice {
2251 range,
2252 anchor,
2253 into,
2254 top,
2255 top_before: top,
2256 target,
2257 vh,
2258 width: cw,
2259 overscan: reading.overscan,
2260 passes: 0,
2261 first_frame,
2262 }
2263 }
2264}
2265
2266impl ListSlice {
2267 /// The content width the rows are measured at.
2268 pub fn width(&self) -> f32 {
2269 self.width
2270 }
2271
2272 /// The rows of the current range nothing has measured: measure each,
2273 /// [`RowHeights::set`] it, then [`Self::reslice`]. Empty is done.
2274 pub fn unmeasured(&self, heights: &RowHeights) -> Vec<usize> {
2275 self.range
2276 .clone()
2277 .filter(|&i| heights.measured(i).is_none())
2278 .collect()
2279 }
2280
2281 /// After measuring: puts the anchor row back where it was and slices
2282 /// again. Measuring moved the numbers the slice was taken from — this
2283 /// row's own, the rows above it, and (through the mean) every row
2284 /// nobody has measured at all — so what is under the pointer would
2285 /// otherwise slide out from under it. True when the range moved and
2286 /// its new rows want measuring, within the pass budget.
2287 pub fn reslice(&mut self, heights: &mut RowHeights) -> bool {
2288 self.passes += 1;
2289 self.top = heights.offset_of(self.anchor) + self.into;
2290 let next = visible_range(heights, self.top, self.vh, self.overscan);
2291 if next == self.range {
2292 return false;
2293 }
2294 self.range = next;
2295 self.passes < LIST_PASSES
2296 }
2297
2298 /// The rows to build, the spacers, and the correction.
2299 pub fn finish(self, heights: &mut RowHeights) -> ListPlan {
2300 let drawn = self.top - self.top_before;
2301 let target = match self.target {
2302 Some((t, row, into)) => heights.offset_of(row) + into - t,
2303 None => drawn,
2304 };
2305 let lead = heights.offset_of(self.range.start);
2306 let tail = heights.total() - heights.offset_of(self.range.end);
2307 ListPlan {
2308 range: self.range,
2309 lead,
2310 tail,
2311 shift: (drawn.abs() > 0.01 || target.abs() > 0.01).then_some((drawn, target)),
2312 first_frame: self.first_frame,
2313 }
2314 }
2315}
2316
2317/// The rows crossing `[top, top + vh)` plus `overscan` on each side, by
2318/// prefix-sum search. The variable-height [`visible_rows`].
2319fn visible_range(
2320 heights: &mut RowHeights,
2321 top: f32,
2322 vh: f32,
2323 overscan: usize,
2324) -> std::ops::Range<usize> {
2325 let rows = heights.len();
2326 if rows == 0 {
2327 return 0..0;
2328 }
2329 let first = heights.row_at(top).saturating_sub(overscan);
2330 let last = (heights.row_at(top + vh.max(0.0)) + 1 + overscan).min(rows);
2331 first..last.max(first)
2332}