Skip to main content

mach/
lib.rs

1//! mach — a powerful yet easy-to-use todo TUI, built with ratatui.
2
3pub mod app;
4pub mod banner;
5pub mod body;
6pub mod cli;
7pub mod due;
8pub mod duepicker;
9pub mod form;
10pub mod fuzzy;
11pub mod image;
12pub mod input;
13pub mod model;
14pub mod open;
15pub mod settings;
16pub mod slash;
17pub mod store;
18pub mod text_input;
19pub mod theme;
20pub mod ui;
21pub mod undo;
22pub mod update;
23mod update_state;
24
25use std::io::{self, IsTerminal};
26use std::time::{Duration, Instant};
27
28use ratatui::DefaultTerminal;
29use ratatui::crossterm::event::{
30    self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
31    KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
32};
33use ratatui::crossterm::execute;
34
35use crate::app::App;
36use crate::store::Store;
37
38pub const VERSION: &str = env!("CARGO_PKG_VERSION");
39
40const HOUSEKEEPING_INTERVAL: Duration = Duration::from_millis(500);
41const GIF_WAIT: Duration = Duration::from_millis(30);
42const IMAGE_WAIT: Duration = Duration::from_millis(16);
43const UPDATE_WAIT: Duration = Duration::from_millis(100);
44
45/// Entry point for the `mach` binary.
46pub fn run() {
47    cli::run();
48}
49
50pub(crate) fn require_interactive_terminal() -> io::Result<()> {
51    if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
52        return Err(io::Error::other(
53            "an interactive terminal is required on stdin and stdout; use a CLI subcommand for scripts",
54        ));
55    }
56    Ok(())
57}
58
59pub fn run_tui(store: Store) -> io::Result<()> {
60    require_interactive_terminal()?;
61
62    // Load and validate persistent state before changing terminal modes. A bad
63    // data file must never strand the user's shell in the alternate screen.
64    let images_root = store.images_dir().to_path_buf();
65    let mut app = App::with_store_and_update_state(
66        VERSION,
67        store,
68        update_state::UpdateStateStore::open_default(),
69    )
70    .map_err(io::Error::other)?;
71
72    // Probe graphics support before the event loop takes stdin.
73    // (ratatui-image prefers the alternate screen; answers are the same either way.)
74    let mut images = image::ImageStore::detect();
75    images.set_root(images_root);
76    images.set_attachments(&app.attachments);
77    app.images = images;
78
79    let (mut terminal, _session) = TerminalSession::enter()?;
80    app.poll_automatic_update_schedule();
81    event_loop(&mut terminal, &mut app)
82}
83
84/// Owns every terminal mode enabled by mach. Keeping cleanup in `Drop` makes
85/// normal errors and unwinding follow the same restoration path.
86struct TerminalSession {
87    enhanced_keyboard: bool,
88}
89
90impl TerminalSession {
91    fn enter() -> io::Result<(DefaultTerminal, Self)> {
92        let terminal = match ratatui::try_init() {
93            Ok(terminal) => terminal,
94            Err(error) => {
95                // `try_init` can fail after raw mode or the alternate screen
96                // was enabled, so unwind any partial initialization too.
97                let _ = ratatui::try_restore();
98                return Err(error);
99            }
100        };
101        let mut session = Self {
102            enhanced_keyboard: false,
103        };
104        let mut out = io::stdout();
105        execute!(out, EnableMouseCapture, EnableBracketedPaste)?;
106        // Disambiguate Ctrl/Alt+arrows. Do not enable
107        // REPORT_ALL_KEYS_AS_ESCAPE_CODES (it breaks plain `/` in many
108        // terminals).
109        session.enhanced_keyboard = execute!(
110            out,
111            PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
112        )
113        .is_ok();
114        Ok((terminal, session))
115    }
116}
117
118impl Drop for TerminalSession {
119    fn drop(&mut self) {
120        let mut out = io::stdout();
121        if self.enhanced_keyboard {
122            let _ = execute!(out, PopKeyboardEnhancementFlags);
123        }
124        let _ = execute!(out, DisableBracketedPaste, DisableMouseCapture);
125        let _ = ratatui::try_restore();
126    }
127}
128
129fn event_loop(terminal: &mut DefaultTerminal, app: &mut App) -> io::Result<()> {
130    const MAX_EVENTS_PER_TICK: usize = 64;
131
132    let mut last_clock = String::new();
133    let mut next_housekeeping = Instant::now();
134    loop {
135        // Input first so keys are not blocked behind GIF encode on draw.
136        // Bound each batch so a continuous mouse/key stream cannot starve
137        // drawing, persistence polling, image work, or update completion.
138        for _ in 0..MAX_EVENTS_PER_TICK {
139            if !event::poll(Duration::ZERO)? {
140                break;
141            }
142            if input::handle_event(app, event::read()?) {
143                app.mark_dirty();
144            }
145            if app.should_quit {
146                return Ok(());
147            }
148        }
149        let _ = app.expire_message();
150        if app.poll_update() {
151            app.mark_dirty();
152        }
153        let now = Instant::now();
154        if housekeeping_due(&mut next_housekeeping, now) {
155            if app.poll_automatic_update_schedule() {
156                app.mark_dirty();
157            }
158            if app.poll_external_changes() {
159                app.mark_dirty();
160            }
161            // Cell pixel size can change without a resize event (e.g. move display).
162            if app.images.recheck_cell_size() {
163                app.mark_dirty();
164            }
165            let clock = crate::due::now_string(&app.settings.date_format);
166            if clock != last_clock {
167                last_clock = clock;
168                app.mark_dirty();
169            }
170        }
171        if app.images.poll_pending() {
172            app.mark_dirty();
173        }
174
175        let gif_advanced = app.form.as_mut().is_some_and(|f| f.tick_gif());
176        if gif_advanced {
177            app.mark_dirty();
178        }
179        let need_fast = app.form.as_ref().is_some_and(|f| f.gif_playing());
180
181        if app.dirty {
182            terminal.draw(|frame| ui::draw(frame, app))?;
183            app.dirty = false;
184        }
185
186        let until_housekeeping = next_housekeeping.saturating_duration_since(Instant::now());
187        let wait = loop_wait(
188            need_fast,
189            app.images.has_pending(),
190            app.update_work_active(),
191            until_housekeeping,
192        );
193        let _ = event::poll(wait)?;
194    }
195}
196
197fn housekeeping_due(next: &mut Instant, now: Instant) -> bool {
198    if now < *next {
199        return false;
200    }
201    *next = now + HOUSEKEEPING_INTERVAL;
202    true
203}
204
205fn loop_wait(
206    need_fast: bool,
207    images_pending: bool,
208    update_active: bool,
209    until_housekeeping: Duration,
210) -> Duration {
211    let activity_wait = if need_fast {
212        GIF_WAIT
213    } else if images_pending {
214        IMAGE_WAIT
215    } else if update_active {
216        UPDATE_WAIT
217    } else {
218        HOUSEKEEPING_INTERVAL
219    };
220    activity_wait.min(until_housekeeping)
221}
222
223#[cfg(test)]
224mod tests {
225    use std::time::{Duration, Instant};
226
227    use super::{HOUSEKEEPING_INTERVAL, UPDATE_WAIT, housekeeping_due, loop_wait};
228
229    #[test]
230    fn housekeeping_runs_immediately_then_on_its_interval() {
231        let start = Instant::now();
232        let mut next = start;
233
234        assert!(housekeeping_due(&mut next, start));
235        assert_eq!(next.duration_since(start), HOUSEKEEPING_INTERVAL);
236        assert!(!housekeeping_due(
237            &mut next,
238            start + HOUSEKEEPING_INTERVAL - Duration::from_millis(1),
239        ));
240        assert!(housekeeping_due(&mut next, start + HOUSEKEEPING_INTERVAL,));
241    }
242
243    #[test]
244    fn housekeeping_deadline_caps_animation_and_idle_waits() {
245        assert_eq!(
246            loop_wait(true, false, false, Duration::from_millis(10)),
247            Duration::from_millis(10),
248        );
249        assert_eq!(
250            loop_wait(true, false, false, Duration::from_millis(200)),
251            Duration::from_millis(30),
252        );
253        assert_eq!(
254            loop_wait(false, false, false, Duration::from_millis(200)),
255            Duration::from_millis(200),
256        );
257        assert_eq!(
258            loop_wait(false, false, true, Duration::from_millis(500)),
259            UPDATE_WAIT,
260        );
261    }
262}