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);
42const UPDATE_WAIT: Duration = Duration::from_millis(100);
43
44pub fn run() {
46 cli::run();
47}
48
49pub(crate) fn require_interactive_terminal() -> io::Result<()> {
50 if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
51 return Err(io::Error::other(
52 "an interactive terminal is required on stdin and stdout; use a CLI subcommand for scripts",
53 ));
54 }
55 Ok(())
56}
57
58pub fn run_tui(store: Store) -> io::Result<()> {
59 require_interactive_terminal()?;
60
61 let images_root = store.images_dir().to_path_buf();
64 let mut app = App::with_store(VERSION, store).map_err(io::Error::other)?;
65
66 let mut images = image::ImageStore::detect();
69 images.set_root(images_root);
70 images.set_attachments(&app.attachments);
71 app.images = images;
72
73 let (mut terminal, _session) = TerminalSession::enter()?;
74 app.start_automatic_update_check();
75 event_loop(&mut terminal, &mut app)
76}
77
78struct TerminalSession {
81 enhanced_keyboard: bool,
82}
83
84impl TerminalSession {
85 fn enter() -> io::Result<(DefaultTerminal, Self)> {
86 let terminal = match ratatui::try_init() {
87 Ok(terminal) => terminal,
88 Err(error) => {
89 let _ = ratatui::try_restore();
92 return Err(error);
93 }
94 };
95 let mut session = Self {
96 enhanced_keyboard: false,
97 };
98 let mut out = io::stdout();
99 execute!(out, EnableMouseCapture, EnableBracketedPaste)?;
100 session.enhanced_keyboard = execute!(
104 out,
105 PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
106 )
107 .is_ok();
108 Ok((terminal, session))
109 }
110}
111
112impl Drop for TerminalSession {
113 fn drop(&mut self) {
114 let mut out = io::stdout();
115 if self.enhanced_keyboard {
116 let _ = execute!(out, PopKeyboardEnhancementFlags);
117 }
118 let _ = execute!(out, DisableBracketedPaste, DisableMouseCapture);
119 let _ = ratatui::try_restore();
120 }
121}
122
123fn event_loop(terminal: &mut DefaultTerminal, app: &mut App) -> io::Result<()> {
124 const MAX_EVENTS_PER_TICK: usize = 64;
125
126 let mut last_clock = String::new();
127 let mut next_housekeeping = Instant::now();
128 loop {
129 for _ in 0..MAX_EVENTS_PER_TICK {
133 if !event::poll(Duration::ZERO)? {
134 break;
135 }
136 if input::handle_event(app, event::read()?) {
137 app.mark_dirty();
138 }
139 if app.should_quit {
140 return Ok(());
141 }
142 }
143 let _ = app.expire_message();
144 if app.poll_update() {
145 app.mark_dirty();
146 }
147 let now = Instant::now();
148 if housekeeping_due(&mut next_housekeeping, now) {
149 if app.poll_external_changes() {
150 app.mark_dirty();
151 }
152 if app.images.recheck_cell_size() {
154 app.mark_dirty();
155 }
156 let clock = crate::due::now_string(&app.settings.date_format);
157 if clock != last_clock {
158 last_clock = clock;
159 app.mark_dirty();
160 }
161 }
162 if app.images.poll_pending() {
163 app.mark_dirty();
164 }
165
166 let gif_advanced = app.form.as_mut().is_some_and(|f| f.tick_gif());
167 if gif_advanced {
168 app.mark_dirty();
169 }
170 let need_fast = app.form.as_ref().is_some_and(|f| f.gif_playing());
171
172 if app.dirty {
173 terminal.draw(|frame| ui::draw(frame, app))?;
174 app.dirty = false;
175 }
176
177 let until_housekeeping = next_housekeeping.saturating_duration_since(Instant::now());
178 let wait = loop_wait(
179 need_fast,
180 app.images.has_pending(),
181 app.update_work_active(),
182 until_housekeeping,
183 );
184 let _ = event::poll(wait)?;
185 }
186}
187
188fn housekeeping_due(next: &mut Instant, now: Instant) -> bool {
189 if now < *next {
190 return false;
191 }
192 *next = now + HOUSEKEEPING_INTERVAL;
193 true
194}
195
196fn loop_wait(
197 need_fast: bool,
198 images_pending: bool,
199 update_active: bool,
200 until_housekeeping: Duration,
201) -> Duration {
202 let activity_wait = if need_fast {
203 GIF_WAIT
204 } else if images_pending {
205 IMAGE_WAIT
206 } else if update_active {
207 UPDATE_WAIT
208 } else {
209 HOUSEKEEPING_INTERVAL
210 };
211 activity_wait.min(until_housekeeping)
212}
213
214#[cfg(test)]
215mod tests {
216 use std::time::{Duration, Instant};
217
218 use super::{HOUSEKEEPING_INTERVAL, UPDATE_WAIT, housekeeping_due, loop_wait};
219
220 #[test]
221 fn housekeeping_runs_immediately_then_on_its_interval() {
222 let start = Instant::now();
223 let mut next = start;
224
225 assert!(housekeeping_due(&mut next, start));
226 assert_eq!(next.duration_since(start), HOUSEKEEPING_INTERVAL);
227 assert!(!housekeeping_due(
228 &mut next,
229 start + HOUSEKEEPING_INTERVAL - Duration::from_millis(1),
230 ));
231 assert!(housekeeping_due(&mut next, start + HOUSEKEEPING_INTERVAL,));
232 }
233
234 #[test]
235 fn housekeeping_deadline_caps_animation_and_idle_waits() {
236 assert_eq!(
237 loop_wait(true, false, false, Duration::from_millis(10)),
238 Duration::from_millis(10),
239 );
240 assert_eq!(
241 loop_wait(true, false, false, Duration::from_millis(200)),
242 Duration::from_millis(30),
243 );
244 assert_eq!(
245 loop_wait(false, false, false, Duration::from_millis(200)),
246 Duration::from_millis(200),
247 );
248 assert_eq!(
249 loop_wait(false, false, true, Duration::from_millis(500)),
250 UPDATE_WAIT,
251 );
252 }
253}