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 current_group = self.focused.and_then(|cur| {
158            self.hit_regions
159                .iter()
160                .find(|h| h.id == cur)
161                .and_then(|h| h.focus_group_id)
162        });
163        let next = if let Some(group_id) = current_group {
164            // Build sub-chain of elements in the same focus group
165            let sub_chain: Vec<u64> = self
166                .chain
167                .iter()
168                .copied()
169                .filter(|&id| {
170                    id == group_id
171                        || self
172                            .hit_regions
173                            .iter()
174                            .any(|h| h.id == id && h.focus_group_id == Some(group_id))
175                })
176                .collect();
177            if sub_chain.is_empty() {
178                return None;
179            }
180            if let Some(cur) = self.focused {
181                if let Some(idx) = sub_chain.iter().position(|&id| id == cur) {
182                    if reverse {
183                        if idx == 0 {
184                            sub_chain[sub_chain.len() - 1]
185                        } else {
186                            sub_chain[idx - 1]
187                        }
188                    } else {
189                        sub_chain[(idx + 1) % sub_chain.len()]
190                    }
191                } else {
192                    sub_chain[0]
193                }
194            } else if reverse {
195                sub_chain[sub_chain.len() - 1]
196            } else {
197                sub_chain[0]
198            }
199        } else {
200            if let Some(cur) = self.focused {
201                if let Some(idx) = self.chain.iter().position(|&id| id == cur) {
202                    if reverse {
203                        if idx == 0 {
204                            self.chain[self.chain.len() - 1]
205                        } else {
206                            self.chain[idx - 1]
207                        }
208                    } else {
209                        self.chain[(idx + 1) % self.chain.len()]
210                    }
211                } else {
212                    self.chain[0]
213                }
214            } else if reverse {
215                self.chain[self.chain.len() - 1]
216            } else {
217                self.chain[0]
218            }
219        };
220        self.focused = Some(next);
221        Some(next)
222    }
223
224    /// Set target ID on a FocusRequester (called during layout).
225    pub fn set_requester_target(requester: &FocusRequester, id: u64) {
226        *requester.target.borrow_mut() = Some(id);
227    }
228}
229
230/// Find the next focusable element in a given spatial direction.
231///
232/// Uses the bounding rects from `hit_regions` to determine which element is
233/// "next" in the given direction from the currently focused element.
234pub fn spatial_focus_next(
235    chain: &[u64],
236    hit_regions: &[HitRegion],
237    current: Option<u64>,
238    dir: FocusDirection,
239) -> Option<u64> {
240    if chain.is_empty() {
241        return None;
242    }
243
244    let current_rect =
245        current.and_then(|id| hit_regions.iter().find(|h| h.id == id).map(|h| h.rect));
246
247    // For Next/Previous, use tab-order navigation
248    match dir {
249        FocusDirection::Next | FocusDirection::Previous => {
250            let mut fm = FocusManager {
251                chain: chain.to_vec(),
252                focused: current,
253                hit_regions: hit_regions.to_vec(),
254            };
255            return fm.move_tab(dir == FocusDirection::Previous);
256        }
257        _ => {}
258    }
259
260    let (cx, cy) = match current_rect {
261        Some(r) => (r.x + r.w / 2.0, r.y + r.h / 2.0),
262        None => {
263            // For games: Tab/Shift-Tab should establish initial focus, not arrows.
264            return None;
265        }
266    };
267
268    let mut best: Option<(u64, f32)> = None;
269
270    for &id in chain {
271        if Some(id) == current {
272            continue;
273        }
274        let Some(hr) = hit_regions.iter().find(|h| h.id == id) else {
275            continue;
276        };
277        let r = hr.rect;
278        let other_cx = r.x + r.w / 2.0;
279        let other_cy = r.y + r.h / 2.0;
280        let dx = other_cx - cx;
281        let dy = other_cy - cy;
282
283        let in_direction = match dir {
284            FocusDirection::Left => dx < 0.0 && dy.abs() <= r.h.max(1.0),
285            FocusDirection::Right => dx > 0.0 && dy.abs() <= r.h.max(1.0),
286            FocusDirection::Up => dy < 0.0 && dx.abs() <= r.w.max(1.0),
287            FocusDirection::Down => dy > 0.0 && dx.abs() <= r.w.max(1.0),
288            _ => false,
289        };
290
291        if !in_direction {
292            continue;
293        }
294
295        let dist = dx * dx + dy * dy;
296        let weight = dist / (r.w * r.h + 1.0).max(1.0);
297
298        match best {
299            Some((_, best_weight)) if weight >= best_weight => {}
300            _ => best = Some((id, weight)),
301        }
302    }
303
304    best.map(|(id, _)| id)
305}
306
307#[derive(Default)]
308pub struct Composer {
309    pub slots: Vec<Box<dyn Any>>,
310    /// Caller identity for each slot, used to detect stale slots
311    /// when the composition tree changes between frames.
312    pub slot_callers: Vec<&'static Location<'static>>,
313    pub cursor: usize,
314    pub keyed_slots: FxHashMap<String, Box<dyn Any>>,
315    /// Per-scope cached state for the `scope!` macro.
316    /// Keyed by the scope key string.
317    pub scope_caches: FxHashMap<String, crate::scope_cache::ScopeCache>,
318}
319
320pub struct ComposeGuard {
321    scope: Scope,
322}
323
324impl ComposeGuard {
325    pub fn begin() -> Self {
326        COMPOSER.with(|c| c.borrow_mut().cursor = 0);
327
328        let scope = ROOT_SCOPE.with(|rs| {
329            if let Some(existing) = rs.borrow().clone() {
330                existing
331            } else {
332                let s = Scope::new();
333                *rs.borrow_mut() = Some(s.clone());
334                s
335            }
336        });
337
338        ComposeGuard { scope }
339    }
340
341    pub fn scope(&self) -> &Scope {
342        &self.scope
343    }
344}
345
346impl Drop for ComposeGuard {
347    fn drop(&mut self) {
348        // ROOT_SCOPE.with(|rs| { Do not clear every frame
349        //     *rs.borrow_mut() = None;
350        // });
351    }
352}
353
354/// Slot-based remember (sequential composition only).
355/// This prevents state aliasing when the composition tree structure changes between frames
356#[track_caller]
357pub fn remember<T: 'static>(init: impl FnOnce() -> T) -> Rc<T> {
358    // Capture BEFORE any closure -> Location::caller() returns the correct
359    // track_caller location only at the function's top level, not inside closures.
360    let caller = Location::caller();
361    COMPOSER.with(|c| {
362        let mut c = c.borrow_mut();
363        let cursor = c.cursor;
364        c.cursor += 1;
365
366        if cursor >= c.slots.len() {
367            let rc: Rc<T> = Rc::new(init());
368            c.slots.push(Box::new(rc.clone()));
369            c.slot_callers.push(caller);
370            return rc;
371        }
372
373        let stored_caller = c.slot_callers.get(cursor).copied();
374        if stored_caller != Some(caller) {
375            let rc: Rc<T> = Rc::new(init());
376            c.slots[cursor] = Box::new(rc.clone());
377            if cursor < c.slot_callers.len() {
378                c.slot_callers[cursor] = caller;
379            } else {
380                c.slot_callers.push(caller);
381            }
382            return rc;
383        }
384
385        if let Some(rc) = c.slots[cursor].downcast_ref::<Rc<T>>() {
386            rc.clone()
387        } else {
388            log::warn!(
389                "remember: slot {} type changed {}. \
390                 Use remember_with_key(key, || ...) for conditional branches.",
391                cursor,
392                std::any::type_name::<T>(),
393            );
394            let rc: Rc<T> = Rc::new(init());
395            c.slots[cursor] = Box::new(rc.clone());
396            rc
397        }
398    })
399}
400
401/// Key-based remember.
402pub fn remember_with_key<T: 'static>(key: impl Into<String>, init: impl FnOnce() -> T) -> Rc<T> {
403    COMPOSER.with(|c| {
404        let mut c = c.borrow_mut();
405        let key = key.into();
406
407        if let Some(existing) = c.keyed_slots.get(&key) {
408            if let Some(rc) = existing.downcast_ref::<Rc<T>>() {
409                return rc.clone();
410            } else {
411                log::warn!(
412                    "remember_with_key: key '{}' reused with a different type; replacing.",
413                    key
414                );
415            }
416        }
417
418        if cfg!(debug_assertions) && c.keyed_slots.len() > 10_000 {
419            log::warn!(
420                "remember_with_key: more than 10k keys stored; \
421                are you generating unbounded dynamic keys (e.g., using timestamps)?"
422            );
423        }
424
425        let rc: Rc<T> = Rc::new(init());
426        c.keyed_slots.insert(key, Box::new(rc.clone()));
427        rc
428    })
429}
430
431/// Raw slot state (`Rc<RefCell<T>>`). Writes via `borrow_mut()` don't request a
432/// frame - prefer [`remember_mutable`] if a write must always recompose.
433#[track_caller]
434pub fn remember_state<T: 'static>(init: impl FnOnce() -> T) -> Rc<RefCell<T>> {
435    remember(|| RefCell::new(init()))
436}
437
438/// Key-based variant of [`remember_state`]. Same no-frame-on-write caveat.
439pub fn remember_state_with_key<T: 'static>(
440    key: impl Into<String>,
441    init: impl FnOnce() -> T,
442) -> Rc<RefCell<T>> {
443    remember_with_key(key, || RefCell::new(init()))
444}
445
446/// Frame - output of composition for a tick: scene + input/semantics.
447#[derive(Clone)]
448pub struct Frame {
449    pub scene: Scene,
450    pub hit_regions: Vec<HitRegion>,
451    pub semantics_nodes: Vec<SemNode>,
452    pub focus_chain: Vec<u64>,
453}
454
455#[derive(Clone, Default)]
456pub struct HitRegion {
457    pub id: u64,
458    pub rect: Rect,
459    /// Tree depth: 0 = root, higher = deeper child. Used for three-pass
460    /// pointer dispatch to determine ancestor/descendant ordering.
461    pub depth: u32,
462    pub parent: Option<u64>,
463    pub on_click: Option<Rc<dyn Fn()>>,
464    pub on_double_click: Option<Rc<dyn Fn()>>,
465    pub on_long_click: Option<Rc<dyn Fn()>>,
466    pub on_scroll: Option<Rc<dyn Fn(crate::Vec2) -> crate::Vec2>>,
467    pub focusable: bool,
468    pub on_pointer_down: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
469    pub on_pointer_move: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
470    pub on_pointer_up: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
471    pub on_pointer_cancel: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
472    pub on_pointer_enter: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
473    pub on_pointer_leave: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
474    pub z_index: f32,
475    pub disabled: bool,
476    pub on_text_change: Option<Rc<dyn Fn(String)>>,
477    pub on_text_submit: Option<Rc<dyn Fn(String)>>,
478    /// If this hit region belongs to a TextField, this persistent key is used
479    /// for looking up platform-managed TextFieldState. Falls back to `id` if None.
480    pub tf_state_key: Option<u64>,
481
482    /// True if this hit region corresponds to a multiline text input (TextArea).
483    pub tf_multiline: bool,
484
485    /// Unclipped top-left of the TextField *content* box (padding-inset).
486    /// Used for pointer->grapheme mapping so parent scroll clipping of `rect`
487    /// does not shift selection into the top of the content.
488    /// `None` for non-textfields.
489    pub tf_content_origin: Option<(f32, f32)>,
490
491    /// When false, the field rejects edits and is not focusable
492    pub tf_enabled: bool,
493
494    /// When true, selection/focus/copy are allowed but mutations are rejected
495    pub tf_read_only: bool,
496
497    /// Controlled text snapshot for this field (last compose).
498    pub tf_value: String,
499
500    /// Font size for this text field in dp (for hit-test / caret mapping).
501    /// 0.0 means use `TF_FONT_DP` default.
502    pub tf_font_size_dp: f32,
503
504    // internal
505    pub on_drag_start: Option<Rc<dyn Fn(crate::dnd::DragStart) -> Option<crate::dnd::DragPayload>>>,
506    pub on_drag_end: Option<Rc<dyn Fn(crate::dnd::DragEnd)>>,
507    pub on_drag_enter: Option<Rc<dyn Fn(crate::dnd::DragOver)>>,
508    pub on_drag_over: Option<Rc<dyn Fn(crate::dnd::DragOver)>>,
509    pub on_drag_leave: Option<Rc<dyn Fn(crate::dnd::DragOver)>>,
510    pub on_drop: Option<Rc<dyn Fn(crate::dnd::DropEvent) -> bool>>,
511    /// Copied onto the drag session when a drag starts from this region.
512    pub drag_preview: Option<crate::dnd::DragPreview>,
513
514    pub on_action: Option<Rc<dyn Fn(crate::shortcuts::Action) -> bool>>,
515
516    /// Called when a key event is received while this element is focused.
517    /// Return `true` to consume the event.
518    pub on_key_event: Option<Rc<dyn Fn(crate::input::KeyEvent) -> bool>>,
519    /// Called before `on_key_event`. Return `true` to consume before normal dispatch.
520    pub on_preview_key_event: Option<Rc<dyn Fn(crate::input::KeyEvent) -> bool>>,
521
522    /// Cursor hint for desktop/web.
523    pub cursor: Option<crate::CursorIcon>,
524
525    /// If `Some(group_id)`, this hit region belongs to a focus group with the
526    /// given id. Tab navigation will cycle within the group instead of moving
527    /// to elements outside it. Set automatically by the layout engine when the
528    /// element is a descendant of a node with `focus_group: true`.
529    pub focus_group_id: Option<u64>,
530
531    /// IME keyboard hints, populated for text-field hit regions so the
532    /// platform runner can configure the OS keyboard / IME on focus.
533    pub keyboard_type: crate::text::KeyboardType,
534    pub capitalization: crate::text::KeyboardCapitalization,
535    pub ime_action: crate::text::ImeAction,
536    /// Whether auto-correct is enabled. `None` = follow platform default;
537    /// password keyboards always resolve to `false` in the layout engine.
538    pub auto_correct: Option<bool>,
539
540    /// Shared interaction source auto-wired by the layout engine
541    /// (press/hover/focus/drag). Used by keyboard activation and focus
542    /// transitions so they stay in parity with pointer input.
543    /// `None` when the component does not need one (no indication/state colors).
544    pub interaction_source: Option<crate::modifier::InteractionSource>,
545}
546
547impl HitRegion {
548    /// Seed a HitRegion with all the modifier's event handlers + dnd + cursor.
549    /// Call‑sites should only override the fields that differ (on_click, focusable, etc.)
550    /// via struct‑update syntax: `HitRegion { focusable: true, ..from_modifier(..) }`.
551    pub fn from_modifier(id: u64, rect: Rect, m: &crate::modifier::Modifier) -> Self {
552        Self {
553            id,
554            rect,
555            z_index: m.z_index,
556            on_click: m.on_click.clone(),
557            on_double_click: m.on_double_click.clone(),
558            on_long_click: m.on_long_click.clone(),
559            on_pointer_down: m.on_pointer_down.clone(),
560            on_pointer_move: m.on_pointer_move.clone(),
561            on_pointer_up: m.on_pointer_up.clone(),
562            on_pointer_cancel: m.on_pointer_cancel.clone(),
563            on_pointer_enter: m.on_pointer_enter.clone(),
564            on_pointer_leave: m.on_pointer_leave.clone(),
565            on_action: m.on_action.clone(),
566            on_key_event: m.on_key_event.clone(),
567            on_preview_key_event: m.on_preview_key_event.clone(),
568            cursor: m.cursor,
569            on_drag_start: m.on_drag_start.clone(),
570            on_drag_end: m.on_drag_end.clone(),
571            on_drag_enter: m.on_drag_enter.clone(),
572            on_drag_over: m.on_drag_over.clone(),
573            on_drag_leave: m.on_drag_leave.clone(),
574            on_scroll: m.on_scroll.clone(),
575            on_drop: m.on_drop.clone(),
576            drag_preview: m.drag_preview.clone(),
577            disabled: m.disabled,
578            tf_enabled: true,
579            tf_read_only: false,
580            ..Default::default()
581        }
582    }
583}
584
585/// Flattened semantics node produced by `layout_and_paint`.
586///
587/// This is the source of truth for accessibility backends: it contains the
588/// resolved screen rect, role, label, and focus/enabled state.
589///
590/// The platform runner should convert this into OS‑specific accessibility trees (when implemented)
591/// (AT‑SPI on Linux, TalkBack on Android, etc.).
592#[derive(Clone)]
593pub struct SemNode {
594    /// Stable id, shared with the associated `HitRegion` / `ViewId`.
595    pub id: u64,
596
597    /// `None` means direct child of the window root.
598    pub parent: Option<u64>,
599
600    pub role: Role,
601    pub label: Option<String>,
602    pub rect: Rect,
603    pub focused: bool,
604    pub enabled: bool,
605    /// Marks this node as a collection of selectable children (e.g., Tabs).
606    pub selectable_group: bool,
607    pub checked: Option<bool>,
608    pub selected: Option<bool>,
609    pub value: Option<String>,
610}
611
612impl Default for SemNode {
613    fn default() -> Self {
614        Self {
615            id: 0,
616            parent: None,
617            role: Role::default(),
618            label: None,
619            rect: Rect::default(),
620            focused: false,
621            enabled: true,
622            selectable_group: false,
623            checked: None,
624            selected: None,
625            value: None,
626        }
627    }
628}
629
630pub struct Scheduler {
631    next_id: u64,
632    /// Per-scope unique IDs, assigned lazily when a scope first executes.
633    /// Keyed by the scope key string from `scope!`.
634    scope_key_to_id: FxHashMap<String, u32>,
635    next_scope_id: u32,
636    /// When set, `id()` allocates from this scope's local counter instead of the global counter.
637    /// The returned ID is `(scope_id << 32) | local_id`, which is stable even when
638    /// prior sibling scopes change their view count.
639    current_scope: Option<String>,
640    /// Per-scope local ID counters. Reset to 0 when a scope re-executes.
641    scope_local_counters: FxHashMap<String, u32>,
642    pub focused: Option<u64>,
643    pub size: (u32, u32),
644}
645
646impl Default for Scheduler {
647    fn default() -> Self {
648        Self::new()
649    }
650}
651
652impl Scheduler {
653    pub fn new() -> Self {
654        Self {
655            next_id: 1,
656            scope_key_to_id: FxHashMap::default(),
657            next_scope_id: 1,
658            current_scope: None,
659            scope_local_counters: FxHashMap::default(),
660            focused: None,
661            size: (1280, 800),
662        }
663    }
664
665    /// Enter a named scope. Subsequent `id()` calls within this scope
666    /// will allocate from the scope's local counter, producing packed
667    /// `(scope_id << 32) | local_id` values that are stable across sibling
668    /// recompositions.
669    pub fn enter_scope(&mut self, key: &str) {
670        self.current_scope = Some(key.to_string());
671        // Reset local counter -> the body will re-assign IDs fresh
672        self.scope_local_counters.insert(key.to_string(), 0);
673        // Ensure a scope_id exists (lazy allocation)
674        self.get_or_create_scope_id(key);
675    }
676
677    /// Exit the current scope. Subsequent `id()` calls return global IDs again.
678    pub fn exit_scope(&mut self) {
679        self.current_scope = None;
680    }
681
682    fn get_or_create_scope_id(&mut self, key: &str) -> u32 {
683        if let Some(&id) = self.scope_key_to_id.get(key) {
684            id
685        } else {
686            let id = self.next_scope_id;
687            self.next_scope_id += 1;
688            self.scope_key_to_id.insert(key.to_string(), id);
689            id
690        }
691    }
692
693    pub fn id(&mut self) -> u64 {
694        if let Some(key) = &self.current_scope {
695            // Scope-local ID: packed (scope_id << 32) | local_id
696            let scope_id = self.scope_key_to_id.get(key).copied().unwrap_or(0);
697            let local = self.scope_local_counters.get_mut(key).unwrap();
698            let id = *local;
699            *local += 1;
700            (scope_id as u64) << 32 | id as u64
701        } else {
702            // Global sequential ID (for non-scoped views)
703            let id = self.next_id;
704            self.next_id += 1;
705            id
706        }
707    }
708
709    pub fn id_count(&self) -> u64 {
710        self.next_id - 1
711    }
712
713    /// Snapshot the current ID counter (before executing a scope body) so the
714    /// delta can be computed after the body returns.
715    pub fn snapshot_id(&self) -> u64 {
716        self.next_id
717    }
718
719    /// Advance the ID counter by `count` without assigning IDs.
720    /// Used by the scope! macro to reserve IDs for a cached scope subtree.
721    pub fn advance_id(&mut self, count: u32) {
722        self.next_id += count as u64;
723    }
724
725    /// Number of IDs assigned since `prev_id` (the value returned by
726    /// `snapshot_id()` before executing a scope body).
727    pub fn ids_used_since(&self, prev_id: u64) -> u32 {
728        (self.next_id - prev_id) as u32
729    }
730
731    pub fn repose<F>(
732        &mut self,
733        mut build_root: F,
734        layout_paint: impl Fn(&View, (u32, u32)) -> (Scene, Vec<HitRegion>, Vec<SemNode>),
735    ) -> Frame
736    where
737        F: FnMut(&mut Scheduler) -> View,
738    {
739        let guard = ComposeGuard::begin();
740        let root = guard.scope.run(|| build_root(self));
741        let (scene, hits, sem) = layout_paint(&root, self.size);
742
743        let focus_chain: Vec<u64> = hits.iter().filter(|h| h.focusable).map(|h| h.id).collect();
744
745        Frame {
746            scene,
747            hit_regions: hits,
748            semantics_nodes: sem,
749            focus_chain,
750        }
751    }
752}
753
754/// Avoids cross-test pollution
755#[cfg(test)]
756pub fn clear_composer() {
757    COMPOSER.with(|c| {
758        let mut c = c.borrow_mut();
759        c.slots.clear();
760        c.slot_callers.clear();
761        c.keyed_slots.clear();
762        c.scope_caches.clear();
763        c.cursor = 0;
764    });
765    ROOT_SCOPE.with(|rs| {
766        *rs.borrow_mut() = None;
767    });
768}