1pub mod app;
4mod archive;
5pub mod banner;
6pub mod body;
7pub mod cli;
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 UPDATE_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.poll_automatic_update_schedule();
82 event_loop(&mut terminal, &mut app)
83}
84
85struct TerminalSession {
88 enhanced_keyboard: bool,
89}
90
91impl TerminalSession {
92 fn enter() -> io::Result<(DefaultTerminal, Self)> {
93 let terminal = match ratatui::try_init() {
94 Ok(terminal) => terminal,
95 Err(error) => {
96 let _ = ratatui::try_restore();
99 return Err(error);
100 }
101 };
102 let mut session = Self {
103 enhanced_keyboard: false,
104 };
105 let mut out = io::stdout();
106 execute!(out, EnableMouseCapture, EnableBracketedPaste)?;
107 session.enhanced_keyboard = execute!(
111 out,
112 PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
113 )
114 .is_ok();
115 Ok((terminal, session))
116 }
117}
118
119impl Drop for TerminalSession {
120 fn drop(&mut self) {
121 let mut out = io::stdout();
122 if self.enhanced_keyboard {
123 let _ = execute!(out, PopKeyboardEnhancementFlags);
124 }
125 let _ = execute!(out, DisableBracketedPaste, DisableMouseCapture);
126 let _ = ratatui::try_restore();
127 }
128}
129
130fn event_loop(terminal: &mut DefaultTerminal, app: &mut App) -> io::Result<()> {
131 const MAX_EVENTS_PER_TICK: usize = 64;
132
133 let mut last_clock = String::new();
134 let mut next_housekeeping = Instant::now();
135 loop {
136 for _ in 0..MAX_EVENTS_PER_TICK {
140 if !event::poll(Duration::ZERO)? {
141 break;
142 }
143 if input::handle_event(app, event::read()?) {
144 app.mark_dirty();
145 }
146 if app.should_quit {
147 return Ok(());
148 }
149 }
150 let _ = app.expire_message();
151 if app.poll_update() {
152 app.mark_dirty();
153 }
154 let now = Instant::now();
155 if housekeeping_due(&mut next_housekeeping, now) {
156 if app.poll_automatic_update_schedule() {
157 app.mark_dirty();
158 }
159 if app.poll_external_changes() {
160 app.mark_dirty();
161 }
162 if app.images.recheck_cell_size() {
164 app.mark_dirty();
165 }
166 let clock = crate::due::now_string(&app.settings.date_format);
167 if clock != last_clock {
168 last_clock = clock;
169 app.mark_dirty();
170 }
171 }
172 if app.images.poll_pending() {
173 app.mark_dirty();
174 }
175
176 let gif_advanced = app.form.as_mut().is_some_and(|f| f.tick_gif());
177 if gif_advanced {
178 app.mark_dirty();
179 }
180 let need_fast = app.form.as_ref().is_some_and(|f| f.gif_playing());
181
182 if app.dirty {
183 terminal.draw(|frame| ui::draw(frame, app))?;
184 app.dirty = false;
185 }
186
187 let until_housekeeping = next_housekeeping.saturating_duration_since(Instant::now());
188 let wait = loop_wait(
189 need_fast,
190 app.images.has_pending(),
191 app.update_work_active(),
192 until_housekeeping,
193 );
194 let _ = event::poll(wait)?;
195 }
196}
197
198fn housekeeping_due(next: &mut Instant, now: Instant) -> bool {
199 if now < *next {
200 return false;
201 }
202 *next = now + HOUSEKEEPING_INTERVAL;
203 true
204}
205
206fn loop_wait(
207 need_fast: bool,
208 images_pending: bool,
209 update_active: bool,
210 until_housekeeping: Duration,
211) -> Duration {
212 let activity_wait = if need_fast {
213 GIF_WAIT
214 } else if images_pending {
215 IMAGE_WAIT
216 } else if update_active {
217 UPDATE_WAIT
218 } else {
219 HOUSEKEEPING_INTERVAL
220 };
221 activity_wait.min(until_housekeeping)
222}
223
224#[cfg(test)]
225mod tests {
226 use std::time::{Duration, Instant};
227
228 use super::{HOUSEKEEPING_INTERVAL, UPDATE_WAIT, housekeeping_due, loop_wait};
229
230 #[test]
231 fn housekeeping_runs_immediately_then_on_its_interval() {
232 let start = Instant::now();
233 let mut next = start;
234
235 assert!(housekeeping_due(&mut next, start));
236 assert_eq!(next.duration_since(start), HOUSEKEEPING_INTERVAL);
237 assert!(!housekeeping_due(
238 &mut next,
239 start + HOUSEKEEPING_INTERVAL - Duration::from_millis(1),
240 ));
241 assert!(housekeeping_due(&mut next, start + HOUSEKEEPING_INTERVAL,));
242 }
243
244 #[test]
245 fn housekeeping_deadline_caps_animation_and_idle_waits() {
246 assert_eq!(
247 loop_wait(true, false, false, Duration::from_millis(10)),
248 Duration::from_millis(10),
249 );
250 assert_eq!(
251 loop_wait(true, false, false, Duration::from_millis(200)),
252 Duration::from_millis(30),
253 );
254 assert_eq!(
255 loop_wait(false, false, false, Duration::from_millis(200)),
256 Duration::from_millis(200),
257 );
258 assert_eq!(
259 loop_wait(false, false, true, Duration::from_millis(500)),
260 UPDATE_WAIT,
261 );
262 }
263}