mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
+73
@@ -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
|
||||
+119
@@ -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
|
||||
@@ -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();
|
||||
|
||||
+13
@@ -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
|
||||
@@ -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)> {
|
||||
|
||||
@@ -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(
|
||||
|
||||
+24
@@ -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
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user