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