Skip to main content

repose_core/
runtime.rs

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