From e942a9b5273622901eace7c902a8bc25d7292d7c Mon Sep 17 00:00:00 2001 From: Saeed Marzban Date: Sat, 14 Nov 2026 09:37:02 +0200 Subject: [PATCH 1/4] Restore terminal modes after a host replays a trimmed log T3 Code rebuilds a hidden terminal pane by replaying a capped log of raw output. Once the log is trimmed, the startup escape codes (alternate screen, mouse, focus, paste) are gone, so the board comes back as scattered cells and ignores clicks; focus-in never fires. ctrl+l and focus in now re-send every mode before the full repaint. Every two minutes the board also re-sends mouse, focus and paste reporting and rewrites every painted cell without clearing, so the log's tail always holds a complete keyframe. --- CHANGELOG.md | 2 +- site/src/content/docs/docs/keys.md | 4 +- src/app.rs | 151 +++++++++++++++++++++++++---- src/ui/input.rs | 2 +- src/ui/mouse.rs | 73 +++++++++++++- 5 files changed, 205 insertions(+), 27 deletions(-) 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..1af08805 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,6 +432,7 @@ 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); + refresh_keyframe_if_due(terminal, &mut input, &mut keyframe, Instant::now())?; terminal.draw(|frame| { let hits = draw_board(frame, &model); frame_rows = frame_text_rows(frame.buffer_mut()); @@ -446,6 +448,12 @@ fn run_board_loop( |model: &BoardModel| { let area = terminal_area(terminal)?; sync_frame_presentation(area, model); + refresh_keyframe_if_due( + terminal, + &mut input, + &mut keyframe, + Instant::now(), + )?; terminal .draw(|frame| { let hits = draw_board(frame, model); @@ -480,7 +488,9 @@ fn run_board_loop( event::read()? }; reflow_click.observe(&next); - if forget_frame_on_repaint_request(terminal, &next)? { + if is_full_repaint_request(&next) { + input.reassert(true)?; + forget_frame(terminal)?; continue; } let event_area = match &next { @@ -830,21 +840,61 @@ 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. +/// 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_on_repaint_request( +fn forget_frame( terminal: &mut ratatui::Terminal, - event: &Event, -) -> Result { - if !is_full_repaint_request(event) { - return Ok(false); - } +) -> Result<(), B::Error> { let size = terminal.size()?; - terminal.resize(Rect::new(0, 0, size.width, size.height))?; - Ok(true) + 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 + } +} + +/// Every [`KEYFRAME_INTERVAL`], re-send mouse, focus and paste reporting and make the next +/// paint write 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. Dropping the last frame (`swap_buffers` resets the previous buffer) +/// rewrites cells with what is already there, so a live terminal shows no change. +fn refresh_keyframe_if_due( + terminal: &mut ratatui::Terminal, + input: &mut crate::ui::mouse::TerminalInputGuard, + keyframe: &mut Keyframe, + now: Instant, +) -> io::Result<()> { + if keyframe.take_due(now) { + input.reassert(false)?; + terminal.swap_buffers(); + } + Ok(()) } /// Copy a finished drag selection from the last painted frame to the clipboard. @@ -7032,7 +7082,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 +7093,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 +7126,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 +7152,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 +7160,73 @@ 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)); + } + + /// A host that replays a trimmed log starts from a blank screen with no modes on. The + /// next keyframe re-sends the modes 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(); + 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); + terminal + .draw(|frame| drop(draw_board(frame, &model))) + .expect("draw"); + 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; + + refresh_keyframe_if_due(&mut terminal, &mut input, &mut keyframe, start).expect("early"); + terminal + .draw(|frame| drop(draw_board(frame, &model))) + .expect("diff draw"); + assert_ne!(terminal.backend().0.buffer(), &painted, "no keyframe yet"); + + input.reassert(false).expect("warm"); + let resent_before = input.written_len(); + refresh_keyframe_if_due( + &mut terminal, + &mut input, + &mut keyframe, + start + KEYFRAME_INTERVAL, + ) + .expect("keyframe"); + assert!(input.written_len() > resent_before, "modes re-sent"); + terminal + .draw(|frame| drop(draw_board(frame, &model))) + .expect("keyframe draw"); + assert_eq!(terminal.backend().0.buffer(), &painted); + assert_eq!( + terminal.backend().1, + clears_before, + "a keyframe never clears" + ); + } + #[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..e076d411 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 { + /// Bytes written so far, for tests that check a re-send happened. + #[cfg(test)] + pub(crate) fn written_len(&self) -> usize + where + W: std::ops::Deref>, + { + self.out.len() + } + + /// 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,36 @@ 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() { + let mut out = Vec::new(); + let mut guard = enable_terminal_input_on(&mut out, true).expect("enable"); + guard.out.clear(); + guard.reassert(false).expect("light reassert"); + let light = String::from_utf8(std::mem::take(guard.out)).expect("utf8"); + for mode in ["\x1b[?1000h", "\x1b[?1006h", "\x1b[?2004h", "\x1b[?1004h"] { + 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"); + 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")); + } } From 9ac9c97b85c14b12e79bf5c8ed95bc288496689f Mon Sep 17 00:00:00 2001 From: Saeed Marzban Date: Sat, 14 Nov 2026 09:55:49 +0200 Subject: [PATCH 2/4] Keyframe after the ordinary paint; test the light and full re-sends (review F-1, F-4, F-2) The keyframe paint diffs against a blank frame and skips blank cells, so landing it on a paint that blanked cells left stale text. Paint normally first, then the keyframe pass. Pull the ctrl+l / focus-in handling into repaint_on_request so a test reaches it. --- src/app.rs | 244 +++++++++++++++++++++++++++++++++++++++--------- src/ui/mouse.rs | 8 +- 2 files changed, 204 insertions(+), 48 deletions(-) diff --git a/src/app.rs b/src/app.rs index 1af08805..5bfd8e89 100644 --- a/src/app.rs +++ b/src/app.rs @@ -432,14 +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); - refresh_keyframe_if_due(terminal, &mut input, &mut keyframe, Instant::now())?; - 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 { @@ -448,20 +455,21 @@ fn run_board_loop( |model: &BoardModel| { let area = terminal_area(terminal)?; sync_frame_presentation(area, model); - refresh_keyframe_if_due( + paint_with_keyframe( terminal, &mut input, &mut keyframe, Instant::now(), - )?; - 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(|_| ()) + |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, @@ -488,9 +496,7 @@ fn run_board_loop( event::read()? }; reflow_click.observe(&next); - if is_full_repaint_request(&next) { - input.reassert(true)?; - forget_frame(terminal)?; + if repaint_on_request(terminal, &mut input, &next)? { continue; } let event_area = match &next { @@ -840,6 +846,26 @@ fn is_full_repaint_request(event: &Event) -> bool { } } +/// 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, +) -> io::Result +where + B: ratatui::backend::Backend, + W: io::Write, +{ + if !is_full_repaint_request(event) { + return Ok(false); + } + 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 @@ -876,23 +902,29 @@ impl Keyframe { } } -/// Every [`KEYFRAME_INTERVAL`], re-send mouse, focus and paste reporting and make the next -/// paint write every non-blank cell, without clearing the screen. +/// 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. Dropping the last frame (`swap_buffers` resets the previous buffer) -/// rewrites cells with what is already there, so a live terminal shows no change. -fn refresh_keyframe_if_due( +/// 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(()) } @@ -7178,9 +7210,22 @@ mod tests { 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 - /// next keyframe re-sends the modes and rewrites every painted cell, with no clear, so - /// a live terminal sees nothing change and a replayed one gets the whole board back. + /// 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); @@ -7189,42 +7234,153 @@ mod tests { let mut terminal = Terminal::new(backend).expect("test terminal"); let mut out = Vec::new(); let mut input = - crate::ui::mouse::enable_terminal_input_on(&mut out, false).expect("input guard"); + crate::ui::mouse::enable_terminal_input_on(&mut out, true).expect("input guard"); + input.take_written(); let start = Instant::now(); let mut keyframe = Keyframe::new(start); - terminal - .draw(|frame| drop(draw_board(frame, &model))) - .expect("draw"); + 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; - - refresh_keyframe_if_due(&mut terminal, &mut input, &mut keyframe, start).expect("early"); - terminal - .draw(|frame| drop(draw_board(frame, &model))) - .expect("diff draw"); + 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" + ); - input.reassert(false).expect("warm"); - let resent_before = input.written_len(); - refresh_keyframe_if_due( + paint_with_keyframe( &mut terminal, &mut input, &mut keyframe, start + KEYFRAME_INTERVAL, + paint, ) .expect("keyframe"); - assert!(input.written_len() > resent_before, "modes re-sent"); - terminal - .draw(|frame| drop(draw_board(frame, &model))) - .expect("keyframe draw"); 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"); + for mode in ["\x1b[?1000h", "\x1b[?1004h", "\x1b[?2004h"] { + 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(); + let mut input = + crate::ui::mouse::enable_terminal_input_on(&mut out, true).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"); + for mode in [ + "\x1b[?1049h", + "\x1b[?1000h", + "\x1b[?1004h", + "\x1b[?2004h", + "\x1b[>1u", + ] { + assert!(resent.contains(mode), "{request:?}: {mode} in {resent:?}"); + } + 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] diff --git a/src/ui/mouse.rs b/src/ui/mouse.rs index e076d411..5ab1dd08 100644 --- a/src/ui/mouse.rs +++ b/src/ui/mouse.rs @@ -129,13 +129,13 @@ pub(crate) struct TerminalInputGuard { } impl TerminalInputGuard { - /// Bytes written so far, for tests that check a re-send happened. + /// Take the bytes written so far, for tests that check what a re-send wrote. #[cfg(test)] - pub(crate) fn written_len(&self) -> usize + pub(crate) fn take_written(&mut self) -> Vec where - W: std::ops::Deref>, + W: std::ops::DerefMut>, { - self.out.len() + std::mem::take(&mut *self.out) } /// Send every capability this guard enabled again. From 52c4b481b55ac12f44ac05e11b7eee4ec54a6336 Mon Sep 17 00:00:00 2001 From: Saeed Marzban Date: Sat, 14 Nov 2026 10:06:48 +0200 Subject: [PATCH 3/4] Tests: no keyboard flags on Windows, where crossterm refuses them PushKeyboardEnhancementFlags always takes the WinAPI path on Windows and returns Unsupported; the board never enables it there (supports_keyboard_enhancement is false), so only the tests asked. --- src/app.rs | 19 ++++++++++--------- src/ui/mouse.rs | 12 ++++++++---- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/app.rs b/src/app.rs index 5bfd8e89..5c5366e2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7233,8 +7233,10 @@ mod tests { 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, true).expect("input guard"); + 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); @@ -7340,8 +7342,10 @@ mod tests { 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, true).expect("input guard"); + crate::ui::mouse::enable_terminal_input_on(&mut out, keyboard).expect("input guard"); terminal .draw(|frame| drop(draw_board(frame, &model))) .expect("draw"); @@ -7355,15 +7359,12 @@ mod tests { 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"); - for mode in [ - "\x1b[?1049h", - "\x1b[?1000h", - "\x1b[?1004h", - "\x1b[?2004h", - "\x1b[>1u", - ] { + for mode in ["\x1b[?1049h", "\x1b[?1000h", "\x1b[?1004h", "\x1b[?2004h"] { 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"); diff --git a/src/ui/mouse.rs b/src/ui/mouse.rs index 5ab1dd08..1a253f9d 100644 --- a/src/ui/mouse.rs +++ b/src/ui/mouse.rs @@ -1202,8 +1202,10 @@ mod terminal_input_tests { #[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, true).expect("enable"); + 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"); @@ -1226,9 +1228,11 @@ mod terminal_input_tests { 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"); - 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:?}"); + 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")); } } From 82f15a2385128bd48af789a680c140dd9696ff02 Mon Sep 17 00:00:00 2001 From: Saeed Marzban Date: Sat, 14 Nov 2026 10:12:28 +0200 Subject: [PATCH 4/4] Tests: no mouse code on Windows, where crossterm uses the console API EnableMouseCapture on Windows sets the console input mode instead of writing ?1000h, so the written bytes hold paste, focus and the alternate screen only. --- src/app.rs | 14 ++++++++++++-- src/ui/mouse.rs | 6 +++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/app.rs b/src/app.rs index 5c5366e2..a13f3ce4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7273,7 +7273,11 @@ mod tests { "a keyframe never clears" ); let resent = String::from_utf8(input.take_written()).expect("utf8"); - for mode in ["\x1b[?1000h", "\x1b[?1004h", "\x1b[?2004h"] { + // 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!( @@ -7359,7 +7363,13 @@ mod tests { 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"); - for mode in ["\x1b[?1049h", "\x1b[?1000h", "\x1b[?1004h", "\x1b[?2004h"] { + // 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 { diff --git a/src/ui/mouse.rs b/src/ui/mouse.rs index 1a253f9d..8e7a8bcb 100644 --- a/src/ui/mouse.rs +++ b/src/ui/mouse.rs @@ -1209,7 +1209,11 @@ mod terminal_input_tests { guard.out.clear(); guard.reassert(false).expect("light reassert"); let light = String::from_utf8(std::mem::take(guard.out)).expect("utf8"); - for mode in ["\x1b[?1000h", "\x1b[?1006h", "\x1b[?2004h", "\x1b[?1004h"] { + // 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!(