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