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

commit1df8ba2c7a8a507e4960ecf0def7ba1fd984c83d
parent518d5293eb
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 12:56
refactor(state): a window's properties are its own (global-state RFC phase 4)

The scale, the display metric, the app id, fullscreen, maximized and vertical
text are a window's window_state::Props, and the scroll phase a field beside
them. Setters write the current window's and the process-wide value; getters
read the current window's, else the process-wide one -- so a worker thread
reads the last value any window set, and a one-window process reads exactly
what it did. units::metric asks cce-ui first (set_metric_resolver); runners
report through window_state::set_metric. The scroll phase is the window's or
the thread's own, so tests no longer race on it.

Checked at a forced scale of 2 against the real config: the demo and the
settings app draw identically to the pixel before and after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                    | 22 +++++++----
 docs/rfc-global-state.md     | 19 ++++++++-
 src/backend/text.rs          |  7 ++++
 src/backend/window_runner.rs |  6 +--
 src/scale.rs                 | 22 ++++++++++-
 src/widget/scroll_motion.rs  | 17 ++++----
 src/window_state.rs          | 92 +++++++++++++++++++++++++++++++++++++++++++-
 7 files changed, 159 insertions(+), 26 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 9f3a9e8..3837db1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1102,8 +1102,8 @@ there:
   **Forward follows the content**: the delta that scrolls a list to show what is to its
   right, so under natural scrolling the fingers go LEFT to go in and right to go back,
   as on every touch surface, and the user's scrolling setting flips both. A test that
-  swipes twice calls `side_swipe::end_gesture()` between, since the runner's phase is
-  process-wide.
+  swipes twice calls `side_swipe::end_gesture()` between, since a gesture's phase is the
+  window's (a thread's own in a test).
 - **The rest of a gesture that turned is the turn's**: `side_swipe::swallow(delta)`,
   asked at the top of a host's wheel handling, is true for it until the lift, and the
   host drops the event. Without it a page narrower than the plate it replaced left the
@@ -1670,7 +1670,13 @@ is done: the style is ONE snapshot (`crate::style::Style`: colour slots, layout
 materials, the registry), published as an `Arc`; each former style `RwLock` is a
 `style::StyleCell` handle with the same `.read()` / `.write()` API, reads take no lock, and
 a reload runs as one `style::batch`, published once. A new style value is a field in its
-module's `style_slots!` block and a `StyleCell` handle, not a new lock. An app
+module's `style_slots!` block and a `StyleCell` handle, not a new lock. Phase 4 is done: a
+window's properties — scale, display metric, app id, fullscreen, maximized, vertical text,
+and the scroll phase — are its `WindowState`'s (`window_state::Props`). The window whose
+code runs reads its own; a thread with no window (a worker) reads the process-wide value,
+which every setter also writes, so one window in a process reads exactly what it did.
+`units::metric` asks cce-ui first (`units::set_metric_resolver`); a runner reports a
+metric through `window_state::set_metric`. 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`.
@@ -2421,11 +2427,11 @@ Three things learned taking it to the apps (2026-10-05):
 
 ### A host may name the phase; a test may pin the settings (2026-09-30)
 
-The phase a wheel event belongs to (`Finger`, `FingerEnd`, `Wheel`) is a
-process GLOBAL the runner publishes before each dispatch, and
-`ScrollMotion::apply_px` reads it. So does anything a test would set it
-through — which, in a suite running tests in parallel, changes what every
-other test's pixel delta means. `ScrollMotion::apply_phase` takes the phase
+The phase a wheel event belongs to (`Finger`, `FingerEnd`, `Wheel`) is the
+window's (`window_state`), published by the runner before each dispatch, and
+`ScrollMotion::apply_px` reads it. Until 2026-10-08 it was one process-wide
+atomic, so a test setting it changed what every other test's pixel delta
+meant; with no window entered it is now each thread's own. `ScrollMotion::apply_phase` takes the phase
 as an argument (`apply_px` is it with the published one), for a host that
 reads the phase itself and hands it on; cce-designer's viewport does, and
 its test drives a flick without touching the global.
diff --git a/docs/rfc-global-state.md b/docs/rfc-global-state.md
index 8c1839c..9955814 100644
--- a/docs/rfc-global-state.md
+++ b/docs/rfc-global-state.md
@@ -156,7 +156,24 @@ go per key.
 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
+### Phase 4 — per-window properties — DONE 2026-10-08
+
+Not by threading them through every frame and event (the scale alone has 27 readers and 37
+setters across the toolkit and 17 apps) but as phase 2 did the interaction state: the
+scale, the display metric, the app id, fullscreen, maximized and vertical text are a
+window's `window_state::Props`, and the scroll phase a field beside them. The setters
+(`scale::set_scale_factor`, `set_app_id`, …, `backend::text::set_vertical_text`,
+`window_state::set_metric`) write the current window's AND the process-wide value; the
+getters read the current window's, else the process-wide one. That last rule is what keeps
+a worker thread right — a page rasterized at the scale, a tile decoded for it, has no
+window entered, and reads the last value any window set — and it makes a single-window
+process read exactly what it did. The metric lives in cce-core, which knows nothing of
+windows: `units::set_metric_resolver` lets cce-ui answer first. The scroll phase, read only
+while a window dispatches, is the window's or the thread's own, never the process's — so
+tests no longer race on it. `each_window_has_its_own_scale_and_a_worker_reads_the_last`.
+Checked at a forced scale of 2 against the real config: the demo and the settings app draw
+identically to the pixel before and after (the settings app's run-to-run hover noise
+aside).
 
 Scale, metric, scroll phase and vertical text travel with the frame and the event context
 instead of process statics.
diff --git a/src/backend/text.rs b/src/backend/text.rs
index 4662862..48e5b4d 100644
--- a/src/backend/text.rs
+++ b/src/backend/text.rs
@@ -83,6 +83,8 @@ fn buffer_tick() -> u64 {
 /// `pub static`s at the crate root (`IS_VERTICAL`, `BAR_THICKNESS`) until 2026-10-07.
 pub fn set_vertical_text(bar_thickness: Option<u32>) {
     use std::sync::atomic::Ordering::Relaxed;
+    // The window's own (`crate::window_state::Props`), and the process's for a worker.
+    crate::window_state::entered(|w| w.props.borrow_mut().vertical_text = bar_thickness);
     if let Some(t) = bar_thickness {
         VERTICAL_BAR_THICKNESS.store(t, Relaxed);
     }
@@ -91,6 +93,11 @@ pub fn set_vertical_text(bar_thickness: Option<u32>) {
 
 /// The vertical bar's thickness while vertical text is on (see [`set_vertical_text`]).
 pub fn vertical_text() -> Option<u32> {
+    crate::window_state::entered(|w| w.props.borrow().vertical_text).unwrap_or_else(process_vertical_text)
+}
+
+/// The process-wide vertical text: the last any window set (what a worker thread reads).
+pub(crate) fn process_vertical_text() -> Option<u32> {
     use std::sync::atomic::Ordering::Relaxed;
     VERTICAL_TEXT.load(Relaxed).then(|| VERTICAL_BAR_THICKNESS.load(Relaxed))
 }
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 9007605..c017427 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -969,12 +969,12 @@ impl<A: Application> OutputHandler for EngineState<A> {
     fn new_output(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _output: wl_output::WlOutput) {
         let scale = crate::wayland::detect_scale_factor(&self.output_state);
         crate::scale::set_scale_factor(scale as f32);
-        crate::units::set_metric(crate::wayland::detect_metric(&self.output_state, scale));
+        crate::window_state::set_metric(crate::wayland::detect_metric(&self.output_state, scale));
     }
     fn update_output(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _output: wl_output::WlOutput) {
         let scale = crate::wayland::detect_scale_factor(&self.output_state);
         crate::scale::set_scale_factor(scale as f32);
-        crate::units::set_metric(crate::wayland::detect_metric(&self.output_state, scale));
+        crate::window_state::set_metric(crate::wayland::detect_metric(&self.output_state, scale));
     }
     fn output_destroyed(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _output: wl_output::WlOutput) {}
 }
@@ -2146,7 +2146,7 @@ fn run_session<'l, A: Application>(
     let scale = detect_scale_factor(&engine_state.output_state);
     engine_state.scale_factor = scale;
     crate::scale::set_scale_factor(scale as f32);
-    crate::units::set_metric(crate::wayland::detect_metric(&engine_state.output_state, scale));
+    crate::window_state::set_metric(crate::wayland::detect_metric(&engine_state.output_state, scale));
 
     // A reconnect re-attaches the SAME app: its state is the thing worth
     // saving, and `A::new` would both discard it and hand a fresh Sender to
diff --git a/src/scale.rs b/src/scale.rs
index 478db1d..da7316f 100644
--- a/src/scale.rs
+++ b/src/scale.rs
@@ -22,41 +22,59 @@ static APP_ID: RwLock<String> = RwLock::new(String::new());
 static FULLSCREEN: RwLock<bool> = RwLock::new(false);
 static MAXIMIZED: RwLock<bool> = RwLock::new(false);
 
+// A window's properties are its own (`crate::window_state::Props`): read from the window
+// whose code is running, else from these process-wide values, which every setter also
+// writes — the last any window set, what a worker thread reads (`docs/rfc-global-state.md`,
+// phase 4).
+
+/// The HiDPI scale of the window being drawn (off a window's thread, the last one set).
 pub fn scale_factor() -> f32 {
+    crate::window_state::entered(|w| w.props.borrow().scale).unwrap_or_else(process_scale_factor)
+}
+
+pub(crate) fn process_scale_factor() -> f32 {
     *SCALE_FACTOR.read().unwrap()
 }
 
 pub fn set_scale_factor(scale: f32) {
+    crate::window_state::entered(|w| w.props.borrow_mut().scale = scale);
     if let Ok(mut lock) = SCALE_FACTOR.write() {
         *lock = scale;
     }
 }
 
 pub fn app_id() -> String {
+    crate::window_state::entered(|w| w.props.borrow().app_id.clone()).unwrap_or_else(process_app_id)
+}
+
+pub(crate) fn process_app_id() -> String {
     APP_ID.read().unwrap().clone()
 }
 
 pub fn set_app_id(id: String) {
+    crate::window_state::entered(|w| w.props.borrow_mut().app_id = id.clone());
     if let Ok(mut lock) = APP_ID.write() {
         *lock = id;
     }
 }
 
 pub fn is_fullscreen() -> bool {
-    *FULLSCREEN.read().unwrap()
+    crate::window_state::entered(|w| w.props.borrow().fullscreen).unwrap_or_else(|| *FULLSCREEN.read().unwrap())
 }
 
 pub fn set_fullscreen(fs: bool) {
+    crate::window_state::entered(|w| w.props.borrow_mut().fullscreen = fs);
     if let Ok(mut lock) = FULLSCREEN.write() {
         *lock = fs;
     }
 }
 
 pub fn is_maximized() -> bool {
-    *MAXIMIZED.read().unwrap()
+    crate::window_state::entered(|w| w.props.borrow().maximized).unwrap_or_else(|| *MAXIMIZED.read().unwrap())
 }
 
 pub fn set_maximized(m: bool) {
+    crate::window_state::entered(|w| w.props.borrow_mut().maximized = m);
     if let Ok(mut lock) = MAXIMIZED.write() {
         *lock = m;
     }
diff --git a/src/widget/scroll_motion.rs b/src/widget/scroll_motion.rs
index 2413545..f4e6d43 100644
--- a/src/widget/scroll_motion.rs
+++ b/src/widget/scroll_motion.rs
@@ -40,7 +40,7 @@
 //! }
 //! ```
 
-use std::sync::atomic::{AtomicU8, Ordering};
+
 use web_time::Instant;
 
 use crate::widget::MouseScrollDelta;
@@ -58,11 +58,12 @@ pub enum ScrollPhase {
     FingerEnd,
 }
 
-static PHASE: AtomicU8 = AtomicU8::new(0);
-
-/// Publish the phase of the wheel event about to be dispatched. Runner-side.
+/// Publish the phase of the wheel event about to be dispatched, for the window being run
+/// (`crate::window_state`; a thread's own with none entered). Runner-side. It was one
+/// process-wide atomic until 2026-10-08, which a test setting it changed for every test
+/// running beside it.
 pub fn set_scroll_phase(phase: ScrollPhase) {
-    PHASE.store(phase as u8, Ordering::Relaxed);
+    crate::window_state::with(|w| w.scroll_phase.set(phase));
 }
 
 /// The phase of the wheel event currently being dispatched. Outside a
@@ -70,11 +71,7 @@ pub fn set_scroll_phase(phase: ScrollPhase) {
 /// synthesize their own wheel events (they get `Wheel` semantics unless a
 /// real gesture is mid-flight).
 pub fn current_scroll_phase() -> ScrollPhase {
-    match PHASE.load(Ordering::Relaxed) {
-        1 => ScrollPhase::Finger,
-        2 => ScrollPhase::FingerEnd,
-        _ => ScrollPhase::Wheel,
-    }
+    crate::window_state::with(|w| w.scroll_phase.get())
 }
 
 /// Pixels one wheel notch moves a list — the toolkit's line unit, shared so
diff --git a/src/window_state.rs b/src/window_state.rs
index 20ab150..cb245e5 100644
--- a/src/window_state.rs
+++ b/src/window_state.rs
@@ -4,7 +4,11 @@
 //! - 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`).
+//! - the input method's composition and the caret it is drawn at (`ime`),
+//! - the phase of the wheel event being dispatched (`widget::scroll_motion`),
+//! - and the window's own properties ([`Props`]): its HiDPI scale, the display metric,
+//!   its app id, whether it is fullscreen or maximized, and vertical text (`scale`,
+//!   `units`, `backend::text`).
 //!
 //! 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
@@ -14,8 +18,14 @@
 //! 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.
+//!
+//! **The window's properties read differently off a window.** A worker thread asking for the
+//! scale (a page rasterized at it, a tile decoded for it) has no window entered; it reads the
+//! process-wide value, which every window's setter also writes — the last one set. So a
+//! process with one window reads exactly what it did when these were only process-wide,
+//! and two windows on one thread each read their own (`docs/rfc-global-state.md`, phase 4).
 
-use std::cell::RefCell;
+use std::cell::{Cell, RefCell};
 use std::rc::Rc;
 
 use crate::widget::context_menu::ContextMenuState;
@@ -29,6 +39,37 @@ pub struct WindowState {
     pub(crate) cursor: RefCell<(f32, f32)>,
     pub(crate) swipe: RefCell<SideSwipe>,
     pub(crate) ime: RefCell<crate::ime::State>,
+    pub(crate) scroll_phase: Cell<crate::widget::ScrollPhase>,
+    pub(crate) props: RefCell<Props>,
+}
+
+/// A window's own properties (see the module docs).
+#[derive(Debug, Clone)]
+pub struct Props {
+    /// The HiDPI scale it is drawn at (`scale::scale_factor`).
+    pub scale: f32,
+    /// The display it is on, in logical px per mm (`units::metric`).
+    pub metric: Option<crate::units::Metric>,
+    pub app_id: String,
+    pub fullscreen: bool,
+    pub maximized: bool,
+    /// Set while it stands on a screen edge as a vertical bar of this thickness
+    /// (`backend::text::vertical_text`).
+    pub vertical_text: Option<u32>,
+}
+
+impl Props {
+    /// The process-wide values: what a window starts from.
+    fn from_process() -> Props {
+        Props {
+            scale: crate::scale::process_scale_factor(),
+            metric: None,
+            app_id: crate::scale::process_app_id(),
+            fullscreen: false,
+            maximized: false,
+            vertical_text: crate::backend::text::process_vertical_text(),
+        }
+    }
 }
 
 impl WindowState {
@@ -40,6 +81,8 @@ impl WindowState {
             cursor: RefCell::new((0.0, 0.0)),
             swipe: RefCell::new(SideSwipe::new()),
             ime: RefCell::new(crate::ime::State::default()),
+            scroll_phase: Cell::new(crate::widget::ScrollPhase::Wheel),
+            props: RefCell::new(Props::from_process()),
         })
     }
 }
@@ -56,6 +99,26 @@ pub fn with<R>(f: impl FnOnce(&WindowState) -> R) -> R {
     f(&state)
 }
 
+/// Run `f` on the window entered on this thread, if one is: `None` off any window's thread
+/// (where a window's properties read as the process-wide values).
+pub fn entered<R>(f: impl FnOnce(&WindowState) -> R) -> Option<R> {
+    let state = CURRENT.with(|c| c.borrow().clone())?;
+    Some(f(&state))
+}
+
+/// The display metric of the window whose code is running: what `units::metric` answers
+/// first ([`crate::units::set_metric_resolver`]).
+fn window_metric() -> Option<crate::units::Metric> {
+    entered(|w| w.props.borrow().metric).flatten()
+}
+
+/// The display metric changed: the current window's, if one is entered, and the
+/// process-wide one (a forced PPI applied to both, `units::effective`).
+pub fn set_metric(m: crate::units::Metric) {
+    entered(|w| w.props.borrow_mut().metric = Some(crate::units::effective(m)));
+    crate::units::set_metric(m);
+}
+
 /// 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 {
@@ -72,6 +135,7 @@ impl Drop for Entered {
 /// 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 {
+    crate::units::set_metric_resolver(window_metric);
     let previous = CURRENT.with(|c| c.replace(Some(Rc::clone(state))));
     Entered { previous }
 }
@@ -80,6 +144,30 @@ pub fn enter(state: &Rc<WindowState>) -> Entered {
 mod tests {
     use super::*;
 
+    /// Two windows draw at their own scales; a worker thread, on no window, reads the last
+    /// one set; and the scroll phase is the window's.
+    #[test]
+    fn each_window_has_its_own_scale_and_a_worker_reads_the_last() {
+        let (a, b) = (WindowState::new(), WindowState::new());
+        {
+            let _in_a = enter(&a);
+            crate::scale::set_scale_factor(2.0);
+            crate::widget::scroll_motion::set_scroll_phase(crate::widget::ScrollPhase::Finger);
+        }
+        {
+            let _in_b = enter(&b);
+            crate::scale::set_scale_factor(1.5);
+            assert_eq!(crate::scale::scale_factor(), 1.5);
+            assert_eq!(crate::widget::scroll_motion::current_scroll_phase(), crate::widget::ScrollPhase::Wheel, "b's own phase");
+            let (seen, process) = std::thread::spawn(|| (crate::scale::scale_factor(), crate::scale::process_scale_factor())).join().unwrap();
+            assert_eq!(seen, process, "a worker, on no window, reads the process-wide scale");
+        }
+        let _in_a = enter(&a);
+        assert_eq!(crate::scale::scale_factor(), 2.0, "a keeps its own");
+        assert_eq!(crate::widget::scroll_motion::current_scroll_phase(), crate::widget::ScrollPhase::Finger);
+        crate::widget::scroll_motion::set_scroll_phase(crate::widget::ScrollPhase::Wheel);
+    }
+
     /// Two windows keep two menus: what one shows the other does not, and the one entered
     /// before comes back when the guard drops.
     #[test]