Skip to main content

slt/context/
runtime.rs

1use super::*;
2
3impl Context {
4    pub(crate) fn new(
5        events: Vec<Event>,
6        width: u32,
7        height: u32,
8        state: &mut FrameState,
9        theme: Theme,
10    ) -> Self {
11        let hook_states = &mut state.hook_states;
12        let named_states = std::mem::take(&mut state.named_states);
13        // Issue #215: hand off the keyed-state map for this frame. Same
14        // lifetime as `named_states`: moved out at frame start, moved back
15        // at frame end (see `run_frame_kernel`).
16        let keyed_states = std::mem::take(&mut state.keyed_states);
17        let screen_hook_map = std::mem::take(&mut state.screen_hook_map);
18        let focus = &mut state.focus;
19        // Issue #217: name→index map from the previous frame, used to resolve
20        // `focus_by_name(name)` at frame start. We move it out so the
21        // `register_focusable_named` calls in this frame can rebuild a fresh
22        // `focus_name_map`. The fresh map is swapped back into
23        // `focus_name_map_prev` at frame end.
24        let focus_name_map_prev = std::mem::take(&mut focus.focus_name_map_prev);
25        let pending_focus_name = focus.pending_focus_name.take();
26        let prev_focus_index = focus.prev_focus_index;
27        let layout_feedback = &mut state.layout_feedback;
28        let diagnostics = &mut state.diagnostics;
29        let consumed = vec![false; events.len()];
30
31        let mut mouse_pos = layout_feedback.last_mouse_pos;
32        let mut click_pos = None;
33        let mut right_click_pos = None;
34        for event in &events {
35            if let Event::Mouse(mouse) = event {
36                mouse_pos = Some((mouse.x, mouse.y));
37                match mouse.kind {
38                    MouseKind::Down(MouseButton::Left) => {
39                        click_pos = Some((mouse.x, mouse.y));
40                    }
41                    MouseKind::Down(MouseButton::Right) => {
42                        // Issue #208: capture last right-click position so
43                        // `response_for` can hit-test against per-widget rects.
44                        right_click_pos = Some((mouse.x, mouse.y));
45                    }
46                    _ => {}
47                }
48            }
49        }
50
51        let mut focus_index = focus.focus_index;
52        if let Some((mx, my)) = click_pos {
53            let mut best: Option<(usize, u64)> = None;
54            for &(fid, rect) in &layout_feedback.prev_focus_rects {
55                if mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom() {
56                    let area = rect.width as u64 * rect.height as u64;
57                    if best.map_or(true, |(_, ba)| area < ba) {
58                        best = Some((fid, area));
59                    }
60                }
61            }
62            if let Some((fid, _)) = best {
63                focus_index = fid;
64            }
65        }
66
67        // Issue #217: resolve a pending `focus_by_name(...)` request against
68        // the previous frame's `name → index` map. If the name wasn't
69        // registered last frame, we keep the request pending for the next
70        // frame so a widget that registers later can still receive focus.
71        // If the request resolves, we consume it.
72        let mut still_pending: Option<String> = None;
73        if let Some(name) = pending_focus_name {
74            if let Some(&resolved) = focus_name_map_prev.get(&name) {
75                focus_index = resolved;
76            } else {
77                still_pending = Some(name);
78            }
79        }
80
81        // Reuse `commands_buf` capacity from the previous frame (issue #150).
82        // `mem::take` swaps an empty Vec into `state.commands_buf`; we then
83        // clear (no-op when reclaimed from a `build_tree` drain, defensive
84        // when reclaimed from the quit path that ran without `build_tree`)
85        // and reuse the allocation. After `build_tree(&mut ctx.commands)`
86        // drains the Vec in place, the empty (but capacity-bearing) Vec is
87        // moved back into `state.commands_buf` at frame end inside
88        // `run_frame_kernel`.
89        let mut commands = std::mem::take(&mut state.commands_buf);
90        commands.clear();
91
92        // Issue #204: reuse the six per-frame `Vec`/`HashSet` allocations
93        // (`context_stack`, `deferred_draws`, `rollback.group_stack`,
94        // `rollback.text_color_stack`, `pending_tooltips`, `hovered_groups`).
95        // Same `mem::take` pattern as `commands_buf` (#150). Each buffer is
96        // empty at frame end (asserted at `run_frame_kernel`) — `mem::take`
97        // hands a `Default::default()` empty back to the state, the Vec/HashSet
98        // we move into `Context` keeps its capacity from the prior frame, and
99        // `clear()` here is a no-op except as a defensive guard against future
100        // refactors that might leak items past the assertions.
101        let mut context_stack = std::mem::take(&mut state.context_stack_buf);
102        context_stack.clear();
103        let mut deferred_draws = std::mem::take(&mut state.deferred_draws_buf);
104        deferred_draws.clear();
105        let mut group_stack = std::mem::take(&mut state.group_stack_buf);
106        group_stack.clear();
107        let mut text_color_stack = std::mem::take(&mut state.text_color_stack_buf);
108        text_color_stack.clear();
109        let mut pending_tooltips = std::mem::take(&mut state.pending_tooltips_buf);
110        pending_tooltips.clear();
111        let hovered_groups = std::mem::take(&mut state.hovered_groups_buf);
112        // `hovered_groups` is `clear()`-ed inside `build_hovered_groups`
113        // immediately below, so we do not pre-clear here — capacity is
114        // preserved across frames.
115
116        let mut ctx = Self {
117            commands,
118            events,
119            consumed,
120            should_quit: false,
121            area_width: width,
122            area_height: height,
123            tick: diagnostics.tick,
124            focus_index,
125            hook_states: std::mem::take(hook_states),
126            named_states,
127            keyed_states,
128            context_stack,
129            prev_focus_count: focus.prev_focus_count,
130            prev_modal_focus_start: focus.prev_modal_focus_start,
131            prev_modal_focus_count: focus.prev_modal_focus_count,
132            prev_scroll_infos: std::mem::take(&mut layout_feedback.prev_scroll_infos),
133            prev_scroll_rects: std::mem::take(&mut layout_feedback.prev_scroll_rects),
134            prev_hit_map: std::mem::take(&mut layout_feedback.prev_hit_map),
135            prev_group_rects: std::mem::take(&mut layout_feedback.prev_group_rects),
136            prev_focus_groups: std::mem::take(&mut layout_feedback.prev_focus_groups),
137            mouse_pos,
138            click_pos,
139            right_click_pos,
140            prev_modal_active: focus.prev_modal_active,
141            clipboard_text: None,
142            debug: diagnostics.debug_mode,
143            debug_layer: diagnostics.debug_layer,
144            theme,
145            is_real_terminal: false,
146            deferred_draws,
147            rollback: ContextRollbackState {
148                last_text_idx: None,
149                focus_count: 0,
150                last_focusable_id: None,
151                pending_focusable_id: None,
152                interaction_count: 0,
153                scroll_count: 0,
154                group_count: 0,
155                group_stack,
156                overlay_depth: 0,
157                modal_active: false,
158                modal_focus_start: 0,
159                modal_focus_count: 0,
160                hook_cursor: 0,
161                dark_mode: theme.is_dark,
162                notification_queue: std::mem::take(&mut diagnostics.notification_queue),
163                text_color_stack,
164            },
165            pending_tooltips,
166            hovered_groups,
167            scroll_lines_per_event: 1,
168            screen_hook_map,
169            widget_theme: WidgetTheme::new(),
170            prev_focus_index,
171            focus_name_map_prev,
172            focus_name_map: std::collections::HashMap::new(),
173            pending_focus_name: still_pending,
174        };
175        ctx.build_hovered_groups();
176        ctx
177    }
178
179    fn build_hovered_groups(&mut self) {
180        self.hovered_groups.clear();
181        if let Some(pos) = self.mouse_pos {
182            for (name, rect) in &self.prev_group_rects {
183                if pos.0 >= rect.x
184                    && pos.0 < rect.x + rect.width
185                    && pos.1 >= rect.y
186                    && pos.1 < rect.y + rect.height
187                {
188                    self.hovered_groups.insert(std::sync::Arc::clone(name));
189                }
190            }
191        }
192    }
193
194    /// Set how many lines each scroll event moves. Default is 1.
195    pub fn set_scroll_speed(&mut self, lines: u32) {
196        self.scroll_lines_per_event = lines.max(1);
197    }
198
199    /// Get the current scroll speed (lines per scroll event).
200    pub fn scroll_speed(&self) -> u32 {
201        self.scroll_lines_per_event
202    }
203
204    /// Get the current focus index.
205    ///
206    /// Widget indices are assigned in the order [`register_focusable()`](Self::register_focusable) is called.
207    /// Indices are 0-based and wrap at [`focus_count()`](Self::focus_count).
208    pub fn focus_index(&self) -> usize {
209        self.focus_index
210    }
211
212    /// Set the focus index to a specific focusable widget.
213    ///
214    /// Widget indices are assigned in the order [`register_focusable()`](Self::register_focusable) is called
215    /// (0-based). If `index` exceeds the number of focusable widgets it will
216    /// be clamped by the modulo in [`register_focusable`](Self::register_focusable).
217    ///
218    /// # Example
219    ///
220    /// ```no_run
221    /// # slt::run(|ui: &mut slt::Context| {
222    /// // Focus the second focusable widget (index 1)
223    /// ui.set_focus_index(1);
224    /// # });
225    /// ```
226    pub fn set_focus_index(&mut self, index: usize) {
227        self.focus_index = index;
228    }
229
230    /// Get the number of focusable widgets registered in the previous frame.
231    ///
232    /// Returns 0 on the very first frame. Useful together with
233    /// [`set_focus_index()`](Self::set_focus_index) for programmatic focus control.
234    ///
235    /// Note: this intentionally reads `prev_focus_count` (the settled count
236    /// from the last completed frame) rather than `focus_count` (the
237    /// still-incrementing counter for the current frame).
238    #[allow(clippy::misnamed_getters)]
239    pub fn focus_count(&self) -> usize {
240        self.prev_focus_count
241    }
242
243    pub(crate) fn process_focus_keys(&mut self) {
244        for (i, event) in self.events.iter().enumerate() {
245            if self.consumed[i] {
246                continue;
247            }
248            if let Event::Key(key) = event {
249                if key.kind != KeyEventKind::Press {
250                    continue;
251                }
252                if key.code == KeyCode::Tab && !key.modifiers.contains(KeyModifiers::SHIFT) {
253                    if self.prev_modal_active && self.prev_modal_focus_count > 0 {
254                        let mut modal_local =
255                            self.focus_index.saturating_sub(self.prev_modal_focus_start);
256                        modal_local %= self.prev_modal_focus_count;
257                        let next = (modal_local + 1) % self.prev_modal_focus_count;
258                        self.focus_index = self.prev_modal_focus_start + next;
259                    } else if self.prev_focus_count > 0 {
260                        self.focus_index = (self.focus_index + 1) % self.prev_focus_count;
261                    }
262                    self.consumed[i] = true;
263                } else if (key.code == KeyCode::Tab && key.modifiers.contains(KeyModifiers::SHIFT))
264                    || key.code == KeyCode::BackTab
265                {
266                    if self.prev_modal_active && self.prev_modal_focus_count > 0 {
267                        let mut modal_local =
268                            self.focus_index.saturating_sub(self.prev_modal_focus_start);
269                        modal_local %= self.prev_modal_focus_count;
270                        let prev = if modal_local == 0 {
271                            self.prev_modal_focus_count - 1
272                        } else {
273                            modal_local - 1
274                        };
275                        self.focus_index = self.prev_modal_focus_start + prev;
276                    } else if self.prev_focus_count > 0 {
277                        self.focus_index = if self.focus_index == 0 {
278                            self.prev_focus_count - 1
279                        } else {
280                            self.focus_index - 1
281                        };
282                    }
283                    self.consumed[i] = true;
284                }
285            }
286        }
287    }
288
289    /// Render a custom [`Widget`].
290    ///
291    /// Calls [`Widget::ui`] with this context and returns the widget's response.
292    pub fn widget<W: Widget>(&mut self, w: &mut W) -> W::Response {
293        w.ui(self)
294    }
295
296    /// Wrap child widgets in a panic boundary.
297    ///
298    /// If the closure panics, the panic is caught and an error message is
299    /// rendered in place of the children. The app continues running.
300    ///
301    /// # Example
302    ///
303    /// ```no_run
304    /// # slt::run(|ui: &mut slt::Context| {
305    /// ui.error_boundary(|ui| {
306    ///     ui.text("risky widget");
307    /// });
308    /// # });
309    /// ```
310    pub fn error_boundary(&mut self, f: impl FnOnce(&mut Context)) {
311        self.error_boundary_with(f, |ui, msg| {
312            ui.styled(
313                format!("⚠ Error: {msg}"),
314                Style::new().fg(ui.theme.error).bold(),
315            );
316        });
317    }
318
319    /// Like [`error_boundary`](Self::error_boundary), but renders a custom
320    /// fallback instead of the default error message.
321    ///
322    /// The fallback closure receives the panic message as a [`String`].
323    ///
324    /// # Example
325    ///
326    /// ```no_run
327    /// # slt::run(|ui: &mut slt::Context| {
328    /// ui.error_boundary_with(
329    ///     |ui| {
330    ///         ui.text("risky widget");
331    ///     },
332    ///     |ui, msg| {
333    ///         ui.text(format!("Recovered from panic: {msg}"));
334    ///     },
335    /// );
336    /// # });
337    /// ```
338    pub fn error_boundary_with(
339        &mut self,
340        f: impl FnOnce(&mut Context),
341        fallback: impl FnOnce(&mut Context, String),
342    ) {
343        let snapshot = ContextCheckpoint::capture(self);
344
345        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
346            f(self);
347        }));
348
349        match result {
350            Ok(()) => {}
351            Err(panic_info) => {
352                if self.is_real_terminal {
353                    #[cfg(feature = "crossterm")]
354                    {
355                        let _ = crossterm::terminal::enable_raw_mode();
356                        let _ = crossterm::execute!(
357                            std::io::stdout(),
358                            crossterm::terminal::EnterAlternateScreen
359                        );
360                    }
361
362                    #[cfg(not(feature = "crossterm"))]
363                    {}
364                }
365
366                snapshot.restore(self);
367
368                let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
369                    (*s).to_string()
370                } else if let Some(s) = panic_info.downcast_ref::<String>() {
371                    s.clone()
372                } else {
373                    "widget panicked".to_string()
374                };
375
376                fallback(self, msg);
377            }
378        }
379    }
380
381    /// Reserve the next interaction slot without emitting a marker command.
382    pub(crate) fn reserve_interaction_slot(&mut self) -> usize {
383        let id = self.rollback.interaction_count;
384        self.rollback.interaction_count += 1;
385        id
386    }
387
388    /// Advance the interaction counter for structural commands that still
389    /// participate in hit-map indexing.
390    pub(crate) fn skip_interaction_slot(&mut self) {
391        self.reserve_interaction_slot();
392    }
393
394    /// Reserve the next interaction ID and emit a marker command.
395    pub(crate) fn next_interaction_id(&mut self) -> usize {
396        let id = self.reserve_interaction_slot();
397        self.commands.push(Command::InteractionMarker(id));
398        id
399    }
400
401    /// Allocate a click/hover interaction slot and return the [`Response`].
402    ///
403    /// Use this in custom widgets to detect mouse clicks and hovers without
404    /// wrapping content in a container. Call it immediately before the text,
405    /// rich text, link, or container that should own the interaction rect.
406    /// Each call reserves one slot in the hit-test map, so the call order
407    /// must be stable across frames.
408    pub fn interaction(&mut self) -> Response {
409        if (self.rollback.modal_active || self.prev_modal_active)
410            && self.rollback.overlay_depth == 0
411        {
412            return Response::none();
413        }
414        let id = self.next_interaction_id();
415        self.response_for(id)
416    }
417
418    pub(crate) fn begin_widget_interaction(&mut self, focused: bool) -> (usize, Response) {
419        let interaction_id = self.next_interaction_id();
420        let mut response = self.response_for(interaction_id);
421        response.focused = focused;
422        // Issue #208: compute focus transitions from the most recent
423        // `register_focusable` call. If that focusable lined up with the
424        // previously-focused widget index from the prior frame, focus
425        // changes since map directly to gained/lost.
426        if let Some(this_id) = self.rollback.last_focusable_id {
427            let was_focused = self
428                .prev_focus_index
429                .map(|prev| prev == this_id)
430                .unwrap_or(false);
431            response.gained_focus = focused && !was_focused;
432            response.lost_focus = !focused && was_focused;
433            // Consume the marker so a single `register_focusable` powers
434            // exactly one `begin_widget_interaction` call.
435            self.rollback.last_focusable_id = None;
436        }
437        (interaction_id, response)
438    }
439
440    pub(crate) fn consume_indices<I>(&mut self, indices: I)
441    where
442        I: IntoIterator<Item = usize>,
443    {
444        for index in indices {
445            self.consumed[index] = true;
446        }
447    }
448
449    pub(crate) fn available_key_presses(
450        &self,
451    ) -> impl Iterator<Item = (usize, &crate::event::KeyEvent)> + '_ {
452        self.events.iter().enumerate().filter_map(|(i, event)| {
453            if self.consumed[i] {
454                return None;
455            }
456            match event {
457                Event::Key(key) if key.kind == KeyEventKind::Press => Some((i, key)),
458                _ => None,
459            }
460        })
461    }
462
463    pub(crate) fn available_pastes(&self) -> impl Iterator<Item = (usize, &str)> + '_ {
464        self.events.iter().enumerate().filter_map(|(i, event)| {
465            if self.consumed[i] {
466                return None;
467            }
468            match event {
469                Event::Paste(text) => Some((i, text.as_str())),
470                _ => None,
471            }
472        })
473    }
474
475    pub(crate) fn left_clicks_in_rect(
476        &self,
477        rect: Rect,
478    ) -> impl Iterator<Item = (usize, &crate::event::MouseEvent)> + '_ {
479        self.mouse_events_in_rect(rect).filter_map(|(i, mouse)| {
480            if matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
481                Some((i, mouse))
482            } else {
483                None
484            }
485        })
486    }
487
488    pub(crate) fn mouse_events_in_rect(
489        &self,
490        rect: Rect,
491    ) -> impl Iterator<Item = (usize, &crate::event::MouseEvent)> + '_ {
492        self.events
493            .iter()
494            .enumerate()
495            .filter_map(move |(i, event)| {
496                if self.consumed[i] {
497                    return None;
498                }
499
500                let Event::Mouse(mouse) = event else {
501                    return None;
502                };
503
504                if mouse.x < rect.x
505                    || mouse.x >= rect.right()
506                    || mouse.y < rect.y
507                    || mouse.y >= rect.bottom()
508                {
509                    return None;
510                }
511
512                Some((i, mouse))
513            })
514    }
515
516    pub(crate) fn left_clicks_for_interaction(
517        &self,
518        interaction_id: usize,
519    ) -> Option<(Rect, Vec<(usize, &crate::event::MouseEvent)>)> {
520        let rect = self.prev_hit_map.get(interaction_id).copied()?;
521        let clicks = self.left_clicks_in_rect(rect).collect();
522        Some((rect, clicks))
523    }
524
525    pub(crate) fn consume_activation_keys(&mut self, focused: bool) -> bool {
526        if !focused {
527            return false;
528        }
529
530        // Activation keys (Enter / Space) are typically 0–1 per frame and
531        // bounded above by the simultaneous-keypress count from the input
532        // pipeline (well under 8 in practice). A `SmallVec` with an 8-slot
533        // inline capacity eliminates the per-focusable `Vec<usize>` heap
534        // allocation that showed up on every focused widget × every frame.
535        // Spillover beyond 8 falls back to the heap automatically. Closes #135.
536        let consumed: smallvec::SmallVec<[usize; 8]> = self
537            .available_key_presses()
538            .filter_map(|(i, key)| {
539                if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
540                    Some(i)
541                } else {
542                    None
543                }
544            })
545            .collect();
546        let activated = !consumed.is_empty();
547        if activated {
548            // `consume_indices` takes `IntoIterator<Item = usize>` — `SmallVec`
549            // satisfies that bound directly, no signature change needed.
550            self.consume_indices(consumed);
551        }
552        activated
553    }
554
555    /// Register a widget as focusable and return whether it currently has focus.
556    ///
557    /// Call this in custom widgets that need keyboard focus. Each call increments
558    /// the internal focus counter, so the call order must be stable across frames.
559    ///
560    /// # Slot reservation by `register_focusable_named`
561    ///
562    /// If [`register_focusable_named`](Self::register_focusable_named) was
563    /// called immediately before this call, it has already allocated a
564    /// slot and bound a name to it; this call **reuses** that slot
565    /// instead of allocating a fresh one. That keeps the name binding
566    /// pointed at the widget the user sees rather than at a dummy slot.
567    pub fn register_focusable(&mut self) -> bool {
568        if (self.rollback.modal_active || self.prev_modal_active)
569            && self.rollback.overlay_depth == 0
570        {
571            self.rollback.last_focusable_id = None;
572            // Drop any pending reservation: the suppressed widget never
573            // attached, so reusing the reserved id from a later widget in
574            // the same frame would silently rebind the name to the wrong
575            // slot.
576            self.rollback.pending_focusable_id = None;
577            return false;
578        }
579        // Issue #217 follow-up: if `register_focusable_named` reserved a
580        // slot for us, reuse it (and skip the FocusMarker push — it was
581        // already emitted when the reservation was made). Otherwise,
582        // allocate a fresh slot the normal way.
583        let (id, freshly_allocated) =
584            if let Some(reserved) = self.rollback.pending_focusable_id.take() {
585                (reserved, false)
586            } else {
587                let id = self.rollback.focus_count;
588                self.rollback.focus_count += 1;
589                (id, true)
590            };
591        // Issue #208: remember this widget's focus id so the immediately
592        // following `begin_widget_interaction` call can compare against
593        // `prev_focus_index` and emit gained/lost focus signals.
594        self.rollback.last_focusable_id = Some(id);
595        if freshly_allocated {
596            self.commands.push(Command::FocusMarker(id));
597        }
598        if self.prev_modal_active
599            && self.prev_modal_focus_count > 0
600            && self.rollback.modal_active
601            && self.rollback.overlay_depth > 0
602        {
603            let mut modal_local_id = id.saturating_sub(self.rollback.modal_focus_start);
604            modal_local_id %= self.prev_modal_focus_count;
605            let mut modal_focus_idx = self.focus_index.saturating_sub(self.prev_modal_focus_start);
606            modal_focus_idx %= self.prev_modal_focus_count;
607            return modal_local_id == modal_focus_idx;
608        }
609        if self.prev_focus_count == 0 {
610            return true;
611        }
612        self.focus_index % self.prev_focus_count == id
613    }
614
615    /// Create persistent state that survives across frames.
616    ///
617    /// Returns a `State<T>` handle. Access with `state.get(ui)` / `state.get_mut(ui)`.
618    ///
619    /// # Rules
620    /// - Must be called in the same order every frame (like React hooks)
621    /// - Do NOT call inside if/else that changes between frames
622    ///
623    /// # Example
624    /// ```ignore
625    /// let count = ui.use_state(|| 0i32);
626    /// let val = count.get(ui);
627    /// ui.text(format!("Count: {val}"));
628    /// if ui.button("+1").clicked {
629    ///     *count.get_mut(ui) += 1;
630    /// }
631    /// ```
632    pub fn use_state<T: 'static>(&mut self, init: impl FnOnce() -> T) -> State<T> {
633        let idx = self.rollback.hook_cursor;
634        self.rollback.hook_cursor += 1;
635
636        if idx >= self.hook_states.len() {
637            self.hook_states.push(Box::new(init()));
638        }
639
640        State::from_idx(idx)
641    }
642
643    /// Component-local persistent state keyed by a stable id.
644    ///
645    /// Unlike [`use_state`](Self::use_state), this is **not order-dependent** —
646    /// the value is looked up by `id` instead of call position. Safe to call
647    /// inside conditional branches or reusable component functions.
648    ///
649    /// Returns a `State<T>` handle. Access with `state.get(ui)` /
650    /// `state.get_mut(ui)`. Persists across frames.
651    ///
652    /// # Scoping
653    ///
654    /// Keys are `&'static str` and live in a single global namespace per
655    /// `Context` (no automatic per-component scoping). Two calls with the same
656    /// `id` in the same frame share the same value, regardless of where they
657    /// occur in the tree. Pick unique ids — for example, prefix with a
658    /// component name (`"counter::value"`).
659    ///
660    /// # Example
661    ///
662    /// ```ignore
663    /// fn counter(ui: &mut slt::Context) {
664    ///     let count = ui.use_state_named_with("counter::value", || 0i32);
665    ///     ui.text(format!("Count: {}", count.get(ui)));
666    ///     if ui.button("+1").clicked {
667    ///         *count.get_mut(ui) += 1;
668    ///     }
669    /// }
670    /// ```
671    pub fn use_state_named_with<T: 'static>(
672        &mut self,
673        id: &'static str,
674        init: impl FnOnce() -> T,
675    ) -> State<T> {
676        self.named_states
677            .entry(id)
678            .or_insert_with(|| Box::new(init()));
679        State::from_named(id)
680    }
681
682    /// Like [`use_state_named_with`](Self::use_state_named_with), but uses
683    /// [`Default::default()`] to initialize the value on first call.
684    ///
685    /// # Example
686    ///
687    /// ```ignore
688    /// let value = ui.use_state_named::<i32>("counter::value");
689    /// ```
690    pub fn use_state_named<T: 'static + Default>(&mut self, id: &'static str) -> State<T> {
691        self.use_state_named_with(id, T::default)
692    }
693
694    /// Smoothly animate between `0.0` and `1.0` driven by a boolean.
695    ///
696    /// Returns the current interpolated value (0.0..=1.0). When `value` is
697    /// `true` the result tweens toward `1.0`; when `false` it tweens back
698    /// toward `0.0`. The transition duration defaults to
699    /// [`DEFAULT_ANIMATE_TICKS`](crate::anim::DEFAULT_ANIMATE_TICKS) (12 ticks
700    /// ≈ 200 ms at 60 Hz). Use [`Context::animate_value`] for custom duration
701    /// or non-binary targets.
702    ///
703    /// State is stored in the per-context named-state map under `id`. The
704    /// id is `&'static str` (single global namespace per context), matching
705    /// [`Context::use_state_named`]. Pick a unique key per call site — two
706    /// `animate_bool` calls with the same id share state.
707    ///
708    /// On the first call, the value snaps to the target with no visible
709    /// transition (so widgets that mount in their final state don't pop).
710    ///
711    /// # Example
712    /// ```ignore
713    /// let opacity = ui.animate_bool("sidebar::visible", is_open);
714    /// // 0.0 ≤ opacity ≤ 1.0; use as alpha or visibility threshold.
715    /// ```
716    pub fn animate_bool(&mut self, id: &'static str, value: bool) -> f64 {
717        let target = if value { 1.0 } else { 0.0 };
718        self.animate_value(id, target, crate::anim::DEFAULT_ANIMATE_TICKS)
719    }
720
721    /// Smoothly animate a `f64` value toward `target` over `duration_ticks`.
722    ///
723    /// Uses a linear-easing [`crate::Tween`] stored implicitly in the
724    /// per-context named-state map under `id`. Returns the current
725    /// interpolated value. On the first call the value snaps to `target`
726    /// with no visible transition; on subsequent calls when `target`
727    /// changes the tween is rebuilt starting from the current interpolated
728    /// value, so retargeting mid-flight does not produce a jump.
729    ///
730    /// `duration_ticks == 0` snaps immediately to the new target.
731    ///
732    /// # Example
733    /// ```ignore
734    /// let bar_height = ui.animate_value("loading::bar", target_height, 30);
735    /// ui.bar(bar_height);
736    /// ```
737    ///
738    /// # Comparison with `Tween`
739    /// Use this shorthand when you want zero boilerplate and linear easing
740    /// is acceptable. For custom easing, a non-static key, or
741    /// non-tick-based control, construct a [`crate::Tween`] explicitly via
742    /// [`Context::use_state_named_with`](Self::use_state_named_with).
743    pub fn animate_value(&mut self, id: &'static str, target: f64, duration_ticks: u64) -> f64 {
744        let tick = self.tick;
745        let entry = self
746            .named_states
747            .entry(id)
748            .or_insert_with(|| Box::new(crate::anim::AnimState::new(target, tick)));
749        let state = entry
750            .downcast_mut::<crate::anim::AnimState>()
751            .unwrap_or_else(|| {
752                panic!(
753                    "animate_value: id {:?} is already used for a different state type",
754                    id
755                )
756            });
757        state.sample(target, duration_ticks, tick)
758    }
759
760    /// Push a value onto the context stack for the duration of `body`.
761    ///
762    /// Inside `body`, child widgets can call
763    /// [`use_context::<T>()`](Self::use_context) or
764    /// [`try_use_context::<T>()`](Self::try_use_context) to look up the
765    /// nearest provided value of type `T`. Provides cascade in LIFO order:
766    /// nested calls with the same `T` shadow outer ones.
767    ///
768    /// The value is automatically popped when `body` returns — including on
769    /// panic, so the context stack is always restored.
770    ///
771    /// # Example
772    ///
773    /// ```ignore
774    /// struct Theme { accent: slt::Color }
775    /// ui.provide(Theme { accent: slt::Color::Red }, |ui| {
776    ///     // Any widget here can `let theme = ui.use_context::<Theme>();`
777    ///     render_button(ui);
778    /// });
779    /// ```
780    pub fn provide<T: 'static, R>(&mut self, value: T, body: impl FnOnce(&mut Context) -> R) -> R {
781        self.context_stack
782            .push(Box::new(value) as Box<dyn std::any::Any>);
783
784        // catch_unwind ensures the entry is popped even if `body` panics, so
785        // the context stack is never left with leaked frames. We re-panic
786        // afterwards so the panic propagates normally to outer scopes.
787        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body(self)));
788
789        // Pop in both success and panic paths.
790        self.context_stack.pop();
791
792        match result {
793            Ok(value) => value,
794            Err(panic) => std::panic::resume_unwind(panic),
795        }
796    }
797
798    /// Look up the nearest provided value of type `T` on the context stack.
799    ///
800    /// Searches from the top of the stack (most-recent
801    /// [`provide`](Self::provide)) downward. Returns the first match.
802    ///
803    /// # Panics
804    ///
805    /// Panics if no value of type `T` is currently provided. Use
806    /// [`try_use_context`](Self::try_use_context) for a non-panicking variant.
807    pub fn use_context<T: 'static>(&self) -> &T {
808        self.try_use_context::<T>().unwrap_or_else(|| {
809            panic!(
810                "no context of type {} was provided; use ui.provide(value, |ui| ...) in a parent scope",
811                std::any::type_name::<T>()
812            )
813        })
814    }
815
816    /// Like [`use_context`](Self::use_context), but returns `None` instead of
817    /// panicking when no value of type `T` is on the stack.
818    pub fn try_use_context<T: 'static>(&self) -> Option<&T> {
819        self.context_stack
820            .iter()
821            .rev()
822            .find_map(|entry| entry.downcast_ref::<T>())
823    }
824
825    /// Memoize a computed value. Recomputes only when `deps` changes.
826    ///
827    /// # Example
828    /// ```ignore
829    /// let doubled = ui.use_memo(&count, |c| c * 2);
830    /// ui.text(format!("Doubled: {doubled}"));
831    /// ```
832    pub fn use_memo<T: 'static, D: PartialEq + Clone + 'static>(
833        &mut self,
834        deps: &D,
835        compute: impl FnOnce(&D) -> T,
836    ) -> &T {
837        let idx = self.rollback.hook_cursor;
838        self.rollback.hook_cursor += 1;
839
840        // First call at this slot: allocate fresh state.
841        if idx >= self.hook_states.len() {
842            let value = compute(deps);
843            self.hook_states.push(Box::new((deps.clone(), value)));
844            return self.hook_states[idx]
845                .downcast_ref::<(D, T)>()
846                .map(|(_, v)| v)
847                .expect("freshly inserted slot must downcast to its own type");
848        }
849
850        // Slot already exists: it must be the same `(D, T)` shape we used last
851        // frame, or the caller broke the rules-of-hooks contract.
852        //
853        // Single downcast on the cache-hit path (closes #133): use
854        // `downcast_mut` to update deps/value in place when they change, and
855        // return `&stored.1` directly — eliminating the redundant second
856        // `downcast_ref` that ran on every call regardless of cache state.
857        match self.hook_states[idx].downcast_mut::<(D, T)>() {
858            Some(stored) => {
859                if stored.0 != *deps {
860                    stored.0 = deps.clone();
861                    stored.1 = compute(deps);
862                }
863                &stored.1
864            }
865            None => panic!(
866                "Hook type mismatch at index {}: expected {}. Hooks must be called in the same order every frame.",
867                idx,
868                std::any::type_name::<(D, T)>()
869            ),
870        }
871    }
872
873    /// Returns `light` color if current theme is light mode, `dark` color if dark mode.
874    pub fn light_dark(&self, light: Color, dark: Color) -> Color {
875        if self.theme.is_dark {
876            dark
877        } else {
878            light
879        }
880    }
881
882    /// Show a toast notification without managing ToastState.
883    ///
884    /// # Examples
885    /// ```
886    /// # use slt::*;
887    /// # TestBackend::new(80, 24).render(|ui| {
888    /// ui.notify("File saved!", ToastLevel::Success);
889    /// # });
890    /// ```
891    pub fn notify(&mut self, message: &str, level: ToastLevel) {
892        let tick = self.tick;
893        self.rollback
894            .notification_queue
895            .push((message.to_string(), level, tick));
896    }
897
898    pub(crate) fn render_notifications(&mut self) {
899        let tick = self.tick;
900        self.rollback
901            .notification_queue
902            .retain(|(_, _, created)| tick.saturating_sub(*created) < 180);
903        if self.rollback.notification_queue.is_empty() {
904            return;
905        }
906
907        // The `overlay` closure captures `self` mutably, so we cannot keep an
908        // immutable borrow of `self.rollback.notification_queue` alive across
909        // the call. Move the queue out for the render, then move it back —
910        // no `String::clone` per notification, no intermediate `Vec` alloc.
911        // Closes the non-empty path of #138.
912        let queue = std::mem::take(&mut self.rollback.notification_queue);
913        let theme = self.theme;
914
915        let _ = self.overlay(|ui| {
916            let _ = ui.row(|ui| {
917                ui.spacer();
918                let _ = ui.col(|ui| {
919                    for (message, level, _) in queue.iter().rev() {
920                        let color = match level {
921                            ToastLevel::Info => theme.primary,
922                            ToastLevel::Success => theme.success,
923                            ToastLevel::Warning => theme.warning,
924                            ToastLevel::Error => theme.error,
925                        };
926                        let mut line = String::with_capacity(2 + message.len());
927                        line.push_str("● ");
928                        line.push_str(message);
929                        ui.styled(line, Style::new().fg(color));
930                    }
931                });
932            });
933        });
934
935        // Restore the queue so subsequent frames can re-render until each
936        // entry's TTL expires above.
937        self.rollback.notification_queue = queue;
938    }
939
940    // ----------------------------------------------------------------
941    // v0.20.0 hooks: keyed state, effects, named focus, key gating
942    // ----------------------------------------------------------------
943
944    /// Component-local persistent state keyed by a runtime string.
945    ///
946    /// Unlike [`use_state_named`](Self::use_state_named), `id` can be a
947    /// runtime value such as `format!("row-{i}")`. The key is converted to
948    /// `String` once per call. The hot path (key already present) performs
949    /// **zero string allocations beyond the [`Into<String>`] conversion at
950    /// the call site** — first looking up by `&str`, only allocating a
951    /// fresh map key on first insert. Together: at most **one allocation
952    /// per call, regardless of cache state**.
953    ///
954    /// # When to use
955    /// - Per-item state in a dynamic list where positional [`use_state`]
956    ///   would break if items are reordered or filtered.
957    /// - Reusable component functions called with a runtime discriminator.
958    ///
959    /// # Namespace
960    /// Keys live in a single global namespace per `Context`. Prefix them
961    /// to avoid collisions: `format!("my_component::item-{i}")`.
962    ///
963    /// # Stale entries
964    /// Removed items leak their state until the `Context` is dropped (or
965    /// the program exits). For long-running sessions with churn, manage
966    /// state externally via a single `Vec<T>` in [`use_state`].
967    ///
968    /// # Example
969    ///
970    /// ```ignore
971    /// for (i, item) in items.iter().enumerate() {
972    ///     let row_state = ui.use_state_keyed(format!("row-{i}"), || ItemState::default());
973    ///     // ...
974    /// }
975    /// ```
976    ///
977    /// [`use_state`]: Self::use_state
978    pub fn use_state_keyed<T: 'static>(
979        &mut self,
980        id: impl Into<String>,
981        init: impl FnOnce() -> T,
982    ) -> State<T> {
983        let key: String = id.into();
984        // Lookup by `&str` first to avoid cloning on the hot
985        // (already-populated) path. Only on first insert do we clone the
986        // key into the map; otherwise the original `key` String is the
987        // sole allocation and is moved into `State::from_keyed`.
988        if !self.keyed_states.contains_key(key.as_str()) {
989            self.keyed_states.insert(key.clone(), Box::new(init()));
990        }
991        State::from_keyed(key)
992    }
993
994    /// Like [`use_state_keyed`](Self::use_state_keyed), but uses
995    /// [`Default::default()`] to initialize the value on first call.
996    ///
997    /// # Example
998    ///
999    /// ```ignore
1000    /// let counter = ui.use_state_keyed_default::<i32>(format!("c-{i}"));
1001    /// ```
1002    pub fn use_state_keyed_default<T: Default + 'static>(
1003        &mut self,
1004        id: impl Into<String>,
1005    ) -> State<T> {
1006        self.use_state_keyed(id, T::default)
1007    }
1008
1009    /// Run a side-effecting closure when `deps` changes.
1010    ///
1011    /// On the **first frame** the hook slot is encountered, `f` is called
1012    /// unconditionally. On **subsequent frames**, `f` is only called when
1013    /// `*deps != stored_deps`. The hook is **positional** (same ordering
1014    /// rules as [`use_state`](Self::use_state)).
1015    ///
1016    /// # Fire-and-forget semantics
1017    ///
1018    /// There is no cleanup callback. If setup resources need teardown,
1019    /// store a handle in [`use_state`](Self::use_state) and drop it on
1020    /// a later frame.
1021    ///
1022    /// # Caveat: `error_boundary` re-fire
1023    ///
1024    /// Effects placed inside an [`error_boundary`](Self::error_boundary)
1025    /// scope can re-fire when the boundary catches a panic and rolls back
1026    /// the hook slots. For non-idempotent side effects (network requests,
1027    /// payments) put the effect outside the boundary or guard with an
1028    /// idempotency key.
1029    ///
1030    /// # Common patterns
1031    ///
1032    /// ```ignore
1033    /// // Run once on first frame:
1034    /// ui.use_effect(|_| initialize_logger(), &());
1035    ///
1036    /// // Run when `selected_tab` changes:
1037    /// ui.use_effect(|tab| load_tab_data(*tab), &selected_tab);
1038    /// ```
1039    pub fn use_effect<D: PartialEq + Clone + 'static>(&mut self, f: impl FnOnce(&D), deps: &D) {
1040        let idx = self.rollback.hook_cursor;
1041        self.rollback.hook_cursor += 1;
1042
1043        if idx >= self.hook_states.len() {
1044            // First encounter: run the effect, then store the deps so we
1045            // can detect future changes.
1046            f(deps);
1047            self.hook_states.push(Box::new(deps.clone()));
1048            return;
1049        }
1050
1051        match self.hook_states[idx].downcast_mut::<D>() {
1052            Some(stored) => {
1053                if *stored != *deps {
1054                    f(deps);
1055                    *stored = deps.clone();
1056                }
1057            }
1058            None => panic!(
1059                "Hook type mismatch at index {idx}: expected {}. \
1060                 Hooks must be called in the same order every frame.",
1061                std::any::type_name::<D>()
1062            ),
1063        }
1064    }
1065
1066    /// Register a focusable slot bound to a stable string name.
1067    ///
1068    /// Returns `true` if the registered slot currently has focus, exactly
1069    /// like [`register_focusable`](Self::register_focusable) — but also
1070    /// records the `name → slot` mapping so other code can later call
1071    /// [`focus_by_name`](Self::focus_by_name) and
1072    /// [`focused_name`](Self::focused_name).
1073    ///
1074    /// # How the slot is shared with the widget that follows
1075    ///
1076    /// Every SLT widget that takes focus (`button`, `text_input`,
1077    /// `tabs`, …) internally calls `register_focusable()` to claim its
1078    /// own slot. To keep the name pointed at the **widget the user
1079    /// sees**, this call:
1080    ///
1081    /// 1. allocates a slot eagerly (so the name binding works even when
1082    ///    no widget follows — useful for tests and for custom focusable
1083    ///    regions),
1084    /// 2. records the `name → slot` mapping into the frame's
1085    ///    `focus_name_map` (first-write-wins on duplicate names within
1086    ///    a frame),
1087    /// 3. **reserves** the slot id so the next `register_focusable()`
1088    ///    on the same frame *reuses* it instead of allocating a fresh
1089    ///    slot — that's how `text_input(&mut state)` placed right after
1090    ///    inherits the name.
1091    ///
1092    /// Names are re-registered each frame; the previous frame's map is
1093    /// kept under `focus_name_map_prev` so [`focus_by_name`] can resolve
1094    /// a name that has already been registered.
1095    ///
1096    /// # Two valid usage shapes
1097    ///
1098    /// **Shape A — name a widget that follows immediately** (the common
1099    /// pattern; the widget reuses the reserved slot):
1100    ///
1101    /// ```ignore
1102    /// let _ = ui.register_focusable_named("search");
1103    /// let _ = ui.text_input(&mut search_state);
1104    /// // later: ui.focus_by_name("search") jumps to the text_input
1105    /// ```
1106    ///
1107    /// **Shape B — register a named focusable region with no inner
1108    /// widget** (e.g. a custom render area that handles its own keys
1109    /// when focused):
1110    ///
1111    /// ```ignore
1112    /// let focused = ui.register_focusable_named("canvas");
1113    /// if focused { /* react to keys via key_presses_when */ }
1114    /// ```
1115    pub fn register_focusable_named(&mut self, name: &str) -> bool {
1116        // Modal/overlay suppression: when a modal is active and we're not
1117        // inside it, focusables outside the modal must be invisible to
1118        // tab/click cycling. Drop the registration entirely (no slot
1119        // allocation, no name binding, no reservation leak).
1120        if (self.rollback.modal_active || self.prev_modal_active)
1121            && self.rollback.overlay_depth == 0
1122        {
1123            self.rollback.pending_focusable_id = None;
1124            return false;
1125        }
1126        // Eagerly allocate the slot — symmetric with `register_focusable`,
1127        // so the slot exists even when no widget follows.
1128        let id = self.rollback.focus_count;
1129        self.rollback.focus_count += 1;
1130        self.rollback.last_focusable_id = Some(id);
1131        self.commands.push(Command::FocusMarker(id));
1132        // First-write-wins on duplicate names within a single frame —
1133        // a second `register_focusable_named("dup")` keeps the first
1134        // slot bound to the name and orphans its own slot's name binding.
1135        self.focus_name_map.entry(name.to_string()).or_insert(id);
1136        // Reserve `id` for the very next `register_focusable()` call to
1137        // reuse, so widgets like `text_input` placed immediately after
1138        // share the named slot rather than allocating a fresh one.
1139        // Last-write-wins on the reservation: stacking two
1140        // `register_focusable_named` calls without an intervening widget
1141        // leaves the second slot reserved (the first slot stays bound to
1142        // its name in `focus_name_map`, just without a widget attached).
1143        self.rollback.pending_focusable_id = Some(id);
1144        // Same focus-index prediction as `register_focusable`.
1145        if self.prev_modal_active
1146            && self.prev_modal_focus_count > 0
1147            && self.rollback.modal_active
1148            && self.rollback.overlay_depth > 0
1149        {
1150            let mut modal_local_id = id.saturating_sub(self.rollback.modal_focus_start);
1151            modal_local_id %= self.prev_modal_focus_count;
1152            let mut modal_focus_idx = self.focus_index.saturating_sub(self.prev_modal_focus_start);
1153            modal_focus_idx %= self.prev_modal_focus_count;
1154            return modal_local_id == modal_focus_idx;
1155        }
1156        if self.prev_focus_count == 0 {
1157            return true;
1158        }
1159        self.focus_index % self.prev_focus_count == id
1160    }
1161
1162    /// Request focus on the named widget.
1163    ///
1164    /// If the named widget was registered last frame the focus change
1165    /// takes effect at the **start of the next frame** (one-frame delay
1166    /// is the deferred-command pattern used throughout SLT). If the name
1167    /// has never been registered, the request stays pending: the next
1168    /// frame to register that name receives focus.
1169    ///
1170    /// Returns `true` if the call **will** resolve — i.e. the name was
1171    /// either registered earlier in this frame (via
1172    /// [`register_focusable_named`](Self::register_focusable_named)) or in
1173    /// the previous frame. Returns `false` only when the name has not been
1174    /// seen by either frame, in which case the request stays pending until
1175    /// some future frame registers the name.
1176    ///
1177    /// # Example
1178    ///
1179    /// ```ignore
1180    /// if ui.button("Find").clicked {
1181    ///     ui.focus_by_name("search");
1182    /// }
1183    /// ```
1184    pub fn focus_by_name(&mut self, name: &str) -> bool {
1185        // Resolve against either the previous frame's settled map or the
1186        // in-progress map being built right now. The latter handles the
1187        // common "register, then focus_by_name in the same frame" pattern
1188        // that callers naturally expect to return `true`.
1189        //
1190        // The actual focus change still lands at the start of the next
1191        // frame via `focus_name_map_prev` lookup in `Context::new`. The
1192        // return value is purely about resolvability: "true" means the name
1193        // is known and the focus shift will land next frame; "false" means
1194        // the request is pending a future registration.
1195        let resolved =
1196            self.focus_name_map_prev.contains_key(name) || self.focus_name_map.contains_key(name);
1197        // Always store the request — even if it resolved this frame, the
1198        // next-frame plumbing (`Context::new`) is what actually applies
1199        // the index. We use take/replace so the caller cannot stack two
1200        // pending names; the most recent wins.
1201        self.pending_focus_name = Some(name.to_string());
1202        resolved
1203    }
1204
1205    /// Return the name of the currently focused widget, if it was
1206    /// registered with
1207    /// [`register_focusable_named`](Self::register_focusable_named) this
1208    /// frame.
1209    ///
1210    /// Returns `None` if the focused widget used the unnamed
1211    /// [`register_focusable`](Self::register_focusable) API or if no widget
1212    /// has focus.
1213    pub fn focused_name(&self) -> Option<&str> {
1214        // Search this frame's map for the entry whose index equals
1215        // `focus_index`. The map is small (one entry per named focusable),
1216        // so a linear scan is fine — typical apps register <50 names.
1217        self.focus_name_map
1218            .iter()
1219            .find_map(|(name, &idx)| (idx == self.focus_index).then_some(name.as_str()))
1220    }
1221
1222    /// Iterate unconsumed key-press events, gated on `active`.
1223    ///
1224    /// When `active` is `false`, returns an empty iterator. When `active`
1225    /// is `true`, behaves identically to the internal
1226    /// `available_key_presses`. The returned indices are valid for
1227    /// [`consume_event`](Self::consume_event).
1228    ///
1229    /// This is the **preferred pattern** for focus-gated keyboard handling
1230    /// in custom widgets. Because the iterator borrows `self.events`
1231    /// immutably, collect the indices first and consume them after the
1232    /// loop:
1233    ///
1234    /// ```ignore
1235    /// let focused = ui.register_focusable();
1236    /// let mut hits: Vec<usize> = Vec::new();
1237    /// for (i, key) in ui.key_presses_when(focused) {
1238    ///     if key.code == slt::KeyCode::Enter {
1239    ///         hits.push(i);
1240    ///         // ... handle Enter ...
1241    ///     }
1242    /// }
1243    /// for i in hits { ui.consume_event(i); }
1244    /// ```
1245    pub fn key_presses_when(
1246        &self,
1247        active: bool,
1248    ) -> impl Iterator<Item = (usize, &crate::event::KeyEvent)> + '_ {
1249        // The `!active` short-circuit at the head of the predicate yields
1250        // an empty iterator at zero allocation cost when the widget isn't
1251        // focused. Indices are still drawn from `self.events` so callers
1252        // can pass them straight to `consume_event`.
1253        self.events
1254            .iter()
1255            .enumerate()
1256            .filter_map(move |(i, event)| {
1257                if !active {
1258                    return None;
1259                }
1260                if self.consumed.get(i).copied().unwrap_or(true) {
1261                    return None;
1262                }
1263                match event {
1264                    Event::Key(key) if key.kind == KeyEventKind::Press => Some((i, key)),
1265                    _ => None,
1266                }
1267            })
1268    }
1269
1270    /// Mark the event at `index` as consumed.
1271    ///
1272    /// Public counterpart to the crate-internal `consume_indices`. Use
1273    /// this in custom widgets after handling an event yielded by
1274    /// [`key_presses_when`](Self::key_presses_when) so subsequent widgets
1275    /// don't react to the same key. Out-of-range indices are silently
1276    /// ignored (matching the iterator-pair semantics).
1277    pub fn consume_event(&mut self, index: usize) {
1278        if let Some(slot) = self.consumed.get_mut(index) {
1279            *slot = true;
1280        }
1281    }
1282
1283    // ── Issue #233: in-frame static-log append ───────────────────────────
1284    //
1285    // The runtime holds the buffer inside `named_states` under a reserved
1286    // sentinel key. `Context::new` (owned by another agent) does not need to
1287    // initialise this field — `or_insert_with` handles first-call creation,
1288    // and `lib::run_frame_kernel` drains the buffer back into `FrameState`
1289    // for the run-loop to consume.
1290
1291    /// Append a line that will be flushed to terminal scrollback **before**
1292    /// the dynamic frame content (issue #233).
1293    ///
1294    /// Lines accumulated this frame are written via the active runtime — for
1295    /// [`crate::run_static`] / [`crate::run_static_with`], they are printed
1296    /// above the inline dynamic area as committed scrollback. For full-screen
1297    /// runtimes ([`crate::run`], [`crate::run_async`]) and inline mode
1298    /// ([`crate::run_inline`]), the buffer is silently dropped after a debug
1299    /// warning is emitted on the first call per frame, since those modes have
1300    /// no scrollback area to write to.
1301    ///
1302    /// The headless [`crate::TestBackend`] accumulates the lines into the
1303    /// frame state where they can be drained by tests via
1304    /// [`Context::take_static_log`] (or by inspecting the buffer when
1305    /// constructing a custom backend).
1306    ///
1307    /// # Order
1308    ///
1309    /// `static_log` may be called any number of times per frame. Lines are
1310    /// flushed in call order, all before the dynamic frame for the same
1311    /// tick.
1312    ///
1313    /// # Example
1314    ///
1315    /// ```
1316    /// # use slt::*;
1317    /// # TestBackend::new(40, 4).render(|ui| {
1318    /// ui.static_log("event 1");
1319    /// ui.static_log(format!("event {}", 2));
1320    /// ui.text("dynamic content");
1321    /// # });
1322    /// ```
1323    pub fn static_log(&mut self, line: impl Into<String>) {
1324        let entry = self
1325            .named_states
1326            .entry(STATIC_LOG_KEY)
1327            .or_insert_with(|| Box::new(Vec::<String>::new()) as Box<dyn std::any::Any>);
1328        if let Some(buf) = entry.downcast_mut::<Vec<String>>() {
1329            buf.push(line.into());
1330        }
1331    }
1332
1333    /// Drain and return the queued static-log lines for the current frame
1334    /// (issue #233). Used by tests / external backends to inspect what
1335    /// `ui.static_log(...)` emitted during a [`crate::TestBackend::render`]
1336    /// call.
1337    pub fn take_static_log(&mut self) -> Vec<String> {
1338        if let Some(boxed) = self.named_states.get_mut(STATIC_LOG_KEY) {
1339            if let Some(buf) = boxed.downcast_mut::<Vec<String>>() {
1340                return std::mem::take(buf);
1341            }
1342        }
1343        Vec::new()
1344    }
1345
1346    // ── Issue #236: widget keymap publishing ─────────────────────────────
1347
1348    /// Publish a widget's keymap so the framework can show it in the help
1349    /// overlay (issue #236).
1350    ///
1351    /// Each call registers `(name, bindings)` for the current frame. Widgets
1352    /// implementing [`crate::keymap::WidgetKeyHelp`] typically forward their
1353    /// `key_help()` slice here:
1354    ///
1355    /// ```
1356    /// # use slt::*;
1357    /// # use slt::keymap::WidgetKeyHelp;
1358    /// struct Counter;
1359    /// impl WidgetKeyHelp for Counter {
1360    ///     fn key_help(&self) -> &'static [(&'static str, &'static str)] {
1361    ///         const HELP: &[(&str, &str)] = &[("↑", "increment"), ("↓", "decrement")];
1362    ///         HELP
1363    ///     }
1364    /// }
1365    /// # TestBackend::new(40, 4).render(|ui| {
1366    /// let counter = Counter;
1367    /// ui.publish_keymap("counter", counter.key_help());
1368    /// # });
1369    /// ```
1370    ///
1371    /// The registry is reset at the start of every frame (the first call on a
1372    /// new tick clears stale entries). Both calls in the same frame
1373    /// accumulate; calls across frames do not leak.
1374    pub fn publish_keymap(
1375        &mut self,
1376        name: &'static str,
1377        bindings: &'static [(&'static str, &'static str)],
1378    ) {
1379        // The registry is cleared at frame start by `run_frame_kernel`
1380        // (issue #236) — see `clear_keymap_registry` in `lib.rs`. We just
1381        // need to insert/append here.
1382        let entry = self
1383            .named_states
1384            .entry(KEYMAP_REGISTRY_KEY)
1385            .or_insert_with(|| {
1386                Box::new(Vec::<crate::keymap::PublishedKeymap>::new()) as Box<dyn std::any::Any>
1387            });
1388        if let Some(vec) = entry.downcast_mut::<Vec<crate::keymap::PublishedKeymap>>() {
1389            vec.push(crate::keymap::PublishedKeymap::new(name, bindings));
1390        }
1391    }
1392
1393    /// Return all keymaps published this frame (issue #236).
1394    ///
1395    /// Empty if no widget called [`Context::publish_keymap`] yet on the
1396    /// current frame. The registry is reset at the start of every frame.
1397    pub fn published_keymaps(&self) -> &[crate::keymap::PublishedKeymap] {
1398        if let Some(boxed) = self.named_states.get(KEYMAP_REGISTRY_KEY) {
1399            if let Some(vec) = boxed.downcast_ref::<Vec<crate::keymap::PublishedKeymap>>() {
1400                return vec;
1401            }
1402        }
1403        &[]
1404    }
1405
1406    /// Render an automatic keymap-help overlay listing every widget keymap
1407    /// published this frame (issue #236).
1408    ///
1409    /// Pass `open = true` to render the overlay (typically gated on a
1410    /// `?` / `F1` keypress). When `open` is `false`, this method is a
1411    /// no-op. The overlay groups bindings by widget name and dismisses
1412    /// when the next frame is rendered with `open = false`.
1413    ///
1414    /// # Example
1415    ///
1416    /// ```
1417    /// # use slt::*;
1418    /// # TestBackend::new(40, 12).render(|ui| {
1419    /// const RICHLOG: &[(&str, &str)] = &[("↑/k", "scroll up"), ("↓/j", "scroll down")];
1420    /// ui.publish_keymap("rich_log", RICHLOG);
1421    /// // Show the help overlay when '?' is pressed
1422    /// let show = ui.key('?');
1423    /// ui.keymap_help_overlay(show);
1424    /// # });
1425    /// ```
1426    pub fn keymap_help_overlay(&mut self, open: bool) {
1427        if !open {
1428            return;
1429        }
1430
1431        let entries: Vec<crate::keymap::PublishedKeymap> = self.published_keymaps().to_vec();
1432        if entries.is_empty() {
1433            return;
1434        }
1435
1436        let theme = self.theme;
1437        let _ = self.modal(|ui| {
1438            ui.styled("Keyboard shortcuts", Style::new().bold().fg(theme.primary));
1439            ui.text("");
1440            for entry in &entries {
1441                ui.styled(entry.name, Style::new().bold().fg(theme.text));
1442                for (key, desc) in entry.bindings {
1443                    let line = format!("  {key:<14}  {desc}");
1444                    ui.styled(line, Style::new().fg(theme.text_dim));
1445                }
1446                ui.text("");
1447            }
1448            ui.styled(
1449                "Press Esc / ? to close",
1450                Style::new().fg(theme.text_dim).italic(),
1451            );
1452        });
1453    }
1454}
1455
1456// Sentinel keys reused from `lib.rs` so the two reads/writes can never drift.
1457use crate::{
1458    KEYMAP_REGISTRY_NAMED_STATE_KEY as KEYMAP_REGISTRY_KEY,
1459    STATIC_LOG_NAMED_STATE_KEY as STATIC_LOG_KEY,
1460};