Keep fullscreen prompts bounded and scrollable (#50109)

## Why

Long drafts need to remain browsable while leaving room for the transcript. Remote image attachments also need to leave an editable prompt row visible.

## What changed

- Cap the fullscreen composer, including padding and hints, at two-thirds of the screen, with a minimum of eight rows when the screen allows.
- Support mouse-wheel browsing without moving the caret or changing transcript selection, search, or activity focus. Show arrows for hidden prompt rows and hide the cursor when the caret is offscreen.
- Resume caret following on editor input, paste, and external edits.
- Keep the selected remote image visible while reserving space for prompt text.

## Testing

Add regression tests and snapshots for fullscreen scrolling, caret restoration after navigation, paste and external edits, remote image navigation and deletion, resizing at scroll boundaries, and disconnected editing.

GitOrigin-RevId: 9a1e4ce14b3b3747e6d136207546327531339258
This commit is contained in:
Ian MacLeod
2026-10-01 22:00:12 +00:00
committed by copyberry
parent e58b493329
commit 6ece7bfc21
19 changed files with 673 additions and 88 deletions
+40 -23
View File
@@ -116,16 +116,24 @@ impl App {
/*height*/ 1,
));
let footer = view.footer_with_navigation(footer_area.width, motion, latest_navigation);
let bottom = chat_widget.bottom_pane_renderable(
prompt_footer.as_ref().or(footer.as_ref()),
if view.has_active_interaction() {
crate::bottom_pane::CommandPopupPlacement::Hidden
} else {
crate::bottom_pane::CommandPopupPlacement::Overlay
},
composer_gap.as_ref(),
working_tip,
);
// Cap the whole composer (including padding and hints) at two-thirds of the screen,
// but allow at least 8 rows on small screens, without exceeding the screen itself.
let max_composer_height = ((u32::from(screen_size.height) * 2 / 3) as u16)
.max(8)
.min(screen_size.height);
let bottom =
chat_widget.bottom_pane_renderable(crate::bottom_pane::ComposerRenderOptions {
max_height: Some(max_composer_height),
footer: prompt_footer.as_ref().or(footer.as_ref()),
command_popup_placement: if view.has_active_interaction() {
crate::bottom_pane::CommandPopupPlacement::Hidden
} else {
crate::bottom_pane::CommandPopupPlacement::Overlay
},
composer_gap: composer_gap.as_ref(),
working_tip,
..Default::default()
});
let dashboard_visible = chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.is_some();
@@ -185,16 +193,19 @@ impl App {
{
*progress = crate::bottom_pane::footer_hint_items_line(&items);
}
let bottom = chat_widget.bottom_pane_renderable(
prompt_footer.as_ref().or(footer.as_ref()),
if view.has_active_interaction() {
crate::bottom_pane::CommandPopupPlacement::Hidden
} else {
crate::bottom_pane::CommandPopupPlacement::Overlay
},
composer_gap.as_ref(),
working_tip,
);
let bottom =
chat_widget.bottom_pane_renderable(crate::bottom_pane::ComposerRenderOptions {
max_height: Some(max_composer_height),
footer: prompt_footer.as_ref().or(footer.as_ref()),
command_popup_placement: if view.has_active_interaction() {
crate::bottom_pane::CommandPopupPlacement::Hidden
} else {
crate::bottom_pane::CommandPopupPlacement::Overlay
},
composer_gap: composer_gap.as_ref(),
working_tip,
..Default::default()
});
footer_height_changed = !dashboard_visible
&& bottom
.desired_height(screen_size.width)
@@ -361,9 +372,15 @@ impl App {
return Ok(true);
}
if composer_ready && self.chat_widget.handle_composer_mouse(*mouse) {
self.transcript_view.end_selection(&self.transcript_cells);
self.transcript_view.cancel_search();
self.transcript_view.clear_activity_focus();
if !matches!(
mouse.kind,
crossterm::event::MouseEventKind::ScrollUp
| crossterm::event::MouseEventKind::ScrollDown
) {
self.transcript_view.end_selection(&self.transcript_cells);
self.transcript_view.cancel_search();
self.transcript_view.clear_activity_focus();
}
tui.frame_requester().schedule_frame();
return Ok(true);
}
+152 -6
View File
@@ -511,12 +511,11 @@ async fn owned_details_keep_the_composer_cursor_and_screen() -> Result<()> {
app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 24))?;
let expected_cursor = app
.chat_widget
.bottom_pane_renderable(
/*footer*/ None,
crate::bottom_pane::CommandPopupPlacement::Overlay,
Some(&crate::bottom_pane::ComposerGap::default()),
/*working_tip*/ None,
)
.bottom_pane_renderable(crate::bottom_pane::ComposerRenderOptions {
command_popup_placement: crate::bottom_pane::CommandPopupPlacement::Overlay,
composer_gap: Some(&crate::bottom_pane::ComposerGap::default()),
..Default::default()
})
.cursor_pos(bottom_area)
.expect("composer cursor");
assert_eq!(
@@ -1537,3 +1536,150 @@ async fn fullscreen_composer_mouse_copy_and_input_ownership() -> Result<()> {
tui.set_owned_screen(/*owned*/ false)?;
Ok(())
}
#[tokio::test]
async fn fullscreen_composer_scrolls_without_moving_caret_or_transcript() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
let size = Size::new(/*width*/ 60, /*height*/ 24);
tui.terminal.resize(size)?;
app.transcript_cells = vec![Arc::new(crate::history_cell::PlainHistoryCell::new(
(1..=40).map(|n| format!("History {n:02}").into()).collect(),
))];
let draft = (1..=35)
.map(|n| format!("Prompt {n:02} 界"))
.collect::<Vec<_>>()
.join("\n");
app.chat_widget.apply_external_edit(draft.clone());
let bottom = app.render_owned_transcript(&mut tui, size)?;
assert!(bottom.y > 0);
let caret = tui.terminal.last_known_cursor_pos;
let original = app.chat_widget.capture_thread_input_state();
let mut frames = Vec::new();
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
frames.push(format!("Following caret\n{}", buffer_text(buffer)));
let wheel = mouse(MouseEventKind::ScrollUp, caret.x, caret.y);
for _ in 0..3 {
assert!(app.handle_owned_transcript_event(&mut tui, &mut server, &wheel)?);
}
assert_eq!(app.chat_widget.capture_thread_input_state(), original);
assert_eq!(app.render_owned_transcript(&mut tui, size)?, bottom);
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
let rendered = buffer_text(buffer);
assert!(!rendered.contains("Prompt 35"), "{rendered}");
assert!(rendered.contains("History 40"), "{rendered}");
frames.push(format!("Wheel up\n{rendered}"));
// The new draft key is applied at the real caret even while that caret is offscreen.
app.chat_widget.handle_key_event(KeyCode::Left.into());
app.render_owned_transcript(&mut tui, size)?;
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
assert!(buffer_text(buffer).contains("Prompt 35"));
frames.push(format!("Edit at caret\n{}", buffer_text(buffer)));
// Returning from an external editor restores the caret even for a same-length edit.
app.chat_widget.handle_key_event(KeyCode::Right.into());
app.render_owned_transcript(&mut tui, size)?;
let caret = tui.terminal.last_known_cursor_pos;
for _ in 0..3 {
assert!(app.handle_owned_transcript_event(
&mut tui,
&mut server,
&mouse(MouseEventKind::ScrollUp, caret.x, caret.y),
)?);
}
app.chat_widget
.apply_external_edit(draft.replace("Prompt", "Edited"));
app.render_owned_transcript(&mut tui, size)?;
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
assert!(buffer_text(buffer).contains("Edited 35"));
// Pasting while browsing also resumes following, without inserting into a visible earlier row.
let caret = tui.terminal.last_known_cursor_pos;
for _ in 0..3 {
assert!(app.handle_owned_transcript_event(
&mut tui,
&mut server,
&mouse(MouseEventKind::ScrollUp, caret.x, caret.y),
)?);
}
app.chat_widget.handle_paste(" pasted here".to_string());
app.render_owned_transcript(&mut tui, size)?;
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
let rendered = buffer_text(buffer);
assert!(
rendered
.lines()
.any(|line| line.contains("Edited 35") && line.contains("pasted here")),
"{rendered}"
);
frames.push(format!("Paste at caret\n{rendered}"));
// A shorter screen still leaves a usable composer.
let small = Size::new(/*width*/ 40, /*height*/ 10);
tui.terminal.resize(small)?;
app.render_owned_transcript(&mut tui, small)?;
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
frames.push(format!("Short screen\n{}", buffer_text(buffer)));
insta::assert_snapshot!(
"fullscreen_prompt_wheel",
normalize_snapshot_paths(frames.join("\n\n"))
);
server.shutdown().await?;
tui.set_owned_screen(/*owned*/ false)?;
Ok(())
}
#[tokio::test]
async fn fullscreen_composer_remote_images_leave_an_editable_prompt_row() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
let size = Size::new(/*width*/ 60, /*height*/ 12);
tui.terminal.resize(size)?;
app.transcript_cells = vec![Arc::new(crate::history_cell::PlainHistoryCell::new(vec![
"History stays visible".into(),
]))];
app.chat_widget
.apply_external_edit("Edit this prompt".to_string());
app.chat_widget.set_remote_image_urls(
(1..=12)
.map(|n| format!("https://example.com/image-{n}.png"))
.collect(),
);
let bottom = app.render_owned_transcript(&mut tui, size)?;
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
let rendered = buffer_text(buffer);
assert!(bottom.y > 0);
assert!(rendered.contains("Edit this prompt"), "{rendered}");
let mut frames = vec![format!("Draft\n{rendered}")];
app.chat_widget.handle_key_event(KeyCode::Home.into());
for (key, label) in [
(KeyCode::Up, "[Image #12]"),
(KeyCode::Up, "[Image #11]"),
(KeyCode::Delete, "[Image #11]"),
(KeyCode::Down, "Edit this prompt"),
] {
app.chat_widget.handle_key_event(key.into());
app.render_owned_transcript(&mut tui, size)?;
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
let rendered = buffer_text(buffer);
assert!(rendered.contains(label), "{key:?}: {rendered}");
assert!(rendered.contains("Edit this prompt"), "{key:?}: {rendered}");
frames.push(format!("{key:?}\n{rendered}"));
}
assert_eq!(
app.chat_widget.remote_image_urls(),
(1..=12)
.filter(|n| *n != 11)
.map(|n| format!("https://example.com/image-{n}.png"))
.collect::<Vec<_>>()
);
insta::assert_snapshot!(
"fullscreen_prompt_remote_images",
normalize_snapshot_paths(frames.join("\n\n"))
);
tui.set_owned_screen(/*owned*/ false)?;
Ok(())
}
@@ -0,0 +1,73 @@
---
source: tui/src/app/owned_transcript_tests.rs
assertion_line: 1654
expression: "normalize_snapshot_paths(frames.join(\"\\n\\n\"))"
---
Draft
History stays visible
[Image #1]
[Image #2]
› Edit this prompt
GPT-5.6-Sol default · /tmp/project
Up
History stays visible
[Image #11]
[Image #12]
› Edit this prompt
GPT-5.6-Sol default · /tmp/project
Up
History stays visible
[Image #10]
[Image #11]
› Edit this prompt
GPT-5.6-Sol default · /tmp/project
Delete
History stays visible
[Image #10]
[Image #11]
› Edit this prompt
GPT-5.6-Sol default · /tmp/project
Down
History stays visible
[Image #1]
[Image #2]
› Edit this prompt
GPT-5.6-Sol default · /tmp/project
@@ -0,0 +1,119 @@
---
source: tui/src/app/owned_transcript_tests.rs
assertion_line: 1600
expression: "normalize_snapshot_paths(frames.join(\"\\n\\n\"))"
---
Following caret
History 34
History 35
History 36
History 37
History 38
History 39
History 40
› Prompt 24 界 ↑
Prompt 25 界
Prompt 26 界
Prompt 27 界
Prompt 28 界
Prompt 29 界
Prompt 30 界
Prompt 31 界
Prompt 32 界
Prompt 33 界
Prompt 34 界
Prompt 35 界
GPT-5.6-Sol default · /tmp/project
Wheel up
History 34
History 35
History 36
History 37
History 38
History 39
History 40
› Prompt 15 界 ↑
Prompt 16 界
Prompt 17 界
Prompt 18 界
Prompt 19 界
Prompt 20 界
Prompt 21 界
Prompt 22 界
Prompt 23 界
Prompt 24 界
Prompt 25 界
Prompt 26 界 ↓
GPT-5.6-Sol default · /tmp/project
Edit at caret
History 34
History 35
History 36
History 37
History 38
History 39
History 40
› Prompt 24 界 ↑
Prompt 25 界
Prompt 26 界
Prompt 27 界
Prompt 28 界
Prompt 29 界
Prompt 30 界
Prompt 31 界
Prompt 32 界
Prompt 33 界
Prompt 34 界
Prompt 35 界
GPT-5.6-Sol default · /tmp/project
Paste at caret
History 34
History 35
History 36
History 37
History 38
History 39
History 40
› Edited 24 界 ↑
Edited 25 界
Edited 26 界
Edited 27 界
Edited 28 界
Edited 29 界
Edited 30 界
Edited 31 界
Edited 32 界
Edited 33 界
Edited 34 界
Edited 35 界 pasted here
GPT-5.6-Sol default · /tmp/project
Short screen
History 40
› Edited 32 界 ↑
Edited 33 界
Edited 34 界
Edited 35 界 pasted here
GPT-5.6-Sol default · /tmp/project
+41 -3
View File
@@ -14,6 +14,7 @@
//! Shortcut help expands above the composer, with its close hint replacing the final shortcuts row
//! so input and persistent status stay anchored when help opens or closes.
//! Escape dismisses visible shortcut help before editing, transcript backtracking, or interruption.
//! Fullscreen limits the composer height; wheel browsing preserves the caret until editor input.
//! Transcript interactions borrow the hint footer through `ComposerRenderOptions`;
//! its resolved presentation drives height, painting, and the focused cursor together.
//!
@@ -1277,6 +1278,7 @@ impl ChatComposer {
self.attachments.local_images = kept_images;
// Import literally so placeholders remain atomic and Replace recovery starts empty.
self.draft.textarea_state.get_mut().follow_cursor();
self.draft.textarea.set_text_clearing_elements("");
let mut remaining: HashMap<&str, usize> = HashMap::new();
for img in &self.attachments.local_images {
@@ -1531,6 +1533,7 @@ impl ChatComposer {
// Clear any existing content, placeholders, and attachments first.
self.footer.flash = None;
self.vim_history = VimHistory::default();
self.draft.textarea_state.get_mut().follow_cursor();
self.draft.textarea.set_text_clearing_elements("");
self.draft.is_bash_mode = false;
self.draft.pending_pastes.clear();
@@ -1912,6 +1915,7 @@ impl ChatComposer {
return (InputResult::None, false);
}
self.draft.textarea_state.get_mut().follow_cursor();
let before = self.before_sparkle_key(key_event);
let result = self.handle_key_event_inner(key_event);
self.after_sparkle_key(before, &result.0);
@@ -4786,9 +4790,20 @@ impl ChatComposer {
let style = user_message_style();
Block::default().style(style).render(composer_rect, buf);
if !remote_images_rect.is_empty() {
Paragraph::new(self.attachments.remote_image_lines())
.style(style)
.render(remote_images_rect, buf);
let first = self
.attachments
.selected_remote_image_index
.unwrap_or_default()
.saturating_sub(usize::from(remote_images_rect.height) - 1);
Paragraph::new(
self.attachments
.remote_image_lines()
.into_iter()
.skip(first)
.collect::<Vec<_>>(),
)
.style(style)
.render(remote_images_rect, buf);
}
if !textarea_rect.is_empty() {
let prompt = if self.draft.input_enabled {
@@ -4821,6 +4836,9 @@ impl ChatComposer {
}
let mut state = self.draft.textarea_state.borrow_mut();
if options.max_height.is_none() {
state.follow_cursor();
}
let textarea_is_empty = self.draft.textarea.text().is_empty() && !self.draft.is_bash_mode;
if self.draft.input_enabled {
if let Some(mask_char) = mask_char {
@@ -4870,6 +4888,26 @@ impl ChatComposer {
Line::from(vec![placeholder]).render(textarea_rect.inner(Margin::new(0, 0)), buf);
}
}
// The reserved right margin indicates hidden rows without changing wrapping.
if options.max_height.is_some()
&& self.draft.input_enabled
&& !textarea_rect.is_empty()
&& textarea_rect.right() < composer_rect.right()
{
if state.scroll > 0 {
buf.set_span(textarea_rect.right(), textarea_rect.y, &"↑".dim(), 1);
}
if state.scroll.saturating_add(textarea_rect.height)
< self.draft.textarea.desired_height(textarea_rect.width)
{
buf.set_span(
textarea_rect.right(),
textarea_rect.bottom() - 1,
&"↓".dim(),
1,
);
}
}
if matches!(self.popups.active, ActivePopup::None)
&& let Some(ignition) = &self.effort_ignition
&& !ignition.is_finished()
@@ -116,7 +116,7 @@ impl ChatComposer {
.len()
.try_into()
.unwrap_or(u16::MAX)
.min(textarea_rect.height.saturating_sub(1));
.min(textarea_rect.height.saturating_sub(2));
let remote_images_separator = u16::from(remote_images_height > 0);
let consumed = remote_images_height.saturating_add(remote_images_separator);
let remote_images_rect = Rect {
@@ -68,6 +68,8 @@ pub(crate) enum CommandPopupPlacement {
/// A borrowed presentation shared by measurement, painting, and cursor placement.
#[derive(Clone, Copy, Default)]
pub(crate) struct ComposerRenderOptions<'a> {
/// Fullscreen limits the composer independently of activity and modal views.
pub(crate) max_height: Option<u16>,
/// Reserve a shared hint row independently of whether it currently contains a notice.
pub(crate) composer_gap: Option<&'a crate::bottom_pane::ComposerGap>,
pub(crate) working_tip: Option<&'a crate::turn_tip::TurnTip>,
@@ -2,6 +2,7 @@
//! editable text using the textarea's last rendered viewport and hides completion suggestions.
//! Double/triple clicks select words/logical lines using the transcript's shared gesture rules.
//! Copy preserves the draft and cursor; confirmed copies clear selection for every gesture.
//! Wheel browsing uses the current viewport without changing popup or selection ownership.
use super::*;
use crate::clipboard_copy::CopyStatus;
@@ -85,11 +86,19 @@ impl ChatComposer {
/// Dispatch after preparation and rendering have refreshed the viewport.
pub(crate) fn handle_mouse(&mut self, event: MouseEvent) -> bool {
if self
.draft
.textarea
.scroll_mouse(event, &mut self.draft.textarea_state.borrow_mut())
{
return true;
}
let handled = self
.draft
.textarea
.handle_mouse(event, *self.draft.textarea_state.borrow());
if handled {
self.draft.textarea_state.borrow_mut().follow_cursor();
self.attachments.clear_remote_image_selection();
self.sync_popups();
}
@@ -138,6 +138,7 @@ impl ChatComposer {
query.editor.insert_str(&pasted);
return true;
}
self.draft.textarea_state.get_mut().follow_cursor();
let started_vim_edit = self.begin_direct_vim_edit();
let elements_before = self.draft.textarea.element_payloads();
let target = self.draft.textarea.edit_target();
@@ -104,6 +104,7 @@ impl ChatComposer {
let before = if key.code == KeyCode::Null {
None
} else {
self.draft.textarea_state.get_mut().follow_cursor();
self.before_sparkle_editor_key(key)
};
let (result, _) = self.handle_input_basic(key);
@@ -2,8 +2,66 @@
use super::super::tests::new_test_composer;
use super::*;
use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
use pretty_assertions::assert_eq;
#[test]
fn offline_delete_reveals_the_edited_row_without_moving_the_caret() {
let (mut composer, _) = new_test_composer();
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 8,
);
let options = ComposerRenderOptions {
max_height: Some(area.height),
..ComposerRenderOptions::default()
};
let text = (1..=20)
.map(|n| format!("line {n:02} abc"))
.collect::<Vec<_>>()
.join("\n");
composer.set_text_content(text.clone(), Vec::new(), Vec::new());
let cursor = text.len() - 2;
composer.draft.textarea.set_cursor(cursor);
let mut buffer = Buffer::empty(area);
composer.render_with_options(area, &mut buffer, /*mask_char*/ None, options);
let (x, y) = composer.cursor_pos_with_options(area, options).unwrap();
for _ in 0..3 {
assert!(composer.handle_mouse(MouseEvent {
kind: MouseEventKind::ScrollUp,
column: x,
row: y,
modifiers: KeyModifiers::NONE,
}));
}
// Disconnect cleanup and blocked submits must not count as edits.
for key in [KeyCode::Null, KeyCode::Enter, KeyCode::Tab] {
composer.handle_restricted_key(key.into(), RestrictedInputMode::Disconnected);
let mut buffer = Buffer::empty(area);
composer.render_with_options(area, &mut buffer, /*mask_char*/ None, options);
assert_eq!(composer.cursor_pos_with_options(area, options), None);
}
composer.handle_restricted_key(KeyCode::Delete.into(), RestrictedInputMode::Disconnected);
let mut buffer = Buffer::empty(area);
composer.render_with_options(area, &mut buffer, /*mask_char*/ None, options);
assert_eq!(
(composer.current_text(), composer.draft.textarea.cursor()),
(text.replace("line 20 abc", "line 20 ac"), cursor)
);
assert!(composer.cursor_pos_with_options(area, options).is_some());
let rows = buffer
.content
.chunks(usize::from(area.width))
.map(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!("offline_delete_after_wheel", rows);
}
#[test]
fn reconnect_expands_pastes_preserving_images_and_cursor() {
let (mut composer, _) = new_test_composer();
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer/reconnect_tests.rs
assertion_line: 58
expression: rows
---
› line 16 abc ↑
line 17 abc
line 18 abc
line 19 abc
line 20 ac
100% context left
+3 -1
View File
@@ -2300,7 +2300,8 @@ impl BottomPane {
flex2.push(/*flex*/ 1, RenderableItem::Owned(above_composer));
let composer: RenderableItem<'_> = if let Some(questions) = question_editor {
RenderableItem::Borrowed(questions.as_ref())
} else if options.textarea_right_reserve == 0
} else if options.max_height.is_none()
&& options.textarea_right_reserve == 0
&& options.warning_count == 0
&& options.footer.is_none()
&& !options.separate_status_line
@@ -2383,6 +2384,7 @@ impl Renderable for ChatComposerPresentation<'_> {
fn desired_height(&self, width: u16) -> u16 {
self.composer
.desired_height_with_options(width, self.options)
.min(self.options.max_height.unwrap_or(u16::MAX))
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
+35 -18
View File
@@ -15,7 +15,8 @@
//! breaks, interior separators hang off the preceding row without changing the editable text.
//! Visible web URLs carry their complete terminal hyperlink destination across wrapped rows;
//! masked rendering never exposes hyperlink destinations.
//! Mouse selection uses those same visual rows and keeps graphemes and elements atomic.
//! Mouse selection and wheel browsing use those same visual rows and keep graphemes and elements
//! atomic. Wheel browsing leaves the caret in place; moving or editing returns to caret following.
//! The editing module resolves replacement targets shared by insertion and paste-context inspection.
use crate::key_hint::KeyBindingListExt;
@@ -173,7 +174,15 @@ struct WrapCache {
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct TextAreaState {
/// Index into wrapped lines of the first visible line.
scroll: u16,
pub(in crate::bottom_pane) scroll: u16,
/// Caret at the time of manual scrolling. A changed caret resumes following automatically.
manual_cursor: Option<usize>,
}
impl TextAreaState {
pub(crate) fn follow_cursor(&mut self) {
self.manual_cursor = None;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -525,20 +534,18 @@ impl TextArea {
/// Returns an on-screen cursor position within `area`, accounting for wrapping and scrolling.
///
/// Returns `None` when the viewport has no visible cells.
/// Returns `None` when the viewport or caret is not visible.
pub fn cursor_pos_with_state(&self, area: Rect, state: TextAreaState) -> Option<(u16, u16)> {
if area.is_empty() {
return None;
}
let lines = self.wrapped_lines(area.width);
let effective_scroll = self.effective_scroll(area, &lines, state.scroll);
let effective_scroll = self.effective_scroll(area, &lines, state);
let (i, col) = wrapping::cursor_position(&self.text, &lines, area.width, self.cursor_pos)?;
let screen_row = i
.saturating_sub(effective_scroll as usize)
.try_into()
.unwrap_or(0);
Some((area.x + col as u16, area.y + screen_row))
let screen_row = i.checked_sub(usize::from(effective_scroll))?;
(screen_row < usize::from(area.height))
.then(|| (area.x + col as u16, area.y + screen_row as u16))
}
pub fn is_empty(&self) -> bool {
@@ -2094,10 +2101,10 @@ impl TextArea {
/// Calculate the scroll offset that should be used to satisfy the
/// invariants given the current area size and wrapped lines.
///
/// - Cursor is always on screen.
/// - Cursor is on screen unless the user scrolled away without moving it.
/// - No scrolling if content fits in the area.
fn effective_scroll(&self, area: Rect, lines: &[Range<usize>], current_scroll: u16) -> u16 {
let total_lines = lines.len() as u16;
fn effective_scroll(&self, area: Rect, lines: &[Range<usize>], state: TextAreaState) -> u16 {
let total_lines = u16::try_from(lines.len()).unwrap_or(u16::MAX);
if area.height >= total_lines {
return 0;
}
@@ -2107,7 +2114,10 @@ impl TextArea {
.map_or(0, |(row, _)| row) as u16;
let max_scroll = total_lines.saturating_sub(area.height);
let mut scroll = current_scroll.min(max_scroll);
let mut scroll = state.scroll.min(max_scroll);
if state.manual_cursor == Some(self.cursor_pos) {
return scroll;
}
// Ensure cursor is visible within [scroll, scroll + area_height)
if cursor_line_idx < scroll {
@@ -2139,7 +2149,7 @@ impl StatefulWidgetRef for &TextArea {
fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
self.rendered_area.set(area);
let lines = self.wrapped_lines(area.width);
let scroll = self.effective_scroll(area, &lines, state.scroll);
let scroll = self.effective_scroll(area, &lines, *state);
state.scroll = scroll;
let start = scroll as usize;
@@ -2158,7 +2168,7 @@ impl TextArea {
) {
self.rendered_area.set(area);
let lines = self.wrapped_lines(area.width);
let scroll = self.effective_scroll(area, &lines, state.scroll);
let scroll = self.effective_scroll(area, &lines, *state);
state.scroll = scroll;
let start = scroll as usize;
@@ -2180,7 +2190,7 @@ impl TextArea {
) {
self.rendered_area.set(area);
let lines = self.wrapped_lines(area.width);
let scroll = self.effective_scroll(area, &lines, state.scroll);
let scroll = self.effective_scroll(area, &lines, *state);
state.scroll = scroll;
let start = scroll as usize;
@@ -4246,7 +4256,10 @@ mod tests {
let area = Rect::new(2, 5, 20, 3);
// Even if an absurd scroll is provided, when content fits the area the
// effective scroll is 0 and the cursor position matches cursor_pos.
let bad_state = TextAreaState { scroll: 999 };
let bad_state = TextAreaState {
scroll: 999,
..Default::default()
};
let (x1, y1) = t.cursor_pos(area).unwrap();
let (x2, y2) = t.cursor_pos_with_state(area, bad_state).unwrap();
assert_eq!((x2, y2), (x1, y1));
@@ -4260,7 +4273,10 @@ mod tests {
// Put cursor somewhere near the end so it's definitely below the first window.
t.set_cursor(t.text().len().saturating_sub(2));
let small_area = Rect::new(0, 0, wrap_width, 2);
let state = TextAreaState { scroll: 0 };
let state = TextAreaState {
scroll: 0,
..Default::default()
};
let (_x, y) = t.cursor_pos_with_state(small_area, state).unwrap();
assert_eq!(y, small_area.y + small_area.height - 1);
@@ -4274,6 +4290,7 @@ mod tests {
let area = Rect::new(0, 0, wrap_width, 3);
let state = TextAreaState {
scroll: lines.saturating_mul(2),
..Default::default()
};
let (_x, y) = t.cursor_pos_with_state(area, state).unwrap();
assert_eq!(y, area.y);
@@ -63,6 +63,33 @@ impl TextArea {
(!range.is_empty()).then_some(range)
}
/// Consume events at the edges too, but only detach from the caret if the viewport moves.
pub(crate) fn scroll_mouse(&self, event: MouseEvent, state: &mut TextAreaState) -> bool {
let rows = match event.kind {
MouseEventKind::ScrollUp => -3,
MouseEventKind::ScrollDown => 3,
_ => return false,
};
if !event.modifiers.is_empty() || !self.contains_mouse(event) {
return false;
}
let area = self.rendered_area.get();
let lines = self.wrapped_lines(area.width);
let total = u16::try_from(lines.len()).unwrap_or(u16::MAX);
if total <= area.height {
return false;
}
let scroll = state
.scroll
.saturating_add_signed(rows)
.min(total - area.height);
if scroll != state.scroll {
state.scroll = scroll;
state.manual_cursor = Some(self.cursor_pos);
}
true
}
pub(crate) fn handle_mouse(&mut self, event: MouseEvent, state: TextAreaState) -> bool {
let area = self.rendered_area.get();
let dragging = self.mouse_selection.as_ref().is_some_and(|s| s.dragging);
@@ -26,6 +26,64 @@ fn mouse(t: &mut TextArea, state: TextAreaState, kind: MouseEventKind, x: u16, y
));
}
#[test]
fn wheel_at_edges_preserves_caret_following_or_browsing_after_resize() {
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 14, /*height*/ 4,
);
let down = MouseEvent {
kind: MouseEventKind::ScrollDown,
column: area.x,
row: area.y,
modifiers: KeyModifiers::NONE,
};
let up = MouseEvent {
kind: MouseEventKind::ScrollUp,
..down
};
let mut t = TextArea::new();
t.insert_str(
"line one abc\nline two abc\nline three ab\nline four abc\nline five abc\nline six abc",
);
let mut frames = Vec::new();
for resized in [Rect { height: 2, ..area }, Rect { width: 7, ..area }] {
for browsing in [false, true] {
let mut state = TextAreaState::default();
render(&t, area, &mut state);
if browsing {
// The first event scrolls to the top; the second is a no-op.
assert!(t.scroll_mouse(up, &mut state));
assert!(t.scroll_mouse(up, &mut state));
} else {
// The caret was already at the bottom; consume this without detaching it.
assert!(t.scroll_mouse(down, &mut state));
}
let buffer = render(&t, resized, &mut state);
let expected_cursor = if browsing {
None
} else {
t.cursor_pos(resized)
};
assert_eq!(t.cursor_pos_with_state(resized, state), expected_cursor);
let rows = buffer
.content
.chunks(usize::from(resized.width))
.map(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
frames.push(format!(
"{}x{} browsing={browsing}\n{rows}",
resized.width, resized.height
));
}
}
insta::assert_snapshot!("wheel_at_edges_after_resize", frames.join("\n\n"));
}
#[test]
fn mouse_click_maps_wrapping_unicode_tabs_and_empty_lines() {
let area = Rect::new(
@@ -0,0 +1,24 @@
---
source: tui/src/bottom_pane/textarea/mouse_tests.rs
assertion_line: 78
expression: "frames.join(\"\\n\\n\")"
---
14x2 browsing=false
line five abc
line six abc
14x2 browsing=true
line one abc
line two abc
7x4 browsing=false
abc
line
six abc
7x4 browsing=true
line
one abc
line
+12 -30
View File
@@ -132,12 +132,8 @@ impl ChatWidget {
.selected_index_for_active_view(crate::app::AGENTS_OVERVIEW_VIEW_ID)
.is_some()
{
return self.bottom_pane_renderable(
/*footer*/ None,
crate::bottom_pane::CommandPopupPlacement::AboveComposer,
/*composer_gap*/ None,
/*working_tip*/ None,
);
return self
.bottom_pane_renderable(crate::bottom_pane::ComposerRenderOptions::default());
}
let active_cell_right_reserve = self.ambient_pet_wrap_reserved_cols();
@@ -198,15 +194,10 @@ impl ChatWidget {
}
flex.push(
/*flex*/ 0,
self.bottom_pane_renderable(
/*footer*/ None,
crate::bottom_pane::CommandPopupPlacement::AboveComposer,
/*composer_gap*/ None,
/*working_tip*/ None,
)
.inset(Insets::tlbr(
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
)),
self.bottom_pane_renderable(crate::bottom_pane::ComposerRenderOptions::default())
.inset(Insets::tlbr(
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
)),
);
RenderableItem::Owned(Box::new(flex))
}
@@ -217,10 +208,7 @@ impl ChatWidget {
/// remain consistent. Owned transcripts reserve their shared hint row above the composer.
pub(crate) fn bottom_pane_renderable<'a>(
&'a self,
footer: Option<&'a crate::bottom_pane::TranscriptFooter>,
command_popup_placement: crate::bottom_pane::CommandPopupPlacement,
composer_gap: Option<&'a crate::bottom_pane::ComposerGap>,
working_tip: Option<&'a crate::turn_tip::TurnTip>,
mut options: crate::bottom_pane::ComposerRenderOptions<'a>,
) -> RenderableItem<'a> {
if self.fork_in_progress {
RenderableItem::Owned(Box::new(
@@ -244,17 +232,11 @@ impl ChatWidget {
} else {
self.ambient_pet_wrap_reserved_cols()
};
self.bottom_pane
.as_renderable_with_options(crate::bottom_pane::ComposerRenderOptions {
composer_gap,
working_tip,
warning_count: self.warning_display_state.count,
textarea_right_reserve: right_reserve,
separate_status_line: command_popup_placement
!= crate::bottom_pane::CommandPopupPlacement::AboveComposer,
command_popup_placement,
footer,
})
options.warning_count = self.warning_display_state.count;
options.textarea_right_reserve = right_reserve;
options.separate_status_line = options.command_popup_placement
!= crate::bottom_pane::CommandPopupPlacement::AboveComposer;
self.bottom_pane.as_renderable_with_options(options)
}
}
@@ -83,12 +83,10 @@ async fn owned_bottom_pane_preserves_draft_cursor_and_read_only_notice() {
/*x*/ 0, /*y*/ 8, /*width*/ 60, /*height*/ 8,
);
let render_bottom = |widget: &ChatWidget| {
let bottom = widget.bottom_pane_renderable(
/*footer*/ None,
crate::bottom_pane::CommandPopupPlacement::Overlay,
/*composer_gap*/ None,
/*working_tip*/ None,
);
let bottom = widget.bottom_pane_renderable(crate::bottom_pane::ComposerRenderOptions {
command_popup_placement: crate::bottom_pane::CommandPopupPlacement::Overlay,
..Default::default()
});
let mut buffer = Buffer::empty(area);
bottom.render(area, &mut buffer);
(buffer, bottom.cursor_pos(area), bottom.cursor_style(area))