Skip to main content

tui/components/
component.rs

1use crossterm::event::{KeyEvent, KeyEventKind, MouseEvent};
2
3use crate::rendering::frame::Frame;
4use crate::rendering::render_context::{Size, ViewContext};
5
6/// Events that a [`Widget`] can handle.
7pub enum Event {
8    Key(KeyEvent),
9    Paste(String),
10    Mouse(MouseEvent),
11    Tick,
12    Resize(Size),
13}
14
15impl TryFrom<crossterm::event::Event> for Event {
16    type Error = ();
17
18    fn try_from(event: crossterm::event::Event) -> Result<Self, ()> {
19        match event {
20            crossterm::event::Event::Key(key)
21                if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) =>
22            {
23                Ok(Event::Key(key))
24            }
25            crossterm::event::Event::Paste(text) => Ok(Event::Paste(text)),
26            crossterm::event::Event::Mouse(mouse) => Ok(Event::Mouse(mouse)),
27            crossterm::event::Event::Resize(cols, rows) => Ok(Event::Resize((cols, rows).into())),
28            _ => Err(()),
29        }
30    }
31}
32
33/// A component that can process events and emit typed messages.
34pub trait Component {
35    /// The message type emitted by this widget.
36    type Message;
37
38    /// Process an event and return the outcome.
39    ///
40    /// - `None` — event not recognized, propagate to parent
41    /// - `Some(vec![])` — event consumed, no messages
42    /// - `Some(vec![msg, ...])` — event consumed, emit messages
43    fn on_event(&mut self, event: &Event) -> impl Future<Output = Option<Vec<Self::Message>>>;
44
45    /// Render the current state to a frame.
46    fn render(&mut self, ctx: &ViewContext) -> Frame;
47}
48
49/// Merge two event outcomes. `None` (ignored) yields to the other.
50/// Messages are concatenated in order.
51pub fn merge<M>(a: Option<Vec<M>>, b: Option<Vec<M>>) -> Option<Vec<M>> {
52    match (a, b) {
53        (None, other) | (other, None) => other,
54        (Some(mut a), Some(b)) => {
55            a.extend(b);
56            Some(a)
57        }
58    }
59}
60
61/// Generic message type for picker components.
62pub enum PickerMessage<T> {
63    Close,
64    CloseAndPopChar,
65    CloseWithChar(char),
66    Confirm(T),
67    CharTyped(char),
68    PopChar,
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use crossterm::event::{KeyCode, KeyEventState, KeyModifiers};
75
76    #[test]
77    fn try_from_key_press_succeeds() {
78        let crossterm_event = crossterm::event::Event::Key(KeyEvent {
79            code: KeyCode::Char('a'),
80            modifiers: KeyModifiers::NONE,
81            kind: KeyEventKind::Press,
82            state: KeyEventState::NONE,
83        });
84        let event = Event::try_from(crossterm_event);
85        assert!(matches!(event, Ok(Event::Key(_))));
86    }
87
88    #[test]
89    fn try_from_key_release_fails() {
90        let crossterm_event = crossterm::event::Event::Key(KeyEvent {
91            code: KeyCode::Char('a'),
92            modifiers: KeyModifiers::NONE,
93            kind: KeyEventKind::Release,
94            state: KeyEventState::NONE,
95        });
96        assert!(Event::try_from(crossterm_event).is_err());
97    }
98
99    #[test]
100    fn try_from_paste_succeeds() {
101        let crossterm_event = crossterm::event::Event::Paste("hello".to_string());
102        let event = Event::try_from(crossterm_event);
103        assert!(matches!(event, Ok(Event::Paste(text)) if text == "hello"));
104    }
105
106    #[test]
107    fn try_from_resize_succeeds() {
108        let crossterm_event = crossterm::event::Event::Resize(80, 24);
109        let event = Event::try_from(crossterm_event);
110        assert!(matches!(event, Ok(Event::Resize(size)) if size.width == 80 && size.height == 24));
111    }
112
113    #[test]
114    fn try_from_focus_gained_fails() {
115        let crossterm_event = crossterm::event::Event::FocusGained;
116        assert!(Event::try_from(crossterm_event).is_err());
117    }
118}