Skip to main content

slt/context/widgets_interactive/
events.rs

1use super::*;
2
3impl Context {
4    /// Render a horizontal divider line.
5    ///
6    /// The line is drawn with the theme's border color and expands to fill the
7    /// container width.
8    pub fn separator(&mut self) -> &mut Self {
9        self.commands.push(Command::Text {
10            content: "─".repeat(200),
11            cursor_offset: None,
12            style: Style::new().fg(self.theme.border).dim(),
13            grow: 0,
14            align: Align::Start,
15            wrap: false,
16            truncate: false,
17            margin: Margin::default(),
18            constraints: Constraints::default(),
19        });
20        self.rollback.last_text_idx = Some(self.commands.len() - 1);
21        self
22    }
23
24    /// Render a horizontal separator line with a custom color.
25    pub fn separator_colored(&mut self, color: Color) -> &mut Self {
26        self.commands.push(Command::Text {
27            content: "─".repeat(200),
28            cursor_offset: None,
29            style: Style::new().fg(color),
30            grow: 0,
31            align: Align::Start,
32            wrap: false,
33            truncate: false,
34            margin: Margin::default(),
35            constraints: Constraints::default(),
36        });
37        self.rollback.last_text_idx = Some(self.commands.len() - 1);
38        self
39    }
40
41    /// Render a help bar showing keybinding hints.
42    ///
43    /// `bindings` is a slice of `(key, action)` pairs. Keys are rendered in the
44    /// theme's primary color; actions in the dim text color. Pairs are separated
45    /// by a `·` character.
46    pub fn help(&mut self, bindings: &[(&str, &str)]) -> Response {
47        if bindings.is_empty() {
48            return Response::none();
49        }
50
51        self.skip_interaction_slot();
52        self.commands.push(Command::BeginContainer {
53            direction: Direction::Row,
54            gap: 2,
55            align: Align::Start,
56            align_self: None,
57            justify: Justify::Start,
58            border: None,
59            border_sides: BorderSides::all(),
60            border_style: Style::new().fg(self.theme.border),
61            bg_color: None,
62            padding: Padding::default(),
63            margin: Margin::default(),
64            constraints: Constraints::default(),
65            title: None,
66            grow: 0,
67            group_name: None,
68        });
69        for (idx, (key, action)) in bindings.iter().enumerate() {
70            if idx > 0 {
71                self.styled("·", Style::new().fg(self.theme.text_dim));
72            }
73            self.styled(*key, Style::new().bold().fg(self.theme.primary));
74            self.styled(*action, Style::new().fg(self.theme.text_dim));
75        }
76        self.commands.push(Command::EndContainer);
77        self.rollback.last_text_idx = None;
78
79        Response::none()
80    }
81
82    /// Render a help bar with custom key/description colors.
83    pub fn help_colored(
84        &mut self,
85        bindings: &[(&str, &str)],
86        key_color: Color,
87        text_color: Color,
88    ) -> Response {
89        if bindings.is_empty() {
90            return Response::none();
91        }
92
93        self.skip_interaction_slot();
94        self.commands.push(Command::BeginContainer {
95            direction: Direction::Row,
96            gap: 2,
97            align: Align::Start,
98            align_self: None,
99            justify: Justify::Start,
100            border: None,
101            border_sides: BorderSides::all(),
102            border_style: Style::new().fg(self.theme.border),
103            bg_color: None,
104            padding: Padding::default(),
105            margin: Margin::default(),
106            constraints: Constraints::default(),
107            title: None,
108            grow: 0,
109            group_name: None,
110        });
111        for (idx, (key, action)) in bindings.iter().enumerate() {
112            if idx > 0 {
113                self.styled("·", Style::new().fg(text_color));
114            }
115            self.styled(*key, Style::new().bold().fg(key_color));
116            self.styled(*action, Style::new().fg(text_color));
117        }
118        self.commands.push(Command::EndContainer);
119        self.rollback.last_text_idx = None;
120
121        Response::none()
122    }
123
124    // ── events ───────────────────────────────────────────────────────
125
126    /// Check if a character key was pressed this frame.
127    ///
128    /// Returns `true` if the key event has not been consumed by another widget.
129    pub fn key(&self, c: char) -> bool {
130        if (self.rollback.modal_active || self.prev_modal_active)
131            && self.rollback.overlay_depth == 0
132        {
133            return false;
134        }
135        self.events.iter().enumerate().any(|(i, e)| {
136            !self.consumed[i]
137                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c))
138        })
139    }
140
141    /// Check if a specific key code was pressed this frame.
142    ///
143    /// Returns `true` if the key event has not been consumed by another widget.
144    /// Blocked when a modal/overlay is active and the caller is outside the overlay.
145    /// Use [`raw_key_code`](Self::raw_key_code) for global shortcuts that must work
146    /// regardless of modal/overlay state.
147    pub fn key_code(&self, code: KeyCode) -> bool {
148        if (self.rollback.modal_active || self.prev_modal_active)
149            && self.rollback.overlay_depth == 0
150        {
151            return false;
152        }
153        self.events.iter().enumerate().any(|(i, e)| {
154            !self.consumed[i]
155                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == code)
156        })
157    }
158
159    /// Check if a specific key code was pressed this frame, ignoring modal/overlay state.
160    ///
161    /// Unlike [`key_code`](Self::key_code), this method bypasses the modal/overlay guard
162    /// so it works even when a modal or overlay is active. Use this for global shortcuts
163    /// (e.g. Esc to close a modal, Ctrl+Q to quit) that must always be reachable.
164    ///
165    /// Returns `true` if the key event has not been consumed by another widget.
166    pub fn raw_key_code(&self, code: KeyCode) -> bool {
167        self.events.iter().enumerate().any(|(i, e)| {
168            !self.consumed[i]
169                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == code)
170        })
171    }
172
173    /// Check if a character key was released this frame.
174    ///
175    /// Returns `true` if the key release event has not been consumed by another widget.
176    pub fn key_release(&self, c: char) -> bool {
177        if (self.rollback.modal_active || self.prev_modal_active)
178            && self.rollback.overlay_depth == 0
179        {
180            return false;
181        }
182        self.events.iter().enumerate().any(|(i, e)| {
183            !self.consumed[i]
184                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Release && k.code == KeyCode::Char(c))
185        })
186    }
187
188    /// Check if a specific key code was released this frame.
189    ///
190    /// Returns `true` if the key release event has not been consumed by another widget.
191    pub fn key_code_release(&self, code: KeyCode) -> bool {
192        if (self.rollback.modal_active || self.prev_modal_active)
193            && self.rollback.overlay_depth == 0
194        {
195            return false;
196        }
197        self.events.iter().enumerate().any(|(i, e)| {
198            !self.consumed[i]
199                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Release && k.code == code)
200        })
201    }
202
203    /// Check for a character key press and consume the event, preventing other
204    /// handlers from seeing it.
205    ///
206    /// Returns `true` if the key was found unconsumed and is now consumed.
207    /// Unlike [`key()`](Self::key) which peeks without consuming, this claims
208    /// exclusive ownership of the event.
209    ///
210    /// Call **after** widgets if you want widgets to have priority over your
211    /// handler, or **before** widgets to intercept first.
212    pub fn consume_key(&mut self, c: char) -> bool {
213        if (self.rollback.modal_active || self.prev_modal_active)
214            && self.rollback.overlay_depth == 0
215        {
216            return false;
217        }
218        let index = self.available_key_presses().find_map(|(i, key)| {
219            if key.code == KeyCode::Char(c) {
220                Some(i)
221            } else {
222                None
223            }
224        });
225        if let Some(index) = index {
226            self.consume_indices([index]);
227            true
228        } else {
229            false
230        }
231    }
232
233    /// Check for a special key press and consume the event, preventing other
234    /// handlers from seeing it.
235    ///
236    /// Returns `true` if the key was found unconsumed and is now consumed.
237    /// Unlike [`key_code()`](Self::key_code) which peeks without consuming,
238    /// this claims exclusive ownership of the event.
239    ///
240    /// Call **after** widgets if you want widgets to have priority over your
241    /// handler, or **before** widgets to intercept first.
242    pub fn consume_key_code(&mut self, code: KeyCode) -> bool {
243        if (self.rollback.modal_active || self.prev_modal_active)
244            && self.rollback.overlay_depth == 0
245        {
246            return false;
247        }
248        let index =
249            self.available_key_presses().find_map(
250                |(i, key)| {
251                    if key.code == code {
252                        Some(i)
253                    } else {
254                        None
255                    }
256                },
257            );
258        if let Some(index) = index {
259            self.consume_indices([index]);
260            true
261        } else {
262            false
263        }
264    }
265
266    /// Check if a character key with specific modifiers was pressed this frame.
267    ///
268    /// Returns `true` if the key event has not been consumed by another widget.
269    pub fn key_mod(&self, c: char, modifiers: KeyModifiers) -> bool {
270        if (self.rollback.modal_active || self.prev_modal_active)
271            && self.rollback.overlay_depth == 0
272        {
273            return false;
274        }
275        self.events.iter().enumerate().any(|(i, e)| {
276            !self.consumed[i]
277                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c) && k.modifiers.contains(modifiers))
278        })
279    }
280
281    /// Like [`key_mod`](Self::key_mod) but bypasses the modal/overlay guard.
282    pub fn raw_key_mod(&self, c: char, modifiers: KeyModifiers) -> bool {
283        self.events.iter().enumerate().any(|(i, e)| {
284            !self.consumed[i]
285                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c) && k.modifiers.contains(modifiers))
286        })
287    }
288
289    /// Return the position of a left mouse button down event this frame, if any.
290    ///
291    /// Returns `None` if no unconsumed mouse-down event occurred.
292    pub fn mouse_down(&self) -> Option<(u32, u32)> {
293        if (self.rollback.modal_active || self.prev_modal_active)
294            && self.rollback.overlay_depth == 0
295        {
296            return None;
297        }
298        self.events.iter().enumerate().find_map(|(i, event)| {
299            if self.consumed[i] {
300                return None;
301            }
302            if let Event::Mouse(mouse) = event {
303                if matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
304                    return Some((mouse.x, mouse.y));
305                }
306            }
307            None
308        })
309    }
310
311    /// Return the current mouse cursor position, if known.
312    ///
313    /// The position is updated on every mouse move or click event. Returns
314    /// `None` until the first mouse event is received.
315    pub fn mouse_pos(&self) -> Option<(u32, u32)> {
316        self.mouse_pos
317    }
318
319    /// Return the first unconsumed paste event text, if any.
320    pub fn paste(&self) -> Option<&str> {
321        if (self.rollback.modal_active || self.prev_modal_active)
322            && self.rollback.overlay_depth == 0
323        {
324            return None;
325        }
326        self.events.iter().enumerate().find_map(|(i, event)| {
327            if self.consumed[i] {
328                return None;
329            }
330            if let Event::Paste(ref text) = event {
331                return Some(text.as_str());
332            }
333            None
334        })
335    }
336
337    /// Check if an unconsumed scroll-up event occurred this frame.
338    pub fn scroll_up(&self) -> bool {
339        if (self.rollback.modal_active || self.prev_modal_active)
340            && self.rollback.overlay_depth == 0
341        {
342            return false;
343        }
344        self.events.iter().enumerate().any(|(i, event)| {
345            !self.consumed[i]
346                && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollUp))
347        })
348    }
349
350    /// Check if an unconsumed scroll-down event occurred this frame.
351    pub fn scroll_down(&self) -> bool {
352        if (self.rollback.modal_active || self.prev_modal_active)
353            && self.rollback.overlay_depth == 0
354        {
355            return false;
356        }
357        self.events.iter().enumerate().any(|(i, event)| {
358            !self.consumed[i]
359                && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollDown))
360        })
361    }
362
363    /// Check if an unconsumed scroll-left event occurred this frame.
364    pub fn scroll_left(&self) -> bool {
365        if (self.rollback.modal_active || self.prev_modal_active)
366            && self.rollback.overlay_depth == 0
367        {
368            return false;
369        }
370        self.events.iter().enumerate().any(|(i, event)| {
371            !self.consumed[i]
372                && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollLeft))
373        })
374    }
375
376    /// Check if an unconsumed scroll-right event occurred this frame.
377    pub fn scroll_right(&self) -> bool {
378        if (self.rollback.modal_active || self.prev_modal_active)
379            && self.rollback.overlay_depth == 0
380        {
381            return false;
382        }
383        self.events.iter().enumerate().any(|(i, event)| {
384            !self.consumed[i]
385                && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollRight))
386        })
387    }
388
389    /// Signal the run loop to exit after this frame.
390    pub fn quit(&mut self) {
391        self.should_quit = true;
392    }
393
394    /// Copy text to the system clipboard via OSC 52.
395    ///
396    /// Works transparently over SSH connections. The text is queued and
397    /// written to the terminal after the current frame renders.
398    ///
399    /// Requires a terminal that supports OSC 52 (most modern terminals:
400    /// Ghostty, kitty, WezTerm, iTerm2, Windows Terminal).
401    pub fn copy_to_clipboard(&mut self, text: impl Into<String>) {
402        self.clipboard_text = Some(text.into());
403    }
404
405    /// Get the current theme.
406    pub fn theme(&self) -> &Theme {
407        &self.theme
408    }
409
410    /// Change the theme for subsequent rendering.
411    ///
412    /// All widgets rendered after this call will use the new theme's colors.
413    pub fn set_theme(&mut self, theme: Theme) {
414        self.theme = theme;
415    }
416
417    /// Check if dark mode is active.
418    pub fn is_dark_mode(&self) -> bool {
419        self.rollback.dark_mode
420    }
421
422    /// Set dark mode. When true, dark_* style variants are applied.
423    pub fn set_dark_mode(&mut self, dark: bool) {
424        self.rollback.dark_mode = dark;
425    }
426
427    // ── info ─────────────────────────────────────────────────────────
428
429    /// Get the terminal width in cells.
430    pub fn width(&self) -> u32 {
431        self.area_width
432    }
433
434    /// Get the current terminal width breakpoint.
435    ///
436    /// Returns a [`Breakpoint`] based on the terminal width:
437    /// - `Xs`: < 40 columns
438    /// - `Sm`: 40-79 columns
439    /// - `Md`: 80-119 columns
440    /// - `Lg`: 120-159 columns
441    /// - `Xl`: >= 160 columns
442    ///
443    /// Use this for responsive layouts that adapt to terminal size:
444    /// ```no_run
445    /// # use slt::{Breakpoint, Context};
446    /// # slt::run(|ui: &mut Context| {
447    /// match ui.breakpoint() {
448    ///     Breakpoint::Xs | Breakpoint::Sm => {
449    ///         ui.col(|ui| { ui.text("Stacked layout"); });
450    ///     }
451    ///     _ => {
452    ///         ui.row(|ui| { ui.text("Side-by-side layout"); });
453    ///     }
454    /// }
455    /// # });
456    /// ```
457    pub fn breakpoint(&self) -> Breakpoint {
458        let w = self.area_width;
459        if w < 40 {
460            Breakpoint::Xs
461        } else if w < 80 {
462            Breakpoint::Sm
463        } else if w < 120 {
464            Breakpoint::Md
465        } else if w < 160 {
466            Breakpoint::Lg
467        } else {
468            Breakpoint::Xl
469        }
470    }
471
472    /// Get the terminal height in cells.
473    pub fn height(&self) -> u32 {
474        self.area_height
475    }
476
477    /// Get the current tick count (increments each frame).
478    ///
479    /// Useful for animations and time-based logic. The tick starts at 0 and
480    /// increases by 1 on every rendered frame.
481    pub fn tick(&self) -> u64 {
482        self.tick
483    }
484
485    /// Return whether the layout debugger is enabled.
486    ///
487    /// The debugger is toggled with F12 at runtime.
488    pub fn debug_enabled(&self) -> bool {
489        self.debug
490    }
491}