Skip to main content

repose_core/
runtime.rs

1use std::any::Any;
2use std::cell::RefCell;
3use std::panic::Location;
4use std::rc::Rc;
5use std::sync::atomic::{AtomicU64, Ordering};
6
7use rustc_hash::FxHashMap;
8
9use crate::scope::Scope;
10use crate::{Rect, Scene, View, semantics::Role};
11
12thread_local! {
13    pub static COMPOSER: RefCell<Composer> = RefCell::new(Composer::default());
14    static ROOT_SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
15
16    /// Programmatic focus requests queued by `FocusRequester`. A queue (not a
17    /// single slot) so multiple requests in one frame are honored in order
18    /// instead of last-wins. `CLEAR_FOCUS_MARKER` entries clear focus.
19    static FOCUS_REQUESTS: RefCell<std::collections::VecDeque<u64>> =
20        const { RefCell::new(std::collections::VecDeque::new()) };
21}
22
23/// Sentinel value meaning "clear focus entirely".
24pub const CLEAR_FOCUS_MARKER: u64 = u64::MAX;
25
26/// Process-wide unique id for namespacing `remember_with_key` slots
27/// (dialogs, menus, sheets, tooltips, ...).
28static COMPONENT_ID: AtomicU64 = AtomicU64::new(1);
29
30/// Returns a fresh unique component id. See [`COMPONENT_ID`].
31pub fn unique_component_id() -> u64 {
32    COMPONENT_ID.fetch_add(1, Ordering::Relaxed)
33}
34
35pub fn take_focus_request() -> Option<u64> {
36    FOCUS_REQUESTS.with(|r| r.borrow_mut().pop_front())
37}
38
39/// Drain all queued focus requests in FIFO order.
40pub fn drain_focus_requests() -> Vec<u64> {
41    FOCUS_REQUESTS.with(|r| r.borrow_mut().drain(..).collect())
42}
43
44/// A handle that can programmatically request focus for a widget.
45///
46/// Similar to Compose's `FocusRequester`. Create one via `remember(FocusRequester::new)`,
47/// attach it via `.focus_requester(...)` on a modifier, and call `request_focus()` to
48/// move keyboard focus to the associated widget on the next frame.
49#[derive(Clone)]
50pub struct FocusRequester {
51    /// Target view ID, set during layout/paint by the modifier system.
52    pub target: Rc<RefCell<Option<u64>>>,
53}
54
55impl FocusRequester {
56    pub fn new() -> Self {
57        Self {
58            target: Rc::new(RefCell::new(None)),
59        }
60    }
61
62    /// Request focus for the associated widget on the next frame.
63    /// Queued; unlike the old single-slot behavior, multiple requests in one
64    /// frame are all honored in order. If the target is not laid out yet the
65    /// request is dropped (target assigned during layout/paint).
66    pub fn request_focus(&self) {
67        if let Some(id) = *self.target.borrow() {
68            FOCUS_REQUESTS.with(|r| r.borrow_mut().push_back(id));
69        }
70    }
71
72    /// Free/clear focus from the associated widget on the next frame.
73    /// If the associated widget currently has focus, focus is cleared entirely.
74    /// Corresponds to Compose's `freeFocus()`.
75    pub fn free_focus(&self) {
76        FOCUS_REQUESTS.with(|r| r.borrow_mut().push_back(CLEAR_FOCUS_MARKER));
77    }
78
79    /// Request focus for the associated widget on the next frame,
80    /// bypassing some focusability checks. Corresponds to Compose's
81    /// `captureFocus()`, which is typically used internally by the focus system.
82    /// In repose this is an alias for `request_focus()`.
83    pub fn capture_focus(&self) {
84        self.request_focus();
85    }
86}
87
88impl Default for FocusRequester {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94/// Direction for focus movement.
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub enum FocusDirection {
97    Next,
98    Previous,
99    Left,
100    Right,
101    Up,
102    Down,
103}
104
105/// A manager for programmatic focus navigation.
106///
107/// Wraps a `&Scheduler` and provides methods to move focus.
108/// Can also be used standalone with a focus chain and focused element.
109#[derive(Clone)]
110pub struct FocusManager {
111    /// The ordered list of focusable element IDs.
112    pub chain: Vec<u64>,
113    /// The currently focused element (if any).
114    pub focused: Option<u64>,
115    /// Hit regions for focus group lookups.
116    pub hit_regions: Vec<HitRegion>,
117}
118
119impl FocusManager {
120    pub fn new(chain: Vec<u64>, focused: Option<u64>) -> Self {
121        Self {
122            chain,
123            focused,
124            hit_regions: Vec::new(),
125        }
126    }
127
128    /// Move focus in the given direction.
129    /// Returns the new focused element ID, or `None` if no movement is possible.
130    pub fn move_focus(&mut self, dir: FocusDirection) -> Option<u64> {
131        match dir {
132            FocusDirection::Next | FocusDirection::Previous => {
133                self.move_tab(dir == FocusDirection::Previous)
134            }
135            _ => None, // use move_focus_spatial when hit regions are available
136        }
137    }
138
139    /// Clears focus entirely on the next frame.
140    /// Corresponds to Compose's `FocusManager.clearFocus()`.
141    /// The `force` parameter is accepted for API compatibility. In repose
142    /// focus is always cleared immediately (no keep-focus mechanism).
143    pub fn clear_focus(&self, _force: bool) {
144        FOCUS_REQUESTS.with(|r| r.borrow_mut().push_back(CLEAR_FOCUS_MARKER));
145    }
146
147    /// Spatial focus navigation: find the closest focusable element in a given
148    /// direction using bounding rect geometry.
149    pub fn move_focus_spatial(
150        &mut self,
151        dir: FocusDirection,
152        hit_regions: &[HitRegion],
153    ) -> Option<u64> {
154        let next = spatial_focus_next(&self.chain, hit_regions, self.focused, dir)?;
155        self.focused = Some(next);
156        Some(next)
157    }
158
159    /// Tab forward or backward in the focus chain.
160    /// When the current focus belongs to a focus group, navigation is restricted
161    /// to elements within that group.
162    pub fn move_tab(&mut self, reverse: bool) -> Option<u64> {
163        if self.chain.is_empty() {
164            return None;
165        }
166        let next = if let Some(sub_chain) =
167            focus_group_chain(&self.chain, &self.hit_regions, self.focused)
168        {
169            if sub_chain.is_empty() {
170                return None;
171            }
172            if let Some(cur) = self.focused {
173                if let Some(idx) = sub_chain.iter().position(|&id| id == cur) {
174                    if reverse {
175                        if idx == 0 {
176                            sub_chain[sub_chain.len() - 1]
177                        } else {
178                            sub_chain[idx - 1]
179                        }
180                    } else {
181                        sub_chain[(idx + 1) % sub_chain.len()]
182                    }
183                } else {
184                    sub_chain[0]
185                }
186            } else if reverse {
187                sub_chain[sub_chain.len() - 1]
188            } else {
189                sub_chain[0]
190            }
191        } else {
192            if let Some(cur) = self.focused {
193                if let Some(idx) = self.chain.iter().position(|&id| id == cur) {
194                    if reverse {
195                        if idx == 0 {
196                            self.chain[self.chain.len() - 1]
197                        } else {
198                            self.chain[idx - 1]
199                        }
200                    } else {
201                        self.chain[(idx + 1) % self.chain.len()]
202                    }
203                } else {
204                    self.chain[0]
205                }
206            } else if reverse {
207                self.chain[self.chain.len() - 1]
208            } else {
209                self.chain[0]
210            }
211        };
212        self.focused = Some(next);
213        Some(next)
214    }
215
216    /// Set target ID on a FocusRequester (called during layout).
217    pub fn set_requester_target(requester: &FocusRequester, id: u64) {
218        *requester.target.borrow_mut() = Some(id);
219    }
220}
221
222/// Sub-chain of ids sharing the focused element's focus group.
223/// Returns `None` when focus is outside any group (full chain applies).
224/// Tab and spatial navigation both scope to this so modals trap focus.
225pub fn focus_group_chain(
226    chain: &[u64],
227    hit_regions: &[HitRegion],
228    current: Option<u64>,
229) -> Option<Vec<u64>> {
230    let cur = current?;
231    let group_id = hit_regions.iter().find(|h| h.id == cur)?.focus_group_id?;
232    Some(
233        chain
234            .iter()
235            .copied()
236            .filter(|&id| {
237                id == group_id
238                    || hit_regions
239                        .iter()
240                        .any(|h| h.id == id && h.focus_group_id == Some(group_id))
241            })
242            .collect(),
243    )
244}
245
246/// Find the next focusable element in a given spatial direction.
247///
248/// Uses the bounding rects from `hit_regions` to determine which element is
249/// "next" in the given direction from the currently focused element.
250pub fn spatial_focus_next(
251    chain: &[u64],
252    hit_regions: &[HitRegion],
253    current: Option<u64>,
254    dir: FocusDirection,
255) -> Option<u64> {
256    if chain.is_empty() {
257        return None;
258    }
259
260    let current_rect =
261        current.and_then(|id| hit_regions.iter().find(|h| h.id == id).map(|h| h.rect));
262
263    // For Next/Previous, use tab-order navigation
264    match dir {
265        FocusDirection::Next | FocusDirection::Previous => {
266            let mut fm = FocusManager {
267                chain: chain.to_vec(),
268                focused: current,
269                hit_regions: hit_regions.to_vec(),
270            };
271            return fm.move_tab(dir == FocusDirection::Previous);
272        }
273        _ => {}
274    }
275
276    let (cx, cy) = match current_rect {
277        Some(r) => (r.x + r.w / 2.0, r.y + r.h / 2.0),
278        None => {
279            // For games: Tab/Shift-Tab should establish initial focus, not arrows.
280            return None;
281        }
282    };
283
284    let mut best: Option<(u64, f32)> = None;
285
286    // Modal trap: arrows stay inside the focused element's focus group.
287    let scoped: Vec<u64>;
288    let chain: &[u64] = match focus_group_chain(chain, hit_regions, current) {
289        Some(sub) => {
290            scoped = sub;
291            &scoped
292        }
293        None => chain,
294    };
295
296    for &id in chain {
297        if Some(id) == current {
298            continue;
299        }
300        let Some(hr) = hit_regions.iter().find(|h| h.id == id) else {
301            continue;
302        };
303        let r = hr.rect;
304        let other_cx = r.x + r.w / 2.0;
305        let other_cy = r.y + r.h / 2.0;
306        let dx = other_cx - cx;
307        let dy = other_cy - cy;
308
309        let in_direction = match dir {
310            FocusDirection::Left => dx < 0.0 && dy.abs() <= r.h.max(1.0),
311            FocusDirection::Right => dx > 0.0 && dy.abs() <= r.h.max(1.0),
312            FocusDirection::Up => dy < 0.0 && dx.abs() <= r.w.max(1.0),
313            FocusDirection::Down => dy > 0.0 && dx.abs() <= r.w.max(1.0),
314            _ => false,
315        };
316
317        if !in_direction {
318            continue;
319        }
320
321        let dist = dx * dx + dy * dy;
322        let weight = dist / (r.w * r.h + 1.0).max(1.0);
323
324        match best {
325            Some((_, best_weight)) if weight >= best_weight => {}
326            _ => best = Some((id, weight)),
327        }
328    }
329
330    best.map(|(id, _)| id)
331}
332
333#[derive(Default)]
334pub struct Composer {
335    pub slots: Vec<Box<dyn Any>>,
336    /// Caller identity for each slot, used to detect stale slots
337    /// when the composition tree changes between frames.
338    pub slot_callers: Vec<&'static Location<'static>>,
339    pub cursor: usize,
340    pub keyed_slots: FxHashMap<String, Box<dyn Any>>,
341    /// Per-scope cached state for the `scope!` macro.
342    /// Keyed by the scope key string.
343    pub scope_caches: FxHashMap<String, crate::scope_cache::ScopeCache>,
344}
345
346pub struct ComposeGuard {
347    scope: Scope,
348}
349
350impl ComposeGuard {
351    pub fn begin() -> Self {
352        COMPOSER.with(|c| c.borrow_mut().cursor = 0);
353
354        let scope = ROOT_SCOPE.with(|rs| {
355            if let Some(existing) = rs.borrow().clone() {
356                existing
357            } else {
358                let s = Scope::new();
359                *rs.borrow_mut() = Some(s.clone());
360                s
361            }
362        });
363
364        ComposeGuard { scope }
365    }
366
367    pub fn scope(&self) -> &Scope {
368        &self.scope
369    }
370}
371
372impl Drop for ComposeGuard {
373    fn drop(&mut self) {
374        // ROOT_SCOPE.with(|rs| { Do not clear every frame
375        //     *rs.borrow_mut() = None;
376        // });
377    }
378}
379
380/// Slot-based remember (sequential composition only).
381/// This prevents state aliasing when the composition tree structure changes between frames
382#[track_caller]
383pub fn remember<T: 'static>(init: impl FnOnce() -> T) -> Rc<T> {
384    // Capture BEFORE any closure -> Location::caller() returns the correct
385    // track_caller location only at the function's top level, not inside closures.
386    let caller = Location::caller();
387    COMPOSER.with(|c| {
388        let mut c = c.borrow_mut();
389        let cursor = c.cursor;
390        c.cursor += 1;
391
392        if cursor >= c.slots.len() {
393            let rc: Rc<T> = Rc::new(init());
394            c.slots.push(Box::new(rc.clone()));
395            c.slot_callers.push(caller);
396            return rc;
397        }
398
399        let stored_caller = c.slot_callers.get(cursor).copied();
400        if stored_caller != Some(caller) {
401            let rc: Rc<T> = Rc::new(init());
402            c.slots[cursor] = Box::new(rc.clone());
403            if cursor < c.slot_callers.len() {
404                c.slot_callers[cursor] = caller;
405            } else {
406                c.slot_callers.push(caller);
407            }
408            return rc;
409        }
410
411        if let Some(rc) = c.slots[cursor].downcast_ref::<Rc<T>>() {
412            rc.clone()
413        } else {
414            log::warn!(
415                "remember: slot {} type changed {}. \
416                 Use remember_with_key(key, || ...) for conditional branches.",
417                cursor,
418                std::any::type_name::<T>(),
419            );
420            let rc: Rc<T> = Rc::new(init());
421            c.slots[cursor] = Box::new(rc.clone());
422            rc
423        }
424    })
425}
426
427/// Key-based remember.
428pub fn remember_with_key<T: 'static>(key: impl Into<String>, init: impl FnOnce() -> T) -> Rc<T> {
429    COMPOSER.with(|c| {
430        let mut c = c.borrow_mut();
431        let key = key.into();
432
433        if let Some(existing) = c.keyed_slots.get(&key) {
434            if let Some(rc) = existing.downcast_ref::<Rc<T>>() {
435                return rc.clone();
436            } else {
437                log::warn!(
438                    "remember_with_key: key '{}' reused with a different type; replacing.",
439                    key
440                );
441            }
442        }
443
444        if cfg!(debug_assertions) && c.keyed_slots.len() > 10_000 {
445            log::warn!(
446                "remember_with_key: more than 10k keys stored; \
447                are you generating unbounded dynamic keys (e.g., using timestamps)?"
448            );
449        }
450
451        let rc: Rc<T> = Rc::new(init());
452        c.keyed_slots.insert(key, Box::new(rc.clone()));
453        rc
454    })
455}
456
457/// Raw slot state (`Rc<RefCell<T>>`). Writes via `borrow_mut()` don't request a
458/// frame - prefer [`remember_mutable`] if a write must always recompose.
459#[track_caller]
460pub fn remember_state<T: 'static>(init: impl FnOnce() -> T) -> Rc<RefCell<T>> {
461    remember(|| RefCell::new(init()))
462}
463
464/// Key-based variant of [`remember_state`]. Same no-frame-on-write caveat.
465pub fn remember_state_with_key<T: 'static>(
466    key: impl Into<String>,
467    init: impl FnOnce() -> T,
468) -> Rc<RefCell<T>> {
469    remember_with_key(key, || RefCell::new(init()))
470}
471
472/// Frame - output of composition for a tick: scene + input/semantics.
473#[derive(Clone)]
474pub struct Frame {
475    pub scene: Scene,
476    pub hit_regions: Vec<HitRegion>,
477    pub semantics_nodes: Vec<SemNode>,
478    pub focus_chain: Vec<u64>,
479}
480
481/// Hit-test region in physical pixels (`rect` carries px magnitudes,
482/// like Compose `Rect`).
483#[derive(Clone, Default)]
484pub struct HitRegion {
485    pub id: u64,
486    pub rect: Rect,
487    /// Tree depth: 0 = root, higher = deeper child. Used for three-pass
488    /// pointer dispatch to determine ancestor/descendant ordering.
489    pub depth: u32,
490    pub parent: Option<u64>,
491    pub on_click: Option<Rc<dyn Fn()>>,
492    pub on_double_click: Option<Rc<dyn Fn()>>,
493    pub on_long_click: Option<Rc<dyn Fn()>>,
494    pub on_scroll: Option<Rc<dyn Fn(crate::Vec2) -> crate::Vec2>>,
495    pub focusable: bool,
496    pub on_pointer_down: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
497    pub on_pointer_move: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
498    pub on_pointer_up: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
499    pub on_pointer_cancel: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
500    pub on_pointer_enter: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
501    pub on_pointer_leave: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
502    pub z_index: f32,
503    pub disabled: bool,
504    pub on_text_change: Option<Rc<dyn Fn(String)>>,
505    pub on_text_submit: Option<Rc<dyn Fn(String)>>,
506    /// If this hit region belongs to a TextField, this persistent key is used
507    /// for looking up platform-managed TextFieldState. Falls back to `id` if None.
508    pub tf_state_key: Option<u64>,
509
510    /// True if this hit region corresponds to a multiline text input (TextArea).
511    pub tf_multiline: bool,
512
513    /// Unclipped top-left of the TextField *content* box (padding-inset).
514    /// Used for pointer->grapheme mapping so parent scroll clipping of `rect`
515    /// does not shift selection into the top of the content.
516    /// `None` for non-textfields.
517    pub tf_content_origin: Option<(f32, f32)>,
518
519    /// When false, the field rejects edits and is not focusable
520    pub tf_enabled: bool,
521
522    /// When true, selection/focus/copy are allowed but mutations are rejected
523    pub tf_read_only: bool,
524
525    /// Controlled text snapshot for this field (last compose).
526    pub tf_value: String,
527
528    /// Font size for this text field in [`Sp`](crate::units::Sp)
529    /// (for hit-test / caret mapping). `Sp::ZERO` means use `TF_FONT_SP` default.
530    pub tf_font_size: crate::units::Sp,
531
532    // internal
533    pub on_drag_start: Option<Rc<dyn Fn(crate::dnd::DragStart) -> Option<crate::dnd::DragPayload>>>,
534    pub on_drag_end: Option<Rc<dyn Fn(crate::dnd::DragEnd)>>,
535    pub on_drag_enter: Option<Rc<dyn Fn(crate::dnd::DragOver)>>,
536    pub on_drag_over: Option<Rc<dyn Fn(crate::dnd::DragOver)>>,
537    pub on_drag_leave: Option<Rc<dyn Fn(crate::dnd::DragOver)>>,
538    pub on_drop: Option<Rc<dyn Fn(crate::dnd::DropEvent) -> bool>>,
539    /// Copied onto the drag session when a drag starts from this region.
540    pub drag_preview: Option<crate::dnd::DragPreview>,
541
542    pub on_action: Option<Rc<dyn Fn(crate::shortcuts::Action) -> bool>>,
543
544    /// Called when a key event is received while this element is focused.
545    /// Return `true` to consume the event.
546    pub on_key_event: Option<Rc<dyn Fn(crate::input::KeyEvent) -> bool>>,
547    /// Called before `on_key_event`. Return `true` to consume before normal dispatch.
548    pub on_preview_key_event: Option<Rc<dyn Fn(crate::input::KeyEvent) -> bool>>,
549
550    /// Cursor hint for desktop/web.
551    pub cursor: Option<crate::CursorIcon>,
552
553    /// If `Some(group_id)`, this hit region belongs to a focus group with the
554    /// given id. Tab navigation will cycle within the group instead of moving
555    /// to elements outside it. Set automatically by the layout engine when the
556    /// element is a descendant of a node with `focus_group: true`.
557    pub focus_group_id: Option<u64>,
558
559    /// IME keyboard hints, populated for text-field hit regions so the
560    /// platform runner can configure the OS keyboard / IME on focus.
561    pub keyboard_type: crate::text::KeyboardType,
562    pub capitalization: crate::text::KeyboardCapitalization,
563    pub ime_action: crate::text::ImeAction,
564    /// Whether auto-correct is enabled. `None` = follow platform default;
565    /// password keyboards always resolve to `false` in the layout engine.
566    pub auto_correct: Option<bool>,
567
568    /// Shared interaction source auto-wired by the layout engine
569    /// (press/hover/focus/drag). Used by keyboard activation and focus
570    /// transitions so they stay in parity with pointer input.
571    /// `None` when the component does not need one (no indication/state colors).
572    pub interaction_source: Option<crate::modifier::InteractionSource>,
573}
574
575impl HitRegion {
576    /// Seed a HitRegion with all the modifier's event handlers + dnd + cursor.
577    /// Call‑sites should only override the fields that differ (on_click, focusable, etc.)
578    /// via struct‑update syntax: `HitRegion { focusable: true, ..from_modifier(..) }`.
579    pub fn from_modifier(id: u64, rect: Rect, m: &crate::modifier::Modifier) -> Self {
580        Self {
581            id,
582            rect,
583            z_index: m.z_index,
584            on_click: m.on_click.clone(),
585            on_double_click: m.on_double_click.clone(),
586            on_long_click: m.on_long_click.clone(),
587            on_pointer_down: m.on_pointer_down.clone(),
588            on_pointer_move: m.on_pointer_move.clone(),
589            on_pointer_up: m.on_pointer_up.clone(),
590            on_pointer_cancel: m.on_pointer_cancel.clone(),
591            on_pointer_enter: m.on_pointer_enter.clone(),
592            on_pointer_leave: m.on_pointer_leave.clone(),
593            on_action: m.on_action.clone(),
594            on_key_event: m.on_key_event.clone(),
595            on_preview_key_event: m.on_preview_key_event.clone(),
596            cursor: m.cursor,
597            on_drag_start: m.on_drag_start.clone(),
598            on_drag_end: m.on_drag_end.clone(),
599            on_drag_enter: m.on_drag_enter.clone(),
600            on_drag_over: m.on_drag_over.clone(),
601            on_drag_leave: m.on_drag_leave.clone(),
602            on_scroll: m.on_scroll.clone(),
603            on_drop: m.on_drop.clone(),
604            drag_preview: m.drag_preview.clone(),
605            disabled: m.disabled,
606            tf_enabled: true,
607            tf_read_only: false,
608            ..Default::default()
609        }
610    }
611}
612
613/// Flattened semantics node produced by `layout_and_paint`.
614///
615/// This is the source of truth for accessibility backends: it contains the
616/// resolved screen rect, role, label, and focus/enabled state.
617///
618/// The platform runner should convert this into OS‑specific accessibility trees (when implemented)
619/// (AT‑SPI on Linux, TalkBack on Android, etc.).
620#[derive(Clone)]
621pub struct SemNode {
622    /// Stable id, shared with the associated `HitRegion` / `ViewId`.
623    pub id: u64,
624
625    /// `None` means direct child of the window root.
626    pub parent: Option<u64>,
627
628    pub role: Role,
629    pub label: Option<String>,
630    pub rect: Rect,
631    pub focused: bool,
632    pub enabled: bool,
633    /// Marks this node as a collection of selectable children (e.g., Tabs).
634    pub selectable_group: bool,
635    pub checked: Option<bool>,
636    pub selected: Option<bool>,
637    pub value: Option<String>,
638}
639
640impl Default for SemNode {
641    fn default() -> Self {
642        Self {
643            id: 0,
644            parent: None,
645            role: Role::default(),
646            label: None,
647            rect: Rect::default(),
648            focused: false,
649            enabled: true,
650            selectable_group: false,
651            checked: None,
652            selected: None,
653            value: None,
654        }
655    }
656}
657
658pub struct Scheduler {
659    next_id: u64,
660    /// Per-scope unique IDs, assigned lazily when a scope first executes.
661    /// Keyed by the scope key string from `scope!`.
662    scope_key_to_id: FxHashMap<String, u32>,
663    next_scope_id: u32,
664    /// Stack of active scope keys. `id()` allocates from the innermost scope
665    /// so nested `scope!` bodies get stable packed IDs and the outer scope
666    /// resumes correctly after the inner exits (previously `exit_scope` reset
667    /// to `None`, leaking outer IDs into the global sequence).
668    current_scope: Vec<String>,
669    /// Per-scope local ID counters. Reset to 0 when a scope re-executes.
670    scope_local_counters: FxHashMap<String, u32>,
671    pub focused: Option<u64>,
672    pub size: (u32, u32),
673}
674
675impl Default for Scheduler {
676    fn default() -> Self {
677        Self::new()
678    }
679}
680
681impl Scheduler {
682    pub fn new() -> Self {
683        Self {
684            next_id: 1,
685            scope_key_to_id: FxHashMap::default(),
686            next_scope_id: 1,
687            current_scope: Vec::new(),
688            scope_local_counters: FxHashMap::default(),
689            focused: None,
690            size: (1280, 800),
691        }
692    }
693
694    /// Enter a named scope. Subsequent `id()` calls within this scope
695    /// will allocate from the scope's local counter, producing packed
696    /// `(scope_id << 32) | local_id` values that are stable across sibling
697    /// recompositions. Nested scopes push; `exit_scope` pops.
698    pub fn enter_scope(&mut self, key: &str) {
699        if !self.current_scope.iter().any(|k| k == key) {
700            self.scope_local_counters.insert(key.to_string(), 0);
701        }
702        self.current_scope.push(key.to_string());
703        self.get_or_create_scope_id(key);
704    }
705
706    /// Exit the innermost scope. No-op if the stack is empty or the top does
707    /// not match (defensive: never corrupt an outer scope).
708    pub fn exit_scope(&mut self) {
709        self.current_scope.pop();
710    }
711
712    /// RAII scope entry: pops on drop, so a panicking scope body cannot leave
713    /// the scheduler stuck in the wrong scope (which previously leaked outer
714    /// IDs into the global sequence).
715    pub fn scope_guard<'a>(&'a mut self, key: &str) -> SchedulerScopeGuard<'a> {
716        self.enter_scope(key);
717        SchedulerScopeGuard { sched: self }
718    }
719
720    /// Panic-safe scope entry that does NOT hold a borrow: the guarded body
721    /// (including nested `scope!`) can keep using the `Scheduler`. Used by
722    /// the `scope!` macro.
723    pub fn scope_guard_raw(&mut self, key: &str) -> SchedulerScopeGuardRaw {
724        let ptr = self as *mut Scheduler;
725        unsafe { SchedulerScopeGuardRaw::enter(ptr, key) }
726    }
727
728    fn get_or_create_scope_id(&mut self, key: &str) -> u32 {
729        if let Some(&id) = self.scope_key_to_id.get(key) {
730            id
731        } else {
732            let id = self.next_scope_id;
733            self.next_scope_id += 1;
734            self.scope_key_to_id.insert(key.to_string(), id);
735            id
736        }
737    }
738
739    pub fn id(&mut self) -> u64 {
740        if let Some(key) = self.current_scope.last().cloned() {
741            let scope_id = self.scope_key_to_id.get(&key).copied().unwrap_or(0);
742            let local = self.scope_local_counters.get_mut(&key).unwrap();
743            let id = *local;
744            *local += 1;
745            (scope_id as u64) << 32 | id as u64
746        } else {
747            // Global sequential ID (for non-scoped views)
748            let id = self.next_id;
749            self.next_id += 1;
750            id
751        }
752    }
753
754    pub fn id_count(&self) -> u64 {
755        self.next_id - 1
756    }
757
758    /// Snapshot the current ID counter (before executing a scope body) so the
759    /// delta can be computed after the body returns.
760    pub fn snapshot_id(&self) -> u64 {
761        self.next_id
762    }
763
764    /// Advance the ID counter by `count` without assigning IDs.
765    /// Used by the scope! macro to reserve IDs for a cached scope subtree.
766    pub fn advance_id(&mut self, count: u32) {
767        self.next_id += count as u64;
768    }
769
770    /// Number of IDs assigned since `prev_id` (the value returned by
771    /// `snapshot_id()` before executing a scope body).
772    pub fn ids_used_since(&self, prev_id: u64) -> u32 {
773        (self.next_id - prev_id) as u32
774    }
775}
776
777/// RAII guard from [`Scheduler::scope_guard`]. Pops the scope on drop.
778pub struct SchedulerScopeGuard<'a> {
779    sched: &'a mut Scheduler,
780}
781
782impl Drop for SchedulerScopeGuard<'_> {
783    fn drop(&mut self) {
784        self.sched.exit_scope();
785    }
786}
787
788/// Panic-safe scope guard that does NOT hold a borrow across the body.
789///
790/// The `scope!` macro uses this (not [`SchedulerScopeGuard`]) so the guarded
791/// body — including nested `scope!` invocations — can keep using the
792/// `Scheduler` normally. At drop time (normal or unwind) no other borrows of
793/// the scheduler are live, since the body has ended.
794pub struct SchedulerScopeGuardRaw {
795    sched: *mut Scheduler,
796}
797
798impl SchedulerScopeGuardRaw {
799    /// # Safety
800    /// `sched` must point to a valid `Scheduler` for the guard's lifetime,
801    /// and the scheduler must not be used while the guard is being dropped
802    /// (guaranteed when the guard outlives the body it protects).
803    pub unsafe fn enter(sched: *mut Scheduler, key: &str) -> Self {
804        unsafe {
805            (*sched).enter_scope(key);
806        }
807        Self { sched }
808    }
809}
810
811impl Drop for SchedulerScopeGuardRaw {
812    fn drop(&mut self) {
813        unsafe {
814            (*self.sched).exit_scope();
815        }
816    }
817}
818
819impl Scheduler {
820    pub fn repose<F>(
821        &mut self,
822        mut build_root: F,
823        layout_paint: impl Fn(&View, (u32, u32)) -> (Scene, Vec<HitRegion>, Vec<SemNode>),
824    ) -> Frame
825    where
826        F: FnMut(&mut Scheduler) -> View,
827    {
828        let guard = ComposeGuard::begin();
829        let root = guard.scope.run(|| build_root(self));
830        let (scene, hits, sem) = layout_paint(&root, self.size);
831
832        let focus_chain: Vec<u64> = hits.iter().filter(|h| h.focusable).map(|h| h.id).collect();
833
834        Frame {
835            scene,
836            hit_regions: hits,
837            semantics_nodes: sem,
838            focus_chain,
839        }
840    }
841}
842
843/// Avoids cross-test pollution
844#[cfg(test)]
845pub fn clear_composer() {
846    COMPOSER.with(|c| {
847        let mut c = c.borrow_mut();
848        c.slots.clear();
849        c.slot_callers.clear();
850        c.keyed_slots.clear();
851        c.scope_caches.clear();
852        c.cursor = 0;
853    });
854    ROOT_SCOPE.with(|rs| {
855        *rs.borrow_mut() = None;
856    });
857}
858
859#[cfg(test)]
860mod focus_trap_tests {
861    use super::*;
862
863    fn region(id: u64, x: f32, group: Option<u64>) -> HitRegion {
864        HitRegion {
865            id,
866            rect: Rect {
867                x,
868                y: 0.0,
869                w: 10.0,
870                h: 10.0,
871            },
872            focus_group_id: group,
873            ..Default::default()
874        }
875    }
876
877    #[test]
878    fn arrows_stay_inside_group() {
879        // Dialog buttons 2,3 in group 9; background button 4 outside;
880        // outsider 1 sits left of button 2 and would win unconstrained.
881        let chain = vec![1, 2, 3, 4];
882        let regions = vec![
883            region(1, 0.0, None),
884            region(2, 20.0, Some(9)),
885            region(3, 40.0, Some(9)),
886            region(4, 60.0, None),
887        ];
888        assert_eq!(
889            spatial_focus_next(&chain, &regions, Some(2), FocusDirection::Left),
890            None,
891            "outsider 1 is left of 2 but outside the group: trapped"
892        );
893        assert_eq!(
894            spatial_focus_next(&chain, &regions, Some(2), FocusDirection::Right),
895            Some(3)
896        );
897        assert_eq!(
898            spatial_focus_next(&chain, &regions, Some(3), FocusDirection::Left),
899            Some(2)
900        );
901        assert_eq!(
902            spatial_focus_next(&chain, &regions, Some(1), FocusDirection::Right),
903            Some(2),
904            "ungrouped focus still sees the full chain"
905        );
906    }
907
908    #[test]
909    fn tab_cycles_inside_group() {
910        let chain = vec![1, 2, 3, 4];
911        let regions = vec![
912            region(1, 0.0, None),
913            region(2, 20.0, Some(9)),
914            region(3, 40.0, Some(9)),
915            region(4, 60.0, None),
916        ];
917        let mut fm = FocusManager::new(chain, Some(2));
918        fm.hit_regions = regions;
919        assert_eq!(fm.move_tab(false), Some(3));
920        assert_eq!(fm.move_tab(false), Some(2));
921        assert_eq!(fm.move_tab(true), Some(3));
922    }
923
924    #[test]
925    fn tab_from_outside_can_enter_group() {
926        let chain = vec![1, 2, 3, 4];
927        let regions = vec![
928            region(1, 0.0, None),
929            region(2, 20.0, Some(9)),
930            region(3, 40.0, Some(9)),
931            region(4, 60.0, None),
932        ];
933        let mut fm = FocusManager::new(chain, Some(1));
934        fm.hit_regions = regions;
935        assert_eq!(fm.move_tab(false), Some(2));
936        let mut fm = FocusManager::new(vec![1, 2, 3, 4], Some(1));
937        fm.hit_regions = vec![
938            region(1, 0.0, None),
939            region(2, 20.0, Some(9)),
940            region(3, 40.0, Some(9)),
941            region(4, 60.0, None),
942        ];
943        assert_eq!(fm.move_tab(true), Some(4));
944    }
945
946    #[test]
947    fn empty_group_never_moves() {
948        let chain = vec![1, 4];
949        let regions = vec![region(1, 0.0, None), region(4, 60.0, None)];
950        let mut fm = FocusManager::new(chain, Some(1));
951        fm.hit_regions = regions.clone();
952        assert_eq!(fm.move_tab(false), Some(4));
953        let chain = vec![1, 2, 4];
954        let regions = vec![
955            region(1, 0.0, None),
956            region(2, 20.0, Some(77)),
957            region(4, 60.0, None),
958        ];
959        let mut fm = FocusManager::new(chain, Some(2));
960        fm.hit_regions = regions;
961        assert_eq!(fm.move_tab(false), Some(2));
962        assert_eq!(
963            spatial_focus_next(&fm.chain, &fm.hit_regions, Some(2), FocusDirection::Right),
964            None
965        );
966    }
967}