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}