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;
5
6use rustc_hash::FxHashMap;
7
8use crate::scope::Scope;
9use crate::{Rect, Scene, View, semantics::Role};
10
11thread_local! {
12    pub static COMPOSER: RefCell<Composer> = RefCell::new(Composer::default());
13    static ROOT_SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
14
15    /// A programmatic focus request, set by `FocusRequester::request_focus()` /
16    /// `free_focus()`. Stores the view ID that should receive focus on the next frame,
17    /// or `Some(CLEAR_FOCUS_MARKER)` to clear focus.
18    static FOCUS_REQUEST: Cell<Option<u64>> = const { Cell::new(None) };
19}
20
21/// Sentinel value meaning "clear focus entirely".
22pub const CLEAR_FOCUS_MARKER: u64 = u64::MAX;
23
24pub fn take_focus_request() -> Option<u64> {
25    FOCUS_REQUEST.with(|r| r.replace(None))
26}
27
28/// A handle that can programmatically request focus for a widget.
29///
30/// Similar to Compose's `FocusRequester`. Create one via `remember(FocusRequester::new)`,
31/// attach it via `.focus_requester(...)` on a modifier, and call `request_focus()` to
32/// move keyboard focus to the associated widget on the next frame.
33#[derive(Clone)]
34pub struct FocusRequester {
35    /// Target view ID, set during layout/paint by the modifier system.
36    pub target: Rc<RefCell<Option<u64>>>,
37}
38
39impl FocusRequester {
40    pub fn new() -> Self {
41        Self {
42            target: Rc::new(RefCell::new(None)),
43        }
44    }
45
46    /// Request focus for the associated widget on the next frame.
47    pub fn request_focus(&self) {
48        if let Some(id) = *self.target.borrow() {
49            FOCUS_REQUEST.with(|r| r.set(Some(id)));
50        }
51    }
52
53    /// Free/clear focus from the associated widget on the next frame.
54    /// If the associated widget currently has focus, focus is cleared entirely.
55    /// Corresponds to Compose's `freeFocus()`.
56    pub fn free_focus(&self) {
57        FOCUS_REQUEST.with(|r| r.set(Some(CLEAR_FOCUS_MARKER)));
58    }
59
60    /// Request focus for the associated widget on the next frame,
61    /// bypassing some focusability checks. Corresponds to Compose's
62    /// `captureFocus()`, which is typically used internally by the focus system.
63    /// In repose this is an alias for `request_focus()`.
64    pub fn capture_focus(&self) {
65        self.request_focus();
66    }
67}
68
69impl Default for FocusRequester {
70    fn default() -> Self {
71        Self::new()
72    }
73}
74
75/// Direction for focus movement.
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77pub enum FocusDirection {
78    Next,
79    Previous,
80    Left,
81    Right,
82    Up,
83    Down,
84}
85
86/// A manager for programmatic focus navigation.
87///
88/// Wraps a `&Scheduler` and provides methods to move focus.
89/// Can also be used standalone with a focus chain and focused element.
90#[derive(Clone)]
91pub struct FocusManager {
92    /// The ordered list of focusable element IDs.
93    pub chain: Vec<u64>,
94    /// The currently focused element (if any).
95    pub focused: Option<u64>,
96    /// Hit regions for focus group lookups.
97    pub hit_regions: Vec<HitRegion>,
98}
99
100impl FocusManager {
101    pub fn new(chain: Vec<u64>, focused: Option<u64>) -> Self {
102        Self {
103            chain,
104            focused,
105            hit_regions: Vec::new(),
106        }
107    }
108
109    /// Move focus in the given direction.
110    /// Returns the new focused element ID, or `None` if no movement is possible.
111    pub fn move_focus(&mut self, dir: FocusDirection) -> Option<u64> {
112        match dir {
113            FocusDirection::Next | FocusDirection::Previous => {
114                self.move_tab(dir == FocusDirection::Previous)
115            }
116            _ => None, // use move_focus_spatial when hit regions are available
117        }
118    }
119
120    /// Clears focus entirely on the next frame.
121    /// Corresponds to Compose's `FocusManager.clearFocus()`.
122    /// The `force` parameter is accepted for API compatibility; in repose
123    /// focus is always cleared immediately (no keep-focus mechanism).
124    pub fn clear_focus(&self, _force: bool) {
125        FOCUS_REQUEST.with(|r| r.set(Some(CLEAR_FOCUS_MARKER)));
126    }
127
128    /// Spatial focus navigation: find the closest focusable element in a given
129    /// direction using bounding rect geometry.
130    pub fn move_focus_spatial(
131        &mut self,
132        dir: FocusDirection,
133        hit_regions: &[HitRegion],
134    ) -> Option<u64> {
135        let next = spatial_focus_next(&self.chain, hit_regions, self.focused, dir)?;
136        self.focused = Some(next);
137        Some(next)
138    }
139
140    /// Tab forward or backward in the focus chain.
141    /// When the current focus belongs to a focus group, navigation is restricted
142    /// to elements within that group.
143    pub fn move_tab(&mut self, reverse: bool) -> Option<u64> {
144        if self.chain.is_empty() {
145            return None;
146        }
147        let current_group = self.focused.and_then(|cur| {
148            self.hit_regions
149                .iter()
150                .find(|h| h.id == cur)
151                .and_then(|h| h.focus_group_id)
152        });
153        let next = if let Some(group_id) = current_group {
154            // Build sub-chain of elements in the same focus group
155            let sub_chain: Vec<u64> = self
156                .chain
157                .iter()
158                .copied()
159                .filter(|&id| {
160                    id == group_id
161                        || self
162                            .hit_regions
163                            .iter()
164                            .any(|h| h.id == id && h.focus_group_id == Some(group_id))
165                })
166                .collect();
167            if sub_chain.is_empty() {
168                return None;
169            }
170            if let Some(cur) = self.focused {
171                if let Some(idx) = sub_chain.iter().position(|&id| id == cur) {
172                    if reverse {
173                        if idx == 0 {
174                            sub_chain[sub_chain.len() - 1]
175                        } else {
176                            sub_chain[idx - 1]
177                        }
178                    } else {
179                        sub_chain[(idx + 1) % sub_chain.len()]
180                    }
181                } else {
182                    sub_chain[0]
183                }
184            } else {
185                sub_chain[0]
186            }
187        } else {
188            if let Some(cur) = self.focused {
189                if let Some(idx) = self.chain.iter().position(|&id| id == cur) {
190                    if reverse {
191                        if idx == 0 {
192                            self.chain[self.chain.len() - 1]
193                        } else {
194                            self.chain[idx - 1]
195                        }
196                    } else {
197                        self.chain[(idx + 1) % self.chain.len()]
198                    }
199                } else {
200                    self.chain[0]
201                }
202            } else {
203                self.chain[0]
204            }
205        };
206        self.focused = Some(next);
207        Some(next)
208    }
209
210    /// Set target ID on a FocusRequester (called during layout).
211    pub fn set_requester_target(requester: &FocusRequester, id: u64) {
212        *requester.target.borrow_mut() = Some(id);
213    }
214}
215
216/// Find the next focusable element in a given spatial direction.
217///
218/// Uses the bounding rects from `hit_regions` to determine which element is
219/// "next" in the given direction from the currently focused element.
220pub fn spatial_focus_next(
221    chain: &[u64],
222    hit_regions: &[HitRegion],
223    current: Option<u64>,
224    dir: FocusDirection,
225) -> Option<u64> {
226    if chain.is_empty() {
227        return None;
228    }
229
230    let current_rect =
231        current.and_then(|id| hit_regions.iter().find(|h| h.id == id).map(|h| h.rect));
232
233    // For Next/Previous, use tab-order navigation
234    match dir {
235        FocusDirection::Next | FocusDirection::Previous => {
236            let mut fm = FocusManager {
237                chain: chain.to_vec(),
238                focused: current,
239                hit_regions: hit_regions.to_vec(),
240            };
241            return fm.move_tab(dir == FocusDirection::Previous);
242        }
243        _ => {}
244    }
245
246    let (cx, cy) = match current_rect {
247        Some(r) => (r.x + r.w / 2.0, r.y + r.h / 2.0),
248        None => return chain.first().copied(),
249    };
250
251    let mut best: Option<(u64, f32)> = None;
252
253    for &id in chain {
254        if Some(id) == current {
255            continue;
256        }
257        let Some(hr) = hit_regions.iter().find(|h| h.id == id) else {
258            continue;
259        };
260        let r = hr.rect;
261        let other_cx = r.x + r.w / 2.0;
262        let other_cy = r.y + r.h / 2.0;
263        let dx = other_cx - cx;
264        let dy = other_cy - cy;
265
266        let in_direction = match dir {
267            FocusDirection::Left => dx < 0.0 && dy.abs() <= r.h.max(1.0),
268            FocusDirection::Right => dx > 0.0 && dy.abs() <= r.h.max(1.0),
269            FocusDirection::Up => dy < 0.0 && dx.abs() <= r.w.max(1.0),
270            FocusDirection::Down => dy > 0.0 && dx.abs() <= r.w.max(1.0),
271            _ => false,
272        };
273
274        if !in_direction {
275            continue;
276        }
277
278        let dist = dx * dx + dy * dy;
279        let weight = dist / (r.w * r.h + 1.0).max(1.0);
280
281        match best {
282            Some((_, best_weight)) if weight >= best_weight => {}
283            _ => best = Some((id, weight)),
284        }
285    }
286
287    best.map(|(id, _)| id)
288}
289
290#[derive(Default)]
291pub struct Composer {
292    pub slots: Vec<Box<dyn Any>>,
293    /// Caller identity for each slot, used to detect stale slots
294    /// when the composition tree changes between frames.
295    pub slot_callers: Vec<&'static Location<'static>>,
296    pub cursor: usize,
297    pub keyed_slots: FxHashMap<String, Box<dyn Any>>,
298    /// Per-scope cached state for the `scope!` macro.
299    /// Keyed by the scope key string.
300    pub scope_caches: FxHashMap<String, crate::scope_cache::ScopeCache>,
301}
302
303pub struct ComposeGuard {
304    scope: Scope,
305}
306
307impl ComposeGuard {
308    pub fn begin() -> Self {
309        COMPOSER.with(|c| c.borrow_mut().cursor = 0);
310
311        let scope = ROOT_SCOPE.with(|rs| {
312            if let Some(existing) = rs.borrow().clone() {
313                existing
314            } else {
315                let s = Scope::new();
316                *rs.borrow_mut() = Some(s.clone());
317                s
318            }
319        });
320
321        ComposeGuard { scope }
322    }
323
324    pub fn scope(&self) -> &Scope {
325        &self.scope
326    }
327}
328
329impl Drop for ComposeGuard {
330    fn drop(&mut self) {
331        // ROOT_SCOPE.with(|rs| { Do not clear every frame
332        //     *rs.borrow_mut() = None;
333        // });
334    }
335}
336
337/// Slot-based remember (sequential composition only).
338/// This prevents state aliasing when the composition tree structure changes between frames
339#[track_caller]
340pub fn remember<T: 'static>(init: impl FnOnce() -> T) -> Rc<T> {
341    // Capture BEFORE any closure -> Location::caller() returns the correct
342    // track_caller location only at the function's top level, not inside closures.
343    let caller = Location::caller();
344    COMPOSER.with(|c| {
345        let mut c = c.borrow_mut();
346        let cursor = c.cursor;
347        c.cursor += 1;
348
349        if cursor >= c.slots.len() {
350            let rc: Rc<T> = Rc::new(init());
351            c.slots.push(Box::new(rc.clone()));
352            c.slot_callers.push(caller);
353            return rc;
354        }
355
356        let stored_caller = c.slot_callers.get(cursor).copied();
357        if stored_caller != Some(caller) {
358            let rc: Rc<T> = Rc::new(init());
359            c.slots[cursor] = Box::new(rc.clone());
360            if cursor < c.slot_callers.len() {
361                c.slot_callers[cursor] = caller;
362            } else {
363                c.slot_callers.push(caller);
364            }
365            return rc;
366        }
367
368        if let Some(rc) = c.slots[cursor].downcast_ref::<Rc<T>>() {
369            rc.clone()
370        } else {
371            log::warn!(
372                "remember: slot {} type changed {}. \
373                 Use remember_with_key(key, || ...) for conditional branches.",
374                cursor,
375                std::any::type_name::<T>(),
376            );
377            let rc: Rc<T> = Rc::new(init());
378            c.slots[cursor] = Box::new(rc.clone());
379            rc
380        }
381    })
382}
383
384/// Key-based remember
385pub fn remember_with_key<T: 'static>(key: impl Into<String>, init: impl FnOnce() -> T) -> Rc<T> {
386    COMPOSER.with(|c| {
387        let mut c = c.borrow_mut();
388        let key = key.into();
389
390        if let Some(existing) = c.keyed_slots.get(&key) {
391            if let Some(rc) = existing.downcast_ref::<Rc<T>>() {
392                return rc.clone();
393            } else {
394                log::warn!(
395                    "remember_with_key: key '{}' reused with a different type; replacing.",
396                    key
397                );
398            }
399        }
400
401        if cfg!(debug_assertions) && c.keyed_slots.len() > 10_000 {
402            log::warn!(
403                "remember_with_key: more than 10k keys stored; \
404                are you generating unbounded dynamic keys (e.g., using timestamps)?"
405            );
406        }
407
408        let rc: Rc<T> = Rc::new(init());
409        c.keyed_slots.insert(key, Box::new(rc.clone()));
410        rc
411    })
412}
413
414#[track_caller]
415pub fn remember_state<T: 'static>(init: impl FnOnce() -> T) -> Rc<RefCell<T>> {
416    remember(|| RefCell::new(init()))
417}
418
419pub fn remember_state_with_key<T: 'static>(
420    key: impl Into<String>,
421    init: impl FnOnce() -> T,
422) -> Rc<RefCell<T>> {
423    remember_with_key(key, || RefCell::new(init()))
424}
425
426/// Frame - output of composition for a tick: scene + input/semantics.
427#[derive(Clone)]
428pub struct Frame {
429    pub scene: Scene,
430    pub hit_regions: Vec<HitRegion>,
431    pub semantics_nodes: Vec<SemNode>,
432    pub focus_chain: Vec<u64>,
433}
434
435#[derive(Clone, Default)]
436pub struct HitRegion {
437    pub id: u64,
438    pub rect: Rect,
439    /// Tree depth: 0 = root, higher = deeper child. Used for three-pass
440    /// pointer dispatch to determine ancestor/descendant ordering.
441    pub depth: u32,
442    pub on_click: Option<Rc<dyn Fn()>>,
443    pub on_scroll: Option<Rc<dyn Fn(crate::Vec2) -> crate::Vec2>>,
444    pub focusable: bool,
445    pub on_pointer_down: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
446    pub on_pointer_move: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
447    pub on_pointer_up: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
448    pub on_pointer_cancel: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
449    pub on_pointer_enter: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
450    pub on_pointer_leave: Option<Rc<dyn Fn(crate::input::PointerEvent)>>,
451    pub z_index: f32,
452    pub on_text_change: Option<Rc<dyn Fn(String)>>,
453    pub on_text_submit: Option<Rc<dyn Fn(String)>>,
454    /// If this hit region belongs to a TextField, this persistent key is used
455    /// for looking up platform-managed TextFieldState. Falls back to `id` if None.
456    pub tf_state_key: Option<u64>,
457
458    /// True if this hit region corresponds to a multiline text input (TextArea).
459    pub tf_multiline: bool,
460
461    // internal
462    pub on_drag_start: Option<Rc<dyn Fn(crate::dnd::DragStart) -> Option<crate::dnd::DragPayload>>>,
463    pub on_drag_end: Option<Rc<dyn Fn(crate::dnd::DragEnd)>>,
464    pub on_drag_enter: Option<Rc<dyn Fn(crate::dnd::DragOver)>>,
465    pub on_drag_over: Option<Rc<dyn Fn(crate::dnd::DragOver)>>,
466    pub on_drag_leave: Option<Rc<dyn Fn(crate::dnd::DragOver)>>,
467    pub on_drop: Option<Rc<dyn Fn(crate::dnd::DropEvent) -> bool>>,
468
469    pub on_action: Option<Rc<dyn Fn(crate::shortcuts::Action) -> bool>>,
470
471    /// Called when a key event is received while this element is focused.
472    /// Return `true` to consume the event.
473    pub on_key_event: Option<Rc<dyn Fn(crate::input::KeyEvent) -> bool>>,
474    /// Called before `on_key_event`. Return `true` to consume before normal dispatch.
475    pub on_preview_key_event: Option<Rc<dyn Fn(crate::input::KeyEvent) -> bool>>,
476
477    /// Cursor hint for desktop/web.
478    pub cursor: Option<crate::CursorIcon>,
479
480    /// If `Some(group_id)`, this hit region belongs to a focus group with the
481    /// given id. Tab navigation will cycle within the group instead of moving
482    /// to elements outside it. Set automatically by the layout engine when the
483    /// element is a descendant of a node with `focus_group: true`.
484    pub focus_group_id: Option<u64>,
485}
486
487impl HitRegion {
488    /// Seed a HitRegion with all the modifier's event handlers + dnd + cursor.
489    /// Call‑sites should only override the fields that differ (on_click, focusable, etc.)
490    /// via struct‑update syntax: `HitRegion { focusable: true, ..from_modifier(..) }`.
491    pub fn from_modifier(id: u64, rect: Rect, m: &crate::modifier::Modifier) -> Self {
492        Self {
493            id,
494            rect,
495            z_index: m.z_index,
496            on_click: m.on_click.clone(),
497            on_pointer_down: m.on_pointer_down.clone(),
498            on_pointer_move: m.on_pointer_move.clone(),
499            on_pointer_up: m.on_pointer_up.clone(),
500            on_pointer_cancel: m.on_pointer_cancel.clone(),
501            on_pointer_enter: m.on_pointer_enter.clone(),
502            on_pointer_leave: m.on_pointer_leave.clone(),
503            on_action: m.on_action.clone(),
504            on_key_event: m.on_key_event.clone(),
505            on_preview_key_event: m.on_preview_key_event.clone(),
506            cursor: m.cursor,
507            on_drag_start: m.on_drag_start.clone(),
508            on_drag_end: m.on_drag_end.clone(),
509            on_drag_enter: m.on_drag_enter.clone(),
510            on_drag_over: m.on_drag_over.clone(),
511            on_drag_leave: m.on_drag_leave.clone(),
512            on_scroll: m.on_scroll.clone(),
513            on_drop: m.on_drop.clone(),
514            ..Default::default()
515        }
516    }
517}
518
519/// Flattened semantics node produced by `layout_and_paint`.
520///
521/// This is the source of truth for accessibility backends: it contains the
522/// resolved screen rect, role, label, and focus/enabled state.
523///
524/// The platform runner should convert this into OS‑specific accessibility trees (when implemented)
525/// (AT‑SPI on Linux, TalkBack on Android, etc.).
526#[derive(Clone)]
527pub struct SemNode {
528    /// Stable id, shared with the associated `HitRegion` / `ViewId`.
529    pub id: u64,
530
531    /// `None` means direct child of the window root.
532    pub parent: Option<u64>,
533
534    pub role: Role,
535    pub label: Option<String>,
536    pub rect: Rect,
537    pub focused: bool,
538    pub enabled: bool,
539    /// Marks this node as a collection of selectable children (e.g., Tabs).
540    pub selectable_group: bool,
541}
542
543pub struct Scheduler {
544    next_id: u64,
545    /// Per-scope unique IDs, assigned lazily when a scope first executes.
546    /// Keyed by the scope key string from `scope!`.
547    scope_key_to_id: FxHashMap<String, u32>,
548    next_scope_id: u32,
549    /// When set, `id()` allocates from this scope's local counter instead of the global counter.
550    /// The returned ID is `(scope_id << 32) | local_id`, which is stable even when
551    /// prior sibling scopes change their view count.
552    current_scope: Option<String>,
553    /// Per-scope local ID counters. Reset to 0 when a scope re-executes.
554    scope_local_counters: FxHashMap<String, u32>,
555    pub focused: Option<u64>,
556    pub size: (u32, u32),
557}
558
559impl Default for Scheduler {
560    fn default() -> Self {
561        Self::new()
562    }
563}
564
565impl Scheduler {
566    pub fn new() -> Self {
567        Self {
568            next_id: 1,
569            scope_key_to_id: FxHashMap::default(),
570            next_scope_id: 1,
571            current_scope: None,
572            scope_local_counters: FxHashMap::default(),
573            focused: None,
574            size: (1280, 800),
575        }
576    }
577
578    /// Enter a named scope. Subsequent `id()` calls within this scope
579    /// will allocate from the scope's local counter, producing packed
580    /// `(scope_id << 32) | local_id` values that are stable across sibling
581    /// recompositions.
582    pub fn enter_scope(&mut self, key: &str) {
583        self.current_scope = Some(key.to_string());
584        // Reset local counter -> the body will re-assign IDs fresh
585        self.scope_local_counters.insert(key.to_string(), 0);
586        // Ensure a scope_id exists (lazy allocation)
587        self.get_or_create_scope_id(key);
588    }
589
590    /// Exit the current scope. Subsequent `id()` calls return global IDs again.
591    pub fn exit_scope(&mut self) {
592        self.current_scope = None;
593    }
594
595    fn get_or_create_scope_id(&mut self, key: &str) -> u32 {
596        if let Some(&id) = self.scope_key_to_id.get(key) {
597            id
598        } else {
599            let id = self.next_scope_id;
600            self.next_scope_id += 1;
601            self.scope_key_to_id.insert(key.to_string(), id);
602            id
603        }
604    }
605
606    pub fn id(&mut self) -> u64 {
607        if let Some(key) = &self.current_scope {
608            // Scope-local ID: packed (scope_id << 32) | local_id
609            let scope_id = self.scope_key_to_id.get(key).copied().unwrap_or(0);
610            let local = self.scope_local_counters.get_mut(key).unwrap();
611            let id = *local;
612            *local += 1;
613            (scope_id as u64) << 32 | id as u64
614        } else {
615            // Global sequential ID (for non-scoped views)
616            let id = self.next_id;
617            self.next_id += 1;
618            id
619        }
620    }
621
622    pub fn id_count(&self) -> u64 {
623        self.next_id - 1
624    }
625
626    /// Snapshot the current ID counter (before executing a scope body) so the
627    /// delta can be computed after the body returns.
628    pub fn snapshot_id(&self) -> u64 {
629        self.next_id
630    }
631
632    /// Advance the ID counter by `count` without assigning IDs.
633    /// Used by the scope! macro to reserve IDs for a cached scope subtree.
634    pub fn advance_id(&mut self, count: u32) {
635        self.next_id += count as u64;
636    }
637
638    /// Number of IDs assigned since `prev_id` (the value returned by
639    /// `snapshot_id()` before executing a scope body).
640    pub fn ids_used_since(&self, prev_id: u64) -> u32 {
641        (self.next_id - prev_id) as u32
642    }
643
644    pub fn repose<F>(
645        &mut self,
646        mut build_root: F,
647        layout_paint: impl Fn(&View, (u32, u32)) -> (Scene, Vec<HitRegion>, Vec<SemNode>),
648    ) -> Frame
649    where
650        F: FnMut(&mut Scheduler) -> View,
651    {
652        let guard = ComposeGuard::begin();
653        let root = guard.scope.run(|| build_root(self));
654        let (scene, hits, sem) = layout_paint(&root, self.size);
655
656        let focus_chain: Vec<u64> = hits.iter().filter(|h| h.focusable).map(|h| h.id).collect();
657
658        Frame {
659            scene,
660            hit_regions: hits,
661            semantics_nodes: sem,
662            focus_chain,
663        }
664    }
665}
666
667/// Avoids cross-test pollution
668#[cfg(test)]
669pub fn clear_composer() {
670    COMPOSER.with(|c| {
671        let mut c = c.borrow_mut();
672        c.slots.clear();
673        c.slot_callers.clear();
674        c.keyed_slots.clear();
675        c.scope_caches.clear();
676        c.cursor = 0;
677    });
678    ROOT_SCOPE.with(|rs| {
679        *rs.borrow_mut() = None;
680    });
681}