bubbletea_rs/
model.rs

1//! This module defines the core `Model` trait, which is central to the
2//! Model-View-Update (MVU) architecture used in `bubbletea-rs` applications.
3//! The `Model` trait provides a clear and consistent interface for managing
4//! application state, processing messages, and rendering the user interface.
5//!
6//! It is designed to be a direct, idiomatic Rust equivalent of Go's `bubbletea`
7//! `Model` interface, facilitating migration and understanding for developers
8//! familiar with the Go version.
9
10use crate::{Cmd, Msg};
11
12/// The Model trait defines the core interface for bubbletea-rs applications.
13///
14/// This trait provides a direct 1-to-1 mapping from Go's Model interface
15/// with identical method signatures and behavior. Models represent your
16/// application's state and logic, following the Model-View-Update pattern.
17///
18/// # Trait Bounds
19///
20/// - `Send`: Ensures the model can be safely transferred between threads
21/// - `Sized`: Ensures the model has a known size at compile time
22/// - `'static`: Ensures the model doesn't contain non-static references
23///
24/// These bounds are required for async safety and Tokio integration.
25///
26/// # Example
27///
28/// ```rust
29/// use bubbletea_rs::{Model, Msg, Cmd, KeyMsg};
30///
31/// struct Counter {
32///     value: i32,
33/// }
34///
35/// impl Model for Counter {
36///     fn init() -> (Self, Option<Cmd>) {
37///         (Self { value: 0 }, None)
38///     }
39///     
40///     fn update(&mut self, msg: Msg) -> Option<Cmd> {
41///         if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
42///             match key_msg.key {
43///                 crossterm::event::KeyCode::Up => self.value += 1,
44///                 crossterm::event::KeyCode::Down => self.value -= 1,
45///                 _ => {}
46///             }
47///         }
48///         None
49///     }
50///     
51///     fn view(&self) -> String {
52///         format!("Counter: {} (↑/↓ to change)", self.value)
53///     }
54/// }
55/// ```
56pub trait Model: Send + Sized + 'static {
57    /// Initialize the model with its initial state and optional startup command.
58    ///
59    /// This method is called once when the application starts and should return
60    /// the initial state of your model along with an optional command to execute
61    /// immediately after initialization.
62    ///
63    /// # Returns
64    ///
65    /// A tuple containing:
66    /// - `Self`: The initialized model with its starting state
67    /// - `Option<Cmd>`: An optional command to run immediately (e.g., loading data)
68    ///
69    /// # Example
70    ///
71    /// ```rust
72    /// # use bubbletea_rs::{Model, Cmd};
73    /// # struct MyModel { count: i32 }
74    /// # impl Model for MyModel {
75    /// fn init() -> (Self, Option<Cmd>) {
76    ///     // Start with a count of 0 and no initial command
77    ///     (MyModel { count: 0 }, None)
78    /// }
79    /// # fn update(&mut self, msg: bubbletea_rs::Msg) -> Option<Cmd> { None }
80    /// # fn view(&self) -> String { String::new() }
81    /// # }
82    /// ```
83    fn init() -> (Self, Option<Cmd>);
84
85    /// Update the model in response to a received message.
86    ///
87    /// This method is called whenever a message is received by your application.
88    /// It should update the model's state based on the message content and
89    /// optionally return a command to execute as a side effect.
90    ///
91    /// # Arguments
92    ///
93    /// * `msg` - The message to process. Use `msg.downcast_ref::<T>()` to check
94    ///   for specific message types.
95    ///
96    /// # Returns
97    ///
98    /// An optional command to execute after the update. Return `None` if no
99    /// side effects are needed.
100    ///
101    /// # Example
102    ///
103    /// ```rust
104    /// # use bubbletea_rs::{Model, Msg, Cmd, KeyMsg};
105    /// # struct MyModel { count: i32 }
106    /// # impl Model for MyModel {
107    /// # fn init() -> (Self, Option<Cmd>) { (MyModel { count: 0 }, None) }
108    /// fn update(&mut self, msg: Msg) -> Option<Cmd> {
109    ///     if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
110    ///         match key_msg.key {
111    ///             crossterm::event::KeyCode::Up => {
112    ///                 self.count += 1;
113    ///                 // No command needed for this update
114    ///                 None
115    ///             }
116    ///             _ => None,
117    ///         }
118    ///     } else {
119    ///         None
120    ///     }
121    /// }
122    /// # fn view(&self) -> String { String::new() }
123    /// # }
124    /// ```
125    fn update(&mut self, msg: Msg) -> Option<Cmd>;
126
127    /// Render the current model state as a string for terminal display.
128    ///
129    /// This method is called whenever the terminal needs to be redrawn.
130    /// It should return a string representation of the current model state
131    /// that will be displayed to the user.
132    ///
133    /// # Returns
134    ///
135    /// A `String` containing the rendered view. This can include:
136    /// - ANSI escape codes for colors and styling
137    /// - Newlines for multi-line layouts
138    /// - Unicode characters for advanced formatting
139    ///
140    /// # Performance Notes
141    ///
142    /// This method may be called frequently during redraws, so avoid
143    /// expensive computations. Consider caching formatted strings if
144    /// the rendering is complex.
145    ///
146    /// # Example
147    ///
148    /// ```rust
149    /// # use bubbletea_rs::{Model, Msg, Cmd};
150    /// # struct MyModel { count: i32, name: String }
151    /// # impl Model for MyModel {
152    /// # fn init() -> (Self, Option<Cmd>) { (MyModel { count: 0, name: "App".to_string() }, None) }
153    /// # fn update(&mut self, msg: Msg) -> Option<Cmd> { None }
154    /// fn view(&self) -> String {
155    ///     format!(
156    ///         "Welcome to {}!\n\nCount: {}\n\nPress ↑/↓ to change",
157    ///         self.name,
158    ///         self.count
159    ///     )
160    /// }
161    /// # }
162    /// ```
163    fn view(&self) -> String;
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::{KeyMsg, QuitMsg};
170    use crossterm::event::{KeyCode, KeyModifiers};
171
172    #[derive(Debug, Clone)]
173    struct CounterModel {
174        count: i32,
175        step: i32,
176    }
177
178    impl Model for CounterModel {
179        fn init() -> (Self, Option<Cmd>) {
180            (Self { count: 0, step: 1 }, None)
181        }
182
183        fn update(&mut self, msg: Msg) -> Option<Cmd> {
184            if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
185                match key_msg.key {
186                    KeyCode::Up | KeyCode::Char('+') => {
187                        self.count += self.step;
188                    }
189                    KeyCode::Down | KeyCode::Char('-') => {
190                        self.count -= self.step;
191                    }
192                    KeyCode::Char('r') => {
193                        self.count = 0;
194                    }
195                    KeyCode::Char('s') => {
196                        self.step = if self.step == 1 { 10 } else { 1 };
197                    }
198                    KeyCode::Char('q') => {
199                        return Some(Box::pin(async { Some(Box::new(QuitMsg) as Msg) }));
200                    }
201                    _ => {}
202                }
203            }
204            None
205        }
206
207        fn view(&self) -> String {
208            format!(
209                "Counter: {}\nStep: {}\n\nControls:\n↑/+ : Increment\n↓/- : Decrement\nr : Reset\ns : Toggle step (1/10)\nq : Quit",
210                self.count, self.step
211            )
212        }
213    }
214
215    #[derive(Debug, Clone)]
216    struct TextInputModel {
217        content: String,
218        cursor: usize,
219        max_length: usize,
220    }
221
222    impl Model for TextInputModel {
223        fn init() -> (Self, Option<Cmd>) {
224            (
225                Self {
226                    content: String::new(),
227                    cursor: 0,
228                    max_length: 100,
229                },
230                None,
231            )
232        }
233
234        fn update(&mut self, msg: Msg) -> Option<Cmd> {
235            if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
236                match key_msg.key {
237                    KeyCode::Char(c) if self.content.len() < self.max_length => {
238                        self.content.insert(self.cursor, c);
239                        self.cursor += 1;
240                    }
241                    KeyCode::Backspace if self.cursor > 0 => {
242                        self.cursor -= 1;
243                        self.content.remove(self.cursor);
244                    }
245                    KeyCode::Delete if self.cursor < self.content.len() => {
246                        self.content.remove(self.cursor);
247                    }
248                    KeyCode::Left if self.cursor > 0 => {
249                        self.cursor -= 1;
250                    }
251                    KeyCode::Right if self.cursor < self.content.len() => {
252                        self.cursor += 1;
253                    }
254                    KeyCode::Home => {
255                        self.cursor = 0;
256                    }
257                    KeyCode::End => {
258                        self.cursor = self.content.len();
259                    }
260                    KeyCode::Esc => {
261                        return Some(Box::pin(async { Some(Box::new(QuitMsg) as Msg) }));
262                    }
263                    _ => {}
264                }
265            }
266            None
267        }
268
269        fn view(&self) -> String {
270            let mut display = self.content.clone();
271            display.insert(self.cursor, '|');
272
273            format!(
274                "Text Input ({}/{})\n\n{}\n\nControls:\nType to add text\n← → : Move cursor\nBackspace/Delete : Remove text\nHome/End : Jump to start/end\nEsc : Quit",
275                self.content.len(),
276                self.max_length,
277                display
278            )
279        }
280    }
281
282    #[test]
283    fn test_counter_model_init() {
284        let (model, cmd) = CounterModel::init();
285        assert_eq!(model.count, 0);
286        assert_eq!(model.step, 1);
287        assert!(cmd.is_none());
288    }
289
290    #[test]
291    fn test_counter_model_update() {
292        let (mut model, _) = CounterModel::init();
293
294        let key_msg = KeyMsg {
295            key: KeyCode::Up,
296            modifiers: KeyModifiers::empty(),
297        };
298        let cmd = model.update(Box::new(key_msg));
299        assert_eq!(model.count, 1);
300        assert!(cmd.is_none());
301
302        let key_msg = KeyMsg {
303            key: KeyCode::Down,
304            modifiers: KeyModifiers::empty(),
305        };
306        model.update(Box::new(key_msg));
307        assert_eq!(model.count, 0);
308
309        model.count = 42;
310        let key_msg = KeyMsg {
311            key: KeyCode::Char('r'),
312            modifiers: KeyModifiers::empty(),
313        };
314        model.update(Box::new(key_msg));
315        assert_eq!(model.count, 0);
316    }
317
318    #[test]
319    fn test_counter_model_view() {
320        let (model, _) = CounterModel::init();
321        let view = model.view();
322        assert!(view.contains("Counter: 0"));
323        assert!(view.contains("Step: 1"));
324        assert!(view.contains("Controls:"));
325    }
326
327    #[test]
328    fn test_text_input_model_init() {
329        let (model, cmd) = TextInputModel::init();
330        assert!(model.content.is_empty());
331        assert_eq!(model.cursor, 0);
332        assert_eq!(model.max_length, 100);
333        assert!(cmd.is_none());
334    }
335
336    #[test]
337    fn test_text_input_model_char_input() {
338        let (mut model, _) = TextInputModel::init();
339
340        let key_msg = KeyMsg {
341            key: KeyCode::Char('H'),
342            modifiers: KeyModifiers::empty(),
343        };
344        model.update(Box::new(key_msg));
345        assert_eq!(model.content, "H");
346        assert_eq!(model.cursor, 1);
347
348        let key_msg = KeyMsg {
349            key: KeyCode::Char('i'),
350            modifiers: KeyModifiers::empty(),
351        };
352        model.update(Box::new(key_msg));
353        assert_eq!(model.content, "Hi");
354        assert_eq!(model.cursor, 2);
355    }
356
357    #[test]
358    fn test_text_input_model_backspace() {
359        let (mut model, _) = TextInputModel::init();
360        model.content = "Hello".to_string();
361        model.cursor = 5;
362
363        let key_msg = KeyMsg {
364            key: KeyCode::Backspace,
365            modifiers: KeyModifiers::empty(),
366        };
367        model.update(Box::new(key_msg));
368        assert_eq!(model.content, "Hell");
369        assert_eq!(model.cursor, 4);
370    }
371
372    #[test]
373    fn test_text_input_model_cursor_movement() {
374        let (mut model, _) = TextInputModel::init();
375        model.content = "Hello".to_string();
376        model.cursor = 2;
377
378        let key_msg = KeyMsg {
379            key: KeyCode::Left,
380            modifiers: KeyModifiers::empty(),
381        };
382        model.update(Box::new(key_msg));
383        assert_eq!(model.cursor, 1);
384
385        let key_msg = KeyMsg {
386            key: KeyCode::Right,
387            modifiers: KeyModifiers::empty(),
388        };
389        model.update(Box::new(key_msg));
390        assert_eq!(model.cursor, 2);
391
392        let key_msg = KeyMsg {
393            key: KeyCode::Home,
394            modifiers: KeyModifiers::empty(),
395        };
396        model.update(Box::new(key_msg));
397        assert_eq!(model.cursor, 0);
398
399        let key_msg = KeyMsg {
400            key: KeyCode::End,
401            modifiers: KeyModifiers::empty(),
402        };
403        model.update(Box::new(key_msg));
404        assert_eq!(model.cursor, 5);
405    }
406
407    #[test]
408    fn test_model_trait_bounds() {
409        fn assert_send<T: Send>() {}
410        fn assert_sized<T: Sized>() {}
411        fn assert_static<T: 'static>() {}
412
413        assert_send::<CounterModel>();
414        assert_sized::<CounterModel>();
415        assert_static::<CounterModel>();
416
417        assert_send::<TextInputModel>();
418        assert_sized::<TextInputModel>();
419        assert_static::<TextInputModel>();
420    }
421
422    #[test]
423    fn test_model_send_sync_static() {
424        fn assert_send_sync_static<T: Send + Sync + 'static>() {}
425        assert_send_sync_static::<CounterModel>();
426        assert_send_sync_static::<TextInputModel>();
427    }
428}