Skip to main content

mach/
lib.rs

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