GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
refactor(focus): one store for keyboard focus; dead UiContext twins removed (global-state RFC phase 1)
docs/rfc-global-state.md: the inventory (~300 statics, 18 thread-locals:
style, interaction state, per-window properties, caches) and the phases.
Phase 1: keyboard focus was stored twice, in the widget::focus thread-local
and in UiContext::focused_widget, kept in step by widgets claiming the
thread-local in FocusIn. The thread-local and its free functions are gone
(widget::focus keeps link_parent_child); EventCtx::is_focused reads the
context, request_focus / release_focus record on it. UiContext's hover_state
and its five methods, and its context_menu field, were written or read by
nobody and are deleted: hover_animation and context_menu are each one store.
Checked in a shadow: the demo's Tab-focused dropdown opens on Enter; the
settings app's Browser page commits a typed field on Enter without opening a
dropdown.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 10 ++++
docs/rfc-global-state.md | 118 +++++++++++++++++++++++++++++++++++++
src/context.rs | 41 +------------
src/widget/container/scroll_box.rs | 2 +-
src/widget/core.rs | 75 +++--------------------
src/widget/input/dropdown.rs | 10 ++--
src/widget/model.rs | 31 +++++++---
7 files changed, 169 insertions(+), 118 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 9e0bf9f..5adf91a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1652,6 +1652,16 @@ are GONE from the trait — events route through `handle_event`, and apps drain
through the concrete inherent `Adapted<W>` methods. See the RFC's blueprint notes before
adding anything to this trait.
+### Global state has a plan (`docs/rfc-global-state.md`)
+
+About 300 statics and 18 thread-locals: style (≈200 `RwLock`s beside the style registry),
+interaction state (context menu, hover highlight, composition — per thread), properties of
+"the" window (scale, metric, scroll phase), and caches (fine). The RFC sorts them and
+phases the moves. Phase 1 is done: keyboard focus has ONE store, `UiContext::focused_widget`
+(the `widget::focus` thread-local is gone; a widget asks `EventCtx::is_focused`, claims with
+`request_focus`), and the context's dead hover and context-menu twins are deleted. Do not
+add a static for state that belongs to a window.
+
### The registry holds pointers, and knows when they die
`UiContext`'s tree (`scene::tree::WidgetTree`) does not own its widgets: the app does, and
diff --git a/docs/rfc-global-state.md b/docs/rfc-global-state.md
new file mode 100644
index 0000000..31fd416
--- /dev/null
+++ b/docs/rfc-global-state.md
@@ -0,0 +1,118 @@
+# RFC: Global state — what lives in statics, and where it should
+
+**Status:** Draft / roadmap (2026-10-08)
+**Scope:** cce-ui's process-wide and per-thread state: what there is, which of it is a cache
+(fine) and which is a window's or an app's state hiding in a static (not), and the order to
+move it.
+**Reads before this:** `CLAUDE.md` § "The registry holds pointers, and knows when they die",
+§ "The context menu draws in its own popup surface"; `docs/rfc-core-rebuild.md`.
+
+---
+
+## 1. Why
+
+The toolkit audit of 2026-10-07 listed global state as the largest remaining structural
+debt. Its costs are concrete:
+
+- **Tests pin settings per thread** (`force_natural_scroll`, `force_scroll_settings`,
+ `motion::force_for_test`, the style registry's per-thread overlay), because what they read
+ is process state another test can change. Every new global grows that list.
+- **One window per thread.** The context menu, the hover highlight, input-method state and
+ the focus thread-local are one per thread: a process with two windows on one loop would
+ share a menu, a highlight and a composition.
+- **Two sources of truth.** Several pieces of interaction state are stored twice, once in a
+ static and once in `UiContext`, kept in step by convention (§ 2.2).
+
+## 2. Inventory (measured 2026-10-08)
+
+About 300 `static` items and 18 thread-locals in `src/`.
+
+### 2.1 Style: about 200 `RwLock`s
+
+`color/mod.rs` and `layout/mod.rs` hold one `RwLock` per configured colour, radius, font and
+size (`BUTTON_CORNER_RADIUS`, `TREE_LEAF_TEXT_COLOR`, …), filled by `reload_colors` /
+`reload_config`, beside the style registry (`layout/registry.rs`: `FLOATS`, `LENS`, the
+flattened config) that holds much of the same configuration again. A read takes one lock; a
+reload writes two hundred, so a frame drawn during a reload can see half of each.
+
+**Verdict:** style IS process-wide (one DE config), so global is defensible — but as ONE
+snapshot (an `Arc<Style>` swapped whole on reload, read without locks), with one per-thread
+override for tests, not two hundred locks and a second registry.
+
+### 2.2 Interaction state: per thread, and partly twice
+
+| State | Where | Twin |
+|---|---|---|
+| keyboard focus | `widget::focus` thread-local `FOCUSED_WIDGET` | `UiContext::focused_widget` — the Tab walk, the accessibility tree and `set_focused_id` use this one; widgets claim the thread-local in `FocusIn` |
+| hover highlight | `widget::hover_animation` thread-locals `HOVER_STATE`, `CURSOR_POS` | `UiContext::hover_state` and five methods — written by nobody, read by nobody |
+| context menu | `context_menu::CONTEXT_MENU` thread-local | `UiContext::context_menu` — never read |
+| a menu's page turn | `PAGE` thread-local | — |
+| side swipe | `side_swipe::SHARED` | — |
+| input-method composition | `ime::STATE` | — |
+| wake hook | `backend::app::WAKE` | — |
+
+Beside these, apps keep a third notion of focus: 139 direct `WidgetHost::focus()` /
+`unfocus()` calls that toggle a widget's own `focused` flag without telling any store.
+
+**Verdict:** a window's interaction state belongs to its `UiContext`. The twins go first:
+they can disagree today.
+
+### 2.3 Properties of "the" window: per process
+
+`scale::scale_factor`, `units::metric`, the scroll phase the runner publishes before a
+dispatch, vertical text (`backend::text::set_vertical_text`). Each is a property of the
+window being drawn, published process-wide.
+
+**Verdict:** per window, handed to what needs it (a `Frame`/`PaintCtx` field, the
+`EventCtx`) — after § 2.2, since the same plumbing carries both.
+
+### 2.4 Caches and switches: fine
+
+Shaped-text buffers, family and face caches, icon rasters, the image-id queue
+(`draw::images`), `OnceLock<bool>` debug switches read once from the environment, the l10n
+catalogue. Process-wide by nature; nothing to do beyond keeping them caches (no state a
+caller depends on between calls).
+
+## 3. Plan
+
+### Phase 1 — one store per piece of interaction state (the twins) — DONE 2026-10-08
+
+Done as written below. `widget::focus` keeps only `link_parent_child`; `EventCtx` gained
+`is_focused`, and `request_focus` records on the context (telling the previous holder, as
+before) where it set the thread-local. cce-designer's dialog and cce-system-interface's
+section navigation call `UiContext::set_focused_id` / `clear_focus` / `has_focus` /
+`is_focused_id`, which deliver FocusIn / FocusOut as the Tab walk does — so their manual
+`w.focus()` after a set went. Checked in a shadow: the demo's dropdown, reached by Tab,
+opens on Enter (its gate reads the context now); the settings app's Browser page takes
+typing in a clicked field and Enter commits it without opening a dropdown (the bug that
+gate exists for). The whole workspace builds.
+
+- **Focus:** `UiContext::focused_widget` is the store. The `widget::focus` thread-local and
+ its free functions go; `EventCtx::request_focus` / `release_focus` act on the context;
+ the dropdown's "am I focused" reads it. Apps that used the free functions
+ (cce-designer, cce-system-interface) call `UiContext`'s.
+- **Hover highlight and the context menu:** the dead `UiContext` twins are deleted, so the
+ thread-locals are the one store each until phase 2 moves them.
+
+### Phase 2 — interaction state into `UiContext`
+
+The context menu, its page turn, the hover highlight, the side swipe and the composition
+move into the context (or a per-window struct beside it), reached through `EventCtx` by
+widgets and through `UiContext` by apps; the free functions stay as deprecated forwarders
+for one release while the apps move. The 139 direct `focus()` / `unfocus()` calls move to
+the context's focus at the same time.
+
+### Phase 3 — style as one snapshot
+
+`Style` (every colour, radius, font and size, and the registry's flattened config) built
+whole by a reload and published as an `Arc`; getters read the current snapshot; tests
+install their own per thread. The two hundred `RwLock`s and the second registry go.
+
+### Phase 4 — per-window properties
+
+Scale, metric, scroll phase and vertical text travel with the frame and the event context
+instead of process statics.
+
+Each phase leaves every app building and drawing as before; the measured check for phases
+2–4 is a shadow session of the apps that use the moved state (cce-files,
+cce-system-interface, cce-designer, cce-gallery, cce-data-editor).
diff --git a/src/context.rs b/src/context.rs
index 335567a..8990d9f 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -1,7 +1,5 @@
use std::collections::HashMap;
use crate::widget::{WidgetHost, WidgetId, Key, NamedKey, MouseButton, ElementState, Event};
-use crate::widget::core::hover_animation::HoverState;
-use crate::widget::core::context_menu::ContextMenuState;
pub struct SpatialGrid {
pub cell_size: f32,
@@ -97,9 +95,7 @@ pub struct UiContext {
/// `RefCell` because `hit_test` receives `&UiContext` — the memo is an
/// implementation detail of a read-only query, not shared state.
covered_cache: std::cell::RefCell<(Option<(f32, f32)>, Vec<WidgetId>)>,
- pub hover_state: HoverState,
pub cursor_pos: (f32, f32),
- pub context_menu: ContextMenuState,
pub active_grab: Option<WidgetId>,
pub drag_start_pos: Option<(f32, f32)>,
pub drag_target: Option<WidgetId>,
@@ -129,9 +125,7 @@ impl UiContext {
active_popovers: Vec::new(),
modals: Vec::new(),
covered_cache: std::cell::RefCell::new((None, Vec::new())),
- hover_state: HoverState::new(),
cursor_pos: (0.0, 0.0),
- context_menu: ContextMenuState::new(),
active_grab: None,
drag_start_pos: None,
drag_target: None,
@@ -1074,42 +1068,13 @@ impl UiContext {
cache.1.clear();
}
- // --- Hover State ---
+ // The hover highlight is `widget::hover_animation`'s (one store; the context's
+ // write-only copy went with the global-state RFC's phase 1). The cursor is the
+ // context's: the scroll box reads it.
pub fn set_cursor_pos(&mut self, x: f32, y: f32) {
self.cursor_pos = (x, y);
}
- pub fn reset_frame_registration(&mut self) {
- self.hover_state.registered_this_frame = false;
- }
-
- pub fn set_scroll_offset(&mut self, offset: f32) {
- self.hover_state.scroll_offset = offset;
- }
-
- pub fn get_scroll_offset(&self) -> f32 {
- self.hover_state.scroll_offset
- }
-
- pub fn register_hovered(&mut self, x: f32, y: f32, w: f32, h: f32, color: [f32; 4]) {
- self.hover_state.target_x = Some(x);
- self.hover_state.target_y = Some(y);
- self.hover_state.target_w = Some(w);
- self.hover_state.target_h = Some(h);
- self.hover_state.target_alpha = color[3];
- self.hover_state.registered_this_frame = true;
- }
-
- pub fn post_render_check(&mut self) {
- if !self.hover_state.registered_this_frame {
- self.hover_state.target_alpha = 0.0;
- let (cx, cy) = self.cursor_pos;
- self.hover_state.target_x = Some(cx);
- self.hover_state.target_y = Some(cy + self.hover_state.scroll_offset);
- self.hover_state.target_w = Some(0.0);
- self.hover_state.target_h = Some(0.0);
- }
- }
// NOTE: the animated hover-highlight for this context previously lived here as
// `tick_hover` / `get_hover_quad`, duplicating the live thread-local implementation in
diff --git a/src/widget/container/scroll_box.rs b/src/widget/container/scroll_box.rs
index 0504ae2..2de0e79 100644
--- a/src/widget/container/scroll_box.rs
+++ b/src/widget/container/scroll_box.rs
@@ -258,7 +258,7 @@ impl ScrollBox {
/// box through the thread-local, and its own `unfocus` was a no-op) — so just release
/// the current holder instead of storing a pointer to a non-WidgetHost.
fn claim_focus(&self, ctx: &mut UiContext) {
- focus::clear_focus(Some(ctx));
+ ctx.clear_focus();
}
pub fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
diff --git a/src/widget/core.rs b/src/widget/core.rs
index 537bf25..e42a03e 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -1,73 +1,13 @@
use crate::widget::WidgetHost;
+/// Keyboard focus lives in the window's [`crate::context::UiContext`]
+/// (`focused_widget`, `set_focused_id`, `clear_focus`, `is_focused_id`, `has_focus`): ONE
+/// store, which the Tab walk, the accessibility tree and every widget read. Until
+/// 2026-10-08 this module kept a second, per-thread, that widgets claimed in `FocusIn`
+/// and apps set directly — kept in step by convention, and apt to disagree with the
+/// context on any path that skipped the event (`docs/rfc-global-state.md`, phase 1).
pub mod focus {
use super::WidgetHost;
- use crate::widget::WidgetId;
- use std::cell::Cell;
-
- // Phase 6bc: the thread-local focus store keys by id, not pointer. Dispatching to the
- // previous holder (`unfocus`) resolves through the caller's generational tree, so a
- // stale id is skipped instead of dereferencing freed memory (the 6w settings UAF class).
- thread_local! {
- static FOCUSED_WIDGET: Cell<Option<WidgetId>> = const { Cell::new(None) };
- }
-
- /// Resolve `id` in `ctx`'s tree (when a ctx is in reach) and call `unfocus()` on it.
- fn unfocus_via(ctx: Option<&mut crate::context::UiContext>, id: WidgetId) {
- if let Some(ctx) = ctx {
- if let Some(ptr) = ctx.tree.get_ptr(id) {
- unsafe {
- (*ptr).unfocus();
- }
- }
- }
- }
-
- pub fn set_focused(w: &mut dyn WidgetHost, ctx: Option<&mut crate::context::UiContext>) {
- set_focused_id(w.base().id(), ctx);
- }
-
- pub fn set_focused_id(id: WidgetId, ctx: Option<&mut crate::context::UiContext>) {
- let old = FOCUSED_WIDGET.with(|cell| cell.get());
- if let Some(old_id) = old {
- if old_id != id {
- unfocus_via(ctx, old_id);
- FOCUSED_WIDGET.with(|cell| cell.set(Some(id)));
- }
- } else {
- FOCUSED_WIDGET.with(|cell| cell.set(Some(id)));
- }
- }
-
- pub fn is_focused(w: &dyn WidgetHost) -> bool {
- is_focused_id(w.base().id())
- }
-
- pub fn is_focused_id(id: WidgetId) -> bool {
- FOCUSED_WIDGET.with(|cell| cell.get() == Some(id))
- }
-
- pub fn clear_focus(ctx: Option<&mut crate::context::UiContext>) {
- if let Some(id) = FOCUSED_WIDGET.with(|cell| cell.take()) {
- unfocus_via(ctx, id);
- }
- }
-
- pub fn clear_if_matches(w: &dyn WidgetHost) {
- clear_if_matches_id(w.base().id());
- }
-
- pub fn clear_if_matches_id(id: WidgetId) {
- FOCUSED_WIDGET.with(|cell| {
- if cell.get() == Some(id) {
- cell.set(None);
- }
- });
- }
-
- pub fn has_focus() -> bool {
- FOCUSED_WIDGET.with(|cell| cell.get().is_some())
- }
pub fn link_parent_child(parent: &mut dyn WidgetHost, child: &mut dyn WidgetHost, ctx: &mut crate::context::UiContext) {
let parent_ptr = unsafe {
@@ -2096,7 +2036,8 @@ impl Widget {
pub fn clear_widget_references(w: &dyn WidgetHost) {
- focus::clear_if_matches(w);
+ // Focus needs no clearing: the context keeps an id, which a dropped widget's
+ // generation no longer resolves.
context_menu::clear_if_matches(w);
}
diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index 260508e..3b4b883 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -1389,7 +1389,7 @@ impl Input for Dropdown {
// An open dropdown always holds focus — the trigger press and `FocusIn`
// both claim it, and `FocusOut` closes it — so the `open` arm is reachable
// either way; it is spelled out so arrows and Escape stay live regardless.
- if !self.open && !crate::widget::focus::is_focused_id(ectx.id) {
+ if !self.open && !ectx.is_focused() {
return false;
}
let handled = self.handle_key(key_event);
@@ -1580,19 +1580,19 @@ mod tests {
// Nothing focused: the Return belongs to someone else, so it is declined and
// the menu stays shut.
- crate::widget::focus::clear_focus(None);
+ dummy.focused_widget = None;
assert!(!dd.keyboard_input(&enter, &mut dummy));
assert!(!dd.open);
// Another widget focused: same — this is the settings-page case, where the
// focused TextBox sits later in the reversed dispatch order.
let other = Dropdown::new(opts, 0);
- crate::widget::focus::set_focused_id(other.id(), None);
+ dummy.focused_widget = Some(other.id());
assert!(!dd.keyboard_input(&enter, &mut dummy));
assert!(!dd.open);
// Focused: Enter opens it, and arms the hover on the selection as before.
- crate::widget::focus::set_focused_id(dd.id(), None);
+ dummy.focused_widget = Some(dd.id());
assert!(dd.keyboard_input(&enter, &mut dummy));
assert!(dd.open);
assert_eq!(dd.hovered_item, Some(0));
@@ -1600,7 +1600,7 @@ mod tests {
// Once open the gate is out of the way, so Escape still closes it even if the
// focus moved on (`FocusOut` closes it too, but the key path must not depend
// on that having run).
- crate::widget::focus::clear_focus(None);
+ dummy.focused_widget = None;
let escape = crate::widget::KeyEvent {
logical_key: Key::Named(NamedKey::Escape),
..enter.clone()
diff --git a/src/widget/model.rs b/src/widget/model.rs
index 0a1d361..eb30922 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -348,21 +348,38 @@ pub struct EventCtx<'a> {
}
impl EventCtx<'_> {
- /// Make this widget the global focus target (legacy `focus::set_focused(self)`).
+ /// Make this widget the window's focus (`UiContext::focused_widget`). Recorded, not
+ /// dispatched: the widget is handling an event now and has its focus already; the
+ /// widget that held focus before is told (`unfocus`). Without a context (inside
+ /// `focus()` / `unfocus()`, whose caller is the context) there is nothing to record.
pub fn request_focus(&mut self) {
- if let Some(ptr) = self.self_ptr {
- unsafe { crate::widget::focus::set_focused(&mut *ptr, self.ui.as_deref_mut()) };
+ let id = self.id;
+ let Some(ui) = self.ui.as_deref_mut() else { return };
+ if let Some(old) = ui.focused_widget.filter(|old| *old != id) {
+ if let Some(ptr) = ui.tree.get_ptr(old) {
+ // SAFETY: a registry-resolved live widget other than this one.
+ unsafe { (*ptr).unfocus() };
+ }
}
+ ui.focused_widget = Some(id);
}
- /// Drop this widget's claim on the global focus if it holds it (legacy
- /// `focus::clear_if_matches(self)` — MenuBar releases focus when its dropdowns close).
+ /// Drop this widget's hold on the window's focus, if it has it (MenuBar releases focus
+ /// when its dropdowns close).
pub fn release_focus(&mut self) {
- if let Some(ptr) = self.self_ptr {
- unsafe { crate::widget::focus::clear_if_matches(&*ptr) };
+ let id = self.id;
+ if let Some(ui) = self.ui.as_deref_mut() {
+ if ui.focused_widget == Some(id) {
+ ui.focused_widget = None;
+ }
}
}
+ /// Whether this widget holds the window's focus.
+ pub fn is_focused(&self) -> bool {
+ self.ui.as_deref().is_some_and(|ui| ui.focused_widget == Some(self.id))
+ }
+
/// Open the shared context menu on this widget (legacy `ctx.handle_right_click(self, …)`),
/// for widgets that must do work *before* the menu opens — Breadcrumb records which segment
/// was right-clicked first, so the menu header can show that segment's path.