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