From 208143f01b41892a0df5dd7f9da87c9ae7dbdf75 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 29 May 2026 10:10:49 +0900 Subject: [PATCH] fix: scroll tui composer around cursor --- crates/tui/src/input.rs | 136 ++++++++++++++++++++++++++++++++++++ crates/tui/src/multi_pod.rs | 7 +- crates/tui/src/ui.rs | 16 ++++- 3 files changed, 155 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/input.rs b/crates/tui/src/input.rs index 59a48bb7..d71e33db 100644 --- a/crates/tui/src/input.rs +++ b/crates/tui/src/input.rs @@ -144,6 +144,8 @@ pub struct InputBuffer { atoms: Vec, /// Insertion point in `0..=atoms.len()`. cursor: usize, + /// Top wrapped row of the visible composer viewport. + scroll_offset: usize, /// Monotonic counter reused across the TUI process lifetime. next_paste_id: u32, } @@ -153,6 +155,7 @@ impl Default for InputBuffer { Self { atoms: Vec::new(), cursor: 0, + scroll_offset: 0, next_paste_id: 1, } } @@ -166,6 +169,7 @@ impl InputBuffer { pub fn clear(&mut self) { self.atoms.clear(); self.cursor = 0; + self.scroll_offset = 0; } pub fn is_empty(&self) -> bool { @@ -697,8 +701,27 @@ impl InputBuffer { lines, cursor_row, cursor_col, + viewport_start_row: 0, } } + + /// Clip a full render to `visible_height` rows, updating the stored + /// vertical scroll offset just enough to keep the cursor row visible. + pub fn apply_cursor_viewport(&mut self, render: &mut InputRender, visible_height: u16) { + let height = visible_height.max(1) as usize; + let total_rows = render.lines.len().max(1); + let max_offset = total_rows.saturating_sub(height); + self.scroll_offset = self.scroll_offset.min(max_offset); + + let cursor_row = render.cursor_row as usize; + if cursor_row < self.scroll_offset { + self.scroll_offset = cursor_row; + } else if cursor_row >= self.scroll_offset.saturating_add(height) { + self.scroll_offset = cursor_row.saturating_add(1).saturating_sub(height); + } + self.scroll_offset = self.scroll_offset.min(max_offset); + render.apply_viewport(self.scroll_offset, height); + } } /// Append a single char, wrapping to a new row first when it would @@ -746,6 +769,119 @@ pub struct InputRender { pub lines: Vec>, pub cursor_row: u16, pub cursor_col: u16, + /// First wrapped row included in `lines` after viewport clipping. + pub viewport_start_row: u16, +} + +impl InputRender { + fn apply_viewport(&mut self, offset: usize, height: usize) { + let offset = offset.min(self.lines.len().saturating_sub(1)); + self.viewport_start_row = offset as u16; + self.cursor_row = self.cursor_row.saturating_sub(self.viewport_start_row); + let lines = std::mem::take(&mut self.lines); + self.lines = lines.into_iter().skip(offset).take(height).collect(); + if self.lines.is_empty() { + self.lines.push(Line::raw("")); + } + } +} + +#[cfg(test)] +mod render_viewport_tests { + use super::*; + + fn buf_from(text: &str) -> InputBuffer { + let mut buf = InputBuffer::new(); + for c in text.chars() { + buf.insert_char(c); + } + buf + } + + fn render_lines(buf: &mut InputBuffer, width: u16, height: u16) -> Vec { + let mut render = buf.render(width); + buf.apply_cursor_viewport(&mut render, height); + render + .lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + }) + .collect() + } + + #[test] + fn short_input_rendering_stays_unscrolled() { + let mut buf = buf_from("one\ntwo"); + let mut render = buf.render(20); + buf.apply_cursor_viewport(&mut render, 5); + + assert_eq!(buf.scroll_offset, 0); + assert_eq!(render.viewport_start_row, 0); + assert_eq!(render.cursor_row, 1); + assert_eq!(render.cursor_col, 3); + assert_eq!(render_lines(&mut buf, 20, 5), ["one", "two"]); + } + + #[test] + fn input_viewport_follows_cursor_at_bottom() { + let mut buf = buf_from("0\n1\n2\n3\n4"); + let mut render = buf.render(20); + buf.apply_cursor_viewport(&mut render, 3); + + assert_eq!(buf.scroll_offset, 2); + assert_eq!(render.viewport_start_row, 2); + assert_eq!(render.cursor_row, 2); + assert_eq!(render.cursor_col, 1); + assert_eq!(render_lines(&mut buf, 20, 3), ["2", "3", "4"]); + } + + #[test] + fn input_viewport_scrolls_when_cursor_moves_above_or_below() { + let mut buf = buf_from("0\n1\n2\n3\n4"); + assert_eq!(render_lines(&mut buf, 20, 3), ["2", "3", "4"]); + assert_eq!(buf.scroll_offset, 2); + + buf.move_up(); + assert_eq!(render_lines(&mut buf, 20, 3), ["2", "3", "4"]); + assert_eq!(buf.scroll_offset, 2); + + buf.move_up(); + assert_eq!(render_lines(&mut buf, 20, 3), ["2", "3", "4"]); + assert_eq!(buf.scroll_offset, 2); + + buf.move_up(); + assert_eq!(render_lines(&mut buf, 20, 3), ["1", "2", "3"]); + assert_eq!(buf.scroll_offset, 1); + + buf.move_down(); + assert_eq!(render_lines(&mut buf, 20, 3), ["1", "2", "3"]); + assert_eq!(buf.scroll_offset, 1); + + buf.move_down(); + assert_eq!(render_lines(&mut buf, 20, 3), ["1", "2", "3"]); + assert_eq!(buf.scroll_offset, 1); + + buf.move_down(); + assert_eq!(render_lines(&mut buf, 20, 3), ["2", "3", "4"]); + assert_eq!(buf.scroll_offset, 2); + } + + #[test] + fn input_viewport_clamps_after_line_deletion() { + let mut buf = buf_from("0\n1\n2\n3\n4\n5"); + assert_eq!(render_lines(&mut buf, 20, 3), ["3", "4", "5"]); + assert_eq!(buf.scroll_offset, 3); + + for _ in 0..6 { + buf.delete_before(); + } + assert_eq!(render_lines(&mut buf, 20, 3), ["0", "1", "2"]); + assert_eq!(buf.scroll_offset, 0); + } } #[cfg(test)] diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index dee73f6d..5097bb2e 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -692,8 +692,10 @@ fn multi_pod_layout(area: Rect, input_height: u16) -> MultiPodLayoutState { fn draw(frame: &mut Frame<'_>, app: &mut MultiPodApp) { let area = frame.area(); let input_content_width = area.width.saturating_sub(2).max(1); - let input_render = app.input.render(input_content_width); + let mut input_render = app.input.render(input_content_width); let input_height = input_area_height(&input_render, area.height); + app.input + .apply_cursor_viewport(&mut input_render, input_height); let layout = multi_pod_layout(area, input_height); draw_title(frame, layout.title); @@ -873,7 +875,8 @@ fn draw_target_status(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRender, area: Rect) { let mut lines: Vec> = Vec::with_capacity(render.lines.len()); for (i, src) in render.lines.iter().enumerate() { - let prefix = if i == 0 { "> " } else { " " }; + let absolute_row = render.viewport_start_row as usize + i; + let prefix = if absolute_row == 0 { "> " } else { " " }; let mut spans = vec![Span::styled(prefix, Style::default().fg(Color::DarkGray))]; spans.extend(src.spans.iter().cloned()); lines.push(Line::from(spans)); diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 759eba7c..5ef06cb9 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -65,12 +65,19 @@ pub fn draw(frame: &mut Frame, app: &mut App) { // Input content starts after the prompt (`> ` or `: `), so the width // available for wrapping is two columns narrower than the frame. let input_content_width = area.width.saturating_sub(2).max(1); - let input_render = if app.is_command_mode() { + let mut input_render = if app.is_command_mode() { app.command_input.render(input_content_width) } else { app.input.render(input_content_width) }; let input_height = input_area_height(&input_render, area.height); + if app.is_command_mode() { + app.command_input + .apply_cursor_viewport(&mut input_render, input_height); + } else { + app.input + .apply_cursor_viewport(&mut input_render, input_height); + } let mini_view_h = task_mini_view_height(&app.task_store); // One blank row separates the history tail from the mini-view so // the latest message doesn't visually crash into the task summary. @@ -1284,7 +1291,12 @@ fn draw_input(frame: &mut Frame, app: &App, render: &crate::input::InputRender, }; let mut lines: Vec> = Vec::with_capacity(render.lines.len()); for (i, src) in render.lines.iter().enumerate() { - let prefix = if i == 0 { prompt } else { continuation }; + let absolute_row = render.viewport_start_row as usize + i; + let prefix = if absolute_row == 0 { + prompt + } else { + continuation + }; let mut spans = vec![Span::styled(prefix.to_owned(), prompt_style)]; spans.extend(src.spans.iter().cloned()); lines.push(Line::from(spans));