diff --git a/CHANGELOG.md b/CHANGELOG.md index 2910b576..f71af6ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,7 @@ first added in the same Unreleased section is not an entry: edit that entry inst ### Fixed -- The board redraws in full when its pane gets focus back, and on `ctrl+l`, instead of showing scattered text after a terminal hid the pane ([keys](https://gettsk.sh/docs/keys/#esc-and-quit)). +- A board whose terminal panel was hidden and reopened comes back whole and clickable within two minutes, or at once on `ctrl+l` ([keys](https://gettsk.sh/docs/keys/#esc-and-quit)). - The starter tour and the site footer link to https://x.com/smarzban. A tour already on your board keeps its notes. - `tsk list -p` and `tsk project archive|unarchive` refuse a name two projects share, like `tsk add`, instead of matching nothing. Thanks @s3rgiosan (#249). - `tsk list --all --group project` labels projects that share a name by their shortest distinct path, such as `site-a/wordpress/wp-content`. Thanks @s3rgiosan (#249). diff --git a/site/src/content/docs/docs/keys.md b/site/src/content/docs/docs/keys.md index c5d791af..884894ed 100644 --- a/site/src/content/docs/docs/keys.md +++ b/site/src/content/docs/docs/keys.md @@ -255,12 +255,12 @@ With the right board focused, board keys act on that board. | Quit from any surface except text entry | `ctrl+q` | | Quit from the board, the task page, quick-add details, Help, or the archived-project card | `ctrl+c` | | Cancel a field, an edit, the block or review card, or the reply box | `Esc` or `ctrl+c` | -| Redraw the whole screen, from any surface | `ctrl+l` | +| Redraw the whole screen and turn mouse and focus reporting back on, from any surface | `ctrl+l` | - With tasks marked, `Esc` first leaves multi-select and clears the marks. In a picker you type into, a card, or the reply box, it closes that first and keeps them. - At the board root, `Esc` quits from any tab. It does not go back to the desk. With the cursor on the inbox or archived header, it does nothing. - On an archived project's board, `Esc` returns to the desk. - In a wide split, `Esc` closes the right column once no editor or overlay is open. If narrowing the terminal hides the right column, `Esc` quits at once. - `ctrl+q` does not quit from a text field, board search, the quick-add line, the palette, the block or review card, or the reply box. -- tsk also redraws the whole screen when its pane gets focus back. Press `ctrl+l` if the board still shows only scattered text, for example after the terminal hid the pane. +- tsk also redraws the whole screen when its pane gets focus back, and every two minutes. Press `ctrl+l` if the board shows only scattered text or ignores clicks, for example after the terminal panel was hidden and reopened. - Unsaved work blocks quitting and closing the Projects view's right board. Save or cancel the draft first. During a failed save, retry or cancel it first. diff --git a/src/app.rs b/src/app.rs index 01323d61..a13f3ce4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -391,7 +391,8 @@ fn run_board_loop( // and a blank alternate screen is what the user would be staring at meanwhile. let keyboard_enhancement = keyboard_enhancement_supported(); ratatui::run(|terminal| -> io::Result<()> { - let _input = enable_terminal_input(keyboard_enhancement)?; + let mut input = enable_terminal_input(keyboard_enhancement)?; + let mut keyframe = Keyframe::new(Instant::now()); let mut save_recovery = SaveRecovery::new(); // The painted frame's text and its declared copyable rects, refreshed by // every board paint: the snapshot a finished drag selection slices its @@ -431,13 +432,21 @@ fn run_board_loop( let next = if let Some(event) = take_pending_after_paint(&mut pending_event, || { let area = terminal_area(terminal)?; sync_frame_presentation(area, &model); - terminal.draw(|frame| { - let hits = draw_board(frame, &model); - frame_rows = frame_text_rows(frame.buffer_mut()); - frame_copyable = hits.copyable.clone(); - frame_hits = hits; - })?; - Ok::<(), io::Error>(()) + paint_with_keyframe( + terminal, + &mut input, + &mut keyframe, + Instant::now(), + |terminal| { + terminal.draw(|frame| { + let hits = draw_board(frame, &model); + frame_rows = frame_text_rows(frame.buffer_mut()); + frame_copyable = hits.copyable.clone(); + frame_hits = hits; + })?; + Ok(()) + }, + ) })? { event } else { @@ -446,14 +455,21 @@ fn run_board_loop( |model: &BoardModel| { let area = terminal_area(terminal)?; sync_frame_presentation(area, model); - terminal - .draw(|frame| { - let hits = draw_board(frame, model); - frame_rows = frame_text_rows(frame.buffer_mut()); - frame_copyable = hits.copyable.clone(); - frame_hits = hits; - }) - .map(|_| ()) + paint_with_keyframe( + terminal, + &mut input, + &mut keyframe, + Instant::now(), + |terminal| { + terminal.draw(|frame| { + let hits = draw_board(frame, model); + frame_rows = frame_text_rows(frame.buffer_mut()); + frame_copyable = hits.copyable.clone(); + frame_hits = hits; + })?; + Ok(()) + }, + ) }, event::poll, &store, @@ -480,7 +496,7 @@ fn run_board_loop( event::read()? }; reflow_click.observe(&next); - if forget_frame_on_repaint_request(terminal, &next)? { + if repaint_on_request(terminal, &mut input, &next)? { continue; } let event_area = match &next { @@ -830,23 +846,89 @@ fn is_full_repaint_request(event: &Event) -> bool { } } -/// On a [full repaint request](is_full_repaint_request), clear the screen and forget the -/// last frame so the next paint writes every cell. Returns whether the event was consumed. -/// -/// `Terminal::resize` to the current size does that without `Terminal::clear`'s cursor -/// position query, which errors out of the board when the terminal never answers it. -fn forget_frame_on_repaint_request( +/// On a [full repaint request](is_full_repaint_request), send every terminal mode again +/// (the host may have dropped them with the screen) and [forget the frame](forget_frame). +/// Returns whether the event was consumed. +fn repaint_on_request( terminal: &mut ratatui::Terminal, + input: &mut crate::ui::mouse::TerminalInputGuard, event: &Event, -) -> Result { +) -> io::Result +where + B: ratatui::backend::Backend, + W: io::Write, +{ if !is_full_repaint_request(event) { return Ok(false); } - let size = terminal.size()?; - terminal.resize(Rect::new(0, 0, size.width, size.height))?; + input.reassert(true)?; + forget_frame(terminal)?; Ok(true) } +/// Clear the screen and forget the last frame so the next paint writes every cell. +/// +/// `Terminal::resize` to the current size does that without `Terminal::clear`'s cursor +/// position query, which errors out of the board when the terminal never answers it. +fn forget_frame( + terminal: &mut ratatui::Terminal, +) -> Result<(), B::Error> { + let size = terminal.size()?; + terminal.resize(Rect::new(0, 0, size.width, size.height)) +} + +/// How often the board re-sends its terminal modes and rewrites the whole frame. +const KEYFRAME_INTERVAL: Duration = Duration::from_secs(120); + +/// When the next [keyframe](refresh_keyframe_if_due) is due. +struct Keyframe { + next: Instant, +} + +impl Keyframe { + fn new(now: Instant) -> Self { + Self { + next: now + KEYFRAME_INTERVAL, + } + } + + /// Whether a keyframe is due at `now`; if so, schedule the next one. + fn take_due(&mut self, now: Instant) -> bool { + if now < self.next { + return false; + } + self.next = now + KEYFRAME_INTERVAL; + true + } +} + +/// Run `paint`; every [`KEYFRAME_INTERVAL`], follow it with a keyframe: re-send mouse, +/// focus and paste reporting and paint again, writing every non-blank cell, without +/// clearing the screen. +/// +/// A host terminal that rebuilds a hidden pane by replaying a capped output log (T3 Code) +/// drops the startup modes and the older paints once the log is trimmed. A keyframe in the +/// log's tail puts both back on the next replay, so the board comes back whole and clickable +/// without a keypress. The keyframe paint diffs against a blank frame (`swap_buffers` resets +/// the previous buffer), so it skips blank cells; the ordinary paint before it has already +/// blanked any cell that changed to blank. A live terminal sees cells rewritten with what +/// is already there. +fn paint_with_keyframe( + terminal: &mut ratatui::Terminal, + input: &mut crate::ui::mouse::TerminalInputGuard, + keyframe: &mut Keyframe, + now: Instant, + mut paint: impl FnMut(&mut ratatui::Terminal) -> io::Result<()>, +) -> io::Result<()> { + paint(terminal)?; + if keyframe.take_due(now) { + input.reassert(false)?; + terminal.swap_buffers(); + paint(terminal)?; + } + Ok(()) +} + /// Copy a finished drag selection from the last painted frame to the clipboard. /// /// Only cells the painters declared copyable are taken, so scrollbars, borders, @@ -7032,7 +7114,8 @@ mod tests { "a diff paint alone leaves it blank" ); - assert!(forget_frame_on_repaint_request(&mut terminal, &request).expect("clear")); + assert!(is_full_repaint_request(&request)); + forget_frame(&mut terminal).expect("clear"); terminal .draw(|frame| drop(draw_board(frame, &model))) .expect("full draw"); @@ -7042,12 +7125,11 @@ mod tests { "{request:?} repaints every cell" ); } - assert!(!forget_frame_on_repaint_request(&mut terminal, &Event::FocusLost).expect("noop")); } /// A terminal that never answers the cursor position query, like a host that drops - /// the request; everything else is a [`TestBackend`]. - struct NoCursorReplyBackend(TestBackend); + /// the request, and counts screen clears; everything else is a [`TestBackend`]. + struct NoCursorReplyBackend(TestBackend, usize); impl ratatui::backend::Backend for NoCursorReplyBackend { type Error = io::Error; @@ -7076,9 +7158,11 @@ mod tests { .map_err(|never| match never {}) } fn clear(&mut self) -> io::Result<()> { + self.1 += 1; self.0.clear().map_err(|never| match never {}) } fn clear_region(&mut self, clear_type: ratatui::backend::ClearType) -> io::Result<()> { + self.1 += 1; self.0 .clear_region(clear_type) .map_err(|never| match never {}) @@ -7100,7 +7184,7 @@ mod tests { fn repaint_request_never_queries_the_cursor_position() { let area = Rect::new(0, 0, 110, 30); let (_domain, model, _) = projects_preview_fixture(); - let backend = NoCursorReplyBackend(TestBackend::new(area.width, area.height)); + let backend = NoCursorReplyBackend(TestBackend::new(area.width, area.height), 0); let mut terminal = Terminal::new(backend).expect("test terminal"); terminal .draw(|frame| drop(draw_board(frame, &model))) @@ -7108,16 +7192,208 @@ mod tests { let painted = terminal.backend().0.buffer().clone(); ratatui::backend::Backend::clear(&mut terminal.backend_mut().0).expect("host clear"); - assert!( - forget_frame_on_repaint_request(&mut terminal, &Event::FocusGained) - .expect("repaint without a cursor query") - ); + forget_frame(&mut terminal).expect("repaint without a cursor query"); terminal .draw(|frame| drop(draw_board(frame, &model))) .expect("full draw"); assert_eq!(terminal.backend().0.buffer(), &painted); } + #[test] + fn keyframe_is_due_every_interval_and_reschedules() { + let start = Instant::now(); + let mut keyframe = Keyframe::new(start); + assert!(!keyframe.take_due(start)); + assert!(!keyframe.take_due(start + KEYFRAME_INTERVAL - Duration::from_millis(1))); + assert!(keyframe.take_due(start + KEYFRAME_INTERVAL)); + assert!(!keyframe.take_due(start + KEYFRAME_INTERVAL + Duration::from_secs(1))); + assert!(keyframe.take_due(start + KEYFRAME_INTERVAL * 2)); + } + + fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String { + let area = buffer.area; + (area.y..area.bottom()) + .map(|y| { + (area.x..area.right()) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n") + } + + /// A host that replays a trimmed log starts from a blank screen with no modes on. The + /// keyframe re-sends mouse, focus and paste reporting, never the alternate screen or the + /// keyboard flags, and rewrites every painted cell with no clear, so a live terminal sees + /// nothing change and a replayed one gets the whole board back. + #[test] + fn keyframe_restores_modes_and_a_dropped_screen_without_clearing() { + let area = Rect::new(0, 0, 110, 30); + let (_domain, model, _) = projects_preview_fixture(); + let backend = NoCursorReplyBackend(TestBackend::new(area.width, area.height), 0); + let mut terminal = Terminal::new(backend).expect("test terminal"); + let mut out = Vec::new(); + // crossterm refuses keyboard flags on Windows, and the board never enables them there. + let keyboard = cfg!(not(windows)); + let mut input = + crate::ui::mouse::enable_terminal_input_on(&mut out, keyboard).expect("input guard"); + input.take_written(); + let start = Instant::now(); + let mut keyframe = Keyframe::new(start); + let paint = |terminal: &mut Terminal| { + terminal.draw(|frame| drop(draw_board(frame, &model)))?; + Ok(()) + }; + paint_with_keyframe(&mut terminal, &mut input, &mut keyframe, start, paint) + .expect("first paint"); + let painted = terminal.backend().0.buffer().clone(); + + ratatui::backend::Backend::clear(&mut terminal.backend_mut().0).expect("host clear"); + let clears_before = terminal.backend().1; + paint_with_keyframe(&mut terminal, &mut input, &mut keyframe, start, paint) + .expect("diff paint"); + assert_ne!(terminal.backend().0.buffer(), &painted, "no keyframe yet"); + assert!( + input.take_written().is_empty(), + "no re-send before the interval" + ); + + paint_with_keyframe( + &mut terminal, + &mut input, + &mut keyframe, + start + KEYFRAME_INTERVAL, + paint, + ) + .expect("keyframe"); + assert_eq!(terminal.backend().0.buffer(), &painted); + assert_eq!( + terminal.backend().1, + clears_before, + "a keyframe never clears" + ); + let resent = String::from_utf8(input.take_written()).expect("utf8"); + // On Windows crossterm turns mouse capture on through the console API, not a code. + for mode in ["\x1b[?1000h", "\x1b[?1004h", "\x1b[?2004h"] + .into_iter() + .filter(|mode| cfg!(not(windows)) || !matches!(*mode, "\x1b[?1000h" | "\x1b[?1006h")) + { + assert!(resent.contains(mode), "{mode} in {resent:?}"); + } + assert!( + !resent.contains("\x1b[?1049h"), + "keyframe left the screen alone" + ); + assert!( + !resent.contains("\x1b[>"), + "keyframe pushed no keyboard flags" + ); + assert!( + !resent.contains("\x1b[<"), + "keyframe popped no keyboard flags" + ); + } + + /// A keyframe that lands on a paint which blanks cells still blanks them: the keyframe + /// pass skips blank cells, so the ordinary diff paint must run first. + #[test] + fn keyframe_on_a_paint_that_blanks_cells_leaves_no_stale_text() { + use ratatui::widgets::Paragraph; + let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("test terminal"); + let mut out = Vec::new(); + let mut input = + crate::ui::mouse::enable_terminal_input_on(&mut out, false).expect("input guard"); + let start = Instant::now(); + let mut keyframe = Keyframe::new(start); + let paint_text = |text: &'static str| { + move |terminal: &mut Terminal| { + terminal + .draw(|frame| frame.render_widget(Paragraph::new(text), frame.area())) + .map_err(|never| -> io::Error { match never {} })?; + Ok(()) + } + }; + paint_with_keyframe( + &mut terminal, + &mut input, + &mut keyframe, + start, + paint_text("Help: all the keys"), + ) + .expect("help open"); + paint_with_keyframe( + &mut terminal, + &mut input, + &mut keyframe, + start + KEYFRAME_INTERVAL, + paint_text("board"), + ) + .expect("help closed on a keyframe"); + let screen = buffer_text(terminal.backend().buffer()); + assert_eq!( + screen.lines().next().map(str::trim_end), + Some("board"), + "{screen:?}" + ); + } + + /// `ctrl+l` and focus in send every mode again, the alternate screen and keyboard flags + /// included, then repaint the whole frame; other events pass through untouched. + #[test] + fn repaint_request_resends_every_mode_and_repaints_in_full() { + let area = Rect::new(0, 0, 110, 30); + let (_domain, model, _) = projects_preview_fixture(); + let backend = NoCursorReplyBackend(TestBackend::new(area.width, area.height), 0); + let mut terminal = Terminal::new(backend).expect("test terminal"); + let mut out = Vec::new(); + // crossterm refuses keyboard flags on Windows, and the board never enables them there. + let keyboard = cfg!(not(windows)); + let mut input = + crate::ui::mouse::enable_terminal_input_on(&mut out, keyboard).expect("input guard"); + terminal + .draw(|frame| drop(draw_board(frame, &model))) + .expect("draw"); + let painted = terminal.backend().0.buffer().clone(); + + for request in [ + Event::FocusGained, + Event::Key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)), + ] { + input.take_written(); + ratatui::backend::Backend::clear(&mut terminal.backend_mut().0).expect("host clear"); + assert!(repaint_on_request(&mut terminal, &mut input, &request).expect("repaint")); + let resent = String::from_utf8(input.take_written()).expect("utf8"); + // On Windows crossterm turns mouse capture on through the console API, not a code. + for mode in ["\x1b[?1049h", "\x1b[?1000h", "\x1b[?1004h", "\x1b[?2004h"] + .into_iter() + .filter(|mode| { + cfg!(not(windows)) || !matches!(*mode, "\x1b[?1000h" | "\x1b[?1006h") + }) + { + assert!(resent.contains(mode), "{request:?}: {mode} in {resent:?}"); + } + if keyboard { + assert!(resent.contains("\x1b[>1u"), "{request:?}: keyboard push"); + } + terminal + .draw(|frame| drop(draw_board(frame, &model))) + .expect("full draw"); + assert_eq!(terminal.backend().0.buffer(), &painted, "{request:?}"); + } + + input.take_written(); + for other in [ + Event::FocusLost, + Event::Key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)), + ] { + assert!(!repaint_on_request(&mut terminal, &mut input, &other).expect("noop")); + } + assert!( + input.take_written().is_empty(), + "other events re-send nothing" + ); + } + #[test] fn projects_preview_task_page_paints_its_wide_column_header() { let area = Rect::new(0, 0, 110, 30); diff --git a/src/ui/input.rs b/src/ui/input.rs index d72d7ca6..a011f54d 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -1038,7 +1038,7 @@ fn help_bindings() -> Vec { help_binding( HelpGroup::AppControls, "ctrl+l", - "redraw the screen", + "redraw, restore the mouse", "repaint refresh blank screen", ), help_binding( diff --git a/src/ui/mouse.rs b/src/ui/mouse.rs index 343e4b53..8e7a8bcb 100644 --- a/src/ui/mouse.rs +++ b/src/ui/mouse.rs @@ -8,7 +8,7 @@ use crossterm::event::{ MouseEvent, MouseEventKind, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }; use crossterm::execute; -use crossterm::terminal::supports_keyboard_enhancement; +use crossterm::terminal::{supports_keyboard_enhancement, EnterAlternateScreen}; use ratatui::layout::{Position, Rect}; use ratatui::widgets::Block; @@ -128,6 +128,43 @@ pub(crate) struct TerminalInputGuard { enabled: Vec, } +impl TerminalInputGuard { + /// Take the bytes written so far, for tests that check what a re-send wrote. + #[cfg(test)] + pub(crate) fn take_written(&mut self) -> Vec + where + W: std::ops::DerefMut>, + { + std::mem::take(&mut *self.out) + } + + /// Send every capability this guard enabled again. + /// + /// A host terminal that rebuilds the pane by replaying a trimmed output log (T3 Code's + /// terminal panel does) loses the escape codes tsk sent at startup, so mouse, focus and + /// paste reports silently stop. Setting a mode that is already on is a no-op everywhere. + /// + /// `full` also re-enters the alternate screen and pushes the keyboard flags again (popping + /// first, so the terminal's flag stack does not grow). Re-entering the alternate screen + /// can clear it, so `full` belongs only with a repaint that clears anyway. + pub(crate) fn reassert(&mut self, full: bool) -> io::Result<()> { + if full { + execute!(self.out, EnterAlternateScreen)?; + } + for capability in self.enabled.clone() { + match capability { + TerminalInputCapability::KeyboardProtocol if !full => {} + TerminalInputCapability::KeyboardProtocol => { + execute!(self.out, PopKeyboardEnhancementFlags)?; + enable_capability(&mut self.out, capability)?; + } + _ => enable_capability(&mut self.out, capability)?, + } + } + Ok(()) + } +} + impl Drop for TerminalInputGuard { fn drop(&mut self) { disable_all(&mut self.out, &self.enabled); @@ -138,7 +175,7 @@ pub(crate) fn keyboard_enhancement_supported() -> bool { supports_keyboard_enhancement().unwrap_or(false) } -fn enable_terminal_input_on( +pub(crate) fn enable_terminal_input_on( out: W, keyboard_enhancement_supported: bool, ) -> io::Result> { @@ -1162,4 +1199,44 @@ mod terminal_input_tests { .expect("focus reporting off on drop"); assert!(on < off); } + + #[test] + fn reassert_resends_reporting_and_only_full_touches_screen_and_keyboard() { + // crossterm refuses keyboard flags on Windows, and the board never enables them there. + let keyboard = cfg!(not(windows)); + let mut out = Vec::new(); + let mut guard = enable_terminal_input_on(&mut out, keyboard).expect("enable"); + guard.out.clear(); + guard.reassert(false).expect("light reassert"); + let light = String::from_utf8(std::mem::take(guard.out)).expect("utf8"); + // On Windows crossterm turns mouse capture on through the console API, not a code. + for mode in ["\x1b[?1000h", "\x1b[?1006h", "\x1b[?2004h", "\x1b[?1004h"] + .into_iter() + .filter(|mode| cfg!(not(windows)) || !matches!(*mode, "\x1b[?1000h" | "\x1b[?1006h")) + { + assert!(light.contains(mode), "{mode} in {light:?}"); + } + assert!( + !light.contains("\x1b[?1049h"), + "light must not touch the screen" + ); + assert!( + !light.contains("\x1b[>"), + "light must not push keyboard flags" + ); + assert!( + !light.contains("\x1b[<"), + "light must not pop keyboard flags" + ); + + guard.reassert(true).expect("full reassert"); + let full = String::from_utf8(std::mem::take(guard.out)).expect("utf8"); + let screen = full.find("\x1b[?1049h").expect("alternate screen"); + if keyboard { + let pop = full.find("\x1b[<1u").expect("pop keyboard flags"); + let push = full.find("\x1b[>1u").expect("push keyboard flags"); + assert!(screen < pop && pop < push, "{full:?}"); + } + assert!(full.contains("\x1b[?1004h")); + } }