1pub 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
43pub 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 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 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
76struct 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 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 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 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 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}