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, 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-key names currently down (`KeyW`, `Digit1`,
675    /// `Space`, ... — winit `KeyCode` debug names, layout-independent
676    /// positions, not glyphs). The platform runner maintains this from
677    /// raw `KeyboardInput` events without passing through focus
678    /// dispatch; games reconcile their event-staged held sets against
679    /// it every frame (GML `keyboard_check` parity). Cleared on window
680    /// focus loss (no key-ups arrive across an alt-tab).
681    pub held_keys: HashSet<String>,
682    /// Window focus as of the last platform event. `false` drops every
683    /// held key on the game side.
684    pub window_focused: bool,
685    /// Polled mouse-button levels, same source as `held_keys`
686    /// (GML `mouse_check_button` parity; repairs a missed button-up
687    /// when the release lands outside the window).
688    pub mouse_primary: bool,
689    pub mouse_secondary: bool,
690    pub mouse_middle: bool,
691    /// App-requested cursor override. When `Some`, the platform
692    /// applies it instead of the hover-derived icon: games hide the
693    /// OS pointer here (`CursorIcon::Hidden`) while drawing their own
694    /// crosshair, GML `window_set_cursor(cr_none)` parity. `None`
695    /// restores hover behavior. Set during composition (any view can
696    /// write it; last write per frame wins), consumed by the runner
697    /// after `frame()`.
698    pub cursor_override: Option<CursorIcon>,
699}
700
701impl Default for Scheduler {
702    fn default() -> Self {
703        Self::new()
704    }
705}
706
707impl Scheduler {
708    pub fn new() -> Self {
709        Self {
710            next_id: 1,
711            scope_key_to_id: FxHashMap::default(),
712            next_scope_id: 1,
713            current_scope: Vec::new(),
714            scope_local_counters: FxHashMap::default(),
715            focused: None,
716            size: (1280, 800),
717            held_keys: HashSet::new(),
718            window_focused: true,
719            mouse_primary: false,
720            mouse_secondary: false,
721            mouse_middle: false,
722            cursor_override: None,
723        }
724    }
725
726    /// Enter a named scope. Subsequent `id()` calls within this scope
727    /// will allocate from the scope's local counter, producing packed
728    /// `(scope_id << 32) | local_id` values that are stable across sibling
729    /// recompositions. Nested scopes push; `exit_scope` pops.
730    pub fn enter_scope(&mut self, key: &str) {
731        if !self.current_scope.iter().any(|k| k == key) {
732            self.scope_local_counters.insert(key.to_string(), 0);
733        }
734        self.current_scope.push(key.to_string());
735        self.get_or_create_scope_id(key);
736    }
737
738    /// Exit the innermost scope. No-op if the stack is empty or the top does
739    /// not match (defensive: never corrupt an outer scope).
740    pub fn exit_scope(&mut self) {
741        self.current_scope.pop();
742    }
743
744    /// RAII scope entry: pops on drop, so a panicking scope body cannot leave
745    /// the scheduler stuck in the wrong scope (which previously leaked outer
746    /// IDs into the global sequence).
747    pub fn scope_guard<'a>(&'a mut self, key: &str) -> SchedulerScopeGuard<'a> {
748        self.enter_scope(key);
749        SchedulerScopeGuard { sched: self }
750    }
751
752    /// Panic-safe scope entry that does NOT hold a borrow: the guarded body
753    /// (including nested `scope!`) can keep using the `Scheduler`. Used by
754    /// the `scope!` macro.
755    pub fn scope_guard_raw(&mut self, key: &str) -> SchedulerScopeGuardRaw {
756        let ptr = self as *mut Scheduler;
757        unsafe { SchedulerScopeGuardRaw::enter(ptr, key) }
758    }
759
760    fn get_or_create_scope_id(&mut self, key: &str) -> u32 {
761        if let Some(&id) = self.scope_key_to_id.get(key) {
762            id
763        } else {
764            let id = self.next_scope_id;
765            self.next_scope_id += 1;
766            self.scope_key_to_id.insert(key.to_string(), id);
767            id
768        }
769    }
770
771    pub fn id(&mut self) -> u64 {
772        if let Some(key) = self.current_scope.last().cloned() {
773            let scope_id = self.scope_key_to_id.get(&key).copied().unwrap_or(0);
774            let local = self.scope_local_counters.get_mut(&key).unwrap();
775            let id = *local;
776            *local += 1;
777            (scope_id as u64) << 32 | id as u64
778        } else {
779            // Global sequential ID (for non-scoped views)
780            let id = self.next_id;
781            self.next_id += 1;
782            id
783        }
784    }
785
786    pub fn id_count(&self) -> u64 {
787        self.next_id - 1
788    }
789
790    /// True while the named physical key is in the polled snapshot
791    /// (`KeyW`, `Digit1`, `Space`, ... — winit `KeyCode` debug names).
792    pub fn is_held(&self, name: &str) -> bool {
793        self.held_keys.contains(name)
794    }
795
796    /// Snapshot the current ID counter (before executing a scope body) so the
797    /// delta can be computed after the body returns.
798    pub fn snapshot_id(&self) -> u64 {
799        self.next_id
800    }
801
802    /// Advance the ID counter by `count` without assigning IDs.
803    /// Used by the scope! macro to reserve IDs for a cached scope subtree.
804    pub fn advance_id(&mut self, count: u32) {
805        self.next_id += count as u64;
806    }
807
808    /// Number of IDs assigned since `prev_id` (the value returned by
809    /// `snapshot_id()` before executing a scope body).
810    pub fn ids_used_since(&self, prev_id: u64) -> u32 {
811        (self.next_id - prev_id) as u32
812    }
813}
814
815/// RAII guard from [`Scheduler::scope_guard`]. Pops the scope on drop.
816pub struct SchedulerScopeGuard<'a> {
817    sched: &'a mut Scheduler,
818}
819
820impl Drop for SchedulerScopeGuard<'_> {
821    fn drop(&mut self) {
822        self.sched.exit_scope();
823    }
824}
825
826/// Panic-safe scope guard that does NOT hold a borrow across the body.
827///
828/// The `scope!` macro uses this (not [`SchedulerScopeGuard`]) so the guarded
829/// body — including nested `scope!` invocations — can keep using the
830/// `Scheduler` normally. At drop time (normal or unwind) no other borrows of
831/// the scheduler are live, since the body has ended.
832pub struct SchedulerScopeGuardRaw {
833    sched: *mut Scheduler,
834}
835
836impl SchedulerScopeGuardRaw {
837    /// # Safety
838    /// `sched` must point to a valid `Scheduler` for the guard's lifetime,
839    /// and the scheduler must not be used while the guard is being dropped
840    /// (guaranteed when the guard outlives the body it protects).
841    pub unsafe fn enter(sched: *mut Scheduler, key: &str) -> Self {
842        unsafe {
843            (*sched).enter_scope(key);
844        }
845        Self { sched }
846    }
847}
848
849impl Drop for SchedulerScopeGuardRaw {
850    fn drop(&mut self) {
851        unsafe {
852            (*self.sched).exit_scope();
853        }
854    }
855}
856
857impl Scheduler {
858    pub fn repose<F>(
859        &mut self,
860        mut build_root: F,
861        layout_paint: impl Fn(&View, (u32, u32)) -> (Scene, Vec<HitRegion>, Vec<SemNode>),
862    ) -> Frame
863    where
864        F: FnMut(&mut Scheduler) -> View,
865    {
866        let guard = ComposeGuard::begin();
867        let root = guard.scope.run(|| build_root(self));
868        let (scene, hits, sem) = layout_paint(&root, self.size);
869
870        let focus_chain: Vec<u64> = hits.iter().filter(|h| h.focusable).map(|h| h.id).collect();
871
872        Frame {
873            scene,
874            hit_regions: hits,
875            semantics_nodes: sem,
876            focus_chain,
877        }
878    }
879}
880
881/// Avoids cross-test pollution
882#[cfg(test)]
883pub fn clear_composer() {
884    COMPOSER.with(|c| {
885        let mut c = c.borrow_mut();
886        c.slots.clear();
887        c.slot_callers.clear();
888        c.keyed_slots.clear();
889        c.scope_caches.clear();
890        c.cursor = 0;
891    });
892    ROOT_SCOPE.with(|rs| {
893        *rs.borrow_mut() = None;
894    });
895}
896
897#[cfg(test)]
898mod focus_trap_tests {
899    use super::*;
900
901    fn region(id: u64, x: f32, group: Option<u64>) -> HitRegion {
902        HitRegion {
903            id,
904            rect: Rect {
905                x,
906                y: 0.0,
907                w: 10.0,
908                h: 10.0,
909            },
910            focus_group_id: group,
911            ..Default::default()
912        }
913    }
914
915    #[test]
916    fn arrows_stay_inside_group() {
917        // Dialog buttons 2,3 in group 9; background button 4 outside;
918        // outsider 1 sits left of button 2 and would win unconstrained.
919        let chain = vec![1, 2, 3, 4];
920        let regions = vec![
921            region(1, 0.0, None),
922            region(2, 20.0, Some(9)),
923            region(3, 40.0, Some(9)),
924            region(4, 60.0, None),
925        ];
926        assert_eq!(
927            spatial_focus_next(&chain, &regions, Some(2), FocusDirection::Left),
928            None,
929            "outsider 1 is left of 2 but outside the group: trapped"
930        );
931        assert_eq!(
932            spatial_focus_next(&chain, &regions, Some(2), FocusDirection::Right),
933            Some(3)
934        );
935        assert_eq!(
936            spatial_focus_next(&chain, &regions, Some(3), FocusDirection::Left),
937            Some(2)
938        );
939        assert_eq!(
940            spatial_focus_next(&chain, &regions, Some(1), FocusDirection::Right),
941            Some(2),
942            "ungrouped focus still sees the full chain"
943        );
944    }
945
946    #[test]
947    fn tab_cycles_inside_group() {
948        let chain = vec![1, 2, 3, 4];
949        let regions = vec![
950            region(1, 0.0, None),
951            region(2, 20.0, Some(9)),
952            region(3, 40.0, Some(9)),
953            region(4, 60.0, None),
954        ];
955        let mut fm = FocusManager::new(chain, Some(2));
956        fm.hit_regions = regions;
957        assert_eq!(fm.move_tab(false), Some(3));
958        assert_eq!(fm.move_tab(false), Some(2));
959        assert_eq!(fm.move_tab(true), Some(3));
960    }
961
962    #[test]
963    fn tab_from_outside_can_enter_group() {
964        let chain = vec![1, 2, 3, 4];
965        let regions = vec![
966            region(1, 0.0, None),
967            region(2, 20.0, Some(9)),
968            region(3, 40.0, Some(9)),
969            region(4, 60.0, None),
970        ];
971        let mut fm = FocusManager::new(chain, Some(1));
972        fm.hit_regions = regions;
973        assert_eq!(fm.move_tab(false), Some(2));
974        let mut fm = FocusManager::new(vec![1, 2, 3, 4], Some(1));
975        fm.hit_regions = vec![
976            region(1, 0.0, None),
977            region(2, 20.0, Some(9)),
978            region(3, 40.0, Some(9)),
979            region(4, 60.0, None),
980        ];
981        assert_eq!(fm.move_tab(true), Some(4));
982    }
983
984    #[test]
985    fn empty_group_never_moves() {
986        let chain = vec![1, 4];
987        let regions = vec![region(1, 0.0, None), region(4, 60.0, None)];
988        let mut fm = FocusManager::new(chain, Some(1));
989        fm.hit_regions = regions.clone();
990        assert_eq!(fm.move_tab(false), Some(4));
991        let chain = vec![1, 2, 4];
992        let regions = vec![
993            region(1, 0.0, None),
994            region(2, 20.0, Some(77)),
995            region(4, 60.0, None),
996        ];
997        let mut fm = FocusManager::new(chain, Some(2));
998        fm.hit_regions = regions;
999        assert_eq!(fm.move_tab(false), Some(2));
1000        assert_eq!(
1001            spatial_focus_next(&fm.chain, &fm.hit_regions, Some(2), FocusDirection::Right),
1002            None
1003        );
1004    }
1005}