fix: scroll tui composer around cursor
This commit is contained in:
parent
97f3df651a
commit
208143f01b
|
|
@ -144,6 +144,8 @@ pub struct InputBuffer {
|
|||
atoms: Vec<Atom>,
|
||||
/// 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<Line<'static>>,
|
||||
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<String> {
|
||||
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)]
|
||||
|
|
|
|||
|
|
@ -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<Line<'static>> = 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));
|
||||
|
|
|
|||
|
|
@ -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<Line<'static>> = 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));
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user