1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
//! All retained UI state lives here, keyed by stable `WidgetId`s: scroll
//! offsets today; hover/focus/caret/animation clocks in M4+. Everything else
//! in the pipeline is a pure function of `(tree, theme, size, scale)`.
use std::collections::HashMap;
use crate::anim::Anim;
use crate::clipboard::{Clipboard, MemoryClipboard};
use crate::id::WidgetId;
use crate::input::EditorState;
/// How long the scrollbar stays fully visible after the last scroll.
const SCROLLBAR_HOLD_SECS: f64 = 0.8;
/// How long the scrollbar takes to fade out after the hold.
const SCROLLBAR_FADE_SECS: f64 = 0.3;
#[derive(Debug, Clone, Copy, Default)]
struct Scroll {
offset_y: f32,
last_change: f64,
/// Frame stamp for garbage collection, like `Anim::seen`.
seen: u64,
/// Sat at the bottom edge after the last clamp (stick-to-bottom).
at_bottom: bool,
}
/// Retained state for one UI surface (window or headless session).
pub struct FrameState {
/// Monotonic time in seconds, advanced by the runner via [`Self::tick`].
now: f64,
scroll: HashMap<WidgetId, Scroll>,
/// Snaps every animation to its final value. Headless rendering sets it.
pub reduced_motion: bool,
/// Elements currently hovered (with hover styling or handlers), with
/// the clock time the hover began (tooltips key off it).
pub(crate) hovered: HashMap<WidgetId, f64>,
/// The pressed element; it captures the pointer until release.
pub(crate) active: Option<WidgetId>,
/// The focused element.
pub(crate) focus: Option<WidgetId>,
/// Whether focus arrived via keyboard (paints the focus ring).
pub(crate) focus_visible: bool,
/// Last pointer position in logical coordinates.
pub(crate) pointer: Option<(f32, f32)>,
/// In-flight style transitions per widget.
pub(crate) anims: HashMap<WidgetId, Anim>,
/// Text editor state per input widget.
pub(crate) editors: HashMap<WidgetId, EditorState>,
/// Open overlay ids, bottom to top.
pub(crate) overlays: Vec<WidgetId>,
/// First-build time of each open overlay (drives enter animations).
pub(crate) overlay_opened: HashMap<WidgetId, f64>,
/// The clipboard; the shell injects the OS clipboard, headless keeps
/// the in-memory default.
pub(crate) clipboard: Box<dyn Clipboard>,
/// Frame stamp for animation garbage collection.
pub(crate) frame_no: u64,
/// Last completed click (element, clock time), for double-click.
pub(crate) last_click: Option<(WidgetId, f64)>,
/// Press-time click counting on inputs (word/line selection):
/// target, time, and how many presses in the chain (1..=3).
pub(crate) last_press: Option<(WidgetId, f64, u8)>,
/// Modifier keys as last reported by [`InputEvent::Modifiers`]:
/// (shift, ctrl, alt, meta).
pub(crate) mods: (bool, bool, bool, bool),
/// Active static-text selection (one at a time, like a browser):
/// element, parley selection, and whether a drag is extending it.
pub(crate) static_sel: Option<(WidgetId, parley::Selection, bool)>,
/// Focused-element type-ahead: target, buffer, last keystroke time.
pub(crate) type_ahead: Option<(WidgetId, String, f64)>,
/// Variable-height virtual lists: measured row heights per
/// container, estimates for the rest (self-correcting offsets).
pub(crate) virtual_heights: HashMap<WidgetId, HeightIndex>,
/// Materialized windows this frame (container -> row range), so the
/// post-layout pass knows which child maps to which row index.
pub(crate) virtual_windows: HashMap<WidgetId, std::ops::Range<usize>>,
/// The autofocus element and the frame it was last seen, so focus
/// moves only when it newly appears.
pub(crate) autofocus_last: Option<(WidgetId, u64)>,
/// In-flight internal drag payload (from `.drag_source`).
pub(crate) dragging: Option<String>,
/// Caret rect of the focused editor from the last paint, in logical
/// coordinates — the runner positions the IME popup with it.
pub(crate) ime_caret: Option<kurbo::Rect>,
/// Pointer position captured when a pointer-placed overlay opened, so
/// context menus stay pinned while the mouse moves.
pub(crate) pointer_pins: std::collections::HashMap<WidgetId, (f32, f32)>,
}
impl Default for FrameState {
fn default() -> Self {
Self {
now: 0.0,
scroll: HashMap::new(),
reduced_motion: false,
hovered: HashMap::new(),
active: None,
focus: None,
focus_visible: false,
pointer: None,
anims: HashMap::new(),
editors: HashMap::new(),
overlays: Vec::new(),
overlay_opened: HashMap::new(),
clipboard: Box::new(MemoryClipboard::default()),
frame_no: 0,
last_click: None,
last_press: None,
mods: (false, false, false, false),
static_sel: None,
type_ahead: None,
virtual_heights: HashMap::new(),
virtual_windows: HashMap::new(),
autofocus_last: None,
dragging: None,
ime_caret: None,
pointer_pins: std::collections::HashMap::new(),
}
}
}
impl FrameState {
/// Creates empty state.
pub fn new() -> Self {
Self::default()
}
/// Advances the clock (seconds since an arbitrary start).
pub fn tick(&mut self, now_seconds: f64) {
self.now = now_seconds;
}
/// The current clock value.
pub fn now(&self) -> f64 {
self.now
}
/// Whether the id is hovered.
pub fn is_hovered(&self, id: WidgetId) -> bool {
self.hovered.contains_key(&id)
}
/// How long the id has been hovered, in seconds.
pub(crate) fn hovered_for(&self, id: WidgetId) -> Option<f64> {
self.hovered.get(&id).map(|t| self.now - t)
}
/// Whether the overlay id is open.
pub fn overlay_open(&self, id: WidgetId) -> bool {
self.overlays.contains(&id)
}
/// Opens an overlay (pushes onto the stack).
pub(crate) fn open_overlay(&mut self, id: WidgetId) {
if !self.overlays.contains(&id) {
self.overlays.push(id);
self.overlay_opened.insert(id, self.now);
}
}
/// Closes an overlay.
pub(crate) fn close_overlay(&mut self, id: WidgetId) {
self.overlays.retain(|o| *o != id);
self.overlay_opened.remove(&id);
self.pointer_pins.remove(&id);
}
/// Whether the id is pressed.
pub fn is_active(&self, id: WidgetId) -> bool {
self.active == Some(id)
}
/// The focused widget, if any.
pub fn focused(&self) -> Option<WidgetId> {
self.focus
}
/// Moves focus programmatically (marks it keyboard-visible).
pub fn set_focus(&mut self, id: Option<WidgetId>) {
self.focus = id;
self.focus_visible = id.is_some();
}
/// Replaces the clipboard implementation (the shell injects arboard).
pub fn set_clipboard(&mut self, clipboard: Box<dyn Clipboard>) {
self.clipboard = clipboard;
}
/// Adds to a scrollable's offset. The offset is clamped to the content
/// range on the next frame build, when content size is known.
pub fn scroll_by(&mut self, id: WidgetId, dy: f32) {
let entry = self.scroll.entry(id).or_default();
entry.offset_y += dy;
entry.last_change = self.now;
// Recomputed at the next clamp; a manual move unpins the bottom.
entry.at_bottom = false;
}
/// The persisted scroll offset for an id (0 when never scrolled).
pub fn scroll_offset(&self, id: WidgetId) -> f32 {
self.scroll.get(&id).map_or(0.0, |s| s.offset_y)
}
/// The focused editor's caret rect from the last paint (logical
/// coordinates); the windowed runner anchors the IME candidate window
/// to it.
pub fn ime_caret(&self) -> Option<kurbo::Rect> {
self.ime_caret
}
/// Sets a scrollable's offset absolutely (clamped to the content range
/// on the next frame build; `f32::MAX` means "the bottom").
pub fn scroll_to(&mut self, id: WidgetId, offset_y: f32) {
let now = self.now;
let entry = self.scroll.entry(id).or_default();
entry.offset_y = offset_y.max(0.0);
entry.last_change = now;
// Recomputed at the next clamp; a manual move unpins the bottom.
entry.at_bottom = false;
}
/// Clamps a stored offset to `0..=max`, returning the clamped value.
/// Called during frame builds once the content height is known; also
/// stamps the entry as alive for [`Self::gc_scroll`] and applies the
/// stick-to-bottom rule (pinned while at the bottom edge).
pub(crate) fn clamp_scroll(&mut self, id: WidgetId, max: f32, stick_bottom: bool) -> f32 {
let frame_no = self.frame_no;
let max = max.max(0.0);
match self.scroll.get_mut(&id) {
Some(s) => {
if stick_bottom && s.at_bottom {
s.offset_y = max;
}
s.offset_y = s.offset_y.clamp(0.0, max);
s.at_bottom = s.offset_y >= max - 1.0;
s.seen = frame_no;
s.offset_y
}
None if stick_bottom => {
// Sticky containers start at the bottom, scrollbar quiet.
self.scroll.insert(
id,
Scroll {
offset_y: max,
last_change: -10.0,
seen: frame_no,
at_bottom: true,
},
);
max
}
None => 0.0,
}
}
/// Drops scroll entries whose container was not in the frame just built,
/// so dynamically keyed scrollables cannot grow the map without bound.
pub(crate) fn gc_scroll(&mut self, frame_no: u64) {
self.scroll.retain(|_, s| s.seen == frame_no);
}
/// Scrollbar opacity for an id: 1.0 while scrolling (held briefly), then
/// fading to 0. With `reduced_motion` the fade is a step function.
pub(crate) fn scrollbar_alpha(&self, id: WidgetId) -> f32 {
let Some(s) = self.scroll.get(&id) else {
return 0.0;
};
let age = self.now - s.last_change;
if age <= SCROLLBAR_HOLD_SECS {
1.0
} else if self.reduced_motion {
0.0
} else {
let t = (age - SCROLLBAR_HOLD_SECS) / SCROLLBAR_FADE_SECS;
#[expect(clippy::cast_possible_truncation, reason = "alpha fits in f32")]
{
(1.0 - t).clamp(0.0, 1.0) as f32
}
}
}
/// `true` while a scrollbar is mid-fade (the runner should keep
/// scheduling frames).
pub(crate) fn scrollbar_animating(&self, id: WidgetId) -> bool {
if self.reduced_motion {
return false;
}
self.scroll.get(&id).is_some_and(|s| {
let age = self.now - s.last_change;
age > 0.0 && age <= SCROLLBAR_HOLD_SECS + SCROLLBAR_FADE_SECS
})
}
}
/// Row-height bookkeeping for one variable-height virtual list:
/// measured heights where rows have materialized, the estimate
/// elsewhere, and a prefix-sum index for offset math.
#[derive(Debug)]
pub(crate) struct HeightIndex {
estimate: f32,
heights: Vec<f32>,
/// prefix[i] = offset of row i; prefix[count] = total height.
prefix: Vec<f32>,
dirty: bool,
}
impl HeightIndex {
pub(crate) fn ensure(&mut self, count: usize, estimate: f32) {
let estimate = if estimate.is_finite() && estimate > 0.0 {
estimate
} else {
1.0
};
if self.heights.len() != count || (self.estimate - estimate).abs() > f32::EPSILON {
self.estimate = estimate;
self.heights = vec![estimate; count];
self.dirty = true;
}
if self.dirty {
self.prefix.clear();
self.prefix.reserve(count + 1);
let mut acc = 0.0;
self.prefix.push(0.0);
for h in &self.heights {
acc += h;
self.prefix.push(acc);
}
self.dirty = false;
}
}
pub(crate) fn new_with(count: usize, estimate: f32) -> Self {
let mut index = Self {
estimate: 1.0,
heights: Vec::new(),
prefix: Vec::new(),
dirty: true,
};
index.ensure(count, estimate);
index
}
/// Offset of a row's top edge.
pub(crate) fn offset_of(&self, i: usize) -> f32 {
self.prefix.get(i).copied().unwrap_or_default()
}
/// Total content height.
pub(crate) fn total(&self) -> f32 {
self.prefix.last().copied().unwrap_or_default()
}
/// The row containing `offset` (binary search over the prefix).
pub(crate) fn index_at(&self, offset: f32) -> usize {
if self.heights.is_empty() {
return 0;
}
let offset = offset.max(0.0);
match self
.prefix
.binary_search_by(|p| p.partial_cmp(&offset).unwrap_or(std::cmp::Ordering::Less))
{
Ok(i) => i.min(self.heights.len().saturating_sub(1)),
Err(i) => i
.saturating_sub(1)
.min(self.heights.len().saturating_sub(1)),
}
}
/// Records a measured row height; offsets correct on the next
/// frame's `ensure`.
pub(crate) fn record(&mut self, i: usize, height: f32) {
if height.is_finite()
&& height > 0.0
&& let Some(slot) = self.heights.get_mut(i)
&& (*slot - height).abs() > 0.25
{
*slot = height;
self.dirty = true;
}
}
}
#[cfg(test)]
mod tests {
use super::HeightIndex;
#[test]
fn prefix_sums_follow_the_estimate() {
let idx = HeightIndex::new_with(5, 30.0);
assert_eq!(idx.offset_of(0), 0.0);
assert_eq!(idx.offset_of(3), 90.0);
assert_eq!(idx.total(), 150.0);
// Boundaries: a row's top belongs to that row.
assert_eq!(idx.index_at(0.0), 0);
assert_eq!(idx.index_at(29.9), 0);
assert_eq!(idx.index_at(30.0), 1);
// Out-of-range clamps instead of panicking.
assert_eq!(idx.index_at(-5.0), 0);
assert_eq!(idx.index_at(1.0e9), 4);
assert_eq!(idx.offset_of(99), 0.0, "missing rows read as origin");
}
#[test]
fn recorded_heights_correct_downstream_offsets() {
let mut idx = HeightIndex::new_with(5, 30.0);
idx.record(1, 64.0);
idx.ensure(5, 30.0); // the next frame rebuilds the prefix
assert_eq!(idx.offset_of(1), 30.0, "upstream rows are untouched");
assert_eq!(idx.offset_of(2), 94.0, "downstream rows shift");
assert_eq!(idx.total(), 184.0);
assert_eq!(idx.index_at(93.0), 1, "the taller row owns its span");
assert_eq!(idx.index_at(94.0), 2);
}
#[test]
fn hostile_measurements_and_jitter_are_ignored() {
let mut idx = HeightIndex::new_with(3, 30.0);
idx.record(0, f32::NAN);
idx.record(0, f32::INFINITY);
idx.record(0, 0.0);
idx.record(0, -12.0);
idx.record(1, 30.1); // sub-quarter-pixel jitter must not thrash
idx.record(99, 50.0); // out of range
idx.ensure(3, 30.0);
assert_eq!(idx.total(), 90.0, "nothing above changed the index");
}
#[test]
fn count_and_estimate_changes_reset_measurements() {
let mut idx = HeightIndex::new_with(3, 30.0);
idx.record(0, 64.0);
idx.ensure(3, 30.0);
assert_eq!(idx.total(), 124.0);
idx.ensure(4, 30.0); // row count changed: re-seed everything
assert_eq!(idx.total(), 120.0);
idx.record(0, 64.0);
idx.ensure(4, 20.0); // estimate changed: re-seed too
assert_eq!(idx.total(), 80.0);
idx.ensure(4, f32::NAN); // hostile estimate falls back to 1.0
assert_eq!(idx.total(), 4.0);
}
#[test]
fn empty_index_is_inert() {
let idx = HeightIndex::new_with(0, 30.0);
assert_eq!(idx.total(), 0.0);
assert_eq!(idx.index_at(10.0), 0);
assert_eq!(idx.offset_of(0), 0.0);
}
}