GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat: a kept renderer across an empty spell, accessible names; unused widgets gone
- wants_surface keeps the renderer. When a layer app has nothing to show,
the runner detaches its renderer (swapchain and VkSurfaceKHR go; device,
pipelines, atlases and image table stay) and drops the surface; when it has
something again it moves the same renderer onto the new surface, as the
menu popup's renderer moves between popups. It built a new renderer each
time, ~45 ms before the notifier's first card. Image ids stay good, so
renderer_init does not run and surface_hidden is gone. Checked on a
private session bus: a card, its expiry, a second card with a thumbnail,
identical to the pixel old and new.
- Widget::accessible_name (Adapted::set_accessible_name): what a screen
reader calls a widget that draws no label of its own; the accessibility
tree prefers it over the label, and nothing draws it.
an_accessible_name_names_a_widget_and_draws_nothing.
- Deleted display::Panel, display::Node, serialize_widgets and
widget::ColumnLayout / RowLayout, used by no app and nothing in the
toolkit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 32 +++--
scripts/style-audit | 2 +-
src/a11y.rs | 30 ++++-
src/backend/app.rs | 16 +--
src/backend/window_runner.rs | 51 +++++--
src/widget/core.rs | 7 +
src/widget/display/mod.rs | 6 -
src/widget/display/node.rs | 292 ----------------------------------------
src/widget/display/panel.rs | 92 -------------
src/widget/display/serialize.rs | 153 ---------------------
src/widget/layout_helper.rs | 106 ---------------
src/widget/mod.rs | 6 +-
src/widget/model.rs | 6 +
13 files changed, 102 insertions(+), 697 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 684b2be..e8a7148 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -653,20 +653,30 @@ live data (uptime, temperatures, a different saved plan per shadow home).
### A layer app can have no surface while it is empty (`Application::wants_surface`, 2026-10-05)
A layer-shell app that is usually empty — the notifier, between notifications — returns
-false from `wants_surface` while it has nothing to show. On that turn the runner drops the
-renderer and then the layer surface (SCTK destroys the role, then the `wl_surface`); on the
-turn it says true again it builds a fresh `wl_surface`, re-attaches the same layer role and
-a new renderer, and the first configure makes it presentable as at session start. The app
+false from `wants_surface` while it has nothing to show. On that turn the runner detaches
+its renderer (`VkRenderer::detach_surface`: the swapchain and `VkSurfaceKHR` go, the device,
+pipelines, atlases and image table stay) and drops the layer surface (SCTK destroys the
+role, then the `wl_surface`); on the turn it says true again it builds a fresh `wl_surface`,
+re-attaches the same layer role, moves the SAME renderer onto it (`attach_surface`, as the
+menu popup's renderer moves between popups), and the first configure makes it presentable
+as at session start. The app
keeps running throughout — its calloop sources, D-Bus thread and state are untouched; only
the surface goes. Why bother: an always-mapped transparent overlay still made the
compositor blur behind it whenever anything under it changed, and it kept a fullscreen
client off direct scanout (scenefx scans out only a one-entry render list).
-`surface_hidden` tells the app it happened. Image ids it uploads from then on are queued
-for the NEXT renderer, so that renderer's `renderer_init` must not re-upload them the way
-it would after a lost connection (the notifier keeps a flag for this). Rebuilding costs a
-renderer: a card after an empty spell appeared ~45 ms after `Notify` in a scale-2 shadow.
-Default true; xdg windows ignore it.
+Since the renderer is the same one, image ids stay good across the gap and
+`renderer_init` does not run; ids uploaded while hidden are drained into it at its next
+frame. Until 2026-10-08 the runner dropped the renderer and built a new one on show — a
+device and every pipeline, about 45 ms before a card after an empty spell appeared — and a
+`surface_hidden` hook told the app, whose `renderer_init` then had to skip re-uploading what
+was already queued (the notifier kept a flag for it). Both are gone. Only if attaching fails
+does the surface stay hidden with no renderer; the next show makes a new one and calls
+`renderer_init` as for a replacement. It is also what a launcher like cce-cloud, which keeps
+one renderer for its life and moves it between popups, would need from the runner. Checked
+in a scale-2 shadow on a private session bus: a card, its expiry, a second card with a
+thumbnail after the empty spell — drawn identically to the pixel by the old binary and the
+new. Default true; xdg windows ignore it.
### `renderer_init` — GPU handles do not survive a reconnect
@@ -1588,8 +1598,8 @@ when unset, the radius rule applied to spacing; do not add a new one.
In the box model the ladder is presets — `Style::root_column()` /
`root_row()`, `pane_column()` / `pane_row()`, `controls_column()` /
-`controls_row()` — and the legacy strategies' `Default`s and
-`ColumnLayout::pane` / `controls` read the same getters. An app picks the
+`controls_row()` — and the container layouts' `Default`s read the same
+getters. An app picks the
rung; a literal padding or gap in an app (`const PAD`, `+ 12.0`) is a number
the ladder should be supplying, and the audit counts them.
diff --git a/scripts/style-audit b/scripts/style-audit
index f658fbd..e9cc338 100755
--- a/scripts/style-audit
+++ b/scripts/style-audit
@@ -44,7 +44,7 @@ for n in "${apps[@]}"; do
if grep -rqE 'PlateSpec::(window|root_at)\(|\.root_plate\(' "$src"; then root=PLATE
elif grep -rqE '(quad|rounded_rect)\([^;]*(0\.0, *0\.0|x: *0\.0, *y: *0\.0)' "$src"; then root=FILL
else root=NONE; fi
- ladder=$(grep -rhoE 'root_plate_(inset|padding|gap)\(\)|plate_(padding|gap)\(\)|control_gap\(\)|Style::(root|pane|controls)_(column|row)\(\)|ColumnLayout::(pane|controls)\(' "$src" | wc -l)
+ ladder=$(grep -rhoE 'root_plate_(inset|padding|gap)\(\)|plate_(padding|gap)\(\)|control_gap\(\)|Style::(root|pane|controls)_(column|row)\(\)' "$src" | wc -l)
literals=$(grep -rhE '^\s*(pub )?const [A-Z_]*(PAD|GAP|MARGIN|INSET)[A-Z_]*: *f32' "$src" | wc -l)
note=""
[ "$root" != PLATE ] && { note="base is not the standard root plate"; fail=1; }
diff --git a/src/a11y.rs b/src/a11y.rs
index 993026b..d702292 100644
--- a/src/a11y.rs
+++ b/src/a11y.rs
@@ -156,7 +156,7 @@ pub fn role_for(type_name: &str, focus: FocusRole, explicit: Option<Role>) -> Ro
"ImageView" => Role::Image,
"Splitter" => Role::Splitter,
"Graph" | "Trackpad" => Role::Canvas,
- "Group" | "ParametersBg" | "Panel" => Role::Group,
+ "Group" | "ParametersBg" => Role::Group,
_ => match focus {
FocusRole::Plate => Role::Button,
FocusRole::Well | FocusRole::None => Role::GenericContainer,
@@ -169,7 +169,8 @@ pub fn widget_node(w: &dyn WidgetHost, children: Vec<NodeId>) -> Node {
let focus = w.focus_role();
let role = role_for(w.type_name(), focus, w.a11y_role());
let mut node = Node::new(role);
- if let Some(label) = w.label().filter(|l| !l.is_empty()) {
+ let name = w.base().accessible_name.clone().filter(|n| !n.is_empty());
+ if let Some(label) = name.or_else(|| w.label().filter(|l| !l.is_empty())) {
node.set_label(label);
}
if let Some(value) = w.a11y_value() {
@@ -229,10 +230,11 @@ pub fn key_for(w: &dyn WidgetHost, action: Action) -> Option<NamedKey> {
}
}
-/// Whether a widget is on screen: visible, with a size, and not parked. Apps park a widget
-/// they are not showing far off the window (x or y below -9000, the toolkit's sentinel —
-/// cce-data-editor's per-type value editors) instead of hiding it; a reader must not see
-/// those either.
+/// Whether a widget is on screen: visible, with a size, and not parked far off the window
+/// (x or y below -9000). A widget an app is not showing should be HIDDEN
+/// (`WidgetHost::set_visible(false)`), which takes it out of the Tab walk too; the sentinel
+/// is a backstop for one only parked. cce-data-editor parked its per-type value editors at
+/// -1000, above the sentinel, so all of them were in the tree until it hid them (2026-10-08).
fn shown_on_screen(w: &dyn WidgetHost) -> bool {
let (x, y, width, height) = w.rect();
w.visible() && width > 0.0 && height > 0.0 && x > -9000.0 && y > -9000.0
@@ -457,6 +459,22 @@ mod tests {
/// A node offers the actions the Linux adapter can carry out, and each by the key a
/// keyboard user presses: Space on a plate, Right / Left on a slider or a range,
/// Up / Down on a spin button, nothing on a text box.
+ /// A control named without a label of its own (a value editor in a table row) is called
+ /// by its accessible name, which wins over a label and adds no label strip.
+ #[test]
+ fn an_accessible_name_names_a_widget_and_draws_nothing() {
+ let mut text = TextBox::new(String::new());
+ let strip = text.label_strip();
+ text.set_accessible_name(Some("Value of font_size"));
+ assert_eq!(widget_node(&text, Vec::new()).label(), Some("Value of font_size"));
+ assert_eq!(text.label_strip(), strip, "no label strip for a name nobody draws");
+ let mut save = Button::new(0.0, 0.0, 80.0, 24.0).with_label("Save");
+ save.set_accessible_name(Some("Save the file"));
+ assert_eq!(widget_node(&save, Vec::new()).label(), Some("Save the file"));
+ save.set_accessible_name(None);
+ assert_eq!(widget_node(&save, Vec::new()).label(), Some("Save"), "back to the label");
+ }
+
#[test]
fn a_node_offers_the_actions_its_keys_carry_out() {
let button = Button::new(0.0, 0.0, 80.0, 24.0).with_label("Save");
diff --git a/src/backend/app.rs b/src/backend/app.rs
index 1160f40..b3aa206 100644
--- a/src/backend/app.rs
+++ b/src/backend/app.rs
@@ -222,10 +222,10 @@ pub trait Application: Sized + 'static {
}
/// For a [`layer`](Self::layer) app that is usually empty — a
/// notification stack — whether there is anything to show right now.
- /// While this is `false` the Wayland runner destroys the layer surface
- /// and its renderer, and it builds both again (a new renderer, so
- /// [`renderer_init`](Self::renderer_init) runs) on the turn it turns
- /// `true`. An always-mapped transparent overlay is not free: the
+ /// While this is `false` the Wayland runner destroys the layer surface,
+ /// keeping its renderer detached, and on the turn it turns `true` builds a
+ /// new surface and moves the same renderer onto it — so image ids stay
+ /// good and [`renderer_init`](Self::renderer_init) does not run. An always-mapped transparent overlay is not free: the
/// compositor blurs behind it whenever anything under it changes, and it
/// keeps a fullscreen client off direct scanout. Asked once a loop turn;
/// ignored for xdg windows. Default `true`: always mapped.
@@ -233,14 +233,6 @@ pub trait Application: Sized + 'static {
fn wants_surface(&self) -> bool {
true
}
- /// The runner has just destroyed the surface and renderer because
- /// [`wants_surface`](Self::wants_surface) said `false`. Image ids
- /// uploaded from here on are queued for the NEXT renderer, so its
- /// `renderer_init` has nothing of theirs to re-upload — unlike the
- /// `renderer_init` after a lost connection, whose ids died with the old
- /// renderer.
- #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
- fn surface_hidden(&mut self) {}
/// Declare the window a UTILITY window: a tool whose shape is decided by
/// its contents. The compositor then never dictates a size to it (every
/// configure is the "you choose" 0x0 — [`WindowSettings::width`]/`height`
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 65ebd79..9c0fc50 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -83,7 +83,7 @@ pub struct EngineState<A: Application> {
/// which is what makes [`Application::wants_surface`] apply.
pub is_layer_app: bool,
/// The app said [`Application::wants_surface`] = false and the layer
- /// surface and renderer are gone until it says true.
+ /// surface is gone until it says true (the renderer is kept, detached).
pub layer_hidden: bool,
pub surface: Option<wl_surface::WlSurface>,
@@ -766,10 +766,14 @@ impl<A: Application> EngineState<A> {
}
}
- /// Destroy the renderer (its swapchain first, as at session end), then
- /// the layer surface — SCTK destroys the role and then the `wl_surface`.
+ /// Let the renderer go of the surface (its swapchain and `VkSurfaceKHR`;
+ /// the device, pipelines, atlases and image table stay), then destroy the
+ /// layer surface — SCTK destroys the role and then the `wl_surface`, which
+ /// must not happen while a swapchain still presents to it.
fn hide_layer_surface(&mut self) {
- self.renderer = None;
+ if let Some(renderer) = self.renderer.as_mut() {
+ renderer.detach_surface();
+ }
self.layer_surface = None;
self.surface = None;
self.layer_hidden = true;
@@ -780,12 +784,15 @@ impl<A: Application> EngineState<A> {
self.entered_outputs.clear();
self.applied_input_regions = None;
log::info!("[window_runner] nothing to show; layer surface unmapped");
- self.inner.as_mut().unwrap().surface_hidden();
}
- /// A fresh `wl_surface` with the app's layer role and a renderer on it.
- /// The first configure then makes it presentable, exactly as at session
- /// start. A renderer that cannot be made leaves the surface hidden.
+ /// A fresh `wl_surface` with the app's layer role, and the renderer moved
+ /// onto it (`attach_surface`: one swapchain, where a new renderer costs a
+ /// device and every pipeline). The first configure then makes it
+ /// presentable, exactly as at session start. The renderer is the same one,
+ /// so its image ids are still good and `renderer_init` is not called; only
+ /// where there is none (an attach that failed before) is one made, and
+ /// that one is announced. A surface nothing can draw to stays hidden.
fn show_layer_surface(&mut self) {
let app = self.inner.as_ref().unwrap();
let settings = app.settings();
@@ -801,21 +808,37 @@ impl<A: Application> EngineState<A> {
let display_ptr = self.display_ptr as *mut std::ffi::c_void;
let surface_ptr = surface.id().as_ptr() as *mut std::ffi::c_void;
self.surface = Some(surface);
- match unsafe { VkRenderer::try_new(display_ptr, surface_ptr, pw, ph, 0.0) } {
- Ok(renderer) => self.renderer = Some(renderer),
+ let made = match self.renderer.as_mut() {
+ Some(renderer) => match unsafe { renderer.attach_surface(display_ptr, surface_ptr, pw, ph) } {
+ Ok(()) => Ok(false),
+ Err(lost) => Err(lost),
+ },
+ None => unsafe { VkRenderer::try_new(display_ptr, surface_ptr, pw, ph, 0.0) }.map(|renderer| {
+ self.renderer = Some(renderer);
+ true
+ }),
+ };
+ let made = match made {
+ Ok(made) => made,
Err(lost) => {
- log::error!("[window_runner] cannot rebuild the renderer, staying unmapped: {lost}");
+ log::error!("[window_runner] cannot draw to the new surface, staying unmapped: {lost}");
+ self.renderer = None;
self.layer_surface = None;
self.surface = None;
return;
}
- }
+ };
self.logical_width = settings.width as f32;
self.logical_height = settings.height as f32;
self.layer_hidden = false;
self.redraw = true;
- log::info!("[window_runner] layer surface mapped again");
- self.inner.as_mut().unwrap().renderer_init(self.renderer.as_mut().unwrap());
+ log::info!(
+ "[window_runner] layer surface mapped again ({})",
+ if made { "a new renderer" } else { "the renderer moved onto it" }
+ );
+ if made {
+ self.inner.as_mut().unwrap().renderer_init(self.renderer.as_mut().unwrap());
+ }
}
/// One warm-down step: a frame callback and a commit with no buffer, so
diff --git a/src/widget/core.rs b/src/widget/core.rs
index aee8db3..df70c8b 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -1929,6 +1929,11 @@ pub struct Widget {
pub w: f32,
pub h: f32,
pub label: Option<String>,
+ /// What a screen reader calls the widget when it draws no label of its own
+ /// (an `aria-label`): the accessibility tree prefers it over [`label`](Self::label),
+ /// and nothing draws it. For a control whose label stands beside it in the host's
+ /// own text — a value editor in a table row, an icon button.
+ pub accessible_name: Option<String>,
pub hovered: bool,
pub row_x: f32,
pub row_w: f32,
@@ -1990,6 +1995,7 @@ impl Widget {
w: 0.0,
h: 0.0,
label: None,
+ accessible_name: None,
hovered: false,
row_x: 0.0,
row_w: 0.0,
@@ -2009,6 +2015,7 @@ impl Widget {
w,
h,
label: None,
+ accessible_name: None,
hovered: false,
row_x: 0.0,
row_w: 0.0,
diff --git a/src/widget/display/mod.rs b/src/widget/display/mod.rs
index 908abfa..aa6d261 100644
--- a/src/widget/display/mod.rs
+++ b/src/widget/display/mod.rs
@@ -1,6 +1,4 @@
pub mod text_label;
-pub mod panel;
-pub mod node;
pub mod label;
pub mod float3;
pub mod progress_bar;
@@ -8,7 +6,6 @@ pub mod status_bar;
pub mod splitter;
pub mod list_item;
pub mod separator;
-pub mod serialize;
pub mod graph;
pub mod usage_bar;
pub mod info_box;
@@ -18,8 +15,6 @@ pub mod text_sizer;
pub use self::text_label::TextLabel;
pub(crate) use self::text_label::make_widget_text_buffer;
-pub use self::panel::Panel;
-pub use self::node::Node;
pub use self::label::{Label, StyledLabel, LabelPrim};
pub use self::float3::Float3;
pub use self::progress_bar::ProgressBar;
@@ -27,7 +22,6 @@ pub use self::status_bar::StatusBar;
pub use self::splitter::Splitter;
pub use self::list_item::{TextItem, InteractiveListItem};
pub use self::separator::Separator;
-pub use self::serialize::serialize_widgets;
pub use self::graph::{GraphNode, Graph, TaggedQuad, WireStyle, node_wires};
pub use self::usage_bar::UsageBar;
pub use self::info_box::InfoBox;
diff --git a/src/widget/display/node.rs b/src/widget/display/node.rs
deleted file mode 100644
index 9ea1d45..0000000
--- a/src/widget/display/node.rs
+++ /dev/null
@@ -1,292 +0,0 @@
-//! Narrow-trait `Node` (Phase 5k) — a network-editor node box: draggable with grid snap
-//! (self-moving, via [`Input::drag_reposition`]), a geometry-visibility toggle sub-zone, and
-//! two controller capabilities ([`ParamController`] + [`GeomController`]) re-exposed through
-//! the `Input` hooks for the legacy `WidgetHost::as_*_controller` downcasts.
-
-use crate::colors;
-use crate::scene::layout::Rect;
-use crate::scene::paint::PaintCtx;
-use crate::widget::{
- Adapted, ElementState, Event, EventCtx, GeomController, Input, Layout, MouseButton, Paint,
- ParamController,
-};
-
-#[derive(Debug, Clone)]
-pub struct Node {
- hovered: bool,
- selected: bool,
- dragging: bool,
- drag_ox: f32,
- drag_oy: f32,
- bounds: Option<(f32, f32, f32, f32)>,
- grid_snap_x: f32,
- grid_snap_y: f32,
- grid_origin_x: f32,
- grid_origin_y: f32,
- name: String,
- pub parameters: Vec<(String, String, String)>,
- geom_visible: bool,
- geom_toggled: bool,
- pub(crate) toggle_hovered: bool,
-}
-
-impl Node {
- pub fn new(x: f32, y: f32, w: f32, h: f32, name: &str) -> Adapted<Node> {
- let mut node = Adapted::new(Node {
- hovered: false,
- selected: false,
- dragging: false,
- drag_ox: 0.0,
- drag_oy: 0.0,
- bounds: None,
- grid_snap_x: 0.0,
- grid_snap_y: 0.0,
- grid_origin_x: 0.0,
- grid_origin_y: 0.0,
- name: name.to_string(),
- parameters: Vec::new(),
- geom_visible: true,
- geom_toggled: false,
- toggle_hovered: false,
- });
- crate::widget::WidgetHost::set_rect(&mut node, x, y, w, h);
- node
- }
-
- pub(crate) fn toggle_rect(rect: Rect) -> (f32, f32, f32, f32) {
- (rect.x + rect.width - 30.0, rect.y + (rect.height - 18.0) / 2.0, 18.0, 18.0)
- }
-
- fn in_toggle(rect: Rect, px: f32, py: f32) -> bool {
- let (tx, ty, tw, th) = Self::toggle_rect(rect);
- px >= tx && px < tx + tw && py >= ty && py < ty + th
- }
-
- pub fn set_grid_snap(&mut self, gx: f32, gy: f32) {
- self.grid_snap_x = gx;
- self.grid_snap_y = gy;
- }
- pub fn set_grid_origin(&mut self, ox: f32, oy: f32) {
- self.grid_origin_x = ox;
- self.grid_origin_y = oy;
- }
- pub fn set_node_name(&mut self, name: &str) {
- self.name = name.to_string();
- }
-}
-
-impl Adapted<Node> {
- pub fn with_params(mut self, params: &[(&str, &str)]) -> Self {
- self.parameters =
- params.iter().map(|(k, v)| (k.to_string(), v.to_string(), "string".to_string())).collect();
- self
- }
-
- pub fn with_grid_snap(mut self, gx: f32, gy: f32) -> Self {
- self.set_grid_snap(gx, gy);
- self
- }
-}
-
-impl Layout for Node {}
-
-impl Paint for Node {
- fn color(&self) -> [f32; 4] {
- if self.dragging {
- colors::node_drag_color()
- } else if self.selected {
- colors::node_selected_color()
- } else {
- colors::node_color()
- }
- }
-
- fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
- ctx.quad(rect, Paint::color(self));
-
- let (tx, ty, tw, th) = Self::toggle_rect(rect);
- let bg_color = if self.toggle_hovered { colors::TOGGLE_HOVER } else { colors::TOGGLE_OFF };
- ctx.quad(Rect { x: tx, y: ty, width: tw, height: th }, bg_color);
- if self.geom_visible {
- let inset = 3.0;
- ctx.quad(
- Rect { x: tx + inset, y: ty + inset, width: tw - inset * 2.0, height: th - inset * 2.0 },
- colors::TOGGLE_ON,
- );
- }
-
- // Deliberately UNBOUNDED, and the only text in the toolkit that is.
- // The name is drawn in the gutter to the right of the node box, so a
- // clip to `rect` would erase every node name on the canvas — and the
- // node has no idea how much gutter it has, because only the canvas
- // placing it knows where the next node or the viewport edge is. The
- // bound for this one belongs to the HOST: clip the node layer, not the
- // node. Do not fix this by clipping to rect.
- ctx.text(
- self.name.clone(),
- rect.x + rect.width + 8.0,
- crate::layout::align_text_y(rect.y, rect.height, 14.0, 0.0),
- 14.0,
- [0xcc, 0xcc, 0xd4],
- );
- }
-}
-
-impl Input for Node {
- fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
- match event {
- Event::PointerMove { x: px, y: py, .. } => {
- let r = ectx.rect;
- let was_hovered = self.hovered;
- self.hovered =
- *px >= r.x && *px <= r.x + r.width && *py >= r.y && *py <= r.y + r.height;
- let was_toggle = self.toggle_hovered;
- self.toggle_hovered = Self::in_toggle(r, *px, *py);
- was_hovered != self.hovered || was_toggle != self.toggle_hovered
- }
- Event::MouseButton {
- button: MouseButton::Left,
- state: ElementState::Pressed,
- x: px,
- y: py,
- ..
- } => {
- if Self::in_toggle(ectx.rect, *px, *py) {
- self.geom_visible = !self.geom_visible;
- self.geom_toggled = true;
- } else {
- self.drag_begin(*px, *py, ectx.rect);
- }
- true
- }
- Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, .. } => {
- if self.dragging {
- self.drag_end();
- true
- } else {
- false
- }
- }
- // Legacy `focus()`/`unfocus()` toggled selection; hosts reach them through the
- // adapter's focus forwards, which arrive here as focus events.
- Event::FocusIn => {
- self.selected = true;
- true
- }
- Event::FocusOut => {
- self.selected = false;
- true
- }
- _ => false,
- }
- }
-
- fn draggable(&self, _rect: Rect) -> bool {
- !self.toggle_hovered
- }
- fn is_dragging(&self) -> bool {
- self.dragging
- }
-
- fn drag_begin(&mut self, px: f32, py: f32, rect: Rect) {
- self.dragging = true;
- self.drag_ox = px - rect.x;
- self.drag_oy = py - rect.y;
- }
-
- fn drag_reposition(&mut self, px: f32, py: f32, rect: Rect) -> Option<(f32, f32)> {
- let nx = px - self.drag_ox;
- let ny = py - self.drag_oy;
- let (nx, ny) = if let Some((bx, by, bw, bh)) = self.bounds {
- (nx.clamp(bx, bx + bw - rect.width), ny.clamp(by, by + bh - rect.height))
- } else {
- (nx, ny)
- };
- let nx = if self.grid_snap_x > 0.0 {
- let relative = nx - self.grid_origin_x;
- (relative / self.grid_snap_x).round() * self.grid_snap_x + self.grid_origin_x
- } else {
- nx
- };
- let ny = if self.grid_snap_y > 0.0 {
- let relative = ny - self.grid_origin_y;
- (relative / self.grid_snap_y).round() * self.grid_snap_y + self.grid_origin_y
- } else {
- ny
- };
- if (nx - rect.x).abs() > 0.01 || (ny - rect.y).abs() > 0.01 {
- Some((nx, ny))
- } else {
- None
- }
- }
-
- fn drag_end(&mut self) {
- self.dragging = false;
- }
-
- fn set_drag_bounds(&mut self, bx: f32, by: f32, bw: f32, bh: f32) {
- self.bounds = Some((bx, by, bw, bh));
- }
-
-}
-
-impl ParamController for Node {
- fn node_params(&self) -> Vec<(String, String, String)> {
- self.parameters.clone()
- }
- fn set_display_params(&mut self, params: &[(String, String, String)]) {
- self.parameters = params.to_vec();
- }
-}
-
-impl GeomController for Node {
- fn set_geom_visible(&mut self, visible: bool) {
- self.geom_visible = visible;
- }
- fn geom_visible(&self) -> bool {
- self.geom_visible
- }
- fn take_geom_toggle(&mut self) -> bool {
- std::mem::take(&mut self.geom_toggled)
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::context::UiContext;
- use crate::widget::WidgetHost;
-
- #[test]
- fn toggle_click_flips_geom_and_press_starts_drag() {
- let mut ctx = UiContext::new();
- let mut node = Node::new(100.0, 100.0, 120.0, 40.0, "geo1");
- ctx.register_host(&mut node);
-
- // Toggle zone: (100+120-30, 100+11) => 18x18 at (190, 111).
- assert!(node.mouse_input(MouseButton::Left, ElementState::Pressed, 195.0, 115.0, &mut ctx));
- let geom: &mut dyn GeomController = &mut *node;
- assert!(!geom.geom_visible(), "toggle click hides geometry");
- assert!(geom.take_geom_toggle(), "toggle flag set once");
- assert!(!geom.take_geom_toggle(), "…and drained");
-
- // A press outside the toggle starts a drag; reposition snaps to the drag origin.
- assert!(node.mouse_input(MouseButton::Left, ElementState::Pressed, 110.0, 110.0, &mut ctx));
- assert!(node.is_dragging());
- assert!(node.drag_update(150.0, 130.0));
- assert_eq!(WidgetHost::rect(&node), (140.0, 120.0, 120.0, 40.0), "moved by the pointer delta");
- assert!(node.mouse_input(MouseButton::Left, ElementState::Released, 150.0, 130.0, &mut ctx));
- assert!(!node.is_dragging());
- }
-
- #[test]
- fn param_controller_roundtrips_through_element() {
- let mut node = Node::new(0.0, 0.0, 10.0, 10.0, "n").with_params(&[("k", "v")]);
- let params = ParamController::node_params(&*node);
- assert_eq!(params, vec![("k".to_string(), "v".to_string(), "string".to_string())]);
- ParamController::set_display_params(&mut *node, &[("a".to_string(), "b".to_string(), "int".to_string())]);
- assert_eq!(node.parameters.len(), 1);
- assert_eq!(node.parameters[0].2, "int");
- }
-}
diff --git a/src/widget/display/panel.rs b/src/widget/display/panel.rs
deleted file mode 100644
index 0ad1218..0000000
--- a/src/widget/display/panel.rs
+++ /dev/null
@@ -1,92 +0,0 @@
-//! Narrow-trait movable panel (Phase 5i leaf sweep). Self-moving via
-//! [`Input::drag_reposition`], with movement clamped to bounds pushed in through
-//! [`Input::set_drag_bounds`] (or the inherent `set_bounds`).
-
-use crate::colors;
-use crate::scene::layout::Rect;
-use crate::scene::paint::PaintCtx;
-use crate::widget::{Adapted, WidgetHost, ElementState, Event, EventCtx, Input, Layout, MouseButton, Paint};
-
-pub struct Panel {
- dragging: bool,
- drag_ox: f32,
- drag_oy: f32,
- bounds: Option<(f32, f32, f32, f32)>,
-}
-
-impl Panel {
- pub fn new(x: f32, y: f32, w: f32, h: f32) -> Adapted<Panel> {
- let mut p = Adapted::new(Panel { dragging: false, drag_ox: 0.0, drag_oy: 0.0, bounds: None });
- WidgetHost::set_rect(&mut p, x, y, w, h);
- p
- }
-
- pub fn set_bounds(&mut self, bx: f32, by: f32, bw: f32, bh: f32) {
- self.bounds = Some((bx, by, bw, bh));
- }
-}
-
-impl Layout for Panel {
- fn inline_label(&self) -> bool {
- true // legacy Panel never inflated for its label
- }
-}
-
-impl Paint for Panel {
- fn color(&self) -> [f32; 4] {
- if self.dragging { colors::PANEL_DRAG } else { colors::PANEL_IDLE }
- }
-
- fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
- ctx.quad(rect, self.color());
- }
-}
-
-impl Input for Panel {
- fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
- match event {
- Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x, y, .. } => {
- self.dragging = true;
- self.drag_ox = x - ectx.rect.x;
- self.drag_oy = y - ectx.rect.y;
- true
- }
- Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, .. } => {
- std::mem::take(&mut self.dragging)
- }
- _ => false,
- }
- }
-
- fn draggable(&self, _rect: Rect) -> bool {
- true
- }
- fn is_dragging(&self) -> bool {
- self.dragging
- }
- fn drag_begin(&mut self, px: f32, py: f32, rect: Rect) {
- self.dragging = true;
- self.drag_ox = px - rect.x;
- self.drag_oy = py - rect.y;
- }
- fn drag_reposition(&mut self, px: f32, py: f32, rect: Rect) -> Option<(f32, f32)> {
- let nx = px - self.drag_ox;
- let ny = py - self.drag_oy;
- let (nx, ny) = if let Some((bx, by, bw, bh)) = self.bounds {
- (nx.clamp(bx, bx + bw - rect.width), ny.clamp(by, by + bh - rect.height))
- } else {
- (nx, ny)
- };
- if (nx - rect.x).abs() > 0.01 || (ny - rect.y).abs() > 0.01 {
- Some((nx, ny))
- } else {
- None
- }
- }
- fn drag_end(&mut self) {
- self.dragging = false;
- }
- fn set_drag_bounds(&mut self, bx: f32, by: f32, bw: f32, bh: f32) {
- self.bounds = Some((bx, by, bw, bh));
- }
-}
diff --git a/src/widget/display/serialize.rs b/src/widget/display/serialize.rs
deleted file mode 100644
index aa4280a..0000000
--- a/src/widget/display/serialize.rs
+++ /dev/null
@@ -1,153 +0,0 @@
-use crate::widget::*;
-
-fn serialize_single_widget(w: &dyn WidgetHost, json: &mut String) {
- let (x, y, width, height) = w.rect();
- let label = w.label().or_else(|| w.base().label.clone()).unwrap_or_default();
- let focused = w.base().focused;
- let hovered = w.base().hovered;
- // Concrete value lookup (6bd value shrink — `value` left `WidgetHost`): the
- // `Input::value` implementors a serialized roster can hold are these five widgets;
- // everything else always reported the default 0.
- let a = w.as_any();
- let value = a
- .downcast_ref::<Checkbox>()
- .map(Input::value)
- .or_else(|| a.downcast_ref::<Dropdown>().map(Input::value))
- .or_else(|| a.downcast_ref::<Slider>().map(Input::value))
- .or_else(|| a.downcast_ref::<RangeSlider>().map(Input::value))
- .or_else(|| a.downcast_ref::<Spinbox>().map(Input::value))
- .unwrap_or(0);
- let type_name = w.type_name();
-
- // Escape JSON label
- let escaped_label = label.replace('\\', "\\\\").replace('"', "\\\"");
-
- json.push_str(&format!(
- "{{\"type\":\"{}\",\"label\":\"{}\",\"rect\":[{},{},{},{}],\"focused\":{},\"hovered\":{},\"value\":{}",
- type_name, escaped_label, x, y, width, height, focused, hovered, value
- ));
-
- // Child handling: there is no ctx here, so tree children were never reachable
- // (the old lookup ran against a fresh empty UiContext). The one child this path
- // could ever surface is the field-derived one — Paginator's strip
- // (`Layout::container_children`) — kept via the 6aw concrete downcast.
- let children: Vec<&(dyn WidgetHost + 'static)> = w
- .as_any()
- .downcast_ref::<Paginator>()
- .map(|p| vec![&p.sidebar_menu as &(dyn WidgetHost + 'static)])
- .unwrap_or_default();
- let mut menu_items = Vec::new();
- let mut is_menu_open = false;
- let mut is_vertical = false;
- let mut checked_states = Vec::new();
- // Concrete capability lookup (Phase 6aw): the MenuController implementors a serialized
- // roster can hold are Adapted<MenuBar> and Adapted<Paginator> — WidgetHost's discovery
- // hooks are gone.
- let mc: Option<&dyn MenuController> = w
- .as_any()
- .downcast_ref::<MenuBar>()
- .map(|m| m as &dyn MenuController)
- .or_else(|| w.as_any().downcast_ref::<Paginator>().map(|p| p as &dyn MenuController));
- if let Some(mc) = mc {
- menu_items = mc.menu_items();
- is_menu_open = mc.is_menu_open();
- is_vertical = mc.is_vertical();
- checked_states = mc.menu_item_checked();
- }
-
- if type_name == "Menu" && is_menu_open && !menu_items.is_empty() {
- json.push_str(",\"children\":[");
- let mut max_len = 0;
- for item in &menu_items {
- max_len = max_len.max(item.len());
- }
- let dw = (max_len as f32 * 7.5 + 40.0).max(120.0);
- let dx = if is_vertical { x + width } else { x };
- let dy = if is_vertical { y } else { y + height };
-
- for (i, item) in menu_items.iter().enumerate() {
- if i > 0 {
- json.push(',');
- }
- let item_y = dy + i as f32 * DROPDOWN_ITEM_H;
- let checked = checked_states.get(i).copied().flatten().unwrap_or(false);
- let item_escaped = item.replace('\\', "\\\\").replace('"', "\\\"");
- json.push_str(&format!(
- "{{\"type\":\"MenuItem\",\"label\":\"{}\",\"rect\":[{},{},{},{}],\"focused\":false,\"hovered\":false,\"value\":{}}}",
- item_escaped, dx, item_y, dw, DROPDOWN_ITEM_H, if checked { 1 } else { 0 }
- ));
- }
- json.push_str("]}");
- } else if !children.is_empty() {
- json.push_str(",\"children\":[");
- let mut first = true;
- for child in &children {
- if !child.visible() {
- continue;
- }
- if !first {
- json.push(',');
- }
- first = false;
- serialize_single_widget(*child, json);
- }
- json.push_str("]}");
- } else {
- json.push('}');
- }
-}
-
-/// Serialize the visible widgets' menu state. Takes dyn refs (not boxes): the designer's
-/// roster is concretely typed since the Phase 6bb retype and lends a per-slot dyn view.
-pub fn serialize_widgets(widgets: &[&dyn WidgetHost]) -> String {
- let mut json = String::new();
- json.push('[');
- let mut first = true;
- for w in widgets {
- if !w.visible() {
- continue;
- }
- if !first {
- json.push(',');
- }
- first = false;
- serialize_single_widget(&**w, &mut json);
- }
- json.push(']');
- json
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- /// The serialized `value` field must keep matching `Input::value` for every
- /// value-bearing widget (the concrete lookup replaced the deleted
- /// `WidgetHost::value` — a new `Input::value` implementor must be added to the
- /// downcast chain in `serialize_single_widget`).
- #[test]
- fn serialized_value_matches_input_value() {
- let mut cb = Checkbox::new();
- assert!(cb.set_value_string("true"));
- let dd = Dropdown::new(vec!["a".into(), "b".into(), "c".into()], 2);
- let mut sl = Slider::new();
- assert!(sl.set_value_string("0.7"));
- let sb = Spinbox::new(7, 0, 10, 1);
- let btn = Button::new(0.0, 0.0, 10.0, 10.0); // no Input::value — always 0
-
- for (w, expect) in [
- (&cb as &dyn WidgetHost, 1),
- (&dd as &dyn WidgetHost, 2),
- (&sl as &dyn WidgetHost, 70),
- (&sb as &dyn WidgetHost, 7),
- (&btn as &dyn WidgetHost, 0),
- ] {
- let json = serialize_widgets(&[w]);
- assert!(
- json.contains(&format!("\"value\":{expect}")),
- "{} serialized without value {expect}: {json}",
- w.type_name()
- );
- }
- }
-}
diff --git a/src/widget/layout_helper.rs b/src/widget/layout_helper.rs
deleted file mode 100644
index 58c5289..0000000
--- a/src/widget/layout_helper.rs
+++ /dev/null
@@ -1,106 +0,0 @@
-use crate::widget::WidgetHost;
-
-pub struct ColumnLayout {
- pub x: f32,
- pub y: f32,
- pub width: f32,
- pub gap: f32,
- pub margin: f32,
- current_y: f32,
-}
-
-impl ColumnLayout {
- pub fn new(x: f32, y: f32, width: f32, gap: f32, margin: f32) -> Self {
- Self {
- x,
- y,
- width,
- gap,
- margin,
- current_y: y + margin,
- }
- }
-
- /// A column inside a pane plate: the pane rung's padding as the margin
- /// and its gap between widgets (`plate_padding` / `plate_gap`).
- pub fn pane(x: f32, y: f32, width: f32) -> Self {
- Self::new(x, y, width, crate::layout::plate_gap(), crate::layout::plate_padding())
- }
-
- /// A column of controls with the control gap between them and no
- /// margin of its own (`control_gap`).
- pub fn controls(x: f32, y: f32, width: f32) -> Self {
- Self::new(x, y, width, crate::layout::control_gap(), 0.0)
- }
-
- /// `height` is the CONTENT height; the widget's block adds its label strip.
- pub fn add_widget(&mut self, widget: &mut dyn WidgetHost, height: f32) {
- let total_h = height + widget.label_strip();
- widget.set_rect(self.x + self.margin, self.current_y, self.width - 2.0 * self.margin, total_h);
- self.current_y += total_h + self.gap;
- }
-
- pub fn add_row(&mut self, widgets: &[*mut dyn WidgetHost], height: f32, gap: f32) {
- let count = widgets.len();
- if count == 0 {
- return;
- }
- let mut max_label_off = 0.0;
- for &widget_ptr in widgets {
- unsafe {
- let off = (*widget_ptr).label_strip();
- if off > max_label_off {
- max_label_off = off;
- }
- }
- }
- let total_h = height + max_label_off;
- let total_width = self.width - 2.0 * self.margin;
- let widget_w = (total_width - (count as f32 - 1.0) * gap) / count as f32;
- let mut curr_x = self.x + self.margin;
- for &widget_ptr in widgets {
- unsafe {
- // Content lines up on one row; a shorter label strip starts lower.
- let off = (*widget_ptr).label_strip();
- (*widget_ptr).set_rect(curr_x, self.current_y + max_label_off - off, widget_w, height + off);
- }
- curr_x += widget_w + gap;
- }
- self.current_y += total_h + self.gap;
- }
-
- pub fn current_y(&self) -> f32 {
- self.current_y - self.gap + self.margin
- }
-}
-
-pub struct RowLayout {
- pub x: f32,
- pub y: f32,
- pub height: f32,
- pub gap: f32,
- pub margin: f32,
- current_x: f32,
-}
-
-impl RowLayout {
- pub fn new(x: f32, y: f32, height: f32, gap: f32, margin: f32) -> Self {
- Self {
- x,
- y,
- height,
- gap,
- margin,
- current_x: x + margin,
- }
- }
-
- pub fn add_widget(&mut self, widget: &mut dyn WidgetHost, width: f32) {
- widget.set_rect(self.current_x, self.y + self.margin, width, self.height - 2.0 * self.margin);
- self.current_x += width + self.gap;
- }
-
- pub fn current_x(&self) -> f32 {
- self.current_x - self.gap + self.margin
- }
-}
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 48004f0..7878b6a 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -634,7 +634,6 @@ pub mod plate_dock;
pub mod container;
pub mod display;
pub mod editor;
-pub mod layout_helper;
pub mod shaping;
#[cfg(feature = "markdown")]
pub mod markdown;
@@ -678,7 +677,6 @@ pub mod side_swipe;
// Re-exports
pub use self::editor::TextEditorState;
pub use self::line_edit::{EditOutcome, LineEdit};
-pub use self::layout_helper::{ColumnLayout, RowLayout};
pub use self::scroll_region::{ScrollRegion, ScrollbarActivity};
pub use self::side_swipe::{SideSwipe, SwipeDir};
pub use self::scroll_motion::{Bounds, ScrollAxis, ScrollMotion, ScrollPhase, ScrollSettings, LINE_PX};
@@ -704,8 +702,8 @@ pub use self::container::{
pub use self::display::{
TextLabel, Label, StyledLabel, LabelPrim, TextItem, UsageBar,
InfoBox, StatusDot, InteractiveListItem,
- GraphNode, Graph, TaggedQuad, node_wires, Float3, ProgressBar, StatusBar, Splitter, Node, Separator,
- DotStatus, Panel, ImageView, serialize_widgets,
+ GraphNode, Graph, TaggedQuad, node_wires, Float3, ProgressBar, StatusBar, Splitter, Separator,
+ DotStatus, ImageView,
truncate_head, truncate_tail,
};
diff --git a/src/widget/model.rs b/src/widget/model.rs
index bd1f03f..f29b09a 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -758,6 +758,12 @@ impl<W: Layout + Paint + Input + 'static> Adapted<W> {
self.inner.sync_label("");
}
+ /// Name the widget to a screen reader without drawing a label
+ /// ([`Widget::accessible_name`]); `None` falls back to the label.
+ pub fn set_accessible_name(&mut self, name: Option<&str>) {
+ self.base.accessible_name = name.map(str::to_string);
+ }
+
// --- The value/polling drains (off `WidgetHost` in the 6bd value shrink): apps read
// widget state through these concrete methods; each forwards to the narrow `Input`
// hook. The last dyn readers went concrete-slot instead (TI roster, cloud JsonControl,