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

docs/rfc-global-state.md (11.3K)

  1 # RFC: Global state — what lives in statics, and where it should
  2 
  3 **Status:** Draft / roadmap (2026-10-08)
  4 **Scope:** cce-ui's process-wide and per-thread state: what there is, which of it is a cache
  5 (fine) and which is a window's or an app's state hiding in a static (not), and the order to
  6 move it.
  7 **Reads before this:** `CLAUDE.md` § "The registry holds pointers, and knows when they die",
  8 § "The context menu draws in its own popup surface"; `docs/rfc-core-rebuild.md`.
  9 
 10 ---
 11 
 12 ## 1. Why
 13 
 14 The toolkit audit of 2026-10-07 listed global state as the largest remaining structural
 15 debt. Its costs are concrete:
 16 
 17 - **Tests pin settings per thread** (`force_natural_scroll`, `force_scroll_settings`,
 18   `motion::force_for_test`, the style registry's per-thread overlay), because what they read
 19   is process state another test can change. Every new global grows that list.
 20 - **One window per thread.** The context menu, the hover highlight, input-method state and
 21   the focus thread-local are one per thread: a process with two windows on one loop would
 22   share a menu, a highlight and a composition.
 23 - **Two sources of truth.** Several pieces of interaction state are stored twice, once in a
 24   static and once in `UiContext`, kept in step by convention (§ 2.2).
 25 
 26 ## 2. Inventory (measured 2026-10-08)
 27 
 28 About 300 `static` items and 18 thread-locals in `src/`.
 29 
 30 ### 2.1 Style: about 200 `RwLock`s
 31 
 32 `color/mod.rs` and `layout/mod.rs` hold one `RwLock` per configured colour, radius, font and
 33 size (`BUTTON_CORNER_RADIUS`, `TREE_LEAF_TEXT_COLOR`, …), filled by `reload_colors` /
 34 `reload_config`, beside the style registry (`layout/registry.rs`: `FLOATS`, `LENS`, the
 35 flattened config) that holds much of the same configuration again. A read takes one lock; a
 36 reload writes two hundred, so a frame drawn during a reload can see half of each.
 37 
 38 **Verdict:** style IS process-wide (one DE config), so global is defensible — but as ONE
 39 snapshot (an `Arc<Style>` swapped whole on reload, read without locks), with one per-thread
 40 override for tests, not two hundred locks and a second registry.
 41 
 42 ### 2.2 Interaction state: per thread, and partly twice
 43 
 44 | State | Where | Twin |
 45 |---|---|---|
 46 | 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` |
 47 | hover highlight | `widget::hover_animation` thread-locals `HOVER_STATE`, `CURSOR_POS` | `UiContext::hover_state` and five methods — written by nobody, read by nobody |
 48 | context menu | `context_menu::CONTEXT_MENU` thread-local | `UiContext::context_menu` — never read |
 49 | a menu's page turn | `PAGE` thread-local | — |
 50 | side swipe | `side_swipe::SHARED` | — |
 51 | input-method composition | `ime::STATE` | — |
 52 | wake hook | `backend::app::WAKE` | — |
 53 
 54 Beside these, apps keep a third notion of focus: 139 direct `WidgetHost::focus()` /
 55 `unfocus()` calls that toggle a widget's own `focused` flag without telling any store.
 56 
 57 **Verdict:** a window's interaction state belongs to its `UiContext`. The twins go first:
 58 they can disagree today.
 59 
 60 ### 2.3 Properties of "the" window: per process
 61 
 62 `scale::scale_factor`, `units::metric`, the scroll phase the runner publishes before a
 63 dispatch, vertical text (`backend::text::set_vertical_text`). Each is a property of the
 64 window being drawn, published process-wide.
 65 
 66 **Verdict:** per window, handed to what needs it (a `Frame`/`PaintCtx` field, the
 67 `EventCtx`) — after § 2.2, since the same plumbing carries both.
 68 
 69 ### 2.4 Caches and switches: fine
 70 
 71 Shaped-text buffers, family and face caches, icon rasters, the image-id queue
 72 (`draw::images`), `OnceLock<bool>` debug switches read once from the environment, the l10n
 73 catalogue. Process-wide by nature; nothing to do beyond keeping them caches (no state a
 74 caller depends on between calls).
 75 
 76 ## 3. Plan
 77 
 78 ### Phase 1 — one store per piece of interaction state (the twins) — DONE 2026-10-08
 79 
 80 Done as written below. `widget::focus` keeps only `link_parent_child`; `EventCtx` gained
 81 `is_focused`, and `request_focus` records on the context (telling the previous holder, as
 82 before) where it set the thread-local. cce-designer's dialog and cce-system-interface's
 83 section navigation call `UiContext::set_focused_id` / `clear_focus` / `has_focus` /
 84 `is_focused_id`, which deliver FocusIn / FocusOut as the Tab walk does — so their manual
 85 `w.focus()` after a set went. Checked in a shadow: the demo's dropdown, reached by Tab,
 86 opens on Enter (its gate reads the context now); the settings app's Browser page takes
 87 typing in a clicked field and Enter commits it without opening a dropdown (the bug that
 88 gate exists for). The whole workspace builds.
 89 
 90 - **Focus:** `UiContext::focused_widget` is the store. The `widget::focus` thread-local and
 91   its free functions go; `EventCtx::request_focus` / `release_focus` act on the context;
 92   the dropdown's "am I focused" reads it. Apps that used the free functions
 93   (cce-designer, cce-system-interface) call `UiContext`'s.
 94 - **Hover highlight and the context menu:** the dead `UiContext` twins are deleted, so the
 95   thread-locals are the one store each until phase 2 moves them.
 96 
 97 ### Phase 2 — interaction state into a window's own state — DONE 2026-10-08
 98 
 99 Done, by a different route than first written below, for a reason found in the counting:
100 the context menu alone is reached 266 times from 18 apps, most of them through free
101 functions with no `UiContext` in hand, and some apps that show a menu have no context at
102 all (the terminal). So the state did not move INTO the context but beside it:
103 `window_state::WindowState` (the context menu, the hover highlight and its cursor, the side
104 swipe recognizer, the input-method composition) is OWNED by a window — the Wayland shell
105 makes one in `run` and keeps it across reconnects; the AppKit and browser shells make one
106 too — and the shell makes it current (`window_state::enter`, a guard) while it runs that
107 window's code. The modules' free functions act on the current one, so not one of their
108 callers changed; with none entered, each thread has a default (tests, tools that draw no
109 window), which is exactly what the thread-locals were. Two windows keep two menus
110 (`each_window_has_its_own_menu`); a shell that runs two windows on one thread enters each
111 around its dispatch. `context_menu::with_state` replaces the designer's reach into the old
112 `CONTEXT_MENU`. The browser clipboard bridge's `PAGE` stays: it is the page's clipboard,
113 not a window's interaction.
114 
115 **Direct focus calls.** Of the 103 `w.focus()` / `w.unfocus()` calls in apps, the 73 in the
116 eleven apps with a `UiContext` moved to `UiContext::focus_widget` / `unfocus_widget`, which
117 do what the direct call did AND keep the window's record of focus (the Tab walk, the
118 accessibility tree) in step: before, unfocusing the focused widget left the record on it,
119 and focusing another left the old one lit. The rest were already paired with a context call,
120 or are in the three apps with no context (cce-authenticator, cce-mail, cce-secrets), where a
121 widget's own flag is the only focus there is. Checked in a shadow: cce-fonts' preview box
122 takes a click and typing as before (its search box ignores clicks before and after — a
123 separate, older bug).
124 
125 The plan as first written:
126 
127 The context menu, its page turn, the hover highlight, the side swipe and the composition
128 move into the context (or a per-window struct beside it), reached through `EventCtx` by
129 widgets and through `UiContext` by apps; the free functions stay as deprecated forwarders
130 for one release while the apps move. The 139 direct `focus()` / `unfocus()` calls move to
131 the context's focus at the same time.
132 
133 ### Phase 3 — style as one snapshot — DONE 2026-10-08
134 
135 `crate::style::Style` is the whole style — the colour slots, the layout slots, the named
136 materials and the registry — published as an `Arc` and replaced whole on change. Each
137 former `static RwLock` (178 of them) is a typed handle on one field, `style::StyleCell`,
138 keeping the `RwLock` API (`.read()` / `.write()` returning a `Result` of a guard), so none of
139 the ~550 places that read or write them changed; `get_style_registry` returns the
140 registry's handle, and cce-grid, which writes it, compiles unchanged. A read takes no lock
141 (each thread keeps the last snapshot and checks a generation); a write publishes a new
142 snapshot when its guard drops, and guards never wait on each other. `reload_config` and
143 the colour load run as one `style::batch`: their writes go to one pending snapshot, which
144 their own reads see, published once — a reload is atomic, and its hundreds of per-key
145 writes cost one copy. 14 slots that were written and never read went with it (13
146 per-widget corner radii whose getters read the registry long ago, and the button hover
147 colour, a config key that did nothing). The per-thread test overlays are as they were.
148 Checked: the suite passes repeatedly; the demo and the settings app, run against the real
149 config, draw identically to the pixel before and after.
150 
151 The keys parsed twice were folded in on 2026-10-08: every layout key's getter reads the
152 registry alone and its setter writes it, so the ~50 slots that duplicated registry keys, the
153 reload's prefix scan that filled them, and the 35 getters that each re-parsed `config.kdl`
154 on first use are gone (`a_style_key_lives_in_the_registry_alone`). The colour slots are a
155 system of their own, filled from the raw KDL rather than the registry, and stay.
156 
157 `Style` (every colour, radius, font and size, and the registry's flattened config) built
158 whole by a reload and published as an `Arc`; getters read the current snapshot; tests
159 install their own per thread. The two hundred `RwLock`s and the second registry go.
160 
161 ### Phase 4 — per-window properties — DONE 2026-10-08
162 
163 Not by threading them through every frame and event (the scale alone has 27 readers and 37
164 setters across the toolkit and 17 apps) but as phase 2 did the interaction state: the
165 scale, the display metric, the app id, fullscreen, maximized and vertical text are a
166 window's `window_state::Props`, and the scroll phase a field beside them. The setters
167 (`scale::set_scale_factor`, `set_app_id`, …, `backend::text::set_vertical_text`,
168 `window_state::set_metric`) write the current window's AND the process-wide value; the
169 getters read the current window's, else the process-wide one. That last rule is what keeps
170 a worker thread right — a page rasterized at the scale, a tile decoded for it, has no
171 window entered, and reads the last value any window set — and it makes a single-window
172 process read exactly what it did. The metric lives in cce-core, which knows nothing of
173 windows: `units::set_metric_resolver` lets cce-ui answer first. The scroll phase, read only
174 while a window dispatches, is the window's or the thread's own, never the process's — so
175 tests no longer race on it. `each_window_has_its_own_scale_and_a_worker_reads_the_last`.
176 Checked at a forced scale of 2 against the real config: the demo and the settings app draw
177 identically to the pixel before and after (the settings app's run-to-run hover noise
178 aside).
179 
180 Scale, metric, scroll phase and vertical text travel with the frame and the event context
181 instead of process statics.
182 
183 Each phase leaves every app building and drawing as before; the measured check for phases
184 2–4 is a shadow session of the apps that use the moved state (cce-files,
185 cce-system-interface, cce-designer, cce-gallery, cce-data-editor).