hojicha_core/core.rs
1//! Core traits and types for the Elm Architecture
2//!
3//! This module contains the foundational traits and types that implement
4//! The Elm Architecture (TEA) pattern in Hojicha.
5//!
6//! ## The Elm Architecture
7//!
8//! TEA is a pattern for organizing interactive applications with:
9//! - **Unidirectional data flow**: Events flow through update to modify state
10//! - **Pure functions**: Update and view are pure, side effects use commands
11//! - **Clear separation**: Model (state), Update (logic), View (presentation)
12//!
13//! ## Core Components
14//!
15//! ### Model Trait
16//! Your application struct implements this trait:
17//! ```
18//! # use hojicha_core::{Model, Cmd, Event};
19//! # use ratatui::{Frame, layout::Rect};
20//! struct MyApp {
21//! counter: i32,
22//! }
23//!
24//! impl Model for MyApp {
25//! type Message = MyMessage;
26//!
27//! fn update(&mut self, event: Event<Self::Message>) -> Cmd<Self::Message> {
28//! // Handle events and return commands
29//! Cmd::none()
30//! }
31//!
32//! fn view(&self, frame: &mut Frame, area: Rect) {
33//! // Render the UI
34//! }
35//! }
36//! # enum MyMessage {}
37//! ```
38//!
39//! ### Commands
40//! Commands represent side effects that produce messages:
41//! ```
42//! # use hojicha_core::Cmd;
43//! # enum Msg { DataLoaded(String) }
44//! let cmd: Cmd<Msg> = Cmd::new(|| {
45//! // Perform side effect
46//! let data = std::fs::read_to_string("data.txt").ok()?;
47//! Some(Msg::DataLoaded(data))
48//! });
49//! ```
50
51use crate::event::Event;
52use ratatui::layout::Rect;
53use ratatui::Frame;
54use std::fmt::Debug;
55
56/// Type alias for exec process callback function
57type ExecCallback<M> = Box<dyn Fn(Option<i32>) -> M + Send>;
58
59/// Type alias for exec process details
60type ExecDetails<M> = (String, Vec<String>, ExecCallback<M>);
61
62/// A message that can be sent to update the model.
63///
64/// Messages are typically enums that represent different events
65/// or state changes in your application.
66pub trait Message: Send + 'static {}
67
68// Blanket implementation for all types that meet the bounds
69impl<T: Send + 'static> Message for T {}
70
71/// The core trait for your application's model.
72///
73/// Your model should implement this trait to define how it:
74/// - Initializes (`init`)
75/// - Updates in response to messages (`update`)
76/// - Renders to the screen (`view`)
77pub trait Model: Sized {
78 /// The type of messages this model can receive
79 type Message: Message;
80
81 /// Initialize the model and return a command to run
82 ///
83 /// This method is called once when the program starts. Use it to:
84 /// - Load initial data
85 /// - Start timers
86 /// - Perform initial setup
87 ///
88 /// Returns:
89 /// - `Cmd::none()` - Start the event loop without any initial command
90 /// - Any other command - Execute the command before starting the event loop
91 ///
92 /// # Example
93 /// ```
94 /// # use hojicha_core::{Model, Cmd, commands};
95 /// # use std::time::Duration;
96 /// # struct MyApp;
97 /// # enum Msg { Tick }
98 /// # impl Model for MyApp {
99 /// # type Message = Msg;
100 /// fn init(&mut self) -> Cmd<Self::Message> {
101 /// // Start a timer that ticks every second
102 /// commands::every(Duration::from_secs(1), |_| Msg::Tick)
103 /// }
104 /// # fn update(&mut self, _: hojicha_core::Event<Self::Message>) -> Cmd<Self::Message> { Cmd::none() }
105 /// # fn view(&self, _: &mut ratatui::Frame, _: ratatui::layout::Rect) {}
106 /// # }
107 /// ```
108 fn init(&mut self) -> Cmd<Self::Message> {
109 Cmd::none()
110 }
111
112 /// Update the model based on a message and return a command
113 ///
114 /// This is the heart of your application logic. Handle events here and
115 /// update your model's state accordingly.
116 ///
117 /// Returns:
118 /// - `Cmd::none()` - Continue running without executing any command
119 /// - `commands::quit()` - Exit the program
120 /// - Any other command - Execute the command and continue
121 ///
122 /// # Example
123 /// ```
124 /// # use hojicha_core::{Model, Cmd, Event, Key, commands};
125 /// # struct Counter { value: i32 }
126 /// # enum Msg { Increment, Decrement }
127 /// # impl Model for Counter {
128 /// # type Message = Msg;
129 /// fn update(&mut self, event: Event<Self::Message>) -> Cmd<Self::Message> {
130 /// match event {
131 /// Event::Key(key) if key.key == Key::Char('q') => {
132 /// commands::quit()
133 /// }
134 /// Event::User(Msg::Increment) => {
135 /// self.value += 1;
136 /// Cmd::none()
137 /// }
138 /// Event::User(Msg::Decrement) => {
139 /// self.value -= 1;
140 /// Cmd::none()
141 /// }
142 /// _ => Cmd::none()
143 /// }
144 /// }
145 /// # fn view(&self, _: &mut ratatui::Frame, _: ratatui::layout::Rect) {}
146 /// # }
147 /// ```
148 fn update(&mut self, msg: Event<Self::Message>) -> Cmd<Self::Message>;
149
150 /// Render the model to the screen
151 ///
152 /// This method is called after each update to render your UI.
153 /// Use ratatui widgets to draw your interface.
154 ///
155 /// # Example
156 /// ```
157 /// # use hojicha_core::Model;
158 /// # use ratatui::{Frame, layout::Rect, widgets::{Block, Borders, Paragraph}};
159 /// # struct MyApp { message: String }
160 /// # impl Model for MyApp {
161 /// # type Message = ();
162 /// # fn update(&mut self, _: hojicha_core::Event<Self::Message>) -> hojicha_core::Cmd<Self::Message> { hojicha_core::Cmd::none() }
163 /// fn view(&self, frame: &mut Frame, area: Rect) {
164 /// let widget = Paragraph::new(self.message.as_str())
165 /// .block(Block::default()
166 /// .title("My App")
167 /// .borders(Borders::ALL));
168 /// frame.render_widget(widget, area);
169 /// }
170 /// # }
171 /// ```
172 fn view(&self, frame: &mut Frame, area: Rect);
173}
174
175/// A command is an asynchronous operation that produces a message.
176///
177/// Commands are used for side effects like:
178/// - HTTP requests
179/// - File I/O
180/// - Timers
181/// - Any async operation
182pub struct Cmd<M: Message> {
183 inner: CmdInner<M>,
184}
185
186pub(crate) enum CmdInner<M: Message> {
187 /// No operation - continue running without doing anything
188 NoOp,
189 /// A simple function command
190 Function(Box<dyn FnOnce() -> Option<M> + Send>),
191 /// A function command that can return errors
192 Fallible(Box<dyn FnOnce() -> crate::Result<Option<M>> + Send>),
193 /// An external process command
194 ExecProcess {
195 program: String,
196 args: Vec<String>,
197 callback: ExecCallback<M>,
198 },
199 /// Quit the program
200 Quit,
201 /// Execute multiple commands concurrently
202 Batch(Vec<Cmd<M>>),
203 /// Execute multiple commands sequentially
204 Sequence(Vec<Cmd<M>>),
205 /// Execute after a delay
206 Tick {
207 duration: std::time::Duration,
208 callback: Box<dyn FnOnce() -> M + Send>,
209 },
210 /// Execute repeatedly at intervals
211 Every {
212 duration: std::time::Duration,
213 callback: Box<dyn FnOnce(std::time::Instant) -> M + Send>,
214 },
215 /// Execute an async future
216 Async(Box<dyn std::future::Future<Output = Option<M>> + Send>),
217}
218
219impl<M: Message> Cmd<M> {
220 /// Create a new command from a function
221 ///
222 /// Note: If the function returns `None`, consider using `Cmd::none()` instead
223 /// for better performance and clearer intent.
224 ///
225 /// # Example
226 /// ```
227 /// # use hojicha_core::Cmd;
228 /// # enum Msg { DataLoaded(String) }
229 /// let cmd: Cmd<Msg> = Cmd::new(|| {
230 /// // Perform a side effect
231 /// let data = std::fs::read_to_string("config.json").ok()?;
232 /// Some(Msg::DataLoaded(data))
233 /// });
234 /// ```
235 pub fn new<F>(f: F) -> Self
236 where
237 F: FnOnce() -> Option<M> + Send + 'static,
238 {
239 Cmd {
240 inner: CmdInner::Function(Box::new(f)),
241 }
242 }
243
244 /// Create a new fallible command that can return errors
245 ///
246 /// Use this when your command might fail and you want to handle errors gracefully.
247 ///
248 /// # Example
249 /// ```
250 /// # use hojicha_core::{Cmd, Result};
251 /// # enum Msg { ConfigLoaded(String) }
252 /// let cmd: Cmd<Msg> = Cmd::fallible(|| {
253 /// let data = std::fs::read_to_string("config.json")?;
254 /// Ok(Some(Msg::ConfigLoaded(data)))
255 /// });
256 /// ```
257 pub fn fallible<F>(f: F) -> Self
258 where
259 F: FnOnce() -> crate::Result<Option<M>> + Send + 'static,
260 {
261 Cmd {
262 inner: CmdInner::Fallible(Box::new(f)),
263 }
264 }
265
266 /// Returns a no-op command that continues running without doing anything
267 ///
268 /// This is the idiomatic way to return "no command" from update().
269 /// The program will continue running without executing any side effects.
270 ///
271 /// # Example
272 /// ```
273 /// # use hojicha_core::{Model, Cmd, Event};
274 /// # use ratatui::{Frame, layout::Rect};
275 /// # struct MyApp;
276 /// # impl Model for MyApp {
277 /// # type Message = ();
278 /// fn update(&mut self, event: Event<Self::Message>) -> Cmd<Self::Message> {
279 /// match event {
280 /// Event::Tick => {
281 /// // Update internal state but don't trigger side effects
282 /// Cmd::none()
283 /// }
284 /// _ => Cmd::none()
285 /// }
286 /// }
287 /// # fn view(&self, _: &mut Frame, _: Rect) {}
288 /// # }
289 /// ```
290 pub fn none() -> Self {
291 Cmd {
292 inner: CmdInner::NoOp,
293 }
294 }
295
296 /// Internal method
297 #[doc(hidden)]
298 pub fn exec_process<F>(program: String, args: Vec<String>, callback: F) -> Self
299 where
300 F: Fn(Option<i32>) -> M + Send + 'static,
301 {
302 Cmd {
303 inner: CmdInner::ExecProcess {
304 program,
305 args,
306 callback: Box::new(callback),
307 },
308 }
309 }
310
311 /// Create a batch command that executes commands concurrently
312 /// Internal method
313 #[doc(hidden)]
314 pub fn batch(cmds: Vec<Cmd<M>>) -> Self {
315 Cmd {
316 inner: CmdInner::Batch(cmds),
317 }
318 }
319
320 /// Create a sequence command that executes commands in order
321 /// Internal method
322 #[doc(hidden)]
323 pub fn sequence(cmds: Vec<Cmd<M>>) -> Self {
324 Cmd {
325 inner: CmdInner::Sequence(cmds),
326 }
327 }
328
329 /// Create a quit command
330 /// Internal method
331 #[doc(hidden)]
332 pub fn quit() -> Self {
333 Cmd {
334 inner: CmdInner::Quit,
335 }
336 }
337
338 /// Create a tick command
339 /// Internal method
340 #[doc(hidden)]
341 pub fn tick<F>(duration: std::time::Duration, callback: F) -> Self
342 where
343 F: FnOnce() -> M + Send + 'static,
344 {
345 Cmd {
346 inner: CmdInner::Tick {
347 duration,
348 callback: Box::new(callback),
349 },
350 }
351 }
352
353 /// Create an every command
354 /// Internal method
355 #[doc(hidden)]
356 pub fn every<F>(duration: std::time::Duration, callback: F) -> Self
357 where
358 F: FnOnce(std::time::Instant) -> M + Send + 'static,
359 {
360 Cmd {
361 inner: CmdInner::Every {
362 duration,
363 callback: Box::new(callback),
364 },
365 }
366 }
367
368 /// Create an async command
369 /// Internal method
370 #[doc(hidden)]
371 pub fn async_cmd<Fut>(future: Fut) -> Self
372 where
373 Fut: std::future::Future<Output = Option<M>> + Send + 'static,
374 {
375 Cmd {
376 inner: CmdInner::Async(Box::new(future)),
377 }
378 }
379
380 /// Execute the command and return its message
381 /// Internal method
382 #[doc(hidden)]
383 pub fn execute(self) -> crate::Result<Option<M>> {
384 match self.inner {
385 CmdInner::Function(func) => Ok(func()),
386 CmdInner::Fallible(func) => func(),
387 CmdInner::ExecProcess {
388 program,
389 args,
390 callback,
391 } => {
392 // In the real implementation, this would be handled by the runtime
393 // For now, we'll just run it directly
394 use std::process::Command;
395 let output = Command::new(&program).args(&args).status();
396 let exit_code = output.ok().and_then(|status| status.code());
397 Ok(Some(callback(exit_code)))
398 }
399 CmdInner::NoOp => {
400 // NoOp commands don't produce messages, just continue running
401 Ok(None)
402 }
403 CmdInner::Quit => {
404 // Quit commands don't produce messages, they're handled specially
405 Ok(None)
406 }
407 CmdInner::Batch(_) | CmdInner::Sequence(_) => {
408 // These are handled specially by the CommandExecutor
409 Ok(None)
410 }
411 CmdInner::Tick { .. } | CmdInner::Every { .. } | CmdInner::Async(_) => {
412 // These are handled specially by the CommandExecutor with async runtime
413 Ok(None)
414 }
415 }
416 }
417
418 /// Execute the command and return its message (for testing only)
419 #[doc(hidden)]
420 pub fn test_execute(self) -> crate::Result<Option<M>> {
421 self.execute()
422 }
423
424 /// Check if this is an exec process command
425 /// Check if this is an exec process command
426 pub fn is_exec_process(&self) -> bool {
427 matches!(self.inner, CmdInner::ExecProcess { .. })
428 }
429
430 /// Check if this is a no-op command
431 pub fn is_noop(&self) -> bool {
432 matches!(self.inner, CmdInner::NoOp)
433 }
434
435 /// Check if this is a quit command
436 pub fn is_quit(&self) -> bool {
437 matches!(self.inner, CmdInner::Quit)
438 }
439
440 /// Extract exec process details if this is an exec process command
441 #[allow(clippy::type_complexity)]
442 /// Internal method
443 #[doc(hidden)]
444 pub fn take_exec_process(self) -> Option<ExecDetails<M>> {
445 match self.inner {
446 CmdInner::ExecProcess {
447 program,
448 args,
449 callback,
450 } => Some((program, args, callback)),
451 _ => None,
452 }
453 }
454
455 /// Check if this is a batch command
456 /// Internal method
457 #[doc(hidden)]
458 pub fn is_batch(&self) -> bool {
459 matches!(self.inner, CmdInner::Batch(_))
460 }
461
462 /// Take the batch commands (consumes the command)
463 /// Internal method
464 #[doc(hidden)]
465 pub fn take_batch(self) -> Option<Vec<Cmd<M>>> {
466 match self.inner {
467 CmdInner::Batch(cmds) => Some(cmds),
468 _ => None,
469 }
470 }
471
472 /// Check if this is a sequence command
473 /// Internal method
474 #[doc(hidden)]
475 pub fn is_sequence(&self) -> bool {
476 matches!(self.inner, CmdInner::Sequence(_))
477 }
478
479 /// Take the sequence commands (consumes the command)
480 /// Internal method
481 #[doc(hidden)]
482 pub fn take_sequence(self) -> Option<Vec<Cmd<M>>> {
483 match self.inner {
484 CmdInner::Sequence(cmds) => Some(cmds),
485 _ => None,
486 }
487 }
488
489 /// Internal method
490 #[doc(hidden)]
491 pub fn is_tick(&self) -> bool {
492 matches!(self.inner, CmdInner::Tick { .. })
493 }
494
495 /// Internal method
496 #[doc(hidden)]
497 pub fn is_every(&self) -> bool {
498 matches!(self.inner, CmdInner::Every { .. })
499 }
500
501 /// Internal method
502 #[doc(hidden)]
503 pub fn take_tick(self) -> Option<(std::time::Duration, Box<dyn FnOnce() -> M + Send>)> {
504 match self.inner {
505 CmdInner::Tick { duration, callback } => Some((duration, callback)),
506 _ => None,
507 }
508 }
509
510 #[allow(clippy::type_complexity)]
511 /// Internal method
512 #[doc(hidden)]
513 pub fn take_every(
514 self,
515 ) -> Option<(
516 std::time::Duration,
517 Box<dyn FnOnce(std::time::Instant) -> M + Send>,
518 )> {
519 match self.inner {
520 CmdInner::Every { duration, callback } => Some((duration, callback)),
521 _ => None,
522 }
523 }
524
525 /// Internal method
526 #[doc(hidden)]
527 pub fn is_async(&self) -> bool {
528 matches!(self.inner, CmdInner::Async(_))
529 }
530
531 /// Internal method
532 #[doc(hidden)]
533 pub fn take_async(self) -> Option<Box<dyn std::future::Future<Output = Option<M>> + Send>> {
534 match self.inner {
535 CmdInner::Async(future) => Some(future),
536 _ => None,
537 }
538 }
539
540 /// Inspect this command for debugging
541 ///
542 /// This allows you to observe command execution without modifying behavior.
543 ///
544 /// # Example
545 /// ```no_run
546 /// # use hojicha_core::Cmd;
547 /// # #[derive(Clone)]
548 /// # struct Msg;
549 /// let cmd: Cmd<Msg> = Cmd::none()
550 /// .inspect(|cmd| println!("Executing command: {:?}", cmd));
551 /// ```
552 pub fn inspect<F>(self, f: F) -> Self
553 where
554 F: FnOnce(&Self),
555 {
556 f(&self);
557 self
558 }
559
560 /// Conditionally inspect this command
561 ///
562 /// Only runs the inspection function if the condition is true.
563 ///
564 /// # Example
565 /// ```
566 /// # use hojicha_core::Cmd;
567 /// # enum Msg { Data(String) }
568 /// # let debug_mode = true;
569 /// let cmd: Cmd<Msg> = Cmd::new(|| Some(Msg::Data("test".into())))
570 /// .inspect_if(debug_mode, |cmd| {
571 /// eprintln!("Debug: executing {}", cmd.debug_name());
572 /// });
573 /// ```
574 pub fn inspect_if<F>(self, condition: bool, f: F) -> Self
575 where
576 F: FnOnce(&Self),
577 {
578 if condition {
579 f(&self);
580 }
581 self
582 }
583
584 /// Get a string representation of the command type for debugging
585 ///
586 /// # Example
587 /// ```
588 /// # use hojicha_core::{Cmd, commands};
589 /// # enum Msg { Tick }
590 /// # use std::time::Duration;
591 /// let cmd: Cmd<Msg> = commands::tick(Duration::from_secs(1), || Msg::Tick);
592 /// assert_eq!(cmd.debug_name(), "Tick");
593 ///
594 /// let noop: Cmd<Msg> = Cmd::none();
595 /// assert_eq!(noop.debug_name(), "NoOp");
596 /// ```
597 pub fn debug_name(&self) -> &'static str {
598 match self.inner {
599 CmdInner::Function(_) => "Function",
600 CmdInner::Fallible(_) => "Fallible",
601 CmdInner::ExecProcess { .. } => "ExecProcess",
602 CmdInner::NoOp => "NoOp",
603 CmdInner::Quit => "Quit",
604 CmdInner::Batch(_) => "Batch",
605 CmdInner::Sequence(_) => "Sequence",
606 CmdInner::Tick { .. } => "Tick",
607 CmdInner::Every { .. } => "Every",
608 CmdInner::Async(_) => "Async",
609 }
610 }
611}
612
613impl<M: Message> Debug for Cmd<M> {
614 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
615 f.debug_struct("Cmd").finish()
616 }
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622 use crate::event::Event;
623 use ratatui::{layout::Rect, Frame};
624
625 // Test model
626 #[derive(Clone)]
627 struct Counter {
628 value: i32,
629 }
630
631 #[derive(Debug, Clone, PartialEq)]
632 enum Msg {
633 Increment,
634 Decrement,
635 SetValue(i32),
636 Noop,
637 }
638
639 impl Model for Counter {
640 type Message = Msg;
641
642 fn init(&mut self) -> Cmd<Self::Message> {
643 Cmd::new(|| Some(Msg::SetValue(0)))
644 }
645
646 fn update(&mut self, msg: Event<Self::Message>) -> Cmd<Self::Message> {
647 if let Event::User(msg) = msg {
648 match msg {
649 Msg::Increment => {
650 self.value += 1;
651 Cmd::new(move || Some(Msg::Noop))
652 }
653 Msg::Decrement => {
654 self.value -= 1;
655 Cmd::new(move || Some(Msg::Noop))
656 }
657 Msg::SetValue(v) => {
658 self.value = v;
659 Cmd::none()
660 }
661 Msg::Noop => Cmd::none(),
662 }
663 } else {
664 Cmd::none()
665 }
666 }
667
668 fn view(&self, frame: &mut Frame, area: Rect) {
669 frame.render_widget(
670 ratatui::widgets::Paragraph::new(format!("Count: {}", self.value)),
671 area,
672 );
673 }
674 }
675
676 #[test]
677 fn test_model_update() {
678 let mut model = Counter { value: 0 };
679
680 model.update(Event::User(Msg::Increment));
681 assert_eq!(model.value, 1);
682
683 model.update(Event::User(Msg::Decrement));
684 assert_eq!(model.value, 0);
685 }
686
687 #[test]
688 fn test_cmd_creation() {
689 let cmd = Cmd::new(|| Some(Msg::Increment));
690 let msg = cmd.test_execute().unwrap();
691 assert!(matches!(msg, Some(Msg::Increment)));
692 }
693
694 #[test]
695 fn test_cmd_none() {
696 let cmd: Cmd<Msg> = Cmd::none();
697 assert!(cmd.is_noop());
698 }
699
700 #[test]
701 fn test_cmd_fallible_success() {
702 let cmd = Cmd::fallible(|| Ok(Some(Msg::Increment)));
703 let result = cmd.test_execute();
704 assert!(result.is_ok());
705 assert_eq!(result.unwrap(), Some(Msg::Increment));
706 }
707
708 #[test]
709 fn test_cmd_fallible_error() {
710 let cmd: Cmd<Msg> =
711 Cmd::fallible(|| Err(crate::Error::from(std::io::Error::other("test error"))));
712 let result = cmd.test_execute();
713 assert!(result.is_err());
714 }
715
716 #[test]
717 fn test_cmd_exec_process() {
718 let cmd = Cmd::exec_process("echo".to_string(), vec!["test".to_string()], |_| {
719 Msg::Increment
720 });
721 assert!(cmd.is_exec_process());
722
723 let exec_details = cmd.take_exec_process();
724 assert!(exec_details.is_some());
725 let (program, args, _) = exec_details.unwrap();
726 assert_eq!(program, "echo");
727 assert_eq!(args, vec!["test"]);
728 }
729
730 #[test]
731 fn test_cmd_debug() {
732 let cmd = Cmd::new(|| Some(Msg::Increment));
733 let debug_str = format!("{cmd:?}");
734 assert!(debug_str.contains("Cmd"));
735 }
736
737 #[test]
738 fn test_model_init() {
739 let mut model = Counter { value: 5 };
740 let cmd = model.init();
741 assert!(!cmd.is_noop());
742
743 let result = cmd.test_execute().unwrap();
744 assert_eq!(result, Some(Msg::SetValue(0)));
745 }
746}