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