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 UPDATE_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.poll_automatic_update_schedule();
82    event_loop(&mut terminal, &mut app)
83}
84
85/// Owns every terminal mode enabled by mach. Keeping cleanup in `Drop` makes
86/// normal errors and unwinding follow the same restoration path.
87struct TerminalSession {
88    enhanced_keyboard: bool,
89}
90
91impl TerminalSession {
92    fn enter() -> io::Result<(DefaultTerminal, Self)> {
93        let terminal = match ratatui::try_init() {
94            Ok(terminal) => terminal,
95            Err(error) => {
96                // `try_init` can fail after raw mode or the alternate screen
97                // was enabled, so unwind any partial initialization too.
98                let _ = ratatui::try_restore();
99                return Err(error);
100            }
101        };
102        let mut session = Self {
103            enhanced_keyboard: false,
104        };
105        let mut out = io::stdout();
106        execute!(out, EnableMouseCapture, EnableBracketedPaste)?;
107        // Disambiguate Ctrl/Alt+arrows. Do not enable
108        // REPORT_ALL_KEYS_AS_ESCAPE_CODES (it breaks plain `/` in many
109        // terminals).
110        session.enhanced_keyboard = execute!(
111            out,
112            PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
113        )
114        .is_ok();
115        Ok((terminal, session))
116    }
117}
118
119impl Drop for TerminalSession {
120    fn drop(&mut self) {
121        let mut out = io::stdout();
122        if self.enhanced_keyboard {
123            let _ = execute!(out, PopKeyboardEnhancementFlags);
124        }
125        let _ = execute!(out, DisableBracketedPaste, DisableMouseCapture);
126        let _ = ratatui::try_restore();
127    }
128}
129
130fn event_loop(terminal: &mut DefaultTerminal, app: &mut App) -> io::Result<()> {
131    const MAX_EVENTS_PER_TICK: usize = 64;
132
133    let mut last_clock = String::new();
134    let mut next_housekeeping = Instant::now();
135    loop {
136        // Input first so keys are not blocked behind GIF encode on draw.
137        // Bound each batch so a continuous mouse/key stream cannot starve
138        // drawing, persistence polling, image work, or update completion.
139        for _ in 0..MAX_EVENTS_PER_TICK {
140            if !event::poll(Duration::ZERO)? {
141                break;
142            }
143            if input::handle_event(app, event::read()?) {
144                app.mark_dirty();
145            }
146            if app.should_quit {
147                return Ok(());
148            }
149        }
150        let _ = app.expire_message();
151        if app.poll_update() {
152            app.mark_dirty();
153        }
154        let now = Instant::now();
155        if housekeeping_due(&mut next_housekeeping, now) {
156            if app.poll_automatic_update_schedule() {
157                app.mark_dirty();
158            }
159            if app.poll_external_changes() {
160                app.mark_dirty();
161            }
162            // Cell pixel size can change without a resize event (e.g. move display).
163            if app.images.recheck_cell_size() {
164                app.mark_dirty();
165            }
166            let clock = crate::due::now_string(&app.settings.date_format);
167            if clock != last_clock {
168                last_clock = clock;
169                app.mark_dirty();
170            }
171        }
172        if app.images.poll_pending() {
173            app.mark_dirty();
174        }
175
176        let gif_advanced = app.form.as_mut().is_some_and(|f| f.tick_gif());
177        if gif_advanced {
178            app.mark_dirty();
179        }
180        let need_fast = app.form.as_ref().is_some_and(|f| f.gif_playing());
181
182        if app.dirty {
183            terminal.draw(|frame| ui::draw(frame, app))?;
184            app.dirty = false;
185        }
186
187        let until_housekeeping = next_housekeeping.saturating_duration_since(Instant::now());
188        let wait = loop_wait(
189            need_fast,
190            app.images.has_pending(),
191            app.update_work_active(),
192            until_housekeeping,
193        );
194        let _ = event::poll(wait)?;
195    }
196}
197
198fn housekeeping_due(next: &mut Instant, now: Instant) -> bool {
199    if now < *next {
200        return false;
201    }
202    *next = now + HOUSEKEEPING_INTERVAL;
203    true
204}
205
206fn loop_wait(
207    need_fast: bool,
208    images_pending: bool,
209    update_active: bool,
210    until_housekeeping: Duration,
211) -> Duration {
212    let activity_wait = if need_fast {
213        GIF_WAIT
214    } else if images_pending {
215        IMAGE_WAIT
216    } else if update_active {
217        UPDATE_WAIT
218    } else {
219        HOUSEKEEPING_INTERVAL
220    };
221    activity_wait.min(until_housekeeping)
222}
223
224#[cfg(test)]
225mod tests {
226    use std::time::{Duration, Instant};
227
228    use super::{HOUSEKEEPING_INTERVAL, UPDATE_WAIT, housekeeping_due, loop_wait};
229
230    #[test]
231    fn housekeeping_runs_immediately_then_on_its_interval() {
232        let start = Instant::now();
233        let mut next = start;
234
235        assert!(housekeeping_due(&mut next, start));
236        assert_eq!(next.duration_since(start), HOUSEKEEPING_INTERVAL);
237        assert!(!housekeeping_due(
238            &mut next,
239            start + HOUSEKEEPING_INTERVAL - Duration::from_millis(1),
240        ));
241        assert!(housekeeping_due(&mut next, start + HOUSEKEEPING_INTERVAL,));
242    }
243
244    #[test]
245    fn housekeeping_deadline_caps_animation_and_idle_waits() {
246        assert_eq!(
247            loop_wait(true, false, false, Duration::from_millis(10)),
248            Duration::from_millis(10),
249        );
250        assert_eq!(
251            loop_wait(true, false, false, Duration::from_millis(200)),
252            Duration::from_millis(30),
253        );
254        assert_eq!(
255            loop_wait(false, false, false, Duration::from_millis(200)),
256            Duration::from_millis(200),
257        );
258        assert_eq!(
259            loop_wait(false, false, true, Duration::from_millis(500)),
260            UPDATE_WAIT,
261        );
262    }
263}