GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
refactor(state): a window owns its interaction state (global-state RFC phase 2)
window_state::WindowState holds the context menu, the hover highlight and its
cursor, the side-swipe recognizer and the input-method composition -- each a
thread-local of its own until now, so every window on a thread shared them. A
window owns one; its shell makes it current (window_state::enter) while it
runs that window's code (the Wayland shell for the whole run, across
reconnects; the AppKit shell around its run loop; the browser shell for the
page's life). The modules' free functions act on the current one, so none of
their callers changed; with none entered each thread has a default, which is
what the thread-locals were. each_window_has_its_own_menu.
context_menu::with_state replaces reaching for CONTEXT_MENU.
UiContext::focus_widget / unfocus_widget do what a direct w.focus() /
w.unfocus() did and keep the window's record of focus in step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 14 ++++-
docs/rfc-global-state.md | 30 +++++++++-
src/backend/window_runner.rs | 5 ++
src/context.rs | 28 +++++++++
src/ime.rs | 31 +++++-----
src/lib.rs | 1 +
src/mac/mod.rs | 4 ++
src/web/shell.rs | 3 +
src/widget/core.rs | 133 +++++++++++++++++++++++--------------------
src/widget/side_swipe.rs | 18 +++---
src/window_state.rs | 102 +++++++++++++++++++++++++++++++++
11 files changed, 279 insertions(+), 90 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 5adf91a..0129c79 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1094,7 +1094,7 @@ there:
- **`refill(options, sliders)`** changes the shown rows' labels and slider values in
place (hover, scroll, band, page rows and a held slider kept) — how a host re-marks a
switch on a page that stays up after it ran. A different row count is refused.
-- **The swipe is `widget::side_swipe`**, one recognizer per thread (`side_swipe::feed`)
+- **The swipe is `widget::side_swipe`**, one recognizer per window (`side_swipe::feed`)
shared by the menu and any plate a host turns into, so one gesture turns one page
however many plates pass under the fingers; the lift (`ScrollPhase::FingerEnd`) or a
250 ms pause readies the next. It fires once the fingers have gone `SWIPE_PX` (40) to
@@ -1659,8 +1659,16 @@ interaction state (context menu, hover highlight, composition — per thread), p
"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.
+`request_focus`), and the context's dead hover and context-menu twins are deleted. Phase 2
+is done: a window's interaction state — the context menu, the hover highlight and its
+cursor, the side swipe, the input-method composition — is a `window_state::WindowState` the
+window OWNS, which its shell makes current (`window_state::enter`) while it runs that
+window's code; the modules' free functions (`context_menu::show`, `ime::caret`, …) act on
+the current one, so no caller changed, and with none entered (a test) each thread has a
+default. `context_menu::with_state` replaces reaching for the old `CONTEXT_MENU`. An app
+that drives a widget's focus itself calls `UiContext::focus_widget` / `unfocus_widget`
+rather than `w.focus()` / `w.unfocus()`, so the window's record of focus follows. Do not
+add a static for state that belongs to a window: give it a field in `WindowState`.
### The registry holds pointers, and knows when they die
diff --git a/docs/rfc-global-state.md b/docs/rfc-global-state.md
index 31fd416..be23bfb 100644
--- a/docs/rfc-global-state.md
+++ b/docs/rfc-global-state.md
@@ -94,7 +94,35 @@ gate exists for). The whole workspace builds.
- **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`
+### Phase 2 — interaction state into a window's own state — DONE 2026-10-08
+
+Done, by a different route than first written below, for a reason found in the counting:
+the context menu alone is reached 266 times from 18 apps, most of them through free
+functions with no `UiContext` in hand, and some apps that show a menu have no context at
+all (the terminal). So the state did not move INTO the context but beside it:
+`window_state::WindowState` (the context menu, the hover highlight and its cursor, the side
+swipe recognizer, the input-method composition) is OWNED by a window — the Wayland shell
+makes one in `run` and keeps it across reconnects; the AppKit and browser shells make one
+too — and the shell makes it current (`window_state::enter`, a guard) while it runs that
+window's code. The modules' free functions act on the current one, so not one of their
+callers changed; with none entered, each thread has a default (tests, tools that draw no
+window), which is exactly what the thread-locals were. Two windows keep two menus
+(`each_window_has_its_own_menu`); a shell that runs two windows on one thread enters each
+around its dispatch. `context_menu::with_state` replaces the designer's reach into the old
+`CONTEXT_MENU`. The browser clipboard bridge's `PAGE` stays: it is the page's clipboard,
+not a window's interaction.
+
+**Direct focus calls.** Of the 103 `w.focus()` / `w.unfocus()` calls in apps, the 73 in the
+eleven apps with a `UiContext` moved to `UiContext::focus_widget` / `unfocus_widget`, which
+do what the direct call did AND keep the window's record of focus (the Tab walk, the
+accessibility tree) in step: before, unfocusing the focused widget left the record on it,
+and focusing another left the old one lit. The rest were already paired with a context call,
+or are in the three apps with no context (cce-authenticator, cce-mail, cce-secrets), where a
+widget's own flag is the only focus there is. Checked in a shadow: cce-fonts' preview box
+takes a click and typing as before (its search box ignores clicks before and after — a
+separate, older bug).
+
+The plan as first written:
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
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 19e8709..9007605 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1885,6 +1885,11 @@ fn raise_fd_limit() {
/// `renderer_init` if they must survive a reconnect.
pub fn run<A: Application>() {
raise_fd_limit();
+ // This window's interaction state (menu, hover highlight, swipe, composition), current
+ // for the whole run: every session, every callback (`crate::window_state`). It outlives
+ // a reconnect, as the app does, so a menu open across one stays open.
+ let window_state = crate::window_state::WindowState::new();
+ let _window = crate::window_state::enter(&window_state);
// Outlives every session: worker threads hold this Sender, and the app's
// own event sources are registered on this loop once.
diff --git a/src/context.rs b/src/context.rs
index 8990d9f..00e42dd 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -651,6 +651,34 @@ impl UiContext {
false
}
+ /// Focus `w` as a direct `w.focus()` did — the widget is told (`focus`), the holder before
+ /// it is told it lost focus (`unfocus`) — and record it as the window's focus, which a
+ /// direct call never did: the Tab walk and the accessibility tree read the record, and
+ /// went on pointing at the widget before. For an app that drives a widget's focus
+ /// itself (`docs/rfc-global-state.md`, phase 2); a focus change the context should
+ /// announce with FocusIn / FocusOut is [`set_focused_id`](Self::set_focused_id).
+ pub fn focus_widget(&mut self, w: &mut dyn WidgetHost) {
+ let id = w.base().id();
+ if let Some(old) = self.focused_widget.filter(|old| *old != id) {
+ if let Some(ptr) = self.tree.get_ptr(old) {
+ // SAFETY: a registry-resolved live widget, not `w` (a different id).
+ unsafe { (*ptr).unfocus() };
+ }
+ }
+ self.focused_widget = Some(id);
+ w.focus();
+ }
+
+ /// Unfocus `w` as a direct `w.unfocus()` did, and drop the window's record of focus if
+ /// it was `w` — which a direct call never did, leaving the Tab walk and the
+ /// accessibility tree on a widget that had let go.
+ pub fn unfocus_widget(&mut self, w: &mut dyn WidgetHost) {
+ if self.focused_widget == Some(w.base().id()) {
+ self.focused_widget = None;
+ }
+ w.unfocus();
+ }
+
pub fn clear_if_matches(&mut self, w: &dyn WidgetHost) {
if self.focused_widget == Some(w.base().id()) {
self.focused_widget = None;
diff --git a/src/ime.rs b/src/ime.rs
index c4e9c48..b417fe8 100644
--- a/src/ime.rs
+++ b/src/ime.rs
@@ -63,7 +63,7 @@ impl Preedit {
}
#[derive(Default)]
-struct State {
+pub(crate) struct State {
preedit: Option<Preedit>,
/// Moves on every change of `preedit`, so a widget can tell it has
/// applied the current one.
@@ -77,15 +77,16 @@ struct State {
pressed: bool,
}
-thread_local! {
- static STATE: RefCell<State> = RefCell::new(State::default());
+/// The current window's composition and caret (`crate::window_state`).
+fn state<R>(f: impl FnOnce(&RefCell<State>) -> R) -> R {
+ crate::window_state::with(|w| f(&w.ime))
}
/// The composition changed: `None` (or empty text) when there is none. A
/// shell's, through `Driver::preedit`.
pub fn set_preedit(preedit: Option<Preedit>) {
let preedit = preedit.filter(|p| !p.text.is_empty());
- STATE.with(|s| {
+ state(|s| {
let mut s = s.borrow_mut();
if s.preedit != preedit {
s.preedit = preedit;
@@ -96,22 +97,22 @@ pub fn set_preedit(preedit: Option<Preedit>) {
/// The composition, if the input method is composing.
pub fn preedit() -> Option<Preedit> {
- STATE.with(|s| s.borrow().preedit.clone())
+ state(|s| s.borrow().preedit.clone())
}
/// Moves whenever the composition does.
pub fn generation() -> u64 {
- STATE.with(|s| s.borrow().generation)
+ state(|s| s.borrow().generation)
}
/// A frame is being built: carets are reported afresh.
pub fn begin_frame() {
- STATE.with(|s| s.borrow_mut().reported = None);
+ state(|s| s.borrow_mut().reported = None);
}
/// The frame is built: what was reported is the caret.
pub fn end_frame() {
- STATE.with(|s| {
+ state(|s| {
let mut s = s.borrow_mut();
s.caret = s.reported;
});
@@ -120,26 +121,26 @@ pub fn end_frame() {
/// A widget editing text has its caret at `x, y` (`w` x `h`), in the
/// window's logical px. Called as it paints.
pub fn report_caret(x: f32, y: f32, w: f32, h: f32) {
- STATE.with(|s| s.borrow_mut().reported = Some([x, y, w, h]));
+ state(|s| s.borrow_mut().reported = Some([x, y, w, h]));
}
/// Where the editing widget's caret was in the last built frame, or `None`
/// when no widget is editing text.
pub fn caret() -> Option<[f32; 4]> {
- STATE.with(|s| s.borrow().caret)
+ state(|s| s.borrow().caret)
}
/// A widget dropped a composition it was showing: the input method should
/// cancel it too. Clears the composition.
pub fn request_reset() {
set_preedit(None);
- STATE.with(|s| s.borrow_mut().reset = true);
+ state(|s| s.borrow_mut().reset = true);
}
/// What this frame has reported so far, replaced by `caret`. For
/// `text_input::capture`, which reads what one painting reported.
pub(crate) fn swap_reported(caret: Option<[f32; 4]>) -> Option<[f32; 4]> {
- STATE.with(|s| std::mem::replace(&mut s.borrow_mut().reported, caret))
+ state(|s| std::mem::replace(&mut s.borrow_mut().reported, caret))
}
/// A pointer or touch press reached the window. A field that is still
@@ -150,7 +151,7 @@ pub(crate) fn swap_reported(caret: Option<[f32; 4]>) -> Option<[f32; 4]> {
/// nothing new when tapped. True when a field is editing, so the caller
/// builds that frame.
pub fn note_press() -> bool {
- STATE.with(|s| {
+ state(|s| {
let mut s = s.borrow_mut();
s.pressed = true;
s.caret.is_some()
@@ -159,12 +160,12 @@ pub fn note_press() -> bool {
/// Whether a press landed since the last call. A shell's.
pub fn take_press() -> bool {
- STATE.with(|s| std::mem::replace(&mut s.borrow_mut().pressed, false))
+ state(|s| std::mem::replace(&mut s.borrow_mut().pressed, false))
}
/// Whether a reset was asked for since the last call. A shell's.
pub fn take_reset() -> bool {
- STATE.with(|s| std::mem::replace(&mut s.borrow_mut().reset, false))
+ state(|s| std::mem::replace(&mut s.borrow_mut().reset, false))
}
#[cfg(test)]
diff --git a/src/lib.rs b/src/lib.rs
index 77f6309..9cf0826 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -36,6 +36,7 @@ pub use cce_core::motion;
/// The user's locale (`locale::locale()`), what every font system is built with.
pub use cce_core::locale;
pub mod l10n;
+pub mod window_state;
pub mod text_input;
#[cfg(not(target_arch = "wasm32"))]
pub mod vk;
diff --git a/src/mac/mod.rs b/src/mac/mod.rs
index 22532a9..2a91045 100644
--- a/src/mac/mod.rs
+++ b/src/mac/mod.rs
@@ -84,6 +84,10 @@ use crate::backend::text::DlText;
/// (a process's `main`), as AppKit requires.
pub fn run<A: Application>() {
let mtm = MainThreadMarker::new().expect("cce-ui's macOS shell runs on the main thread");
+ // The window's interaction state (`crate::window_state`), current while the run loop
+ // below runs this window's code — which is until `run` returns.
+ let window_state = crate::window_state::WindowState::new();
+ let _window = crate::window_state::enter(&window_state);
let ns_app = NSApplication::sharedApplication(mtm);
ns_app.setActivationPolicy(NSApplicationActivationPolicy::Regular);
diff --git a/src/web/shell.rs b/src/web/shell.rs
index 73399bb..10c2888 100644
--- a/src/web/shell.rs
+++ b/src/web/shell.rs
@@ -117,6 +117,9 @@ pub async fn run<A: Application>(canvas: HtmlCanvasElement, fonts: Fonts, sizing
if let Some(lang) = web_sys::window().and_then(|w| w.navigator().language()) {
crate::locale::set_locale(&lang);
}
+ // The page's window's interaction state (`crate::window_state`). The loop runs on the
+ // page's callbacks after this returns, so the state is entered for the page's life.
+ std::mem::forget(crate::window_state::enter(&crate::window_state::WindowState::new()));
let fs = crate::create_font_system();
let mut renderer = WebRenderer::new(canvas.clone()).await?;
diff --git a/src/widget/core.rs b/src/widget/core.rs
index e42a03e..aee8db3 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -75,37 +75,41 @@ pub mod hover_animation {
}
}
- thread_local! {
- pub static HOVER_STATE: RefCell<HoverState> = RefCell::new(HoverState::new());
- pub static CURSOR_POS: RefCell<(f32, f32)> = const { RefCell::new((0.0, 0.0)) };
+ // The highlight and the cursor it follows are the current window's
+ // (`crate::window_state`), not the thread's.
+ fn hover_state<R>(f: impl FnOnce(&RefCell<HoverState>) -> R) -> R {
+ crate::window_state::with(|w| f(&w.hover))
+ }
+ fn cursor_state<R>(f: impl FnOnce(&RefCell<(f32, f32)>) -> R) -> R {
+ crate::window_state::with(|w| f(&w.cursor))
}
pub fn set_cursor_pos(x: f32, y: f32) {
- CURSOR_POS.with(|pos| {
+ cursor_state(|pos| {
*pos.borrow_mut() = (x, y);
});
}
pub fn reset_frame_registration() {
- HOVER_STATE.with(|state| {
+ hover_state(|state| {
state.borrow_mut().registered_this_frame = false;
});
}
pub fn set_scroll_offset(offset: f32) {
- HOVER_STATE.with(|state| {
+ hover_state(|state| {
state.borrow_mut().scroll_offset = offset;
});
}
pub fn get_scroll_offset() -> f32 {
- HOVER_STATE.with(|state| {
+ hover_state(|state| {
state.borrow().scroll_offset
})
}
pub fn register_hovered(x: f32, y: f32, w: f32, h: f32, color: [f32; 4]) {
- HOVER_STATE.with(|state| {
+ hover_state(|state| {
let mut s = state.borrow_mut();
s.target_x = Some(x);
s.target_y = Some(y);
@@ -117,11 +121,11 @@ pub mod hover_animation {
}
pub fn post_render_check() {
- HOVER_STATE.with(|state| {
+ hover_state(|state| {
let mut s = state.borrow_mut();
if !s.registered_this_frame {
s.target_alpha = 0.0;
- let (cx, cy) = CURSOR_POS.with(|pos| *pos.borrow());
+ let (cx, cy) = cursor_state(|pos| *pos.borrow());
s.target_x = Some(cx);
s.target_y = Some(cy + s.scroll_offset);
s.target_w = Some(0.0);
@@ -131,7 +135,7 @@ pub mod hover_animation {
}
pub fn tick(dt: f32) -> bool {
- HOVER_STATE.with(|state| {
+ hover_state(|state| {
let mut s = state.borrow_mut();
let decay = 15.0;
// The per-tick approach fraction; 1 lands on the target at once,
@@ -195,7 +199,7 @@ pub mod hover_animation {
}
pub fn get_quad() -> Option<(f32, f32, f32, f32, [f32; 4])> {
- HOVER_STATE.with(|state| {
+ hover_state(|state| {
let s = state.borrow();
if s.current_alpha > 0.001 {
Some((
@@ -1691,8 +1695,11 @@ pub mod context_menu {
}
}
- thread_local! {
- pub static CONTEXT_MENU: RefCell<ContextMenuState> = RefCell::new(ContextMenuState::new());
+ /// Run `f` on the current window's menu (`crate::window_state`): the one menu every
+ /// function here acts on. It was a thread-local, `CONTEXT_MENU`, until 2026-10-08;
+ /// `with_state(..)` callers read it through this now.
+ pub fn with_state<R>(f: impl FnOnce(&RefCell<ContextMenuState>) -> R) -> R {
+ crate::window_state::with(|w| f(&w.context_menu))
}
/// Make row `idx` of the shown menu lead to a page — see [`PageTurn`].
@@ -1700,65 +1707,65 @@ pub mod context_menu {
/// action.
/// Give the open menu's rows their actions — see [`ContextMenuState::set_row_actions`].
pub fn set_row_actions(actions: Vec<Option<crate::widget::ContextAction>>) {
- CONTEXT_MENU.with(|m| m.borrow_mut().set_row_actions(actions));
+ with_state(|m| m.borrow_mut().set_row_actions(actions));
}
/// The action row `idx` of the open menu runs, if it was given one.
pub fn row_action(idx: usize) -> Option<crate::widget::ContextAction> {
- CONTEXT_MENU.with(|m| m.borrow().actions.get(idx).copied().flatten())
+ with_state(|m| m.borrow().actions.get(idx).copied().flatten())
}
/// How many of the open menu's first rows are headers (a title, `File:` / `Key:`).
pub fn header_count() -> usize {
- CONTEXT_MENU.with(|m| m.borrow().header_count)
+ with_state(|m| m.borrow().header_count)
}
pub fn set_row_page(idx: usize) {
- CONTEXT_MENU.with(|m| m.borrow_mut().set_row_page(idx));
+ with_state(|m| m.borrow_mut().set_row_page(idx));
}
pub fn leads_to_page(idx: usize) -> bool {
- CONTEXT_MENU.with(|m| m.borrow().leads_to_page(idx))
+ with_state(|m| m.borrow().leads_to_page(idx))
}
/// See [`ContextMenuState::show_page`].
pub fn show_page(x: f32, y: f32, back: Option<&str>, options: Vec<String>, header_count: usize, target: WidgetId) {
- CONTEXT_MENU.with(|m| m.borrow_mut().show_page(x, y, back, options, header_count, target));
+ with_state(|m| m.borrow_mut().show_page(x, y, back, options, header_count, target));
}
/// See [`ContextMenuState::refill`].
pub fn refill(options: Vec<String>, sliders: &[Option<MenuSlider>]) -> bool {
- CONTEXT_MENU.with(|m| m.borrow_mut().refill(options, sliders))
+ with_state(|m| m.borrow_mut().refill(options, sliders))
}
/// See [`ContextMenuState::turn_at`].
pub fn turn_at(px: f32, py: f32) -> Option<PageTurn> {
- CONTEXT_MENU.with(|m| m.borrow().turn_at(px, py))
+ with_state(|m| m.borrow().turn_at(px, py))
}
/// See [`ContextMenuState::take_turn`].
pub fn take_turn() -> Option<PageTurn> {
- CONTEXT_MENU.with(|m| m.borrow_mut().take_turn())
+ with_state(|m| m.borrow_mut().take_turn())
}
/// Whether the shown menu is a page turned to in place of another plate.
pub fn is_turned() -> bool {
- CONTEXT_MENU.with(|m| m.borrow().turned)
+ with_state(|m| m.borrow().turned)
}
/// The title of the plate the shown page goes back to.
pub fn back_title() -> Option<String> {
- CONTEXT_MENU.with(|m| m.borrow().back.clone())
+ with_state(|m| m.borrow().back.clone())
}
pub fn is_visible() -> bool {
- CONTEXT_MENU.with(|m| m.borrow().visible)
+ with_state(|m| m.borrow().visible)
}
pub fn show(x: f32, y: f32, options: Vec<String>, header_count: usize, target: WidgetId) {
- CONTEXT_MENU.with(|m| m.borrow_mut().show(x, y, options, header_count, target));
+ with_state(|m| m.borrow_mut().show(x, y, options, header_count, target));
}
pub fn hide() {
- CONTEXT_MENU.with(|m| m.borrow_mut().hide());
+ with_state(|m| m.borrow_mut().hide());
}
pub fn clear_if_matches(w: &dyn WidgetHost) {
let id = w.base().id();
- CONTEXT_MENU.with(|m| {
+ with_state(|m| {
// Already borrowed means the widget is being dropped from INSIDE
// the menu's own code — the slider rows' paint stamp, dropped at
// the end of `paint` under `paint_with_labels`' borrow. A widget
@@ -1775,26 +1782,26 @@ pub mod context_menu {
/// Whether the runner draws the menu in its own popup surface — see
/// [`ContextMenuState::hosted`]. Set by the runner, never by an app.
pub fn set_hosted(hosted: bool) {
- CONTEXT_MENU.with(|m| m.borrow_mut().hosted = hosted);
+ with_state(|m| m.borrow_mut().hosted = hosted);
}
pub fn is_hosted() -> bool {
- CONTEXT_MENU.with(|m| m.borrow().hosted)
+ with_state(|m| m.borrow().hosted)
}
/// See [`ContextMenuState::generation`].
pub fn generation() -> u64 {
- CONTEXT_MENU.with(|m| m.borrow().generation)
+ with_state(|m| m.borrow().generation)
}
/// See [`ContextMenuState::place`].
pub fn place(x: f32, y: f32, max_h: f32) {
- CONTEXT_MENU.with(|m| m.borrow_mut().place(x, y, max_h));
+ with_state(|m| m.borrow_mut().place(x, y, max_h));
}
/// See [`ContextMenuState::constrain_to`].
pub fn constrain_to(bx: f32, by: f32, bw: f32, bh: f32) {
- CONTEXT_MENU.with(|m| m.borrow_mut().constrain_to(bx, by, bw, bh));
+ with_state(|m| m.borrow_mut().constrain_to(bx, by, bw, bh));
}
/// `(anchor, w, content_h)` — what a popup positioner is built from.
pub fn natural_geometry() -> ((f32, f32), f32, f32) {
- CONTEXT_MENU.with(|m| {
+ with_state(|m| {
let m = m.borrow();
let (w, h) = m.surface_size();
(m.anchor, w, h)
@@ -1803,40 +1810,40 @@ pub mod context_menu {
/// Whether a page turn is being animated: a host drawing the menu
/// itself asks for frames while it is.
pub fn is_turning() -> bool {
- CONTEXT_MENU.with(|m| m.borrow().turn_progress().is_some())
+ with_state(|m| m.borrow().turn_progress().is_some())
}
/// See [`ContextMenuState::turn_from_size`].
pub fn turn_from_size(w: f32, h: f32, forward: bool) {
- CONTEXT_MENU.with(|m| m.borrow_mut().turn_from_size(w, h, forward));
+ with_state(|m| m.borrow_mut().turn_from_size(w, h, forward));
}
/// Paint the menu with its top-left at the origin, whether or not it is
/// [hosted](set_hosted) — the runner's popup surface draws it this way.
/// Paints a COPY, so no borrow of the menu is held while the paint runs
/// (the slider stamp's drop reaches back into this cell).
pub fn paint_hosted(ctx: &mut crate::scene::paint::PaintCtx) {
- let mut menu = CONTEXT_MENU.with(|m| m.borrow().clone());
+ let mut menu = with_state(|m| m.borrow().clone());
menu.hosted = false;
menu.in_popup = true;
let (x, y) = (menu.x, menu.y);
ctx.translate(-x, -y, |ctx| menu.paint_with_labels(ctx));
}
- pub fn x() -> f32 { CONTEXT_MENU.with(|m| m.borrow().x) }
- pub fn y() -> f32 { CONTEXT_MENU.with(|m| m.borrow().y) }
- pub fn w() -> f32 { CONTEXT_MENU.with(|m| m.borrow().w) }
- pub fn h() -> f32 { CONTEXT_MENU.with(|m| m.borrow().h) }
- pub fn hovered_item() -> Option<usize> { CONTEXT_MENU.with(|m| m.borrow().hovered_item) }
- pub fn options() -> Vec<String> { CONTEXT_MENU.with(|m| m.borrow().options.clone()) }
+ pub fn x() -> f32 { with_state(|m| m.borrow().x) }
+ pub fn y() -> f32 { with_state(|m| m.borrow().y) }
+ pub fn w() -> f32 { with_state(|m| m.borrow().w) }
+ pub fn h() -> f32 { with_state(|m| m.borrow().h) }
+ pub fn hovered_item() -> Option<usize> { with_state(|m| m.borrow().hovered_item) }
+ pub fn options() -> Vec<String> { with_state(|m| m.borrow().options.clone()) }
/// Highlight a row from the keyboard — see
/// [`ContextMenuState::set_hovered_item`]. A host that walks a menu
/// with the arrow keys (a dropdown) sets the open row with this and
/// moves with [`step_hovered`], and runs [`hovered_item`] on Enter.
pub fn set_hovered_item(idx: Option<usize>) {
- CONTEXT_MENU.with(|m| m.borrow_mut().set_hovered_item(idx));
+ with_state(|m| m.borrow_mut().set_hovered_item(idx));
}
/// See [`ContextMenuState::step_hovered`].
pub fn step_hovered(dir: i32) -> Option<usize> {
- CONTEXT_MENU.with(|m| m.borrow_mut().step_hovered(dir))
+ with_state(|m| m.borrow_mut().step_hovered(dir))
}
/// The row under a point, PAD-aware — the ONE row hit test. Every host
@@ -1846,72 +1853,72 @@ pub mod context_menu {
/// row, and runs off the end on the last one (2026-09-22 audit: six
/// call sites across five apps had it).
pub fn row_at(px: f32, py: f32) -> Option<usize> {
- CONTEXT_MENU.with(|m| m.borrow().row_at(px, py))
+ with_state(|m| m.borrow().row_at(px, py))
}
/// A row's top, PAD-aware — for a host painting the rows itself.
pub fn row_y(idx: usize) -> f32 {
- CONTEXT_MENU.with(|m| m.borrow().row_y(idx))
+ with_state(|m| m.borrow().row_y(idx))
}
pub fn hit_test(px: f32, py: f32) -> bool {
- CONTEXT_MENU.with(|m| m.borrow().hit_test(px, py))
+ with_state(|m| m.borrow().hit_test(px, py))
}
pub fn cursor_moved(px: f32, py: f32) -> bool {
- CONTEXT_MENU.with(|m| m.borrow_mut().cursor_moved(px, py))
+ with_state(|m| m.borrow_mut().cursor_moved(px, py))
}
/// Make row `idx` of the shown menu a slider — see [`MenuSlider`]. Call
/// after [`show`], which clears every row back to an action.
pub fn set_row_slider(idx: usize, slider: MenuSlider) {
- CONTEXT_MENU.with(|m| m.borrow_mut().set_row_slider(idx, slider));
+ with_state(|m| m.borrow_mut().set_row_slider(idx, slider));
}
pub fn slider(idx: usize) -> Option<MenuSlider> {
- CONTEXT_MENU.with(|m| m.borrow().slider(idx))
+ with_state(|m| m.borrow().slider(idx))
}
/// The wheel, for hosts that route it: steps the slider under the
/// pointer. `false` when no slider row is there — let it scroll the page.
pub fn mouse_wheel(delta: &MouseScrollDelta, px: f32, py: f32) -> bool {
- CONTEXT_MENU.with(|m| m.borrow_mut().mouse_wheel(delta, px, py))
+ with_state(|m| m.borrow_mut().mouse_wheel(delta, px, py))
}
/// A left press, for hosts that dispatch the menu themselves: `true` when
/// it landed on a slider row, which the host must then NOT treat as an
/// action or a dismissal.
pub fn slider_press(px: f32, py: f32) -> bool {
- CONTEXT_MENU.with(|m| m.borrow_mut().slider_press(px, py))
+ with_state(|m| m.borrow_mut().slider_press(px, py))
}
pub fn slider_dragging() -> bool {
- CONTEXT_MENU.with(|m| m.borrow().slider_drag.is_some())
+ with_state(|m| m.borrow().slider_drag.is_some())
}
pub fn slider_release() -> bool {
- CONTEXT_MENU.with(|m| m.borrow_mut().slider_release())
+ with_state(|m| m.borrow_mut().slider_release())
}
/// `(row, value)` of the last slider change since the last call.
pub fn take_slider_change() -> Option<(usize, f32)> {
- CONTEXT_MENU.with(|m| m.borrow_mut().take_slider_change())
+ with_state(|m| m.borrow_mut().take_slider_change())
}
pub fn mouse_input(button: MouseButton, state: ElementState, px: f32, py: f32, ctx: Option<&mut crate::context::UiContext>) -> bool {
- CONTEXT_MENU.with(|m| m.borrow_mut().mouse_input(button, state, px, py, ctx))
+ with_state(|m| m.borrow_mut().mouse_input(button, state, px, py, ctx))
}
/// Paint the menu as a lit plate — see [`ContextMenuState::paint`]. Hosts on
/// the display-list path call this in place of the [`extra_quads`] loop.
pub fn paint(ctx: &mut crate::scene::paint::PaintCtx) {
- CONTEXT_MENU.with(|m| m.borrow().paint(ctx));
+ with_state(|m| m.borrow().paint(ctx));
}
pub fn extra_quads() -> Vec<(f32, f32, f32, f32, [f32; 4])> {
- CONTEXT_MENU.with(|m| m.borrow().extra_quads())
+ with_state(|m| m.borrow().extra_quads())
}
pub fn text_labels() -> Vec<TextLabel> {
- CONTEXT_MENU.with(|m| m.borrow().text_labels())
+ with_state(|m| m.borrow().text_labels())
}
/// Plate and labels in one call — see
/// [`ContextMenuState::paint_with_labels`].
pub fn paint_with_labels(ctx: &mut crate::scene::paint::PaintCtx) {
- CONTEXT_MENU.with(|m| m.borrow().paint_with_labels(ctx));
+ with_state(|m| m.borrow().paint_with_labels(ctx));
}
}
@@ -2518,7 +2525,7 @@ mod context_menu_page_tests {
let (mx, my) = (context_menu::x(), context_menu::y());
context_menu::show_page(mx, my, Some("View"), vec!["One".into()], 0, WidgetId(1));
assert!(context_menu::is_turning());
- let drawn = context_menu::CONTEXT_MENU.with(|m| m.borrow().drawn_rect());
+ let drawn = context_menu::with_state(|m| m.borrow().drawn_rect());
let own = context_menu::h();
assert!(own < h0 && drawn.height > own && drawn.height <= h0, "on its way down: {} between {own} and {h0}", drawn.height);
let (_, w, h) = context_menu::natural_geometry();
diff --git a/src/widget/side_swipe.rs b/src/widget/side_swipe.rs
index 8a0a4fe..8e327c3 100644
--- a/src/widget/side_swipe.rs
+++ b/src/widget/side_swipe.rs
@@ -105,13 +105,15 @@ impl SideSwipe {
}
}
-thread_local! {
- static SHARED: std::cell::RefCell<SideSwipe> = std::cell::RefCell::new(SideSwipe::new());
+/// The current window's recognizer (`crate::window_state`), one per window.
+fn shared<R>(f: impl FnOnce(&std::cell::RefCell<SideSwipe>) -> R) -> R {
+ crate::window_state::with(|w| f(&w.swipe))
}
-/// Feed a wheel event to the thread's one recognizer — see the module docs.
+/// Feed a wheel event to the current window's recognizer (`crate::window_state`) — see the
+/// module docs.
pub fn feed(delta: &MouseScrollDelta) -> Option<SwipeDir> {
- SHARED.with(|s| s.borrow_mut().feed(delta))
+ shared(|s| s.borrow_mut().feed(delta))
}
/// Whether `delta` continues a gesture that has turned a page already. The
@@ -125,7 +127,7 @@ pub fn swallow(delta: &MouseScrollDelta) -> bool {
}
fn swallow_at(delta: &MouseScrollDelta, phase: ScrollPhase, now: Instant) -> bool {
- SHARED.with(|s| {
+ shared(|s| {
let mut s = s.borrow_mut();
let live = s.fired && phase != ScrollPhase::FingerEnd && s.last.is_some_and(|t| now.duration_since(t) <= SWIPE_GAP);
if live {
@@ -139,7 +141,7 @@ fn swallow_at(delta: &MouseScrollDelta, phase: ScrollPhase, now: Instant) -> boo
/// that swipes twice calls between, since the runner's phase is one value
/// for the whole process and not the test's to set.
pub fn end_gesture() {
- SHARED.with(|s| s.borrow_mut().reset());
+ shared(|s| s.borrow_mut().reset());
}
#[cfg(test)]
@@ -175,10 +177,10 @@ mod tests {
let t = Instant::now();
let f = ScrollPhase::Finger;
assert!(!swallow_at(&px(-30.0, 0.0), f, t), "nothing has turned");
- SHARED.with(|s| s.borrow_mut().feed_at(&px(-50.0, 0.0), f, t));
+ shared(|s| s.borrow_mut().feed_at(&px(-50.0, 0.0), f, t));
assert!(swallow_at(&px(-30.0, 0.0), f, t), "the rest of the turn");
assert!(!swallow_at(&px(0.0, 0.0), ScrollPhase::FingerEnd, t), "the lift is not");
- SHARED.with(|s| s.borrow_mut().feed_at(&px(0.0, 0.0), ScrollPhase::FingerEnd, t));
+ shared(|s| s.borrow_mut().feed_at(&px(0.0, 0.0), ScrollPhase::FingerEnd, t));
assert!(!swallow_at(&px(-30.0, 0.0), f, t), "a new gesture");
end_gesture();
}
diff --git a/src/window_state.rs b/src/window_state.rs
new file mode 100644
index 0000000..20ab150
--- /dev/null
+++ b/src/window_state.rs
@@ -0,0 +1,102 @@
+//! A window's interaction state: what the user is doing in one window right now, apart from
+//! the widgets themselves (`docs/rfc-global-state.md`, phase 2).
+//!
+//! - the open context menu (`widget::context_menu`),
+//! - the hover highlight and the cursor it follows (`widget::hover_animation`),
+//! - the side swipe being recognized (`widget::side_swipe`),
+//! - the input method's composition and the caret it is drawn at (`ime`).
+//!
+//! Each lived in a thread-local of its own until 2026-10-08, so every window on a thread
+//! shared one menu, one highlight and one composition. Now a window OWNS a
+//! [`WindowState`], and its shell makes it the current one ([`enter`]) for as long as it
+//! runs that window's code; the modules' free functions — `context_menu::show`,
+//! `hover_animation::tick`, `ime::caret`, … — act on the current one, so the many apps
+//! and widgets that call them (an app need not have a `UiContext` to show a menu) are
+//! unchanged. With none entered — a test, a tool that draws no window — each thread has a
+//! default one, which is what the thread-locals were.
+
+use std::cell::RefCell;
+use std::rc::Rc;
+
+use crate::widget::context_menu::ContextMenuState;
+use crate::widget::hover_animation::HoverState;
+use crate::widget::side_swipe::SideSwipe;
+
+/// One window's interaction state (see the module docs).
+pub struct WindowState {
+ pub(crate) context_menu: RefCell<ContextMenuState>,
+ pub(crate) hover: RefCell<HoverState>,
+ pub(crate) cursor: RefCell<(f32, f32)>,
+ pub(crate) swipe: RefCell<SideSwipe>,
+ pub(crate) ime: RefCell<crate::ime::State>,
+}
+
+impl WindowState {
+ /// A window's state with nothing open, hovered, swiped or composed.
+ pub fn new() -> Rc<WindowState> {
+ Rc::new(WindowState {
+ context_menu: RefCell::new(ContextMenuState::new()),
+ hover: RefCell::new(HoverState::new()),
+ cursor: RefCell::new((0.0, 0.0)),
+ swipe: RefCell::new(SideSwipe::new()),
+ ime: RefCell::new(crate::ime::State::default()),
+ })
+ }
+}
+
+thread_local! {
+ static CURRENT: RefCell<Option<Rc<WindowState>>> = const { RefCell::new(None) };
+ static DEFAULT: Rc<WindowState> = WindowState::new();
+}
+
+/// Run `f` on the current window's state: the one entered last on this thread, else the
+/// thread's default.
+pub fn with<R>(f: impl FnOnce(&WindowState) -> R) -> R {
+ let state = CURRENT.with(|c| c.borrow().clone()).unwrap_or_else(|| DEFAULT.with(Rc::clone));
+ f(&state)
+}
+
+/// While this lives, `state` is the current window's (see [`enter`]).
+#[must_use = "the window's state is current only while this guard lives"]
+pub struct Entered {
+ previous: Option<Rc<WindowState>>,
+}
+
+impl Drop for Entered {
+ fn drop(&mut self) {
+ let previous = self.previous.take();
+ CURRENT.with(|c| *c.borrow_mut() = previous);
+ }
+}
+
+/// Make `state` the current window's until the guard drops (then the one before it is
+/// again). A shell enters its window's state around everything it runs for that window.
+pub fn enter(state: &Rc<WindowState>) -> Entered {
+ let previous = CURRENT.with(|c| c.replace(Some(Rc::clone(state))));
+ Entered { previous }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Two windows keep two menus: what one shows the other does not, and the one entered
+ /// before comes back when the guard drops.
+ #[test]
+ fn each_window_has_its_own_menu() {
+ use crate::widget::context_menu as cm;
+ let (a, b) = (WindowState::new(), WindowState::new());
+ let _in_a = enter(&a);
+ cm::show(10.0, 10.0, vec!["Copy".into()], 0, crate::widget::WidgetId(1));
+ assert!(cm::is_visible());
+ {
+ let _in_b = enter(&b);
+ assert!(!cm::is_visible(), "the other window has no menu open");
+ cm::show(20.0, 20.0, vec!["Paste".into(), "Cut".into()], 0, crate::widget::WidgetId(2));
+ assert_eq!(cm::options().len(), 2);
+ }
+ assert!(cm::is_visible());
+ assert_eq!(cm::options(), ["Copy"], "back in the first window, its own menu");
+ cm::hide();
+ }
+}