hojicha_core/commands.rs
1//! Command utilities for handling side effects
2//!
3//! This module provides functions for creating commands that perform side effects
4//! in your Hojicha application. Commands are the Elm Architecture's way of handling
5//! operations that interact with the outside world.
6//!
7//! ## Core Command Types
8//!
9//! - **Synchronous**: Simple functions that return messages
10//! - **Asynchronous**: Futures that eventually produce messages
11//! - **Timed**: Commands that execute after delays or at intervals
12//! - **Composite**: Batch and sequence commands for complex flows
13//!
14//! ## Common Patterns
15//!
16//! ### No-op Command
17//! ```
18//! # use hojicha_core::commands::noop;
19//! # use hojicha_core::{Model, Cmd, Event};
20//! # struct MyModel;
21//! # impl Model for MyModel {
22//! # type Message = ();
23//! # fn update(&mut self, event: Event<Self::Message>) -> Cmd<Self::Message> {
24//! // Handle event but don't trigger side effects
25//! noop()
26//! # }
27//! # fn view(&self) -> String { String::new() }
28//! # }
29//! ```
30//!
31//! ### Concurrent Commands
32//! ```
33//! # use hojicha_core::commands::{batch, tick};
34//! # use hojicha_core::Cmd;
35//! # use std::time::Duration;
36//! # enum Msg { Tick1, Tick2 }
37//! let cmd: Cmd<Msg> = batch(vec![
38//! tick(Duration::from_secs(1), || Msg::Tick1),
39//! tick(Duration::from_secs(2), || Msg::Tick2),
40//! ]);
41//! ```
42//!
43//! ### Sequential Commands
44//! ```
45//! # use hojicha_core::commands::sequence;
46//! # use hojicha_core::Cmd;
47//! # enum Msg { First, Second }
48//! let cmd: Cmd<Msg> = sequence(vec![
49//! Cmd::new(|| Some(Msg::First)),
50//! Cmd::new(|| Some(Msg::Second)),
51//! ]);
52//! ```
53
54use crate::core::{Cmd, Message};
55use crate::event::WindowSize;
56use std::process::Command;
57use std::time::Duration;
58
59// Import panic recovery utilities from runtime crate
60// These are used to wrap Model methods for safe execution
61// Note: These are always available, not feature-gated
62
63/// Default maximum batch size
64///
65/// Batches larger than this will trigger a warning in debug mode.
66/// This is a soft limit - the batch will still be created.
67#[cfg(debug_assertions)]
68const DEFAULT_MAX_BATCH_SIZE: usize = 100;
69
70/// Hard maximum batch size
71///
72/// Batches larger than this will be automatically chunked.
73/// This prevents accidental memory exhaustion from massive batches.
74const HARD_MAX_BATCH_SIZE: usize = 1000;
75
76/// Special message types for terminal control
77#[derive(Debug, Clone)]
78pub enum TerminalControlMsg {
79 /// Hide the terminal cursor from view
80 HideCursor,
81 /// Show the terminal cursor
82 ShowCursor,
83 /// Enter the alternate screen buffer (like vim/less use)
84 EnterAltScreen,
85 /// Exit the alternate screen buffer and return to main screen
86 ExitAltScreen,
87 /// Set the terminal window title to the given string
88 SetWindowTitle(String),
89 /// Enable mouse tracking for cell motion (only when button pressed)
90 EnableMouseCellMotion,
91 /// Enable mouse tracking for all motion events (including hover)
92 EnableMouseAllMotion,
93 /// Disable all mouse tracking
94 DisableMouse,
95 /// Clear the entire screen
96 ClearScreen,
97 /// Clear the current line
98 ClearLine,
99}
100
101/// Create a no-op command
102///
103/// This is the most common command return value, indicating that the program
104/// should continue running without performing any side effects.
105///
106/// # Returns
107/// A command that does nothing but allows the program to continue.
108///
109/// # Example
110/// ```
111/// use hojicha_core::{Model, Cmd, Event, commands::noop};
112///
113/// #[derive(Debug)]
114/// enum AppMessage {
115/// DoNothing,
116/// }
117///
118/// struct MyApp;
119///
120/// impl Model for MyApp {
121/// type Message = AppMessage;
122///
123/// fn update(&mut self, event: Event<Self::Message>) -> Cmd<Self::Message> {
124/// match event {
125/// Event::User(AppMessage::DoNothing) => {
126/// // Just continue running without side effects
127/// noop()
128/// }
129/// _ => noop()
130/// }
131/// }
132///
133/// fn view(&self) -> String {
134/// "App running".to_string()
135/// }
136/// }
137/// ```
138#[must_use]
139pub fn noop<M: Message>() -> Cmd<M> {
140 Cmd::noop()
141}
142
143/// Create a no-op command
144///
145/// # Deprecated
146/// This function is deprecated. Use `commands::noop()` instead for clearer intent.
147/// The name "none" was confusing because it returns `Some(Cmd)` rather than `None`.
148///
149/// # Example
150/// ```
151/// # use hojicha_core::{Cmd, commands};
152/// # enum Msg {}
153/// // Old way (deprecated):
154/// let cmd: Cmd<Msg> = commands::none();
155///
156/// // New way (preferred):
157/// let cmd: Cmd<Msg> = commands::noop();
158/// ```
159#[deprecated(
160 since = "0.2.1",
161 note = "Use commands::noop() instead. The name 'none' was confusing."
162)]
163#[must_use]
164pub fn none<M: Message>() -> Cmd<M> {
165 noop()
166}
167
168/// Batch multiple commands to run concurrently
169///
170/// All commands in the batch will execute simultaneously. Use this when you need
171/// multiple independent operations to happen at the same time.
172///
173/// Note: For performance optimization:
174/// - Empty vectors return `Cmd::noop()`
175/// - Single-element vectors return the element directly
176/// - Use `batch_strict()` if you need guaranteed batch semantics
177///
178/// # Safety
179/// - Batches larger than 100 commands will trigger a debug warning
180/// - Batches larger than 1000 commands will be automatically chunked
181///
182/// # Panics
183/// Panics if the input vector has exactly one element but that element cannot be retrieved.
184/// This should never happen in practice as we check the length beforehand.
185///
186/// # Returns
187/// A command that runs all input commands concurrently.
188///
189/// # Example
190/// ```
191/// use hojicha_core::{Model, Cmd, Event, commands::{batch, tick, spawn}};
192/// use std::time::Duration;
193///
194/// #[derive(Debug, Clone)]
195/// enum AppMessage {
196/// ConfigLoaded(String),
197/// TimerExpired,
198/// DataFetched(String),
199/// }
200///
201/// struct MyApp {
202/// config: String,
203/// data: String,
204/// }
205///
206/// impl Model for MyApp {
207/// type Message = AppMessage;
208///
209/// fn init(&mut self) -> Cmd<Self::Message> {
210/// // Start multiple operations simultaneously
211/// batch(vec![
212/// // Load config file
213/// Cmd::new(|| {
214/// let config = std::fs::read_to_string("config.json")
215/// .unwrap_or_else(|_| "default".to_string());
216/// Some(AppMessage::ConfigLoaded(config))
217/// }),
218/// // Start a 5-second timer
219/// tick(Duration::from_secs(5), || AppMessage::TimerExpired),
220/// // Fetch data asynchronously
221/// spawn(async {
222/// tokio::time::sleep(Duration::from_secs(2)).await;
223/// Some(AppMessage::DataFetched("async data".to_string()))
224/// }),
225/// ])
226/// }
227///
228/// fn update(&mut self, event: Event<Self::Message>) -> Cmd<Self::Message> {
229/// match event {
230/// Event::User(AppMessage::ConfigLoaded(config)) => {
231/// self.config = config;
232/// Cmd::noop()
233/// }
234/// Event::User(AppMessage::TimerExpired) => {
235/// println!("Timer expired!");
236/// Cmd::noop()
237/// }
238/// Event::User(AppMessage::DataFetched(data)) => {
239/// self.data = data;
240/// Cmd::noop()
241/// }
242/// _ => Cmd::noop()
243/// }
244/// }
245///
246/// fn view(&self) -> String {
247/// format!("Config: {}, Data: {}", self.config, self.data)
248/// }
249/// }
250/// ```
251#[must_use]
252pub fn batch<M: Message>(cmds: Vec<Cmd<M>>) -> Cmd<M> {
253 match cmds.len() {
254 0 => Cmd::noop(),
255 1 => cmds.into_iter().next().unwrap(),
256 n if n > HARD_MAX_BATCH_SIZE => {
257 // Chunk very large batches to prevent memory issues
258 eprintln!("Warning: Batch of {n} commands exceeds hard limit of {HARD_MAX_BATCH_SIZE}. Chunking into smaller batches.");
259 batch_chunked(cmds, HARD_MAX_BATCH_SIZE)
260 }
261 n => {
262 #[cfg(debug_assertions)]
263 if n > DEFAULT_MAX_BATCH_SIZE {
264 eprintln!(
265 "Warning: Large batch of {n} commands (recommended max: {DEFAULT_MAX_BATCH_SIZE})"
266 );
267 }
268 let _ = n; // Suppress unused variable warning in release builds
269 Cmd::batch(cmds)
270 }
271 }
272}
273
274/// Sequence commands to run one after another
275///
276/// Note: For performance optimization:
277/// - Empty vectors return `Cmd::noop()`
278/// - Single-element vectors return the element directly
279/// - Use `sequence_strict()` if you need guaranteed sequence semantics
280///
281/// # Panics
282/// Panics if the input vector has exactly one element but that element cannot be retrieved.
283/// This should never happen in practice as we check the length beforehand.
284///
285/// # Example
286/// ```
287/// # use hojicha_core::{Cmd, commands::sequence};
288/// # enum Msg { Save, Notify }
289/// # fn save_to_disk() -> Cmd<Msg> { Cmd::noop() }
290/// # fn show_notification() -> Cmd<Msg> { Cmd::noop() }
291/// // Sequence commands to run one after another
292/// let cmd: Cmd<Msg> = sequence(vec![
293/// save_to_disk(),
294/// show_notification(),
295/// ]);
296/// ```
297#[must_use]
298pub fn sequence<M: Message>(cmds: Vec<Cmd<M>>) -> Cmd<M> {
299 match cmds.len() {
300 0 => Cmd::noop(),
301 1 => cmds.into_iter().next().unwrap(),
302 _ => Cmd::sequence(cmds),
303 }
304}
305
306/// Create a batch command with strict semantics
307///
308/// Unlike `batch()`, this always returns a batch command regardless of the
309/// number of elements. Use this when you need guaranteed batch behavior.
310///
311/// # Example
312/// ```no_run
313/// # use hojicha_core::{Cmd, commands::batch_strict};
314/// # enum Msg { Action }
315/// # fn maybe_cmd() -> Cmd<Msg> { Cmd::noop() }
316/// // Always returns a batch, even with 0 or 1 elements
317/// batch_strict(vec![maybe_cmd()])
318/// # ;
319/// ```
320#[must_use]
321pub fn batch_strict<M: Message>(cmds: Vec<Cmd<M>>) -> Cmd<M> {
322 Cmd::batch(cmds)
323}
324
325/// Create a batch command with a specific size limit
326///
327/// Batches larger than the limit will be automatically chunked.
328///
329/// # Example
330/// ```no_run
331/// # use hojicha_core::{Cmd, commands::batch_with_limit};
332/// # enum Msg { Action }
333/// # let large_vec_of_commands: Vec<Cmd<Msg>> = vec![];
334/// // Create batches with max 50 commands each
335/// batch_with_limit(large_vec_of_commands, 50)
336/// # ;
337/// ```
338#[must_use]
339pub fn batch_with_limit<M: Message>(cmds: Vec<Cmd<M>>, limit: usize) -> Cmd<M> {
340 if cmds.len() <= limit {
341 batch(cmds)
342 } else {
343 batch_chunked(cmds, limit)
344 }
345}
346
347/// Internal helper to chunk large batches
348#[doc(hidden)]
349pub(crate) fn batch_chunked<M: Message>(mut cmds: Vec<Cmd<M>>, chunk_size: usize) -> Cmd<M> {
350 let mut chunks = Vec::new();
351
352 while !cmds.is_empty() {
353 let chunk: Vec<Cmd<M>> = cmds.drain(..chunk_size.min(cmds.len())).collect();
354 chunks.push(Cmd::batch(chunk));
355 }
356
357 // Batch the batches - this creates a two-level batch
358 // This ensures all commands still run concurrently
359 Cmd::batch(chunks)
360}
361
362/// Create a sequence command with strict semantics
363///
364/// Unlike `sequence()`, this always returns a sequence command regardless of the
365/// number of elements. Use this when you need guaranteed sequence behavior.
366///
367/// # Example
368/// ```no_run
369/// # use hojicha_core::{Cmd, commands::sequence_strict};
370/// # enum Msg { Action }
371/// # fn maybe_cmd() -> Cmd<Msg> { Cmd::noop() }
372/// // Always returns a sequence, even with 0 or 1 elements
373/// sequence_strict(vec![maybe_cmd()])
374/// # ;
375/// ```
376#[must_use]
377pub fn sequence_strict<M: Message>(cmds: Vec<Cmd<M>>) -> Cmd<M> {
378 Cmd::sequence(cmds)
379}
380
381/// Create a command that sends a message after a delay
382///
383/// This command waits for the specified duration, then executes the callback
384/// and sends the resulting message to your update function.
385///
386/// # Parameters
387/// - `duration`: How long to wait before executing
388/// - `f`: Function that creates the message to send
389///
390/// # Returns
391/// A command that will send a message after the specified delay.
392///
393/// # Example
394/// ```
395/// use hojicha_core::{Model, Cmd, Event, commands::tick};
396/// use std::time::Duration;
397///
398/// #[derive(Debug, Clone)]
399/// enum AppMessage {
400/// StartTimeout,
401/// TimeoutExpired,
402/// CustomTimeout(String),
403/// }
404///
405/// struct TimeoutApp {
406/// status: String,
407/// }
408///
409/// impl Model for TimeoutApp {
410/// type Message = AppMessage;
411///
412/// fn update(&mut self, event: Event<Self::Message>) -> Cmd<Self::Message> {
413/// match event {
414/// Event::User(AppMessage::StartTimeout) => {
415/// self.status = "Timer started...".to_string();
416/// // Wait 3 seconds, then send TimeoutExpired message
417/// tick(Duration::from_secs(3), || AppMessage::TimeoutExpired)
418/// }
419/// Event::User(AppMessage::TimeoutExpired) => {
420/// self.status = "Timeout completed!".to_string();
421/// // Chain another timer with custom data
422/// tick(Duration::from_secs(1), || {
423/// AppMessage::CustomTimeout("Custom data".to_string())
424/// })
425/// }
426/// Event::User(AppMessage::CustomTimeout(data)) => {
427/// self.status = format!("Got custom timeout: {}", data);
428/// Cmd::noop()
429/// }
430/// _ => Cmd::noop()
431/// }
432/// }
433///
434/// fn view(&self) -> String {
435/// format!("Status: {}", self.status)
436/// }
437/// }
438/// ```
439pub fn tick<M, F>(duration: Duration, f: F) -> Cmd<M>
440where
441 M: Message,
442 F: FnOnce() -> M + Send + 'static,
443{
444 #[allow(deprecated)]
445 Cmd::tick(duration, f)
446}
447
448/// Create a command that ticks at regular intervals
449///
450/// This command creates a recurring timer that sends messages at regular intervals.
451/// Unlike `tick()` which only fires once, `every()` continues indefinitely.
452///
453/// Similar to Bubbletea's Every command, this aligns with system clock
454/// boundaries. For example, `every(Duration::from_secs(1))` will tick
455/// at the start of each second.
456///
457/// # Parameters
458/// - `duration`: Interval between ticks
459/// - `f`: Function that creates messages, receives the current timestamp
460///
461/// # Returns
462/// A command that sends messages at regular intervals until the program exits.
463///
464/// # Example
465/// ```
466/// use hojicha_core::{Model, Cmd, Event, commands::every};
467/// use std::time::{Duration, Instant};
468///
469/// #[derive(Debug, Clone)]
470/// enum ClockMessage {
471/// StartClock,
472/// Tick(Instant),
473/// StopClock,
474/// }
475///
476/// struct ClockApp {
477/// running: bool,
478/// last_tick: Option<Instant>,
479/// tick_count: u32,
480/// }
481///
482/// impl Model for ClockApp {
483/// type Message = ClockMessage;
484///
485/// fn init(&mut self) -> Cmd<Self::Message> {
486/// // Start ticking every second
487/// every(Duration::from_secs(1), |instant| ClockMessage::Tick(instant))
488/// }
489///
490/// fn update(&mut self, event: Event<Self::Message>) -> Cmd<Self::Message> {
491/// match event {
492/// Event::User(ClockMessage::StartClock) => {
493/// self.running = true;
494/// // Start a new ticker
495/// every(Duration::from_secs(1), |instant| ClockMessage::Tick(instant))
496/// }
497/// Event::User(ClockMessage::Tick(instant)) => {
498/// if self.running {
499/// self.last_tick = Some(instant);
500/// self.tick_count += 1;
501/// }
502/// Cmd::noop()
503/// }
504/// Event::User(ClockMessage::StopClock) => {
505/// self.running = false;
506/// Cmd::noop()
507/// }
508/// _ => Cmd::noop()
509/// }
510/// }
511///
512/// fn view(&self) -> String {
513/// let status = if self.running { "Running" } else { "Stopped" };
514/// format!(
515/// "Clock: {} | Ticks: {} | Last: {:?}",
516/// status, self.tick_count, self.last_tick
517/// )
518/// }
519/// }
520/// ```
521pub fn every<M, F>(duration: Duration, f: F) -> Cmd<M>
522where
523 M: Message,
524 F: FnOnce(std::time::Instant) -> M + Send + 'static,
525{
526 #[allow(deprecated)]
527 Cmd::every(duration, f)
528}
529
530/// Query the terminal for its current size
531///
532/// This command returns a `WindowSize` message with the current terminal dimensions.
533/// Note that resize events are automatically delivered when the terminal size changes,
534/// so you typically won't need to use this command directly.
535///
536/// # Example
537/// ```
538/// # use hojicha_core::{Cmd, commands::window_size, event::WindowSize};
539/// # enum Msg { GotSize(WindowSize) }
540/// // Query the terminal size
541/// let cmd: Cmd<Msg> = window_size(|size| Msg::GotSize(size));
542/// ```
543pub fn window_size<M, F>(f: F) -> Cmd<M>
544where
545 M: Message,
546 F: Fn(WindowSize) -> M + Send + Sync + 'static,
547{
548 Cmd::new(move || {
549 // Query the actual terminal size using crossterm
550 match crossterm::terminal::size() {
551 Ok((width, height)) => Some(f(WindowSize { width, height })),
552 Err(_) => {
553 // Fall back to reasonable defaults if we can't query the terminal
554 Some(f(WindowSize {
555 width: 80,
556 height: 24,
557 }))
558 }
559 }
560 })
561}
562
563/// Set the terminal window title
564///
565/// # Example
566/// ```
567/// # use hojicha_core::{Cmd, commands::set_window_title};
568/// # enum Msg {}
569/// // Set the terminal window title
570/// let cmd: Cmd<Msg> = set_window_title("My Awesome App");
571/// ```
572pub fn set_window_title<M: Message>(title: impl Into<String>) -> Cmd<M> {
573 let title = title.into();
574 Cmd::new(move || {
575 use crossterm::{execute, terminal::SetTitle};
576 let _ = execute!(std::io::stdout(), SetTitle(&title));
577 None
578 })
579}
580
581/// Send an interrupt signal (simulates Ctrl+C)
582///
583/// This is useful for graceful shutdown or interrupting long-running operations.
584///
585/// # Example
586/// ```
587/// # use hojicha_core::{Cmd, commands::interrupt};
588/// # enum Msg {}
589/// // Send an interrupt signal (simulates Ctrl+C)
590/// let cmd: Cmd<Msg> = interrupt();
591/// ```
592#[must_use]
593pub fn interrupt<M: Message>() -> Cmd<M> {
594 Cmd::new(|| {
595 #[cfg(unix)]
596 {
597 // Send SIGINT to current process
598 unsafe {
599 libc::kill(libc::getpid(), libc::SIGINT);
600 }
601 }
602 None
603 })
604}
605
606/// Macro to generate simple terminal control commands
607///
608/// This reduces code duplication for commands that just signal
609/// the runtime to perform a terminal operation.
610macro_rules! terminal_cmd {
611 ($(
612 $(#[$attr:meta])*
613 $vis:vis fn $name:ident() -> $doc:literal;
614 )+) => {
615 $(
616 $(#[$attr])*
617 #[doc = $doc]
618 #[doc = ""]
619 #[doc = "This command signals the runtime to perform the operation."]
620 $vis fn $name<M: Message>() -> Cmd<M> {
621 Cmd::new(|| None)
622 }
623 )+
624 };
625}
626
627// Generate all the simple terminal control commands
628terminal_cmd! {
629 /// Hide the terminal cursor
630 pub fn hide_cursor() -> "Hide the terminal cursor from view";
631
632 /// Show the terminal cursor
633 pub fn show_cursor() -> "Show the terminal cursor";
634
635 /// Enter alternate screen buffer
636 pub fn enter_alt_screen() -> "Enter the alternate screen buffer (like vim/less use)";
637
638 /// Exit alternate screen buffer
639 pub fn exit_alt_screen() -> "Exit the alternate screen buffer and return to main screen";
640}
641
642/// Create a custom command from an async function
643///
644/// This allows you to create commands that perform async operations like
645/// HTTP requests, database queries, or other I/O operations.
646///
647/// # Example
648/// ```
649/// # use hojicha_core::{Cmd, commands::custom_async};
650/// # enum Message { DataFetched(String) }
651/// // Create an async command
652/// let cmd: Cmd<Message> = custom_async(|| async {
653/// // Perform async operation
654/// let data = "example data".to_string();
655/// Some(Message::DataFetched(data))
656/// });
657/// ```
658pub fn custom_async<M, F, Fut>(f: F) -> Cmd<M>
659where
660 M: Message,
661 F: FnOnce() -> Fut + Send + 'static,
662 Fut: std::future::Future<Output = Option<M>> + Send + 'static,
663{
664 Cmd::async_cmd(f())
665}
666
667/// Spawn a simple async task
668///
669/// This command spawns an async task on the shared runtime managed by the program.
670/// Unlike `custom_async`, this uses the existing runtime rather than creating a new one.
671///
672/// # Example
673/// ```
674/// # use hojicha_core::{Cmd, commands::spawn};
675/// # use std::time::Duration;
676/// # enum Message { TimerComplete }
677/// // Spawn an async task
678/// let cmd: Cmd<Message> = spawn(async {
679/// tokio::time::sleep(Duration::from_secs(1)).await;
680/// Some(Message::TimerComplete)
681/// });
682/// ```
683pub fn spawn<M, Fut>(fut: Fut) -> Cmd<M>
684where
685 M: Message,
686 Fut: std::future::Future<Output = Option<M>> + Send + 'static,
687{
688 Cmd::async_cmd(fut)
689}
690
691/// Create a custom command from a blocking function
692///
693/// This is a convenience wrapper for creating simple custom commands.
694///
695/// # Example
696/// ```
697/// # use hojicha_core::{Cmd, commands::custom};
698/// # enum Message { ComputationComplete(i32) }
699/// # fn expensive_computation() -> i32 { 42 }
700/// // Create a custom command
701/// let cmd: Cmd<Message> = custom(|| {
702/// // Perform some custom logic
703/// let result = expensive_computation();
704/// Some(Message::ComputationComplete(result))
705/// });
706/// ```
707pub fn custom<M, F>(f: F) -> Cmd<M>
708where
709 M: Message,
710 F: FnOnce() -> Option<M> + Send + 'static,
711{
712 Cmd::new(f)
713}
714
715/// Create a custom fallible command
716///
717/// This allows you to create commands that can fail and handle errors gracefully.
718///
719/// # Example
720/// ```
721/// # use hojicha_core::{Cmd, commands::custom_fallible};
722/// # enum Message { ConfigLoaded(String) }
723/// // Create a fallible command
724/// let cmd: Cmd<Message> = custom_fallible(|| {
725/// // Perform operation that might fail
726/// let data = std::fs::read_to_string("config.json")?;
727/// Ok(Some(Message::ConfigLoaded(data)))
728/// });
729/// ```
730pub fn custom_fallible<M, F>(f: F) -> Cmd<M>
731where
732 M: Message,
733 F: FnOnce() -> crate::Result<Option<M>> + Send + 'static,
734{
735 Cmd::fallible(f)
736}
737
738/// Create a fallible command that converts errors to messages
739///
740/// This allows errors to be handled by the model's update method rather than
741/// just being logged.
742///
743/// # Example
744/// ```
745/// # use hojicha_core::{Cmd, commands::fallible_with_error};
746/// # enum Msg { DataLoaded(String), ErrorOccurred(String) }
747/// // Create a fallible command with error handling
748/// let cmd: Cmd<Msg> = fallible_with_error(
749/// || {
750/// let data = std::fs::read_to_string("data.json")?;
751/// Ok(Some(Msg::DataLoaded(data)))
752/// },
753/// |err| Msg::ErrorOccurred(err.to_string())
754/// );
755/// ```
756pub fn fallible_with_error<M, F, E>(f: F, error_handler: E) -> Cmd<M>
757where
758 M: Message,
759 F: FnOnce() -> crate::Result<Option<M>> + Send + 'static,
760 E: FnOnce(crate::error::Error) -> M + Send + 'static,
761{
762 Cmd::new(move || match f() {
763 Ok(msg) => msg,
764 Err(err) => Some(error_handler(err)),
765 })
766}
767
768/// Execute a command in a subprocess, releasing the terminal while it runs
769///
770/// This is useful for running interactive programs like editors or shells.
771/// The terminal will be restored after the command completes.
772///
773/// # Example
774/// ```
775/// # use hojicha_core::{Cmd, commands::exec};
776/// # enum Msg { EditorClosed(Option<i32>) }
777/// // Execute an external program
778/// let cmd: Cmd<Msg> = exec("vim", vec!["file.txt"], |exit_status| {
779/// Msg::EditorClosed(exit_status)
780/// });
781/// ```
782pub fn exec<M, F>(program: impl Into<String>, args: Vec<impl Into<String>>, callback: F) -> Cmd<M>
783where
784 M: Message,
785 F: Fn(Option<i32>) -> M + Send + 'static,
786{
787 let program = program.into();
788 let args: Vec<String> = args.into_iter().map(Into::into).collect();
789
790 Cmd::exec_process(program, args, callback)
791}
792
793/// Execute a shell command, releasing the terminal while it runs
794///
795/// # Example
796/// ```
797/// # use hojicha_core::{Cmd, commands::exec_command};
798/// # enum Msg { CommandFinished(Option<i32>) }
799/// // Execute a shell command
800/// let cmd: Cmd<Msg> = exec_command("ls -la", |exit_status| {
801/// Msg::CommandFinished(exit_status)
802/// });
803/// ```
804pub fn exec_command<M, F>(command: impl Into<String>, callback: F) -> Cmd<M>
805where
806 M: Message,
807 F: Fn(Option<i32>) -> M + Send + 'static,
808{
809 let command = command.into();
810
811 Cmd::new(move || {
812 let output = if cfg!(target_os = "windows") {
813 Command::new("cmd").args(["/C", &command]).status()
814 } else {
815 Command::new("sh").args(["-c", &command]).status()
816 };
817
818 let exit_code = output.ok().and_then(|status| status.code());
819 Some(callback(exit_code))
820 })
821}
822
823// Generate mouse and screen control commands
824terminal_cmd! {
825 /// Enable mouse cell motion tracking
826 ///
827 /// This enables mouse events only when a button is pressed.
828 pub fn enable_mouse_cell_motion() -> "Enable mouse tracking for cell motion (only when button pressed)";
829
830 /// Enable mouse all motion tracking
831 ///
832 /// This enables mouse movement events regardless of whether a button is pressed,
833 /// allowing for hover interactions.
834 pub fn enable_mouse_all_motion() -> "Enable mouse tracking for all motion events (including hover)";
835
836 /// Disable mouse tracking
837 pub fn disable_mouse() -> "Disable all mouse tracking";
838
839 /// Clear the entire screen
840 pub fn clear_screen() -> "Clear the entire screen";
841
842 /// Clear the current line
843 pub fn clear_line() -> "Clear the current line";
844
845 /// Suspend the program (Ctrl+Z)
846 ///
847 /// This will suspend the program and return control to the shell.
848 /// When the program is resumed, a Resume event will be sent.
849 pub fn suspend() -> "Suspend the program (Ctrl+Z)";
850}
851
852/// Quit the program gracefully
853///
854/// This command signals the program to exit cleanly. When this command is executed,
855/// the event loop will stop and the program will terminate. All cleanup operations
856/// (like restoring terminal state) will be performed automatically.
857///
858/// # Returns
859/// A command that terminates the program when executed.
860///
861/// # Example
862///
863/// ```
864/// use hojicha_core::{Model, Event, Cmd, Key, commands::quit};
865///
866/// #[derive(Debug, Clone)]
867/// enum AppMessage {
868/// RequestQuit,
869/// ConfirmQuit,
870/// }
871///
872/// struct MyApp {
873/// quit_requested: bool,
874/// }
875///
876/// impl Model for MyApp {
877/// type Message = AppMessage;
878///
879/// fn update(&mut self, event: Event<Self::Message>) -> Cmd<Self::Message> {
880/// match event {
881/// // Handle keyboard input
882/// Event::Key(key) => match key.key {
883/// Key::Char('q') | Key::Esc => {
884/// // Quit immediately on 'q' or Escape
885/// quit()
886/// }
887/// Key::Char('c') if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) => {
888/// // Handle Ctrl+C gracefully
889/// quit()
890/// }
891/// _ => Cmd::noop()
892/// },
893///
894/// // Handle application messages
895/// Event::User(AppMessage::RequestQuit) => {
896/// if self.quit_requested {
897/// // Second request - quit immediately
898/// quit()
899/// } else {
900/// // First request - ask for confirmation
901/// self.quit_requested = true;
902/// Cmd::noop()
903/// }
904/// }
905/// Event::User(AppMessage::ConfirmQuit) => {
906/// // Confirmed - quit the application
907/// quit()
908/// }
909///
910/// _ => Cmd::noop()
911/// }
912/// }
913///
914/// fn view(&self) -> String {
915/// if self.quit_requested {
916/// "Are you sure you want to quit? Press 'q' again to confirm.".to_string()
917/// } else {
918/// "Press 'q' to quit, Ctrl+C to exit immediately".to_string()
919/// }
920/// }
921/// }
922/// ```
923#[must_use]
924pub fn quit<M: Message>() -> Cmd<M> {
925 #[allow(deprecated)]
926 Cmd::quit()
927}
928
929/// Macro to generate crossterm commands that execute terminal sequences
930///
931/// This reduces duplication for commands that use crossterm to send
932/// control sequences to the terminal.
933macro_rules! crossterm_cmd {
934 ($(
935 $(#[$attr:meta])*
936 $vis:vis fn $name:ident($cmd_type:path) -> $doc:literal;
937 )+) => {
938 $(
939 $(#[$attr])*
940 #[doc = $doc]
941 #[doc = ""]
942 #[doc = "This command sends a control sequence to the terminal."]
943 $vis fn $name<M: Message>() -> Cmd<M> {
944 Cmd::new(|| {
945 use crossterm::execute;
946 use std::io;
947 let _ = execute!(io::stdout(), $cmd_type);
948 None
949 })
950 }
951 )+
952 };
953}
954
955// Generate crossterm-based commands
956crossterm_cmd! {
957 /// Enable bracketed paste mode
958 ///
959 /// When enabled, pasted text will be delivered as a single `Event::Paste(String)`
960 /// instead of individual key events. This prevents pasted text from
961 /// accidentally triggering keyboard shortcuts.
962 pub fn enable_bracketed_paste(crossterm::event::EnableBracketedPaste) -> "Enable bracketed paste mode";
963
964 /// Disable bracketed paste mode
965 pub fn disable_bracketed_paste(crossterm::event::DisableBracketedPaste) -> "Disable bracketed paste mode";
966
967 /// Enable focus change reporting
968 ///
969 /// When enabled, the program will receive `Event::Focus` when the terminal
970 /// gains focus and `Event::Blur` when it loses focus.
971 pub fn enable_focus_change(crossterm::event::EnableFocusChange) -> "Enable focus change reporting";
972
973 /// Disable focus change reporting
974 pub fn disable_focus_change(crossterm::event::DisableFocusChange) -> "Disable focus change reporting";
975}
976
977#[cfg(test)]
978mod tests {
979 use super::*;
980
981 #[derive(Debug, PartialEq)]
982 enum TestMsg {
983 One,
984 Two,
985 Three,
986 }
987
988 #[test]
989 fn test_batch_empty() {
990 let result: Cmd<TestMsg> = batch(vec![]);
991 assert!(!result.is_quit());
992 }
993
994 #[test]
995 fn test_batch_single() {
996 let cmd = Cmd::new(|| Some(TestMsg::One));
997 let result = batch(vec![cmd]);
998 assert!(!result.is_quit());
999 }
1000
1001 #[test]
1002 fn test_batch_multiple() {
1003 let cmds = vec![
1004 Cmd::new(|| Some(TestMsg::One)),
1005 Cmd::new(|| Some(TestMsg::Two)),
1006 Cmd::new(|| Some(TestMsg::Three)),
1007 ];
1008 let result = batch(cmds);
1009 assert!(!result.is_quit());
1010 }
1011
1012 #[test]
1013 fn test_sequence_executes_in_order() {
1014 let cmd = sequence(vec![Cmd::new(|| Some(TestMsg::One))]);
1015 let msg = cmd.execute().unwrap();
1016 assert_eq!(msg, Some(TestMsg::One));
1017 }
1018
1019 #[test]
1020 fn test_tick_command() {
1021 let cmd = tick(Duration::from_millis(10), || TestMsg::One);
1022 // Tick commands are now async and handled by the executor
1023 // They return None from execute() since they need async handling
1024 let msg = cmd.execute().unwrap();
1025 assert_eq!(msg, None);
1026 }
1027
1028 #[test]
1029 fn test_every_command() {
1030 let cmd: Cmd<TestMsg> = every(Duration::from_millis(1), |_| TestMsg::One);
1031 // Every commands are now async and handled by the executor
1032 // They return None from execute() since they need async handling
1033 let result = cmd.test_execute().unwrap();
1034 assert_eq!(result, None);
1035 }
1036
1037 #[test]
1038 fn test_window_size_command() {
1039 // Test that window_size returns a valid WindowSize
1040 #[derive(Debug, PartialEq)]
1041 enum SizeMsg {
1042 Size(WindowSize),
1043 }
1044
1045 let cmd: Cmd<SizeMsg> = window_size(SizeMsg::Size);
1046 let result = cmd.test_execute().unwrap();
1047
1048 // Verify we got a size message
1049 assert!(matches!(result, Some(SizeMsg::Size(_))));
1050
1051 // The actual dimensions will vary based on terminal, but should be positive
1052 if let Some(SizeMsg::Size(size)) = result {
1053 assert!(size.width > 0);
1054 assert!(size.height > 0);
1055 }
1056 }
1057
1058 #[test]
1059 fn test_cursor_commands() {
1060 let hide_cmd: Cmd<TestMsg> = hide_cursor();
1061 let show_cmd: Cmd<TestMsg> = show_cursor();
1062
1063 assert!(hide_cmd.test_execute().is_ok());
1064 assert!(show_cmd.test_execute().is_ok());
1065 }
1066
1067 #[test]
1068 fn test_alt_screen_commands() {
1069 let enter_cmd: Cmd<TestMsg> = enter_alt_screen();
1070 let exit_cmd: Cmd<TestMsg> = exit_alt_screen();
1071
1072 assert!(enter_cmd.test_execute().is_ok());
1073 assert!(exit_cmd.test_execute().is_ok());
1074 }
1075
1076 #[test]
1077 fn test_custom_command() {
1078 let cmd = custom::<TestMsg, _>(|| Some(TestMsg::One));
1079 let result = cmd.execute();
1080 assert!(result.is_ok());
1081 assert_eq!(result.unwrap(), Some(TestMsg::One));
1082 }
1083
1084 #[test]
1085 fn test_custom_fallible_success() {
1086 let cmd = custom_fallible::<TestMsg, _>(|| Ok(Some(TestMsg::Two)));
1087 let result = cmd.execute();
1088 assert!(result.is_ok());
1089 assert_eq!(result.unwrap(), Some(TestMsg::Two));
1090 }
1091
1092 #[test]
1093 fn test_custom_fallible_error() {
1094 use std::io;
1095 let cmd = custom_fallible::<TestMsg, _>(|| {
1096 Err(crate::error::Error::Io(io::Error::other("test error")))
1097 });
1098 let result = cmd.execute();
1099 assert!(result.is_err());
1100 }
1101
1102 #[test]
1103 fn test_custom_async_command() {
1104 let cmd = custom_async::<TestMsg, _, _>(|| async { Some(TestMsg::Three) });
1105 let result = cmd.execute();
1106 assert!(result.is_ok());
1107 // Now async commands return None since they use shared runtime
1108 assert_eq!(result.unwrap(), None);
1109 }
1110
1111 #[test]
1112 fn test_window_title_command() {
1113 let cmd: Cmd<TestMsg> = set_window_title("Test Title");
1114 assert!(cmd.test_execute().is_ok());
1115
1116 let cmd_empty: Cmd<TestMsg> = set_window_title("");
1117 assert!(cmd_empty.test_execute().is_ok());
1118 }
1119
1120 #[test]
1121 fn test_exec_command() {
1122 let cmd: Cmd<TestMsg> = exec("echo", vec!["hello"], |_| TestMsg::One);
1123 assert!(cmd.is_exec_process());
1124
1125 let process_info = cmd.take_exec_process();
1126 assert!(process_info.is_some());
1127 }
1128
1129 #[test]
1130 fn test_mouse_commands() {
1131 let cell_motion: Cmd<TestMsg> = enable_mouse_cell_motion();
1132 let all_motion: Cmd<TestMsg> = enable_mouse_all_motion();
1133 let disable: Cmd<TestMsg> = disable_mouse();
1134
1135 assert!(cell_motion.test_execute().is_ok());
1136 assert!(all_motion.test_execute().is_ok());
1137 assert!(disable.test_execute().is_ok());
1138 }
1139
1140 #[test]
1141 fn test_clear_commands() {
1142 let clear_screen: Cmd<TestMsg> = clear_screen();
1143 let clear_line: Cmd<TestMsg> = clear_line();
1144
1145 assert!(clear_screen.test_execute().is_ok());
1146 assert!(clear_line.test_execute().is_ok());
1147 }
1148
1149 #[test]
1150 fn test_suspend_command() {
1151 let cmd: Cmd<TestMsg> = suspend();
1152 assert!(cmd.test_execute().is_ok());
1153 }
1154
1155 #[test]
1156 fn test_crossterm_commands() {
1157 let enable_paste: Cmd<TestMsg> = enable_bracketed_paste();
1158 let disable_paste: Cmd<TestMsg> = disable_bracketed_paste();
1159 let enable_focus: Cmd<TestMsg> = enable_focus_change();
1160 let disable_focus: Cmd<TestMsg> = disable_focus_change();
1161
1162 assert!(enable_paste.test_execute().is_ok());
1163 assert!(disable_paste.test_execute().is_ok());
1164 assert!(enable_focus.test_execute().is_ok());
1165 assert!(disable_focus.test_execute().is_ok());
1166 }
1167
1168 #[test]
1169 fn test_batch_with_mixed_types() {
1170 let cmds = vec![
1171 Cmd::new(|| Some(TestMsg::One)),
1172 Cmd::new(|| Some(TestMsg::Two)),
1173 ];
1174
1175 let batch_cmd = batch(cmds);
1176 // Batch commands should be recognized as batch type
1177 assert!(batch_cmd.is_batch());
1178 }
1179
1180 #[test]
1181 fn test_sequence_execution_order() {
1182 let cmds = vec![
1183 Cmd::new(|| Some(TestMsg::One)),
1184 Cmd::new(|| Some(TestMsg::Two)),
1185 ];
1186
1187 let seq_cmd = sequence(cmds);
1188 // Sequence commands should be recognized as sequence type
1189 assert!(seq_cmd.is_sequence());
1190 }
1191
1192 // Property-based tests
1193 mod property_tests {
1194 use super::*;
1195 use proptest::prelude::*;
1196
1197 proptest! {
1198 #[test]
1199 fn prop_batch_command_properties(cmd_count in 0usize..50) {
1200 let cmds: Vec<Cmd<TestMsg>> = (0..cmd_count)
1201 .map(|_| Cmd::new(|| Some(TestMsg::One)))
1202 .collect();
1203
1204 let batch_cmd = batch(cmds);
1205
1206 // Batch commands should never be quit commands
1207 prop_assert!(!batch_cmd.is_quit());
1208
1209 // Empty batches should be recognized as no-ops
1210 if cmd_count == 0 {
1211 prop_assert!(batch_cmd.is_noop());
1212 } else if cmd_count >= 2 {
1213 // Only multi-command batches are considered "batch" type
1214 prop_assert!(batch_cmd.is_batch());
1215 }
1216 // Single commands may be optimized and not be recognized as batch
1217 }
1218 }
1219
1220 proptest! {
1221 #[test]
1222 fn prop_sequence_command_properties(cmd_count in 1usize..20) {
1223 let cmds: Vec<Cmd<TestMsg>> = (0..cmd_count)
1224 .map(|i| Cmd::new(move || match i % 3 {
1225 0 => Some(TestMsg::One),
1226 1 => Some(TestMsg::Two),
1227 _ => Some(TestMsg::Three),
1228 }))
1229 .collect();
1230
1231 let seq_cmd = sequence(cmds);
1232
1233 // Sequence commands should never be quit commands
1234 prop_assert!(!seq_cmd.is_quit());
1235
1236 // Only multi-command sequences are considered "sequence" type
1237 if cmd_count >= 2 {
1238 prop_assert!(seq_cmd.is_sequence());
1239 }
1240 // Single commands may be optimized and not be recognized as sequence
1241 }
1242 }
1243
1244 proptest! {
1245 #[test]
1246 fn prop_batch_with_limit_properties(
1247 cmd_count in 2usize..50, // Start at 2 to avoid single-command optimization
1248 limit in 1usize..20
1249 ) {
1250 let cmds: Vec<Cmd<TestMsg>> = (0..cmd_count)
1251 .map(|_| Cmd::new(|| Some(TestMsg::One)))
1252 .collect();
1253
1254 let batch_cmd = batch_with_limit(cmds, limit);
1255
1256 // Batch with limit should preserve basic properties
1257 prop_assert!(!batch_cmd.is_quit());
1258
1259 // Should be recognized as batch for multi-command inputs
1260 prop_assert!(batch_cmd.is_batch());
1261 }
1262 }
1263 }
1264
1265 // Additional behavioral tests
1266 mod behavioral_tests {
1267 use super::*;
1268
1269 /// Behavioral test: fallible commands with error handlers
1270 #[test]
1271 fn test_fallible_with_error_behavior() {
1272 use crate::error::Error;
1273
1274 let fallible_cmd = fallible_with_error(
1275 || Err(Error::Custom("test error".to_string().into())),
1276 |_| TestMsg::Three,
1277 );
1278
1279 assert!(!fallible_cmd.is_quit());
1280 assert!(!fallible_cmd.is_noop());
1281
1282 // Should execute and handle the error
1283 let result = fallible_cmd.execute().unwrap();
1284 assert_eq!(result, Some(TestMsg::Three));
1285 }
1286 }
1287}