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 screen_hook_map = std::mem::take(&mut state.screen_hook_map);
13        let focus = &mut state.focus;
14        let layout_feedback = &mut state.layout_feedback;
15        let diagnostics = &mut state.diagnostics;
16        let consumed = vec![false; events.len()];
17
18        let mut mouse_pos = layout_feedback.last_mouse_pos;
19        let mut click_pos = None;
20        for event in &events {
21            if let Event::Mouse(mouse) = event {
22                mouse_pos = Some((mouse.x, mouse.y));
23                if matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
24                    click_pos = Some((mouse.x, mouse.y));
25                }
26            }
27        }
28
29        let mut focus_index = focus.focus_index;
30        if let Some((mx, my)) = click_pos {
31            let mut best: Option<(usize, u64)> = None;
32            for &(fid, rect) in &layout_feedback.prev_focus_rects {
33                if mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom() {
34                    let area = rect.width as u64 * rect.height as u64;
35                    if best.map_or(true, |(_, ba)| area < ba) {
36                        best = Some((fid, area));
37                    }
38                }
39            }
40            if let Some((fid, _)) = best {
41                focus_index = fid;
42            }
43        }
44
45        Self {
46            commands: Vec::new(),
47            events,
48            consumed,
49            should_quit: false,
50            area_width: width,
51            area_height: height,
52            tick: diagnostics.tick,
53            focus_index,
54            hook_states: std::mem::take(hook_states),
55            prev_focus_count: focus.prev_focus_count,
56            prev_modal_focus_start: focus.prev_modal_focus_start,
57            prev_modal_focus_count: focus.prev_modal_focus_count,
58            prev_scroll_infos: std::mem::take(&mut layout_feedback.prev_scroll_infos),
59            prev_scroll_rects: std::mem::take(&mut layout_feedback.prev_scroll_rects),
60            prev_hit_map: std::mem::take(&mut layout_feedback.prev_hit_map),
61            prev_group_rects: std::mem::take(&mut layout_feedback.prev_group_rects),
62            prev_focus_groups: std::mem::take(&mut layout_feedback.prev_focus_groups),
63            _prev_focus_rects: std::mem::take(&mut layout_feedback.prev_focus_rects),
64            mouse_pos,
65            click_pos,
66            prev_modal_active: focus.prev_modal_active,
67            clipboard_text: None,
68            debug: diagnostics.debug_mode,
69            theme,
70            is_real_terminal: false,
71            deferred_draws: Vec::new(),
72            rollback: ContextRollbackState {
73                last_text_idx: None,
74                focus_count: 0,
75                interaction_count: 0,
76                scroll_count: 0,
77                group_count: 0,
78                group_stack: Vec::new(),
79                overlay_depth: 0,
80                modal_active: false,
81                modal_focus_start: 0,
82                modal_focus_count: 0,
83                hook_cursor: 0,
84                dark_mode: theme.is_dark,
85                notification_queue: std::mem::take(&mut diagnostics.notification_queue),
86                pending_tooltips: Vec::new(),
87                text_color_stack: Vec::new(),
88            },
89            scroll_lines_per_event: 1,
90            screen_hook_map,
91            widget_theme: WidgetTheme::new(),
92        }
93    }
94
95    /// Set how many lines each scroll event moves. Default is 1.
96    pub fn set_scroll_speed(&mut self, lines: u32) {
97        self.scroll_lines_per_event = lines.max(1);
98    }
99
100    /// Get the current scroll speed (lines per scroll event).
101    pub fn scroll_speed(&self) -> u32 {
102        self.scroll_lines_per_event
103    }
104
105    /// Get the current focus index.
106    ///
107    /// Widget indices are assigned in the order [`register_focusable()`](Self::register_focusable) is called.
108    /// Indices are 0-based and wrap at [`focus_count()`](Self::focus_count).
109    pub fn focus_index(&self) -> usize {
110        self.focus_index
111    }
112
113    /// Set the focus index to a specific focusable widget.
114    ///
115    /// Widget indices are assigned in the order [`register_focusable()`](Self::register_focusable) is called
116    /// (0-based). If `index` exceeds the number of focusable widgets it will
117    /// be clamped by the modulo in [`register_focusable`](Self::register_focusable).
118    ///
119    /// # Example
120    ///
121    /// ```no_run
122    /// # slt::run(|ui: &mut slt::Context| {
123    /// // Focus the second focusable widget (index 1)
124    /// ui.set_focus_index(1);
125    /// # });
126    /// ```
127    pub fn set_focus_index(&mut self, index: usize) {
128        self.focus_index = index;
129    }
130
131    /// Get the number of focusable widgets registered in the previous frame.
132    ///
133    /// Returns 0 on the very first frame. Useful together with
134    /// [`set_focus_index()`](Self::set_focus_index) for programmatic focus control.
135    ///
136    /// Note: this intentionally reads `prev_focus_count` (the settled count
137    /// from the last completed frame) rather than `focus_count` (the
138    /// still-incrementing counter for the current frame).
139    #[allow(clippy::misnamed_getters)]
140    pub fn focus_count(&self) -> usize {
141        self.prev_focus_count
142    }
143
144    pub(crate) fn process_focus_keys(&mut self) {
145        for (i, event) in self.events.iter().enumerate() {
146            if self.consumed[i] {
147                continue;
148            }
149            if let Event::Key(key) = event {
150                if key.kind != KeyEventKind::Press {
151                    continue;
152                }
153                if key.code == KeyCode::Tab && !key.modifiers.contains(KeyModifiers::SHIFT) {
154                    if self.prev_modal_active && self.prev_modal_focus_count > 0 {
155                        let mut modal_local =
156                            self.focus_index.saturating_sub(self.prev_modal_focus_start);
157                        modal_local %= self.prev_modal_focus_count;
158                        let next = (modal_local + 1) % self.prev_modal_focus_count;
159                        self.focus_index = self.prev_modal_focus_start + next;
160                    } else if self.prev_focus_count > 0 {
161                        self.focus_index = (self.focus_index + 1) % self.prev_focus_count;
162                    }
163                    self.consumed[i] = true;
164                } else if (key.code == KeyCode::Tab && key.modifiers.contains(KeyModifiers::SHIFT))
165                    || key.code == KeyCode::BackTab
166                {
167                    if self.prev_modal_active && self.prev_modal_focus_count > 0 {
168                        let mut modal_local =
169                            self.focus_index.saturating_sub(self.prev_modal_focus_start);
170                        modal_local %= self.prev_modal_focus_count;
171                        let prev = if modal_local == 0 {
172                            self.prev_modal_focus_count - 1
173                        } else {
174                            modal_local - 1
175                        };
176                        self.focus_index = self.prev_modal_focus_start + prev;
177                    } else if self.prev_focus_count > 0 {
178                        self.focus_index = if self.focus_index == 0 {
179                            self.prev_focus_count - 1
180                        } else {
181                            self.focus_index - 1
182                        };
183                    }
184                    self.consumed[i] = true;
185                }
186            }
187        }
188    }
189
190    /// Render a custom [`Widget`].
191    ///
192    /// Calls [`Widget::ui`] with this context and returns the widget's response.
193    pub fn widget<W: Widget>(&mut self, w: &mut W) -> W::Response {
194        w.ui(self)
195    }
196
197    /// Wrap child widgets in a panic boundary.
198    ///
199    /// If the closure panics, the panic is caught and an error message is
200    /// rendered in place of the children. The app continues running.
201    ///
202    /// # Example
203    ///
204    /// ```no_run
205    /// # slt::run(|ui: &mut slt::Context| {
206    /// ui.error_boundary(|ui| {
207    ///     ui.text("risky widget");
208    /// });
209    /// # });
210    /// ```
211    pub fn error_boundary(&mut self, f: impl FnOnce(&mut Context)) {
212        self.error_boundary_with(f, |ui, msg| {
213            ui.styled(
214                format!("⚠ Error: {msg}"),
215                Style::new().fg(ui.theme.error).bold(),
216            );
217        });
218    }
219
220    /// Like [`error_boundary`](Self::error_boundary), but renders a custom
221    /// fallback instead of the default error message.
222    ///
223    /// The fallback closure receives the panic message as a [`String`].
224    ///
225    /// # Example
226    ///
227    /// ```no_run
228    /// # slt::run(|ui: &mut slt::Context| {
229    /// ui.error_boundary_with(
230    ///     |ui| {
231    ///         ui.text("risky widget");
232    ///     },
233    ///     |ui, msg| {
234    ///         ui.text(format!("Recovered from panic: {msg}"));
235    ///     },
236    /// );
237    /// # });
238    /// ```
239    pub fn error_boundary_with(
240        &mut self,
241        f: impl FnOnce(&mut Context),
242        fallback: impl FnOnce(&mut Context, String),
243    ) {
244        let snapshot = ContextCheckpoint::capture(self);
245
246        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
247            f(self);
248        }));
249
250        match result {
251            Ok(()) => {}
252            Err(panic_info) => {
253                if self.is_real_terminal {
254                    #[cfg(feature = "crossterm")]
255                    {
256                        let _ = crossterm::terminal::enable_raw_mode();
257                        let _ = crossterm::execute!(
258                            std::io::stdout(),
259                            crossterm::terminal::EnterAlternateScreen
260                        );
261                    }
262
263                    #[cfg(not(feature = "crossterm"))]
264                    {}
265                }
266
267                snapshot.restore(self);
268
269                let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
270                    (*s).to_string()
271                } else if let Some(s) = panic_info.downcast_ref::<String>() {
272                    s.clone()
273                } else {
274                    "widget panicked".to_string()
275                };
276
277                fallback(self, msg);
278            }
279        }
280    }
281
282    /// Reserve the next interaction slot without emitting a marker command.
283    pub(crate) fn reserve_interaction_slot(&mut self) -> usize {
284        let id = self.rollback.interaction_count;
285        self.rollback.interaction_count += 1;
286        id
287    }
288
289    /// Advance the interaction counter for structural commands that still
290    /// participate in hit-map indexing.
291    pub(crate) fn skip_interaction_slot(&mut self) {
292        self.reserve_interaction_slot();
293    }
294
295    /// Reserve the next interaction ID and emit a marker command.
296    pub(crate) fn next_interaction_id(&mut self) -> usize {
297        let id = self.reserve_interaction_slot();
298        self.commands.push(Command::InteractionMarker(id));
299        id
300    }
301
302    /// Allocate a click/hover interaction slot and return the [`Response`].
303    ///
304    /// Use this in custom widgets to detect mouse clicks and hovers without
305    /// wrapping content in a container. Call it immediately before the text,
306    /// rich text, link, or container that should own the interaction rect.
307    /// Each call reserves one slot in the hit-test map, so the call order
308    /// must be stable across frames.
309    pub fn interaction(&mut self) -> Response {
310        if (self.rollback.modal_active || self.prev_modal_active)
311            && self.rollback.overlay_depth == 0
312        {
313            return Response::none();
314        }
315        let id = self.next_interaction_id();
316        self.response_for(id)
317    }
318
319    pub(crate) fn begin_widget_interaction(&mut self, focused: bool) -> (usize, Response) {
320        let interaction_id = self.next_interaction_id();
321        let mut response = self.response_for(interaction_id);
322        response.focused = focused;
323        (interaction_id, response)
324    }
325
326    pub(crate) fn consume_indices<I>(&mut self, indices: I)
327    where
328        I: IntoIterator<Item = usize>,
329    {
330        for index in indices {
331            self.consumed[index] = true;
332        }
333    }
334
335    pub(crate) fn available_key_presses(
336        &self,
337    ) -> impl Iterator<Item = (usize, &crate::event::KeyEvent)> + '_ {
338        self.events.iter().enumerate().filter_map(|(i, event)| {
339            if self.consumed[i] {
340                return None;
341            }
342            match event {
343                Event::Key(key) if key.kind == KeyEventKind::Press => Some((i, key)),
344                _ => None,
345            }
346        })
347    }
348
349    pub(crate) fn available_pastes(&self) -> impl Iterator<Item = (usize, &str)> + '_ {
350        self.events.iter().enumerate().filter_map(|(i, event)| {
351            if self.consumed[i] {
352                return None;
353            }
354            match event {
355                Event::Paste(text) => Some((i, text.as_str())),
356                _ => None,
357            }
358        })
359    }
360
361    pub(crate) fn left_clicks_in_rect(
362        &self,
363        rect: Rect,
364    ) -> impl Iterator<Item = (usize, &crate::event::MouseEvent)> + '_ {
365        self.mouse_events_in_rect(rect).filter_map(|(i, mouse)| {
366            if matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
367                Some((i, mouse))
368            } else {
369                None
370            }
371        })
372    }
373
374    pub(crate) fn mouse_events_in_rect(
375        &self,
376        rect: Rect,
377    ) -> impl Iterator<Item = (usize, &crate::event::MouseEvent)> + '_ {
378        self.events
379            .iter()
380            .enumerate()
381            .filter_map(move |(i, event)| {
382                if self.consumed[i] {
383                    return None;
384                }
385
386                let Event::Mouse(mouse) = event else {
387                    return None;
388                };
389
390                if mouse.x < rect.x
391                    || mouse.x >= rect.right()
392                    || mouse.y < rect.y
393                    || mouse.y >= rect.bottom()
394                {
395                    return None;
396                }
397
398                Some((i, mouse))
399            })
400    }
401
402    pub(crate) fn left_clicks_for_interaction(
403        &self,
404        interaction_id: usize,
405    ) -> Option<(Rect, Vec<(usize, &crate::event::MouseEvent)>)> {
406        let rect = self.prev_hit_map.get(interaction_id).copied()?;
407        let clicks = self.left_clicks_in_rect(rect).collect();
408        Some((rect, clicks))
409    }
410
411    pub(crate) fn consume_activation_keys(&mut self, focused: bool) -> bool {
412        if !focused {
413            return false;
414        }
415
416        let consumed: Vec<usize> = self
417            .available_key_presses()
418            .filter_map(|(i, key)| {
419                if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
420                    Some(i)
421                } else {
422                    None
423                }
424            })
425            .collect();
426        let activated = !consumed.is_empty();
427        self.consume_indices(consumed);
428        activated
429    }
430
431    /// Register a widget as focusable and return whether it currently has focus.
432    ///
433    /// Call this in custom widgets that need keyboard focus. Each call increments
434    /// the internal focus counter, so the call order must be stable across frames.
435    pub fn register_focusable(&mut self) -> bool {
436        if (self.rollback.modal_active || self.prev_modal_active)
437            && self.rollback.overlay_depth == 0
438        {
439            return false;
440        }
441        let id = self.rollback.focus_count;
442        self.rollback.focus_count += 1;
443        self.commands.push(Command::FocusMarker(id));
444        if self.prev_modal_active
445            && self.prev_modal_focus_count > 0
446            && self.rollback.modal_active
447            && self.rollback.overlay_depth > 0
448        {
449            let mut modal_local_id = id.saturating_sub(self.rollback.modal_focus_start);
450            modal_local_id %= self.prev_modal_focus_count;
451            let mut modal_focus_idx = self.focus_index.saturating_sub(self.prev_modal_focus_start);
452            modal_focus_idx %= self.prev_modal_focus_count;
453            return modal_local_id == modal_focus_idx;
454        }
455        if self.prev_focus_count == 0 {
456            return true;
457        }
458        self.focus_index % self.prev_focus_count == id
459    }
460
461    /// Create persistent state that survives across frames.
462    ///
463    /// Returns a `State<T>` handle. Access with `state.get(ui)` / `state.get_mut(ui)`.
464    ///
465    /// # Rules
466    /// - Must be called in the same order every frame (like React hooks)
467    /// - Do NOT call inside if/else that changes between frames
468    ///
469    /// # Example
470    /// ```ignore
471    /// let count = ui.use_state(|| 0i32);
472    /// let val = count.get(ui);
473    /// ui.text(format!("Count: {val}"));
474    /// if ui.button("+1").clicked {
475    ///     *count.get_mut(ui) += 1;
476    /// }
477    /// ```
478    pub fn use_state<T: 'static>(&mut self, init: impl FnOnce() -> T) -> State<T> {
479        let idx = self.rollback.hook_cursor;
480        self.rollback.hook_cursor += 1;
481
482        if idx >= self.hook_states.len() {
483            self.hook_states.push(Box::new(init()));
484        }
485
486        State::from_idx(idx)
487    }
488
489    /// Memoize a computed value. Recomputes only when `deps` changes.
490    ///
491    /// # Example
492    /// ```ignore
493    /// let doubled = ui.use_memo(&count, |c| c * 2);
494    /// ui.text(format!("Doubled: {doubled}"));
495    /// ```
496    pub fn use_memo<T: 'static, D: PartialEq + Clone + 'static>(
497        &mut self,
498        deps: &D,
499        compute: impl FnOnce(&D) -> T,
500    ) -> &T {
501        let idx = self.rollback.hook_cursor;
502        self.rollback.hook_cursor += 1;
503
504        let should_recompute = if idx >= self.hook_states.len() {
505            true
506        } else {
507            let (stored_deps, _) = self.hook_states[idx]
508                .downcast_ref::<(D, T)>()
509                .unwrap_or_else(|| {
510                    panic!(
511                        "Hook type mismatch at index {}: expected {}. Hooks must be called in the same order every frame.",
512                        idx,
513                        std::any::type_name::<(D, T)>()
514                    )
515                });
516            stored_deps != deps
517        };
518
519        if should_recompute {
520            let value = compute(deps);
521            let slot = Box::new((deps.clone(), value));
522            if idx < self.hook_states.len() {
523                self.hook_states[idx] = slot;
524            } else {
525                self.hook_states.push(slot);
526            }
527        }
528
529        let (_, value) = self.hook_states[idx]
530            .downcast_ref::<(D, T)>()
531            .unwrap_or_else(|| {
532                panic!(
533                    "Hook type mismatch at index {}: expected {}. Hooks must be called in the same order every frame.",
534                    idx,
535                    std::any::type_name::<(D, T)>()
536                )
537            });
538        value
539    }
540
541    /// Returns `light` color if current theme is light mode, `dark` color if dark mode.
542    pub fn light_dark(&self, light: Color, dark: Color) -> Color {
543        if self.theme.is_dark {
544            dark
545        } else {
546            light
547        }
548    }
549
550    /// Show a toast notification without managing ToastState.
551    ///
552    /// # Examples
553    /// ```
554    /// # use slt::*;
555    /// # TestBackend::new(80, 24).render(|ui| {
556    /// ui.notify("File saved!", ToastLevel::Success);
557    /// # });
558    /// ```
559    pub fn notify(&mut self, message: &str, level: ToastLevel) {
560        let tick = self.tick;
561        self.rollback
562            .notification_queue
563            .push((message.to_string(), level, tick));
564    }
565
566    pub(crate) fn render_notifications(&mut self) {
567        self.rollback
568            .notification_queue
569            .retain(|(_, _, created)| self.tick.saturating_sub(*created) < 180);
570        if self.rollback.notification_queue.is_empty() {
571            return;
572        }
573
574        let items: Vec<(String, Color)> = self
575            .rollback
576            .notification_queue
577            .iter()
578            .rev()
579            .map(|(message, level, _)| {
580                let color = match level {
581                    ToastLevel::Info => self.theme.primary,
582                    ToastLevel::Success => self.theme.success,
583                    ToastLevel::Warning => self.theme.warning,
584                    ToastLevel::Error => self.theme.error,
585                };
586                (message.clone(), color)
587            })
588            .collect();
589
590        let _ = self.overlay(|ui| {
591            let _ = ui.row(|ui| {
592                ui.spacer();
593                let _ = ui.col(|ui| {
594                    for (message, color) in &items {
595                        let mut line = String::with_capacity(2 + message.len());
596                        line.push_str("● ");
597                        line.push_str(message);
598                        ui.styled(line, Style::new().fg(*color));
599                    }
600                });
601            });
602        });
603    }
604}