git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit8806c768493409fb4621adec8c6db85b1888ccf9
parentac65b91938
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 12:24
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.