1pub mod app;
4mod archive;
5pub mod banner;
6pub mod cli;
7pub mod description;
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
46pub 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 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 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
88struct 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 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 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 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 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}