diff --git a/docs/architecture.md b/docs/architecture.md index 71f8f44..9cdccf1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,6 +40,8 @@ Frontends inspect `hover()` for optional read-only content and logical-line/grap The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active. +Redraws bracket Ratatui's buffer diff and final cursor placement with DEC synchronized updates (mode 2026), presenting complete frames on supporting terminals rather than exposing intermediate rows. The hardware cursor is hidden before painting even when that extension is unsupported. Each frame releases synchronization; terminal cleanup also releases it before leaving the alternate screen or invoking caller panic diagnostics. + The TUI uses Ratatui constraint-based layout for a flexible buffer viewport, a one-row status bar, and a one-row prompt/message area. Scrolling, clipping, highlights, and cursor placement use the allocated regions, not terminal-wide offsets. Below three rows the status bar is hidden; at one row the buffer remains visible unless an active command/search prompt takes priority. Zero-sized terminals render nothing. Layout and terminal-cell text mapping remain frontend-owned. The buffer viewport includes a fixed left gutter with right-aligned hybrid line numbers: the highlighted primary cursor line shows its one-based absolute number; every other line shows its distance from the primary. Secondary carets do not establish separate numbering origins. Gutter width follows the total line count plus one separator cell, including an empty final row after a trailing newline. Horizontal scrolling affects only text; search/selection highlights and carets never paint over the gutter. Rows beyond the buffer have no number. The gutter is hidden when it would leave no text column. diff --git a/docs/development.md b/docs/development.md index b0efc6c..9ccf8a3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -36,7 +36,8 @@ Rust tests live alongside their modules; native LSP integration checks are in [` - Markdown navigation checks cover inline/reference destinations, escaped/percent-encoded paths, heading markup and duplicates, same-document fragments, dirty target reuse, failed navigation atomicity, primary-only following, and `gf` prefix cancellation/configuration overrides. - Jump-history checks cover cross-buffer/same-document navigation, search and picker recording, counts and repeated visits, bounded history, branch truncation, closed buffers, edited-coordinate clamping, dirty text/undo preservation, Ctrl+i/Tab aliases, and custom bindings. Real LSP fixtures cover definition round-trips and delayed replies after leaving and returning to the same source location. - Session and Ruby checks exercise real Ruby execution, command/capability ownership, effect validation, configuration fallback, plugin retirement, crash/timeout recovery, and inherited-pipe cleanup. -- Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover cleanup on entry failure, normal return, I/O errors, and panics, including original panic-payload propagation. +- Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover cleanup on entry failure, normal return, I/O errors, and panics during synchronized redraw, including original panic-payload propagation. +- Redraw checks inspect actual Crossterm output during repeated scrolling: supporting terminals receive synchronized frames, terminals ignoring synchronization never paint with a visible hardware cursor, and the final caret remains visible. - Native LSP integration checks launch framed-protocol fixture subprocesses, covering initialization/settings, opt-in startup, synchronization, UTF-16/file-URI conversion, dirty targets, stale requests/diagnostics, and transport failures. Stack checks cover independent initialization, diagnostic merging/clearing, sibling failure isolation in active/parked buffers, same-revision rename/close identity, aggregate limits, first-server position routing, and both shutdowns. Semantic ordering uses protocol/file gates; elapsed deadlines only bound stuck subprocesses. - Hover regression checks cover content forms, sanitization, cancelled/superseded replies, invalid results, scrolling/dismissal, Unicode clipping, and preserved editing state. - Semantic-token checks cover full/range requests, legend/modifier remapping, UTF-16 boundaries and grapheme rounding, refresh/change coalescing, stale rename/close replies, null/empty/error fallback, primary/sibling retirement, and rendered cell precedence with syntax, diagnostics, search, selections, and carets. diff --git a/src/tui.rs b/src/tui.rs index 8919579..93706a7 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -15,7 +15,10 @@ use crossterm::{ KeyModifiers, }, execute, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, + terminal::{ + BeginSynchronizedUpdate, EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen, + disable_raw_mode, enable_raw_mode, + }, }; use ratatui::{ Frame, Terminal, @@ -56,6 +59,7 @@ fn restore_terminal() { let _ = disable_raw_mode(); let _ = execute!( io::stdout(), + EndSynchronizedUpdate, DisableBracketedPaste, LeaveAlternateScreen, Show @@ -134,6 +138,19 @@ impl BufferViewports { } } +fn draw_frame( + terminal: &mut Terminal>, + render: impl FnOnce(&mut Frame), +) -> io::Result<()> { + crossterm::queue!(terminal.backend_mut(), BeginSynchronizedUpdate)?; + // Terminals without synchronized updates still hide the painting cursor. + let result = terminal + .hide_cursor() + .and_then(|()| terminal.draw(render).map(|_| ())); + let finish = execute!(terminal.backend_mut(), EndSynchronizedUpdate); + result.and(finish) +} + /// Renders session state and translates terminal input; the session owns plugin /// execution and retirement, so the adapter needs no lifecycle callback. pub fn run(session: &mut EditorSession) -> io::Result<()> { @@ -145,7 +162,9 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> { while !session.editor().should_quit() { redraw |= session.poll(); if redraw { - terminal.draw(|frame| viewports.draw(frame, session.editor()))?; + draw_frame(&mut terminal, |frame| { + viewports.draw(frame, session.editor()) + })?; redraw = false; } if !event::poll(Duration::from_millis(25))? { @@ -4104,6 +4123,74 @@ end ); } + #[test] + fn scrolling_never_paints_with_an_exposed_cursor_even_without_synchronized_updates() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor + .apply(vec![crate::editor::Effect::Replace( + (0..40).map(|row| format!("row {row}\n")).collect(), + )]) + .unwrap(); + let mut output = Vec::new(); + let mut terminal = Terminal::with_options( + CrosstermBackend::new(&mut output), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 20, 5)), + }, + ) + .unwrap(); + let mut viewports = BufferViewports::default(); + for key in std::iter::repeat_n('j', 20).chain(std::iter::repeat_n('k', 20)) { + editor.handle(Input::Char(key)); + draw_frame(&mut terminal, |frame| viewports.draw(frame, &editor)).unwrap(); + } + drop(terminal); + let output = std::str::from_utf8(&output).unwrap(); + for supports_sync in [false, true] { + let (mut visible, mut synchronized) = (true, false); + let mut screen = [[b' '; 20]; 5]; + let (mut x, mut y) = (0, 0); + for escape in output.split('\x1b').skip(1) { + let rest = escape.strip_prefix('[').unwrap(); + let end = rest.find(|ch| ('@'..='~').contains(&ch)).unwrap(); + let (command, text) = rest.split_at(end + 1); + match command { + "?25h" => visible = true, + "?25l" => visible = false, + "?2026h" => synchronized = supports_sync, + "?2026l" => synchronized = false, + _ if command.ends_with('H') => { + let (row, column) = command[..command.len() - 1].split_once(';').unwrap(); + y = row.parse::().unwrap() - 1; + x = column.parse::().unwrap() - 1; + } + _ => {} + } + for byte in text.bytes().filter(|&byte| byte >= b' ' && byte != 127) { + assert!( + if supports_sync { + synchronized + } else { + !visible + }, + "partial frame exposed during redraw (sync={supports_sync}): {text:?}", + ); + if y < screen.len() && x < screen[y].len() { + screen[y][x] = byte; + } + x += 1; + } + } + assert!(visible); + assert!(!synchronized); + for (row, expected) in [(0, "row 0"), (1, "row 1"), (2, "row 2")] { + let line = std::str::from_utf8(&screen[row]).unwrap().trim_end(); + assert!(line.ends_with(expected), "{line:?}"); + } + assert_eq!(screen[y][x], b'r', "caret was not restored to the source"); + } + } + #[cfg(unix)] #[test] fn terminal_session_scopes_the_callers_panic_hook() { @@ -4125,7 +4212,15 @@ end match mode.as_str() { "normal" => Ok(()), "error" => Err(io::Error::from(io::ErrorKind::BrokenPipe)), - "panic" => panic::panic_any(73_u64), + "panic" => { + let mut terminal = Terminal::with_options( + CrosstermBackend::new(io::stdout()), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 20, 5)), + }, + )?; + draw_frame(&mut terminal, |_| panic::panic_any(73_u64)) + } "keys" => { let mut editor = Editor::open(None, vec![]).unwrap(); editor.handle(Input::Ctrl('p')); @@ -4244,6 +4339,9 @@ end "{mode}: {before:?}" ); if mode == "panic" { + let begin = before.find("\x1b[?2026h").unwrap(); + let end = before.find("\x1b[?2026l").unwrap(); + assert!(begin < end && end < before.find("CALLER_HOOK").unwrap()); assert!( before.find("\x1b[?1049l").unwrap() < before.find("CALLER_HOOK").unwrap(), "{before:?}",