Terminal Markdown previewer — GUI-like experience.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #26 from RivoLink/chore/improve-watch-mode

chore: improve watch mode

authored by

Rivo Link and committed by
GitHub
d557e2f1 6e275cbb

+241 -62
+87 -3
src/app/mod.rs
··· 14 14 }; 15 15 use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; 16 16 17 + pub(crate) const FLASH_DURATION_MS: u64 = 1500; 18 + 17 19 pub(super) mod search; 18 20 pub(crate) use search::SearchState; 19 21 ··· 27 29 Opened(String), 28 30 NoFile, 29 31 EditorNotFound(String), 32 + } 33 + 34 + #[derive(Clone, Debug, PartialEq, Eq)] 35 + pub(crate) enum WatchFlash { 36 + Activated, 37 + Deactivated, 38 + Stdin, 39 + NoFile, 40 + FileNotFound, 41 + NotActive, 30 42 } 31 43 32 44 pub(super) mod theme_picker; ··· 67 79 editor_flash_active: bool, 68 80 file_picker_open: bool, 69 81 picker_loading: bool, 82 + watch_flash_active: bool, 83 + watch_error: bool, 70 84 } 71 85 72 86 pub(crate) struct AppConfig { ··· 89 103 pub(super) filename: String, 90 104 pub(super) source: String, 91 105 watch: bool, 106 + watch_error: bool, 92 107 pub(super) filepath: Option<PathBuf>, 93 108 pub(super) last_file_state: Option<FileState>, 94 109 pub(super) last_content_hash: u64, ··· 109 124 pub(super) render_width: usize, 110 125 pub(super) editor_config: Option<String>, 111 126 pub(super) editor_flash: Option<(EditorFlash, Instant)>, 127 + watch_flash: Option<(WatchFlash, Instant)>, 112 128 } 113 129 114 130 impl App { ··· 182 198 filename, 183 199 source, 184 200 watch, 201 + watch_error: false, 185 202 filepath, 186 203 last_file_state, 187 204 last_content_hash: 0, ··· 221 238 render_width: 80, 222 239 editor_config: None, 223 240 editor_flash: None, 241 + watch_flash: None, 224 242 }; 225 243 app.store_current_theme_preview(); 226 244 app.refresh_static_caches(); ··· 235 253 self.watch 236 254 } 237 255 256 + pub(crate) fn is_watch_error(&self) -> bool { 257 + self.watch_error 258 + } 259 + 260 + pub(crate) fn set_watch_error(&mut self, error: bool) { 261 + self.watch_error = error; 262 + } 263 + 238 264 pub(crate) fn debug_input_enabled(&self) -> bool { 239 265 self.debug_input 240 266 } ··· 380 406 watch: self.watch, 381 407 flash_active: self 382 408 .reload_flash 383 - .map(|t| t.elapsed() < Duration::from_millis(1500)) 409 + .map(|t| t.elapsed() < Duration::from_millis(FLASH_DURATION_MS)) 384 410 .unwrap_or(false), 385 411 editor_flash_active: self 386 412 .editor_flash 387 413 .as_ref() 388 - .map(|(_, t)| t.elapsed() < Duration::from_millis(2000)) 414 + .map(|(_, t)| t.elapsed() < Duration::from_millis(FLASH_DURATION_MS)) 389 415 .unwrap_or(false), 390 416 file_picker_open: self.is_file_picker_open(), 391 417 picker_loading: self.is_picker_loading(), 418 + watch_flash_active: self 419 + .watch_flash 420 + .as_ref() 421 + .map(|(_, t)| t.elapsed() < Duration::from_millis(FLASH_DURATION_MS)) 422 + .unwrap_or(false), 423 + watch_error: self.watch_error, 392 424 }; 393 425 394 426 if self.status_cache_key.as_ref() == Some(&cache_key) { ··· 446 478 self.editor_flash = None; 447 479 } 448 480 481 + pub(crate) fn toggle_watch(&mut self) { 482 + let p = match &self.filepath { 483 + None => { 484 + self.set_watch_flash(if self.filename == "stdin" { 485 + WatchFlash::Stdin 486 + } else { 487 + WatchFlash::NoFile 488 + }); 489 + return; 490 + } 491 + Some(p) => p, 492 + }; 493 + if !p.exists() { 494 + self.set_watch_flash(WatchFlash::FileNotFound); 495 + return; 496 + } 497 + self.watch = !self.watch; 498 + self.set_watch_flash(if self.watch { 499 + WatchFlash::Activated 500 + } else { 501 + WatchFlash::Deactivated 502 + }); 503 + if self.watch { 504 + self.last_file_state = None; 505 + self.last_content_hash = hash_str(&self.source); 506 + self.last_hash_check = Some(Instant::now()); 507 + self.watch_error = false; 508 + } 509 + } 510 + 511 + pub(crate) fn watch_flash(&self) -> Option<(&WatchFlash, &Instant)> { 512 + self.watch_flash.as_ref().map(|(f, t)| (f, t)) 513 + } 514 + 515 + pub(crate) fn set_watch_flash(&mut self, flash: WatchFlash) { 516 + self.watch_flash = Some((flash, Instant::now())); 517 + } 518 + 519 + pub(crate) fn watch_flash_for_no_file(&self) -> WatchFlash { 520 + if self.filename == "stdin" { 521 + WatchFlash::Stdin 522 + } else { 523 + WatchFlash::NoFile 524 + } 525 + } 526 + 527 + pub(crate) fn clear_watch_flash(&mut self) { 528 + self.watch_flash = None; 529 + } 530 + 449 531 pub(crate) fn filepath(&self) -> Option<&std::path::Path> { 450 532 self.filepath.as_deref() 451 533 } ··· 657 739 self.last_file_state = file_state; 658 740 self.last_content_hash = content_hash; 659 741 self.last_hash_check = Some(Instant::now()); 660 - self.reload_flash = Some(Instant::now()); 742 + if self.watch_flash.is_none() { 743 + self.reload_flash = Some(Instant::now()); 744 + } 661 745 true 662 746 } 663 747 }
+14 -9
src/render/modal.rs
··· 41 41 42 42 pub(super) fn render_help_popup(f: &mut Frame) { 43 43 let theme = app_theme(); 44 - let area = centered_rect(54, 17, f.area()); 44 + let area = centered_rect(54, 19, f.area()); 45 45 let section_style = Style::default() 46 46 .fg(theme.ui.toc_primary_active) 47 47 .add_modifier(Modifier::BOLD); ··· 83 83 Span::styled("top/bottom", text_style), 84 84 ]), 85 85 Line::from(""), 86 + Line::from(vec![Span::styled("Watch", section_style)]), 87 + Line::from(vec![ 88 + Span::styled("Ctrl+W, w ", key_style), 89 + Span::styled("toggle watch", text_style), 90 + Span::raw(" "), 91 + Span::styled("Ctrl+R, r ", key_style), 92 + Span::styled("reload", text_style), 93 + ]), 94 + Line::from(""), 86 95 Line::from(vec![Span::styled("Actions", section_style)]), 87 96 Line::from(vec![ 88 97 Span::styled("Shift+E ", key_style), ··· 99 108 Span::styled("help", text_style), 100 109 ]), 101 110 Line::from(vec![ 102 - Span::styled("r ", key_style), 103 - Span::styled("reload (watch)", text_style), 104 - Span::raw(" "), 105 - Span::styled("q ", key_style), 106 - Span::styled("quit", text_style), 107 - ]), 108 - Line::from(vec![ 109 111 Span::styled("t ", key_style), 110 112 Span::styled("toggle toc", text_style), 113 + Span::raw(" "), 114 + Span::styled("q ", key_style), 115 + Span::styled("quit", text_style), 111 116 ]), 112 117 Line::from(""), 113 118 modal_footer_line(&["esc close", "? close"], theme.ui.toc_bg), ··· 565 570 if entries.is_empty() { 566 571 lines.push(Line::from(vec![Span::styled( 567 572 "No editors found", 568 - Style::default().fg(theme.ui.status_search_error_fg), 573 + Style::default().fg(theme.ui.status_error_fg), 569 574 )])); 570 575 } else { 571 576 let has_terminal = entries.iter().any(|e| e.kind == EditorKind::Terminal);
+44 -15
src/render/status.rs
··· 1 1 use crate::{ 2 - app::{App, EditorFlash}, 2 + app::{App, EditorFlash, WatchFlash, FLASH_DURATION_MS}, 3 3 theme::app_theme, 4 4 }; 5 5 use ratatui::{ ··· 52 52 )] 53 53 } 54 54 55 + fn watch_flash_section(app: &App) -> Option<Vec<Span<'static>>> { 56 + let (flash, started) = app.watch_flash()?; 57 + if started.elapsed() >= std::time::Duration::from_millis(FLASH_DURATION_MS) { 58 + return None; 59 + } 60 + let theme = app_theme(); 61 + let bar_bg = status_bar_bg(); 62 + let (text, fg) = match flash { 63 + WatchFlash::Activated => (" Watch mode activated ", theme.ui.status_success_fg), 64 + WatchFlash::Deactivated => (" Watch mode deactivated ", theme.ui.status_warning_fg), 65 + WatchFlash::Stdin => (" Stdin cannot be watched ", theme.ui.status_error_fg), 66 + WatchFlash::NoFile => (" No file to watch ", theme.ui.status_error_fg), 67 + WatchFlash::FileNotFound => (" File not found ", theme.ui.status_error_fg), 68 + WatchFlash::NotActive => (" Watch mode is not active ", theme.ui.status_warning_fg), 69 + }; 70 + Some(vec![Span::styled(text, Style::default().fg(fg).bg(bar_bg))]) 71 + } 72 + 55 73 pub(crate) fn status_watch_section(app: &App) -> Option<Vec<Span<'static>>> { 56 74 let theme = app_theme(); 57 75 if !app.is_watch_enabled() { 58 76 return None; 59 77 } 60 78 79 + if app.is_watch_error() { 80 + return Some(vec![Span::styled( 81 + " ⟳ error ", 82 + Style::default() 83 + .fg(theme.ui.status_error_fg) 84 + .bg(theme.ui.status_error_bg), 85 + )]); 86 + } 87 + 61 88 let flash_active = app 62 89 .reload_flash_started() 63 - .map(|t| t.elapsed() < std::time::Duration::from_millis(1500)) 90 + .map(|t| t.elapsed() < std::time::Duration::from_millis(FLASH_DURATION_MS)) 64 91 .unwrap_or(false); 65 92 let span = if flash_active { 66 93 Span::styled( 67 94 " ⟳ reloaded ", 68 95 Style::default() 69 96 .fg(theme.ui.status_reloaded_fg) 70 - .bg(theme.ui.status_reloaded_bg) 71 - .add_modifier(Modifier::BOLD), 97 + .bg(theme.ui.status_reloaded_bg), 72 98 ) 73 99 } else { 74 100 Span::styled( ··· 100 126 Span::styled( 101 127 format!(" ✗ {} ", app.search_query()), 102 128 Style::default() 103 - .fg(theme.ui.status_search_error_fg) 104 - .bg(theme.ui.status_search_bg), 129 + .fg(theme.ui.status_error_fg) 130 + .bg(theme.ui.status_error_bg), 105 131 ) 106 132 } else { 107 133 Span::styled( 108 134 format!(" {}/{} ", app.search_index() + 1, app.search_match_count()), 109 135 Style::default() 110 - .fg(theme.ui.status_search_match_fg) 111 - .bg(theme.ui.status_search_bg), 136 + .fg(theme.ui.status_success_fg) 137 + .bg(theme.ui.status_success_bg), 112 138 ) 113 139 }; 114 140 Some(vec![span]) ··· 149 175 150 176 fn editor_flash_section(app: &App) -> Option<Vec<Span<'static>>> { 151 177 let (flash, started) = app.editor_flash()?; 152 - if started.elapsed() >= std::time::Duration::from_millis(2000) { 178 + if started.elapsed() >= std::time::Duration::from_millis(FLASH_DURATION_MS) { 153 179 return None; 154 180 } 155 181 let theme = app_theme(); 156 182 let bar_bg = status_bar_bg(); 157 183 let (message, fg) = match flash { 158 - EditorFlash::Opened(name) => (format!(" Opened in {name} "), theme.ui.status_reloaded_fg), 159 - EditorFlash::NoFile => ( 160 - " No file to edit ".to_string(), 161 - theme.ui.status_search_error_fg, 162 - ), 184 + EditorFlash::Opened(name) => (format!(" Opened in {name} "), theme.ui.status_success_fg), 185 + EditorFlash::NoFile => (" No file to edit ".to_string(), theme.ui.status_error_fg), 163 186 EditorFlash::EditorNotFound(msg) => ( 164 187 format!(" Editor not found: {msg} "), 165 - theme.ui.status_search_error_fg, 188 + theme.ui.status_error_fg, 166 189 ), 167 190 }; 168 191 Some(vec![Span::styled( ··· 176 199 let outer_separator = Span::raw(" "); 177 200 178 201 if let Some(flash_section) = editor_flash_section(app) { 202 + let mut left = status_brand_section(); 203 + left.extend(flash_section); 204 + return join_span_sections(vec![left], outer_separator); 205 + } 206 + 207 + if let Some(flash_section) = watch_flash_section(app) { 179 208 let mut left = status_brand_section(); 180 209 left.extend(flash_section); 181 210 return join_span_sections(vec![left], outer_separator);
+67 -21
src/runtime.rs
··· 1 1 use crate::{ 2 - app::{App, EditorFlash, FileChange}, 2 + app::{App, EditorFlash, FileChange, WatchFlash, FLASH_DURATION_MS}, 3 3 editor::{self, classify, open_in_editor, split_editor_cmd, EditorResult}, 4 4 render::{ui, CONTENT_HORIZONTAL_PADDING, SCROLLBAR_WIDTH}, 5 5 }; ··· 68 68 initial_draw_done: bool, 69 69 ) -> Result<()> { 70 70 const WATCH_INTERVAL: Duration = Duration::from_millis(250); 71 - const FLASH_DURATION: Duration = Duration::from_millis(1500); 71 + const FLASH_DURATION: Duration = Duration::from_millis(FLASH_DURATION_MS); 72 72 const MOUSE_SCROLL_STEP: usize = 3; 73 73 const RESIZE_DEBOUNCE: Duration = Duration::from_millis(120); 74 74 const PICKER_LOAD_POLL_INTERVAL: Duration = Duration::from_millis(50); ··· 92 92 let editor_flash_timeout = app 93 93 .editor_flash() 94 94 .and_then(|(_, started)| EDITOR_FLASH_DURATION.checked_sub(started.elapsed())); 95 + let watch_flash_timeout = app 96 + .watch_flash() 97 + .and_then(|(_, started)| WATCH_FLASH_DURATION.checked_sub(started.elapsed())); 95 98 let resize_timeout = 96 99 pending_resize.and_then(|started| RESIZE_DEBOUNCE.checked_sub(started.elapsed())); 97 100 let poll_timeout = [ ··· 107 110 }, 108 111 flash_timeout, 109 112 editor_flash_timeout, 113 + watch_flash_timeout, 110 114 resize_timeout, 111 115 ] 112 116 .into_iter() ··· 210 214 _ => state_changed = false, 211 215 } 212 216 } else if app.is_theme_picker_open() { 213 - let dismiss = matches!(key.code, KeyCode::Esc | KeyCode::Char('T')) 214 - || (key.code == KeyCode::Char('c') 215 - && key.modifiers.contains(KeyModifiers::CONTROL)); 216 - if dismiss { 217 - app.restore_theme_picker_preview(ss, themes); 218 - needs_redraw = true; 219 - state_changed = false; 220 - } 221 217 match key.code { 222 - _ if dismiss => {} 218 + KeyCode::Esc | KeyCode::Char('T') => { 219 + app.restore_theme_picker_preview(ss, themes); 220 + needs_redraw = true; 221 + state_changed = false; 222 + } 223 + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { 224 + app.restore_theme_picker_preview(ss, themes); 225 + needs_redraw = true; 226 + state_changed = false; 227 + } 223 228 KeyCode::Enter => app.close_theme_picker(), 224 229 KeyCode::Char('j') | KeyCode::Down => { 225 230 app.move_theme_picker_down(); ··· 292 297 KeyCode::Char('?') => { 293 298 app.open_help(); 294 299 } 295 - KeyCode::Char('r') if app.is_watch_enabled() => { 296 - app.request_reload(ss, themes); 300 + KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => { 301 + app.toggle_watch(); 302 + } 303 + KeyCode::Char('w') => { 304 + app.toggle_watch(); 305 + } 306 + KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => { 307 + if app.filepath().is_none() { 308 + let flash = app.watch_flash_for_no_file(); 309 + app.set_watch_flash(flash); 310 + } else if !app.is_watch_enabled() { 311 + app.set_watch_flash(WatchFlash::NotActive); 312 + } else if !app.request_reload(ss, themes) { 313 + app.set_watch_flash(WatchFlash::FileNotFound); 314 + } 315 + } 316 + KeyCode::Char('r') => { 317 + if app.filepath().is_none() { 318 + let flash = app.watch_flash_for_no_file(); 319 + app.set_watch_flash(flash); 320 + } else if !app.is_watch_enabled() { 321 + app.set_watch_flash(WatchFlash::NotActive); 322 + } else if !app.request_reload(ss, themes) { 323 + app.set_watch_flash(WatchFlash::FileNotFound); 324 + } 297 325 } 298 326 KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => { 299 327 app.begin_search() ··· 357 385 } 358 386 359 387 if app.is_watch_enabled() { 360 - if let Some(change) = app.check_modified() { 361 - std::thread::sleep(Duration::from_millis(50)); 362 - if app.reload(ss, themes) { 363 - app.set_last_file_state(match change { 364 - FileChange::Metadata(state) | FileChange::Content(state) => state, 365 - }); 366 - needs_redraw = true; 388 + let file_ok = app.filepath().map(|p| p.exists()).unwrap_or(false); 389 + if !file_ok && !app.is_watch_error() { 390 + app.set_watch_error(true); 391 + needs_redraw = true; 392 + } else if file_ok && app.is_watch_error() { 393 + app.set_watch_error(false); 394 + needs_redraw = true; 395 + } 396 + if file_ok { 397 + if let Some(change) = app.check_modified() { 398 + std::thread::sleep(Duration::from_millis(50)); 399 + if app.reload(ss, themes) { 400 + app.set_last_file_state(match change { 401 + FileChange::Metadata(state) | FileChange::Content(state) => state, 402 + }); 403 + needs_redraw = true; 404 + } 367 405 } 368 406 } 369 407 if let Some(t) = app.reload_flash_started() { ··· 380 418 needs_redraw = true; 381 419 } 382 420 } 421 + 422 + if let Some((_, started)) = app.watch_flash() { 423 + if started.elapsed() >= WATCH_FLASH_DURATION { 424 + app.clear_watch_flash(); 425 + needs_redraw = true; 426 + } 427 + } 383 428 } 384 429 Ok(()) 385 430 } 386 431 387 - const EDITOR_FLASH_DURATION: Duration = Duration::from_millis(2000); 432 + const EDITOR_FLASH_DURATION: Duration = Duration::from_millis(FLASH_DURATION_MS); 433 + const WATCH_FLASH_DURATION: Duration = Duration::from_millis(FLASH_DURATION_MS); 388 434 389 435 fn strip_unc_prefix(path: std::path::PathBuf) -> std::path::PathBuf { 390 436 if cfg!(target_os = "windows") {
+29 -14
src/theme.rs
··· 41 41 pub(crate) status_reloaded_bg: Color, 42 42 pub(crate) status_search_fg: Color, 43 43 pub(crate) status_search_bg: Color, 44 - pub(crate) status_search_error_fg: Color, 45 - pub(crate) status_search_match_fg: Color, 44 + pub(crate) status_success_fg: Color, 45 + pub(crate) status_success_bg: Color, 46 + pub(crate) status_warning_fg: Color, 47 + pub(crate) status_error_fg: Color, 48 + pub(crate) status_error_bg: Color, 46 49 pub(crate) status_shortcut_fg: Color, 47 50 pub(crate) status_percent_fg: Color, 48 51 pub(crate) toc_header_fg: Color, ··· 102 105 status_reloaded_fg: Color::Rgb(245, 248, 250), 103 106 status_reloaded_bg: Color::Rgb(58, 168, 116), 104 107 status_search_fg: Color::Rgb(142, 114, 24), 105 - status_search_bg: Color::Rgb(235, 238, 226), 106 - status_search_error_fg: Color::Rgb(188, 74, 74), 107 - status_search_match_fg: Color::Rgb(48, 140, 98), 108 + status_search_bg: Color::Rgb(240, 234, 200), 109 + status_success_fg: Color::Rgb(48, 140, 98), 110 + status_success_bg: Color::Rgb(212, 234, 222), 111 + status_warning_fg: Color::Rgb(180, 142, 28), 112 + status_error_fg: Color::Rgb(188, 74, 74), 113 + status_error_bg: Color::Rgb(240, 218, 218), 108 114 status_shortcut_fg: Color::Rgb(98, 116, 134), 109 115 status_percent_fg: Color::Rgb(76, 122, 168), 110 116 toc_header_fg: Color::Rgb(92, 108, 126), ··· 134 140 status_reloaded_fg: Color::Rgb(16, 18, 26), 135 141 status_reloaded_bg: Color::Rgb(95, 200, 148), 136 142 status_search_fg: Color::Rgb(240, 210, 95), 137 - status_search_bg: Color::Rgb(26, 28, 42), 138 - status_search_error_fg: Color::Rgb(218, 95, 95), 139 - status_search_match_fg: Color::Rgb(115, 208, 148), 143 + status_search_bg: Color::Rgb(36, 32, 16), 144 + status_success_fg: Color::Rgb(120, 210, 170), 145 + status_success_bg: Color::Rgb(18, 30, 24), 146 + status_warning_fg: Color::Rgb(240, 200, 60), 147 + status_error_fg: Color::Rgb(218, 95, 95), 148 + status_error_bg: Color::Rgb(42, 18, 18), 140 149 status_shortcut_fg: Color::Rgb(58, 68, 98), 141 150 status_percent_fg: Color::Rgb(105, 178, 218), 142 151 toc_header_fg: Color::Rgb(88, 88, 96), ··· 232 241 status_reloaded_fg: Color::Rgb(14, 21, 18), 233 242 status_reloaded_bg: Color::Rgb(132, 214, 154), 234 243 status_search_fg: Color::Rgb(236, 214, 123), 235 - status_search_bg: Color::Rgb(30, 36, 34), 236 - status_search_error_fg: Color::Rgb(224, 120, 120), 237 - status_search_match_fg: Color::Rgb(132, 214, 154), 244 + status_search_bg: Color::Rgb(38, 34, 18), 245 + status_success_fg: Color::Rgb(120, 214, 170), 246 + status_success_bg: Color::Rgb(20, 32, 24), 247 + status_warning_fg: Color::Rgb(236, 214, 123), 248 + status_error_fg: Color::Rgb(224, 120, 120), 249 + status_error_bg: Color::Rgb(42, 20, 20), 238 250 status_shortcut_fg: Color::Rgb(82, 104, 92), 239 251 status_percent_fg: Color::Rgb(126, 198, 170), 240 252 toc_header_fg: Color::Rgb(102, 118, 106), ··· 301 313 status_reloaded_fg: Color::Rgb(0, 43, 54), 302 314 status_reloaded_bg: Color::Rgb(133, 153, 0), 303 315 status_search_fg: Color::Rgb(181, 137, 0), 304 - status_search_bg: Color::Rgb(12, 54, 62), 305 - status_search_error_fg: Color::Rgb(220, 50, 47), 306 - status_search_match_fg: Color::Rgb(133, 153, 0), 316 + status_search_bg: Color::Rgb(32, 28, 0), 317 + status_success_fg: Color::Rgb(42, 161, 152), 318 + status_success_bg: Color::Rgb(0, 36, 32), 319 + status_warning_fg: Color::Rgb(181, 137, 0), 320 + status_error_fg: Color::Rgb(220, 50, 47), 321 + status_error_bg: Color::Rgb(48, 16, 15), 307 322 status_shortcut_fg: Color::Rgb(88, 110, 117), 308 323 status_percent_fg: Color::Rgb(42, 161, 152), 309 324 toc_header_fg: Color::Rgb(101, 123, 131),