Skip to main content

repose_core/
runtime.rs

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