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