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    pub slot_callers: Vec<&'static Location<'static>>,
338    pub cursor: usize,
339    pub keyed_slots: FxHashMap<String, Box<dyn Any>>,
340    pub keyed_owner: FxHashMap<String, String>,
341    pub live_keyed_owners: rustc_hash::FxHashSet<String>,
342    pub scope_caches: FxHashMap<String, crate::scope_cache::ScopeCache>,
343    pub live_scope_keys: rustc_hash::FxHashSet<String>,
344}
345
346pub struct ComposeGuard {
347    scope: Scope,
348}
349
350pub(crate) fn current_scope_key_for_remember() -> Option<String> {
351    crate::scope_cache::current_scope_key()
352}
353
354impl ComposeGuard {
355    pub fn begin() -> Self {
356        COMPOSER.with(|c| {
357            let mut c = c.borrow_mut();
358            c.cursor = 0;
359            c.live_scope_keys.clear();
360            c.live_keyed_owners.clear();
361            c.live_scope_keys.insert(String::new());
362            c.live_keyed_owners.insert(String::new());
363        });
364
365        let scope = ROOT_SCOPE.with(|rs| {
366            if let Some(existing) = rs.borrow().clone() {
367                existing
368            } else {
369                let s = Scope::new();
370                *rs.borrow_mut() = Some(s.clone());
371                s
372            }
373        });
374
375        ComposeGuard { scope }
376    }
377
378    pub fn scope(&self) -> &Scope {
379        &self.scope
380    }
381}
382
383impl Drop for ComposeGuard {
384    fn drop(&mut self) {
385        COMPOSER.with(|c| {
386            let mut c = c.borrow_mut();
387            let n = c.cursor;
388            if c.slots.len() > n {
389                c.slots.truncate(n);
390            }
391            if c.slot_callers.len() > n {
392                c.slot_callers.truncate(n);
393            }
394        });
395        crate::scope_cache::gc_dead_scopes();
396    }
397}
398
399/// Dispose the root composition scope and clear all composer caches.
400///
401/// Call once on process exit (desktop `exiting`, tests). After this the next
402/// `ComposeGuard::begin` starts from a fresh root scope.
403pub fn shutdown_composition() {
404    ROOT_SCOPE.with(|rs| {
405        if let Some(scope) = rs.borrow_mut().take() {
406            scope.dispose();
407        }
408    });
409    COMPOSER.with(|c| {
410        let mut c = c.borrow_mut();
411        c.slots.clear();
412        c.slot_callers.clear();
413        c.keyed_slots.clear();
414        c.keyed_owner.clear();
415        c.live_keyed_owners.clear();
416        c.scope_caches.clear();
417        c.live_scope_keys.clear();
418        c.cursor = 0;
419    });
420    crate::scope_cache::clear_all_scope_deps();
421}
422
423/// Slot-based remember (sequential composition only).
424/// This prevents state aliasing when the composition tree structure changes between frames
425#[track_caller]
426pub fn remember<T: 'static>(init: impl FnOnce() -> T) -> Rc<T> {
427    // Capture BEFORE any closure -> Location::caller() returns the correct
428    // track_caller location only at the function's top level, not inside closures.
429    let caller = Location::caller();
430    COMPOSER.with(|c| {
431        let mut c = c.borrow_mut();
432        let cursor = c.cursor;
433        c.cursor += 1;
434
435        if cursor >= c.slots.len() {
436            let rc: Rc<T> = Rc::new(init());
437            c.slots.push(Box::new(rc.clone()));
438            c.slot_callers.push(caller);
439            return rc;
440        }
441
442        let stored_caller = c.slot_callers.get(cursor).copied();
443        if stored_caller != Some(caller) {
444            let rc: Rc<T> = Rc::new(init());
445            c.slots[cursor] = Box::new(rc.clone());
446            if cursor < c.slot_callers.len() {
447                c.slot_callers[cursor] = caller;
448            } else {
449                c.slot_callers.push(caller);
450            }
451            return rc;
452        }
453
454        if let Some(rc) = c.slots[cursor].downcast_ref::<Rc<T>>() {
455            rc.clone()
456        } else {
457            log::warn!(
458                "remember: slot {} type changed {}. \
459                 Use remember_with_key(key, || ...) for conditional branches.",
460                cursor,
461                std::any::type_name::<T>(),
462            );
463            let rc: Rc<T> = Rc::new(init());
464            c.slots[cursor] = Box::new(rc.clone());
465            rc
466        }
467    })
468}
469
470/// Key-based remember.
471pub fn remember_with_key<T: 'static>(key: impl Into<String>, init: impl FnOnce() -> T) -> Rc<T> {
472    let owner = current_scope_key_for_remember().unwrap_or_default();
473    COMPOSER.with(|c| {
474        let mut c = c.borrow_mut();
475        let key = key.into();
476
477        if let Some(existing) = c.keyed_slots.get(&key) {
478            if let Some(rc) = existing.downcast_ref::<Rc<T>>() {
479                let rc = rc.clone();
480                c.keyed_owner.insert(key.clone(), owner.clone());
481                c.live_keyed_owners.insert(owner);
482                return rc;
483            } else {
484                log::warn!(
485                    "remember_with_key: key '{}' reused with a different type; replacing.",
486                    key
487                );
488            }
489        }
490
491        if cfg!(debug_assertions) && c.keyed_slots.len() > 10_000 {
492            log::warn!(
493                "remember_with_key: more than 10k keys stored; \
494                are you generating unbounded dynamic keys (e.g., using timestamps)?"
495            );
496        }
497
498        let rc: Rc<T> = Rc::new(init());
499        c.keyed_slots.insert(key.clone(), Box::new(rc.clone()));
500        c.keyed_owner.insert(key, owner.clone());
501        c.live_keyed_owners.insert(owner);
502        rc
503    })
504}
505
506/// Raw slot state (`Rc<RefCell<T>>`). Writes via `borrow_mut()` don't request a
507/// frame - prefer [`remember_mutable`] if a write must always recompose.
508#[track_caller]
509pub fn remember_state<T: 'static>(init: impl FnOnce() -> T) -> Rc<RefCell<T>> {
510    remember(|| RefCell::new(init()))
511}
512
513/// Key-based variant of [`remember_state`]. Same no-frame-on-write caveat.
514pub fn remember_state_with_key<T: 'static>(
515    key: impl Into<String>,
516    init: impl FnOnce() -> T,
517) -> Rc<RefCell<T>> {
518    remember_with_key(key, || RefCell::new(init()))
519}
520
521/// Frame - output of composition for a tick: scene + input/semantics.
522#[derive(Clone)]
523pub struct Frame {
524    pub scene: Scene,
525    pub hit_regions: Vec<HitRegion>,
526    pub semantics_nodes: Vec<SemNode>,
527    pub focus_chain: Vec<u64>,
528}
529
530/// Hit-test region in physical pixels (`rect` carries px magnitudes,
531/// like Compose `Rect`).
532#[derive(Clone, Default)]
533pub struct HitRegion {
534    pub id: u64,
535    pub rect: Rect,
536    /// Tree depth: 0 = root, higher = deeper child. Used for three-pass
537    /// pointer dispatch to determine ancestor/descendant ordering.
538    pub depth: u32,
539    pub parent: Option<u64>,
540    pub on_click: Option<Rc<dyn Fn()>>,
541    pub on_double_click: Option<Rc<dyn Fn()>>,
542    pub on_long_click: Option<Rc<dyn Fn()>>,
543    pub on_scroll: Option<Rc<dyn Fn(crate::Vec2) -> crate::Vec2>>,
544    pub focusable: bool,
545    pub on_pointer_down: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
546    pub on_pointer_move: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
547    pub on_pointer_up: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
548    pub on_pointer_cancel: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
549    pub on_pointer_enter: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
550    pub on_pointer_leave: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
551    pub z_index: f32,
552    pub disabled: bool,
553    pub on_text_change: Option<Rc<dyn Fn(String)>>,
554    pub on_text_submit: Option<Rc<dyn Fn(String)>>,
555    /// If this hit region belongs to a TextField, this persistent key is used
556    /// for looking up platform-managed TextFieldState. Falls back to `id` if None.
557    pub tf_state_key: Option<u64>,
558
559    /// True if this hit region corresponds to a multiline text input (TextArea).
560    pub tf_multiline: bool,
561
562    /// Unclipped top-left of the TextField *content* box (padding-inset).
563    /// Used for pointer->grapheme mapping so parent scroll clipping of `rect`
564    /// does not shift selection into the top of the content.
565    /// `None` for non-textfields.
566    pub tf_content_origin: Option<(f32, f32)>,
567
568    /// When false, the field rejects edits and is not focusable
569    pub tf_enabled: bool,
570
571    /// When true, selection/focus/copy are allowed but mutations are rejected
572    pub tf_read_only: bool,
573
574    /// Controlled text snapshot for this field (last compose).
575    pub tf_value: String,
576
577    /// Font size for this text field in [`Sp`](crate::units::Sp)
578    /// (for hit-test / caret mapping). `Sp::ZERO` means use `TF_FONT_SP` default.
579    pub tf_font_size: crate::units::Sp,
580
581    // internal
582    pub on_drag_start: Option<Rc<dyn Fn(crate::dnd::DragStart) -> Option<crate::dnd::DragPayload>>>,
583    pub on_drag_end: Option<Rc<dyn Fn(crate::dnd::DragEnd)>>,
584    pub on_drag_enter: Option<Rc<dyn Fn(crate::dnd::DragOver)>>,
585    pub on_drag_over: Option<Rc<dyn Fn(crate::dnd::DragOver)>>,
586    pub on_drag_leave: Option<Rc<dyn Fn(crate::dnd::DragOver)>>,
587    pub on_drop: Option<Rc<dyn Fn(crate::dnd::DropEvent) -> bool>>,
588    /// Copied onto the drag session when a drag starts from this region.
589    pub drag_preview: Option<crate::dnd::DragPreview>,
590
591    pub on_action: Option<Rc<dyn Fn(crate::shortcuts::Action) -> bool>>,
592
593    /// Called when a key event is received while this element is focused.
594    /// Return `true` to consume the event.
595    pub on_key_event: Option<Rc<dyn Fn(crate::input::KeyEvent) -> bool>>,
596    /// Called before `on_key_event`. Return `true` to consume before normal dispatch.
597    pub on_preview_key_event: Option<Rc<dyn Fn(crate::input::KeyEvent) -> bool>>,
598
599    /// Cursor hint for desktop/web.
600    pub cursor: Option<crate::CursorIcon>,
601
602    /// If `Some(group_id)`, this hit region belongs to a focus group with the
603    /// given id. Tab navigation will cycle within the group instead of moving
604    /// to elements outside it. Set automatically by the layout engine when the
605    /// element is a descendant of a node with `focus_group: true`.
606    pub focus_group_id: Option<u64>,
607
608    /// IME keyboard hints, populated for text-field hit regions so the
609    /// platform runner can configure the OS keyboard / IME on focus.
610    pub keyboard_type: crate::text::KeyboardType,
611    pub capitalization: crate::text::KeyboardCapitalization,
612    pub ime_action: crate::text::ImeAction,
613    /// Whether auto-correct is enabled. `None` = follow platform default;
614    /// password keyboards always resolve to `false` in the layout engine.
615    pub auto_correct: Option<bool>,
616
617    /// Shared interaction source auto-wired by the layout engine
618    /// (press/hover/focus/drag). Used by keyboard activation and focus
619    /// transitions so they stay in parity with pointer input.
620    /// `None` when the component does not need one (no indication/state colors).
621    pub interaction_source: Option<crate::modifier::InteractionSource>,
622}
623
624impl HitRegion {
625    /// Seed a HitRegion with all the modifier's event handlers + dnd + cursor.
626    /// Call‑sites should only override the fields that differ (on_click, focusable, etc.)
627    /// via struct‑update syntax: `HitRegion { focusable: true, ..from_modifier(..) }`.
628    pub fn from_modifier(id: u64, rect: Rect, m: &crate::modifier::Modifier) -> Self {
629        Self {
630            id,
631            rect,
632            z_index: m.z_index,
633            on_click: m.on_click.clone(),
634            on_double_click: m.on_double_click.clone(),
635            on_long_click: m.on_long_click.clone(),
636            on_pointer_down: m.on_pointer_down.clone(),
637            on_pointer_move: m.on_pointer_move.clone(),
638            on_pointer_up: m.on_pointer_up.clone(),
639            on_pointer_cancel: m.on_pointer_cancel.clone(),
640            on_pointer_enter: m.on_pointer_enter.clone(),
641            on_pointer_leave: m.on_pointer_leave.clone(),
642            on_action: m.on_action.clone(),
643            on_key_event: m.on_key_event.clone(),
644            on_preview_key_event: m.on_preview_key_event.clone(),
645            cursor: m.cursor.clone(),
646            on_drag_start: m.on_drag_start.clone(),
647            on_drag_end: m.on_drag_end.clone(),
648            on_drag_enter: m.on_drag_enter.clone(),
649            on_drag_over: m.on_drag_over.clone(),
650            on_drag_leave: m.on_drag_leave.clone(),
651            on_scroll: m.on_scroll.clone(),
652            on_drop: m.on_drop.clone(),
653            drag_preview: m.drag_preview.clone(),
654            disabled: m.disabled,
655            tf_enabled: true,
656            tf_read_only: false,
657            ..Default::default()
658        }
659    }
660}
661
662/// Flattened semantics node produced by `layout_and_paint`.
663///
664/// This is the source of truth for accessibility backends: it contains the
665/// resolved screen rect, role, label, and focus/enabled state.
666///
667/// The platform runner should convert this into OS‑specific accessibility trees (when implemented)
668/// (AT‑SPI on Linux, TalkBack on Android, etc.).
669#[derive(Clone)]
670pub struct SemNode {
671    /// Stable id, shared with the associated `HitRegion` / `ViewId`.
672    pub id: u64,
673
674    /// `None` means direct child of the window root.
675    pub parent: Option<u64>,
676
677    pub role: Role,
678    pub label: Option<String>,
679    pub rect: Rect,
680    pub focused: bool,
681    pub enabled: bool,
682    /// Marks this node as a collection of selectable children (e.g., Tabs).
683    pub selectable_group: bool,
684    pub checked: Option<bool>,
685    pub selected: Option<bool>,
686    pub value: Option<String>,
687}
688
689impl Default for SemNode {
690    fn default() -> Self {
691        Self {
692            id: 0,
693            parent: None,
694            role: Role::default(),
695            label: None,
696            rect: Rect::default(),
697            focused: false,
698            enabled: true,
699            selectable_group: false,
700            checked: None,
701            selected: None,
702            value: None,
703        }
704    }
705}
706
707pub struct Scheduler {
708    next_id: u64,
709    /// Per-scope unique IDs, assigned lazily when a scope first executes.
710    /// Keyed by the scope key string from `scope!`.
711    scope_key_to_id: FxHashMap<String, u32>,
712    next_scope_id: u32,
713    /// Stack of active scope keys. `id()` allocates from the innermost scope
714    /// so nested `scope!` bodies get stable packed IDs and the outer scope
715    /// resumes correctly after the inner exits (previously `exit_scope` reset
716    /// to `None`, leaking outer IDs into the global sequence).
717    current_scope: Vec<String>,
718    /// Per-scope local ID counters. Reset to 0 when a scope re-executes.
719    scope_local_counters: FxHashMap<String, u32>,
720    pub focused: Option<u64>,
721    pub size: (u32, u32),
722    /// Last known mouse pointer position in physical px, updated on
723    /// mouse move/press/release without passing through focus dispatch.
724    pub pointer_pos_px: Option<(f32, f32)>,
725    /// Polled physical keys currently down, layout-independent
726    /// positions (see [`PhysicalKey`]). The platform runner maintains
727    /// this from raw `KeyboardInput` events without passing through
728    /// focus dispatch; games reconcile their event-staged held sets
729    /// against it every frame (GML `keyboard_check` parity). Cleared
730    /// on window focus loss (no key-ups arrive across an alt-tab).
731    pub held_keys: HashSet<PhysicalKey>,
732    /// Window focus as of the last platform event. `false` drops every
733    /// held key on the game side.
734    pub window_focused: bool,
735    /// Live touch contacts in physical px: `(touch id, x, y)` per
736    /// finger currently down. Written by the platform runners from
737    /// raw `WindowEvent::Touch` (Started/Moved/Ended-Cancelled), read
738    /// by games through `feed_polled`-style staging snapshots
739    /// (GML `device_mouse_x_to_gui(i)` parity: stable per-finger slot
740    /// ids, positions sampled every tick while held).
741    pub touch_points: Vec<(u64, f32, f32)>,
742    /// Polled mouse-button levels, same source as `held_keys`
743    /// (GML `mouse_check_button` parity; repairs a missed button-up
744    /// when the release lands outside the window).
745    pub mouse_primary: bool,
746    pub mouse_secondary: bool,
747    pub mouse_middle: bool,
748    /// App-requested cursor override. When `Some`, the platform
749    /// applies it instead of the hover-derived icon: games hide the
750    /// OS pointer here (`CursorIcon::Hidden`) while drawing their own
751    /// crosshair, GML `window_set_cursor(cr_none)` parity. `None`
752    /// restores hover behavior. Set during composition (any view can
753    /// write it; last write per frame wins), consumed by the runner
754    /// after `frame()`.
755    pub cursor_override: Option<CursorIcon>,
756}
757
758impl Default for Scheduler {
759    fn default() -> Self {
760        Self::new()
761    }
762}
763
764impl Scheduler {
765    pub fn new() -> Self {
766        Self {
767            next_id: 1,
768            scope_key_to_id: FxHashMap::default(),
769            next_scope_id: 1,
770            current_scope: Vec::new(),
771            scope_local_counters: FxHashMap::default(),
772            focused: None,
773            size: (1280, 800),
774            pointer_pos_px: None,
775            held_keys: HashSet::new(),
776            window_focused: true,
777            touch_points: Vec::new(),
778            mouse_primary: false,
779            mouse_secondary: false,
780            mouse_middle: false,
781            cursor_override: None,
782        }
783    }
784
785    /// Enter a named scope. Subsequent `id()` calls within this scope
786    /// will allocate from the scope's local counter, producing packed
787    /// `(scope_id << 32) | local_id` values that are stable across sibling
788    /// recompositions. Nested scopes push; `exit_scope` pops.
789    pub fn enter_scope(&mut self, key: &str) {
790        if !self.current_scope.iter().any(|k| k == key) {
791            self.scope_local_counters.insert(key.to_string(), 0);
792        }
793        self.current_scope.push(key.to_string());
794        self.get_or_create_scope_id(key);
795    }
796
797    /// Exit the innermost scope. No-op if the stack is empty or the top does
798    /// not match (defensive: never corrupt an outer scope).
799    pub fn exit_scope(&mut self) {
800        self.current_scope.pop();
801    }
802
803    /// RAII scope entry: pops on drop, so a panicking scope body cannot leave
804    /// the scheduler stuck in the wrong scope (which previously leaked outer
805    /// IDs into the global sequence).
806    pub fn scope_guard<'a>(&'a mut self, key: &str) -> SchedulerScopeGuard<'a> {
807        self.enter_scope(key);
808        SchedulerScopeGuard { sched: self }
809    }
810
811    /// Panic-safe scope entry that does NOT hold a borrow: the guarded body
812    /// (including nested `scope!`) can keep using the `Scheduler`. Used by
813    /// the `scope!` macro.
814    pub fn scope_guard_raw(&mut self, key: &str) -> SchedulerScopeGuardRaw {
815        let ptr = self as *mut Scheduler;
816        unsafe { SchedulerScopeGuardRaw::enter(ptr, key) }
817    }
818
819    fn get_or_create_scope_id(&mut self, key: &str) -> u32 {
820        if let Some(&id) = self.scope_key_to_id.get(key) {
821            id
822        } else {
823            let id = self.next_scope_id;
824            self.next_scope_id += 1;
825            self.scope_key_to_id.insert(key.to_string(), id);
826            id
827        }
828    }
829
830    pub fn id(&mut self) -> u64 {
831        if let Some(key) = self.current_scope.last().cloned() {
832            let scope_id = self.scope_key_to_id.get(&key).copied().unwrap_or(0);
833            let local = self.scope_local_counters.get_mut(&key).unwrap();
834            let id = *local;
835            *local += 1;
836            (scope_id as u64) << 32 | id as u64
837        } else {
838            // Global sequential ID (for non-scoped views)
839            let id = self.next_id;
840            self.next_id += 1;
841            id
842        }
843    }
844
845    pub fn id_count(&self) -> u64 {
846        self.next_id - 1
847    }
848
849    /// True while the named physical key is in the polled snapshot.
850    pub fn is_held(&self, key: PhysicalKey) -> bool {
851        self.held_keys.contains(&key)
852    }
853
854    /// Snapshot the current ID counter (before executing a scope body) so the
855    /// delta can be computed after the body returns.
856    pub fn snapshot_id(&self) -> u64 {
857        self.next_id
858    }
859
860    /// Advance the ID counter by `count` without assigning IDs.
861    /// Used by the scope! macro to reserve IDs for a cached scope subtree.
862    pub fn advance_id(&mut self, count: u32) {
863        self.next_id += count as u64;
864    }
865
866    /// Number of IDs assigned since `prev_id` (the value returned by
867    /// `snapshot_id()` before executing a scope body).
868    pub fn ids_used_since(&self, prev_id: u64) -> u32 {
869        (self.next_id - prev_id) as u32
870    }
871}
872
873/// RAII guard from [`Scheduler::scope_guard`]. Pops the scope on drop.
874pub struct SchedulerScopeGuard<'a> {
875    sched: &'a mut Scheduler,
876}
877
878impl Drop for SchedulerScopeGuard<'_> {
879    fn drop(&mut self) {
880        self.sched.exit_scope();
881    }
882}
883
884/// Panic-safe scope guard that does NOT hold a borrow across the body.
885///
886/// The `scope!` macro uses this (not [`SchedulerScopeGuard`]) so the guarded
887/// body — including nested `scope!` invocations — can keep using the
888/// `Scheduler` normally. At drop time (normal or unwind) no other borrows of
889/// the scheduler are live, since the body has ended.
890pub struct SchedulerScopeGuardRaw {
891    sched: *mut Scheduler,
892}
893
894impl SchedulerScopeGuardRaw {
895    /// # Safety
896    /// `sched` must point to a valid `Scheduler` for the guard's lifetime,
897    /// and the scheduler must not be used while the guard is being dropped
898    /// (guaranteed when the guard outlives the body it protects).
899    pub unsafe fn enter(sched: *mut Scheduler, key: &str) -> Self {
900        unsafe {
901            (*sched).enter_scope(key);
902        }
903        Self { sched }
904    }
905}
906
907impl Drop for SchedulerScopeGuardRaw {
908    fn drop(&mut self) {
909        unsafe {
910            (*self.sched).exit_scope();
911        }
912    }
913}
914
915impl Scheduler {
916    pub fn repose<F>(
917        &mut self,
918        mut build_root: F,
919        layout_paint: impl Fn(&View, (u32, u32)) -> (Scene, Vec<HitRegion>, Vec<SemNode>),
920    ) -> Frame
921    where
922        F: FnMut(&mut Scheduler) -> View,
923    {
924        let guard = ComposeGuard::begin();
925        let root = guard.scope.run(|| build_root(self));
926        let (scene, hits, sem) = layout_paint(&root, self.size);
927
928        let focus_chain: Vec<u64> = hits.iter().filter(|h| h.focusable).map(|h| h.id).collect();
929
930        Frame {
931            scene,
932            hit_regions: hits,
933            semantics_nodes: sem,
934            focus_chain,
935        }
936    }
937}
938
939/// Test helper: full composer reset. Production shutdown should call
940/// `shutdown_composition`.
941#[cfg(test)]
942pub fn clear_composer() {
943    shutdown_composition();
944}
945
946#[cfg(test)]
947mod focus_trap_tests {
948    use super::*;
949
950    fn region(id: u64, x: f32, group: Option<u64>) -> HitRegion {
951        HitRegion {
952            id,
953            rect: Rect {
954                x,
955                y: 0.0,
956                w: 10.0,
957                h: 10.0,
958            },
959            focus_group_id: group,
960            ..Default::default()
961        }
962    }
963
964    #[test]
965    fn arrows_stay_inside_group() {
966        // Dialog buttons 2,3 in group 9; background button 4 outside;
967        // outsider 1 sits left of button 2 and would win unconstrained.
968        let chain = vec![1, 2, 3, 4];
969        let regions = vec![
970            region(1, 0.0, None),
971            region(2, 20.0, Some(9)),
972            region(3, 40.0, Some(9)),
973            region(4, 60.0, None),
974        ];
975        assert_eq!(
976            spatial_focus_next(&chain, &regions, Some(2), FocusDirection::Left),
977            None,
978            "outsider 1 is left of 2 but outside the group: trapped"
979        );
980        assert_eq!(
981            spatial_focus_next(&chain, &regions, Some(2), FocusDirection::Right),
982            Some(3)
983        );
984        assert_eq!(
985            spatial_focus_next(&chain, &regions, Some(3), FocusDirection::Left),
986            Some(2)
987        );
988        assert_eq!(
989            spatial_focus_next(&chain, &regions, Some(1), FocusDirection::Right),
990            Some(2),
991            "ungrouped focus still sees the full chain"
992        );
993    }
994
995    #[test]
996    fn tab_cycles_inside_group() {
997        let chain = vec![1, 2, 3, 4];
998        let regions = vec![
999            region(1, 0.0, None),
1000            region(2, 20.0, Some(9)),
1001            region(3, 40.0, Some(9)),
1002            region(4, 60.0, None),
1003        ];
1004        let mut fm = FocusManager::new(chain, Some(2));
1005        fm.hit_regions = regions;
1006        assert_eq!(fm.move_tab(false), Some(3));
1007        assert_eq!(fm.move_tab(false), Some(2));
1008        assert_eq!(fm.move_tab(true), Some(3));
1009    }
1010
1011    #[test]
1012    fn tab_from_outside_can_enter_group() {
1013        let chain = vec![1, 2, 3, 4];
1014        let regions = vec![
1015            region(1, 0.0, None),
1016            region(2, 20.0, Some(9)),
1017            region(3, 40.0, Some(9)),
1018            region(4, 60.0, None),
1019        ];
1020        let mut fm = FocusManager::new(chain, Some(1));
1021        fm.hit_regions = regions;
1022        assert_eq!(fm.move_tab(false), Some(2));
1023        let mut fm = FocusManager::new(vec![1, 2, 3, 4], Some(1));
1024        fm.hit_regions = vec![
1025            region(1, 0.0, None),
1026            region(2, 20.0, Some(9)),
1027            region(3, 40.0, Some(9)),
1028            region(4, 60.0, None),
1029        ];
1030        assert_eq!(fm.move_tab(true), Some(4));
1031    }
1032
1033    #[test]
1034    fn empty_group_never_moves() {
1035        let chain = vec![1, 4];
1036        let regions = vec![region(1, 0.0, None), region(4, 60.0, None)];
1037        let mut fm = FocusManager::new(chain, Some(1));
1038        fm.hit_regions = regions.clone();
1039        assert_eq!(fm.move_tab(false), Some(4));
1040        let chain = vec![1, 2, 4];
1041        let regions = vec![
1042            region(1, 0.0, None),
1043            region(2, 20.0, Some(77)),
1044            region(4, 60.0, None),
1045        ];
1046        let mut fm = FocusManager::new(chain, Some(2));
1047        fm.hit_regions = regions;
1048        assert_eq!(fm.move_tab(false), Some(2));
1049        assert_eq!(
1050            spatial_focus_next(&fm.chain, &fm.hit_regions, Some(2), FocusDirection::Right),
1051            None
1052        );
1053    }
1054}