tui: refactor ChatWidget and BottomPane to use Renderables (#5565)

- introduce RenderableItem to support both owned and borrowed children
in composite Renderables
- refactor some of our gnarlier manual layouts, BottomPane and
ChatWidget, to use ColumnRenderable
- Renderable and friends now handle cursor_pos()
This commit is contained in:
Jeremy Rose
2025-11-05 09:50:40 -08:00
committed by GitHub
parent 9a10e80ab7
commit 62474a30e8
27 changed files with 512 additions and 440 deletions

View File

@@ -9,6 +9,7 @@ use crate::file_search::FileSearchManager;
use crate::history_cell::HistoryCell;
use crate::pager_overlay::Overlay;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::Renderable;
use crate::resume_picker::ResumeSelection;
use crate::tui;
use crate::tui::TuiEvent;
@@ -233,7 +234,7 @@ impl App {
tui.draw(
self.chat_widget.desired_height(tui.terminal.size()?.width),
|frame| {
frame.render_widget_ref(&self.chat_widget, frame.area());
self.chat_widget.render(frame.area(), frame.buffer);
if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) {
frame.set_cursor_position((x, y));
}

View File

@@ -260,10 +260,6 @@ impl BottomPaneView for ApprovalOverlay {
self.enqueue_request(request);
None
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.list.cursor_pos(area)
}
}
impl Renderable for ApprovalOverlay {
@@ -274,6 +270,10 @@ impl Renderable for ApprovalOverlay {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.list.render(area, buf);
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.list.cursor_pos(area)
}
}
struct ApprovalRequestState {

View File

@@ -1,7 +1,6 @@
use crate::bottom_pane::ApprovalRequest;
use crate::render::renderable::Renderable;
use crossterm::event::KeyEvent;
use ratatui::layout::Rect;
use super::CancellationEvent;
@@ -27,11 +26,6 @@ pub(crate) trait BottomPaneView: Renderable {
false
}
/// Cursor position when this view is active.
fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> {
None
}
/// Try to handle approval request; return the original value if not
/// consumed.
fn try_consume_approval_request(

View File

@@ -35,6 +35,9 @@ use crate::bottom_pane::prompt_args::parse_slash_name;
use crate::bottom_pane::prompt_args::prompt_argument_names;
use crate::bottom_pane::prompt_args::prompt_command_with_arg_placeholders;
use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders;
use crate::render::Insets;
use crate::render::RectExt;
use crate::render::renderable::Renderable;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
use crate::style::user_message_style;
@@ -158,24 +161,6 @@ impl ChatComposer {
this
}
pub fn desired_height(&self, width: u16) -> u16 {
let footer_props = self.footer_props();
let footer_hint_height = self
.custom_footer_height()
.unwrap_or_else(|| footer_height(footer_props));
let footer_spacing = Self::footer_spacing(footer_hint_height);
let footer_total_height = footer_hint_height + footer_spacing;
const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1;
self.textarea
.desired_height(width.saturating_sub(COLS_WITH_MARGIN))
+ 2
+ match &self.active_popup {
ActivePopup::None => footer_total_height,
ActivePopup::Command(c) => c.calculate_required_height(width),
ActivePopup::File(c) => c.calculate_required_height(),
}
}
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
let footer_props = self.footer_props();
let footer_hint_height = self
@@ -190,18 +175,9 @@ impl ChatComposer {
ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()),
ActivePopup::None => Constraint::Max(footer_total_height),
};
let mut area = area;
if area.height > 1 {
area.height -= 1;
area.y += 1;
}
let [composer_rect, popup_rect] =
Layout::vertical([Constraint::Min(1), popup_constraint]).areas(area);
let mut textarea_rect = composer_rect;
textarea_rect.width = textarea_rect.width.saturating_sub(
LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */
);
textarea_rect.x = textarea_rect.x.saturating_add(LIVE_PREFIX_COLS);
Layout::vertical([Constraint::Min(3), popup_constraint]).areas(area);
let textarea_rect = composer_rect.inset(Insets::tlbr(1, LIVE_PREFIX_COLS, 1, 1));
[composer_rect, textarea_rect, popup_rect]
}
@@ -213,12 +189,6 @@ impl ChatComposer {
}
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let [_, textarea_rect, _] = self.layout_areas(area);
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
}
/// Returns true if the composer currently contains no user input.
pub(crate) fn is_empty(&self) -> bool {
self.textarea.is_empty()
@@ -1541,8 +1511,32 @@ impl ChatComposer {
}
}
impl WidgetRef for ChatComposer {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
impl Renderable for ChatComposer {
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let [_, textarea_rect, _] = self.layout_areas(area);
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
}
fn desired_height(&self, width: u16) -> u16 {
let footer_props = self.footer_props();
let footer_hint_height = self
.custom_footer_height()
.unwrap_or_else(|| footer_height(footer_props));
let footer_spacing = Self::footer_spacing(footer_hint_height);
let footer_total_height = footer_hint_height + footer_spacing;
const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1;
self.textarea
.desired_height(width.saturating_sub(COLS_WITH_MARGIN))
+ 2
+ match &self.active_popup {
ActivePopup::None => footer_total_height,
ActivePopup::Command(c) => c.calculate_required_height(width),
ActivePopup::File(c) => c.calculate_required_height(),
}
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let [composer_rect, textarea_rect, popup_rect] = self.layout_areas(area);
match &self.active_popup {
ActivePopup::Command(popup) => {
@@ -1591,16 +1585,15 @@ impl WidgetRef for ChatComposer {
}
}
let style = user_message_style();
let mut block_rect = composer_rect;
block_rect.y = composer_rect.y.saturating_sub(1);
block_rect.height = composer_rect.height.saturating_add(1);
Block::default().style(style).render_ref(block_rect, buf);
Block::default().style(style).render_ref(composer_rect, buf);
if !textarea_rect.is_empty() {
buf.set_span(
composer_rect.x,
composer_rect.y,
textarea_rect.x - LIVE_PREFIX_COLS,
textarea_rect.y,
&"".bold(),
composer_rect.width,
textarea_rect.width,
);
}
let mut state = self.textarea_state.borrow_mut();
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
@@ -1692,7 +1685,7 @@ mod tests {
let area = Rect::new(0, 0, 40, 6);
let mut buf = Buffer::empty(area);
composer.render_ref(area, &mut buf);
composer.render(area, &mut buf);
let row_to_string = |y: u16| {
let mut row = String::new();
@@ -1756,7 +1749,7 @@ mod tests {
let height = footer_lines + footer_spacing + 8;
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|f| f.render_widget_ref(composer, f.area()))
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.unwrap();
insta::assert_snapshot!(name, terminal.backend());
}
@@ -2276,7 +2269,7 @@ mod tests {
}
terminal
.draw(|f| f.render_widget_ref(composer, f.area()))
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}"));
insta::assert_snapshot!(name, terminal.backend());
@@ -2302,12 +2295,12 @@ mod tests {
// Type "/mo" humanlike so paste-burst doesnt interfere.
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
let mut terminal = match Terminal::new(TestBackend::new(60, 4)) {
let mut terminal = match Terminal::new(TestBackend::new(60, 5)) {
Ok(t) => t,
Err(e) => panic!("Failed to create terminal: {e}"),
};
terminal
.draw(|f| f.render_widget_ref(composer, f.area()))
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.unwrap_or_else(|e| panic!("Failed to draw composer: {e}"));
// Visual snapshot should show the slash popup with /model as the first entry.

View File

@@ -103,26 +103,6 @@ impl BottomPaneView for CustomPromptView {
self.textarea.insert_str(&pasted);
true
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if area.height < 2 || area.width <= 2 {
return None;
}
let text_area_height = self.input_height(area.width).saturating_sub(1);
if text_area_height == 0 {
return None;
}
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
let top_line_count = 1u16 + extra_offset;
let textarea_rect = Rect {
x: area.x.saturating_add(2),
y: area.y.saturating_add(top_line_count).saturating_add(1),
width: area.width.saturating_sub(2),
height: text_area_height,
};
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
}
}
impl Renderable for CustomPromptView {
@@ -232,6 +212,26 @@ impl Renderable for CustomPromptView {
);
}
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if area.height < 2 || area.width <= 2 {
return None;
}
let text_area_height = self.input_height(area.width).saturating_sub(1);
if text_area_height == 0 {
return None;
}
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
let top_line_count = 1u16 + extra_offset;
let textarea_rect = Rect {
x: area.x.saturating_add(2),
y: area.y.saturating_add(top_line_count).saturating_add(1),
width: area.width.saturating_sub(2),
height: text_area_height,
};
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
}
}
impl CustomPromptView {

View File

@@ -163,6 +163,12 @@ impl BottomPaneView for FeedbackNoteView {
self.textarea.insert_str(&pasted);
true
}
}
impl Renderable for FeedbackNoteView {
fn desired_height(&self, width: u16) -> u16 {
1u16 + self.input_height(width) + 3u16
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if area.height < 2 || area.width <= 2 {
@@ -182,12 +188,6 @@ impl BottomPaneView for FeedbackNoteView {
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
}
}
impl Renderable for FeedbackNoteView {
fn desired_height(&self, width: u16) -> u16 {
1u16 + self.input_height(width) + 3u16
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {

View File

@@ -3,19 +3,16 @@ use std::path::PathBuf;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::queued_user_messages::QueuedUserMessages;
use crate::render::Insets;
use crate::render::RectExt;
use crate::render::renderable::Renderable as _;
use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableItem;
use crate::tui::FrameRequester;
use bottom_pane_view::BottomPaneView;
use codex_file_search::FileMatch;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use std::time::Duration;
mod approval_overlay;
@@ -126,77 +123,6 @@ impl BottomPane {
self.request_redraw();
}
pub fn desired_height(&self, width: u16) -> u16 {
let top_margin = 1;
// Base height depends on whether a modal/overlay is active.
let base = match self.active_view().as_ref() {
Some(view) => view.desired_height(width),
None => {
let status_height = self
.status
.as_ref()
.map_or(0, |status| status.desired_height(width));
let queue_height = self.queued_user_messages.desired_height(width);
let spacing_height = if status_height == 0 && queue_height == 0 {
0
} else {
1
};
self.composer
.desired_height(width)
.saturating_add(spacing_height)
.saturating_add(status_height)
.saturating_add(queue_height)
}
};
// Account for bottom padding rows. Top spacing is handled in layout().
base.saturating_add(top_margin)
}
fn layout(&self, area: Rect) -> [Rect; 2] {
// At small heights, bottom pane takes the entire height.
let top_margin = if area.height <= 1 { 0 } else { 1 };
let area = area.inset(Insets::tlbr(top_margin, 0, 0, 0));
if self.active_view().is_some() {
return [Rect::ZERO, area];
}
let has_queue = !self.queued_user_messages.messages.is_empty();
let mut status_height = self
.status
.as_ref()
.map_or(0, |status| status.desired_height(area.width))
.min(area.height.saturating_sub(1));
if has_queue && status_height > 1 {
status_height = status_height.saturating_sub(1);
}
let combined_height = status_height
.saturating_add(self.queued_user_messages.desired_height(area.width))
.min(area.height.saturating_sub(1));
let [status_area, _, content_area] = Layout::vertical([
Constraint::Length(combined_height),
Constraint::Length(if combined_height == 0 { 0 } else { 1 }),
Constraint::Min(1),
])
.areas(area);
[status_area, content_area]
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
// Hide the cursor whenever an overlay view is active (e.g. the
// status indicator shown while a task is running, or approval modal).
// In these states the textarea is not interactable, so we should not
// show its caret.
let [_, content] = self.layout(area);
if let Some(view) = self.active_view() {
view.cursor_pos(content)
} else {
self.composer.cursor_pos(content)
}
}
/// Forward a key event to the active view or the composer.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
// If a modal/view is active, handle it here; otherwise forward to composer.
@@ -540,39 +466,36 @@ impl BottomPane {
pub(crate) fn take_recent_submission_images(&mut self) -> Vec<PathBuf> {
self.composer.take_recent_submission_images()
}
fn as_renderable(&'_ self) -> RenderableItem<'_> {
if let Some(view) = self.active_view() {
RenderableItem::Borrowed(view)
} else {
let mut flex = FlexRenderable::new();
if let Some(status) = &self.status {
flex.push(0, RenderableItem::Borrowed(status));
}
flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages));
if self.status.is_some() || !self.queued_user_messages.messages.is_empty() {
flex.push(0, RenderableItem::Owned("".into()));
}
let mut flex2 = FlexRenderable::new();
flex2.push(1, RenderableItem::Owned(flex.into()));
flex2.push(0, RenderableItem::Borrowed(&self.composer));
RenderableItem::Owned(Box::new(flex2))
}
}
}
impl WidgetRef for &BottomPane {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let [top_area, content_area] = self.layout(area);
// When a modal view is active, it owns the whole content area.
if let Some(view) = self.active_view() {
view.render(content_area, buf);
} else {
let status_height = self
.status
.as_ref()
.map(|status| status.desired_height(top_area.width).min(top_area.height))
.unwrap_or(0);
if let Some(status) = &self.status
&& status_height > 0
{
status.render_ref(top_area, buf);
impl Renderable for BottomPane {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.as_renderable().render(area, buf);
}
let queue_area = Rect {
x: top_area.x,
y: top_area.y.saturating_add(status_height),
width: top_area.width,
height: top_area.height.saturating_sub(status_height),
};
if queue_area.height > 0 {
self.queued_user_messages.render(queue_area, buf);
}
self.composer.render_ref(content_area, buf);
fn desired_height(&self, width: u16) -> u16 {
self.as_renderable().desired_height(width)
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.as_renderable().cursor_pos(area)
}
}
@@ -599,7 +522,7 @@ mod tests {
fn render_snapshot(pane: &BottomPane, area: Rect) -> String {
let mut buf = Buffer::empty(area);
(&pane).render_ref(area, &mut buf);
pane.render(area, &mut buf);
snapshot_buffer(&buf)
}
@@ -651,7 +574,7 @@ mod tests {
// Render and verify the top row does not include an overlay.
let area = Rect::new(0, 0, 60, 6);
let mut buf = Buffer::empty(area);
(&pane).render_ref(area, &mut buf);
pane.render(area, &mut buf);
let mut r0 = String::new();
for x in 0..area.width {
@@ -665,7 +588,7 @@ mod tests {
#[test]
fn composer_shown_after_denied_while_task_running() {
let (tx_raw, rx) = unbounded_channel::<AppEvent>();
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
@@ -700,14 +623,14 @@ mod tests {
std::thread::sleep(Duration::from_millis(120));
let area = Rect::new(0, 0, 40, 6);
let mut buf = Buffer::empty(area);
(&pane).render_ref(area, &mut buf);
let mut row1 = String::new();
pane.render(area, &mut buf);
let mut row0 = String::new();
for x in 0..area.width {
row1.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' '));
row0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
}
assert!(
row1.contains("Working"),
"expected Working header after denial on row 1: {row1:?}"
row0.contains("Working"),
"expected Working header after denial on row 0: {row0:?}"
);
// Composer placeholder should be visible somewhere below.
@@ -726,9 +649,6 @@ mod tests {
found_composer,
"expected composer visible under status line"
);
// Drain the channel to avoid unused warnings.
drop(rx);
}
#[test]
@@ -750,16 +670,10 @@ mod tests {
// Use a height that allows the status line to be visible above the composer.
let area = Rect::new(0, 0, 40, 6);
let mut buf = Buffer::empty(area);
(&pane).render_ref(area, &mut buf);
pane.render(area, &mut buf);
let mut row0 = String::new();
for x in 0..area.width {
row0.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' '));
}
assert!(
row0.contains("Working"),
"expected Working header: {row0:?}"
);
let bufs = snapshot_buffer(&buf);
assert!(bufs.contains("• Working"), "expected Working header");
}
#[test]
@@ -791,36 +705,6 @@ mod tests {
);
}
#[test]
fn status_hidden_when_height_too_small() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
});
pane.set_task_running(true);
// Height=2 → composer takes the full space; status collapses when there is no room.
let area2 = Rect::new(0, 0, 20, 2);
assert_snapshot!(
"status_hidden_when_height_too_small_height_2",
render_snapshot(&pane, area2)
);
// Height=1 → no padding; single row is the composer (status hidden).
let area1 = Rect::new(0, 0, 20, 1);
assert_snapshot!(
"status_hidden_when_height_too_small_height_1",
render_snapshot(&pane, area1)
);
}
#[test]
fn queued_messages_visible_when_status_hidden_snapshot() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();

View File

@@ -4,5 +4,6 @@ expression: terminal.backend()
---
" "
" /mo "
" "
" /model choose what model and reasoning effort to use "
" /mention mention a file "

View File

@@ -2,7 +2,6 @@
source: tui/src/bottom_pane/mod.rs
expression: "render_snapshot(&pane, area)"
---
↳ Queued follow-up question
⌥ + ↑ edit

View File

@@ -2,7 +2,6 @@
source: tui/src/bottom_pane/mod.rs
expression: "render_snapshot(&pane, area)"
---
• Working (0s • esc to interru

View File

@@ -2,7 +2,6 @@
source: tui/src/bottom_pane/mod.rs
expression: "render_snapshot(&pane, area)"
---
• Working (0s • esc to interrupt)
↳ Queued follow-up question
⌥ + ↑ edit

View File

@@ -1,6 +0,0 @@
---
source: tui/src/bottom_pane/mod.rs
expression: "render_snapshot(&pane, area2)"
---
Ask Codex to do a

View File

@@ -54,15 +54,11 @@ use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use rand::Rng;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use tokio::sync::mpsc::UnboundedSender;
use tracing::debug;
@@ -92,8 +88,12 @@ use crate::history_cell::McpToolCallCell;
use crate::markdown::append_markdown;
#[cfg(target_os = "windows")]
use crate::onboarding::WSL_INSTRUCTIONS;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt;
use crate::render::renderable::RenderableItem;
use crate::slash_command::SlashCommand;
use crate::status::RateLimitSnapshotDisplay;
use crate::text_formatting::truncate_text;
@@ -293,6 +293,15 @@ impl From<String> for UserMessage {
}
}
impl From<&str> for UserMessage {
fn from(text: &str) -> Self {
Self {
text: text.to_string(),
image_paths: Vec::new(),
}
}
}
fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Option<UserMessage> {
if text.is_empty() && image_paths.is_empty() {
None
@@ -951,27 +960,6 @@ impl ChatWidget {
}
}
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
let bottom_min = self.bottom_pane.desired_height(area.width).min(area.height);
let remaining = area.height.saturating_sub(bottom_min);
let active_desired = self
.active_cell
.as_ref()
.map_or(0, |c| c.desired_height(area.width) + 1);
let active_height = active_desired.min(remaining);
// Note: no header area; remaining is not used beyond computing active height.
let header_height = 0u16;
Layout::vertical([
Constraint::Length(header_height),
Constraint::Length(active_height),
Constraint::Min(bottom_min),
])
.areas(area)
}
pub(crate) fn new(
common: ChatWidgetInit,
conversation_manager: Arc<ConversationManager>,
@@ -1100,14 +1088,6 @@ impl ChatWidget {
}
}
pub fn desired_height(&self, width: u16) -> u16 {
self.bottom_pane.desired_height(width)
+ self
.active_cell
.as_ref()
.map_or(0, |c| c.desired_height(width) + 1)
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
KeyEvent {
@@ -1158,12 +1138,7 @@ impl ChatWidget {
text,
image_paths: self.bottom_pane.take_recent_submission_images(),
};
if self.bottom_pane.is_task_running() {
self.queued_user_messages.push_back(user_message);
self.refresh_queued_user_messages();
} else {
self.submit_user_message(user_message);
}
self.queue_user_message(user_message);
}
InputResult::Command(cmd) => {
self.dispatch_command(cmd);
@@ -1220,7 +1195,7 @@ impl ChatWidget {
return;
}
const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md");
self.submit_text_message(INIT_PROMPT.to_string());
self.submit_user_message(INIT_PROMPT.to_string().into());
}
SlashCommand::Compact => {
self.clear_token_usage();
@@ -1368,6 +1343,15 @@ impl ChatWidget {
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
}
fn queue_user_message(&mut self, user_message: UserMessage) {
if self.bottom_pane.is_task_running() {
self.queued_user_messages.push_back(user_message);
self.refresh_queued_user_messages();
} else {
self.submit_user_message(user_message);
}
}
fn submit_user_message(&mut self, user_message: UserMessage) {
let UserMessage { text, image_paths } = user_message;
if text.is_empty() && image_paths.is_empty() {
@@ -2322,16 +2306,6 @@ impl ChatWidget {
self.bottom_pane.show_view(Box::new(view));
}
/// Programmatically submit a user text message as if typed in the
/// composer. The text will be added to conversation history and sent to
/// the agent.
pub(crate) fn submit_text_message(&mut self, text: String) {
if text.is_empty() {
return;
}
self.submit_user_message(text.into());
}
pub(crate) fn token_usage(&self) -> TokenUsage {
self.token_info
.as_ref()
@@ -2357,30 +2331,34 @@ impl ChatWidget {
self.token_info = None;
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let [_, _, bottom_pane_area] = self.layout_areas(area);
self.bottom_pane.cursor_pos(bottom_pane_area)
fn as_renderable(&self) -> RenderableItem<'_> {
let active_cell_renderable = match &self.active_cell {
Some(cell) => RenderableItem::Borrowed(cell).inset(Insets::tlbr(1, 0, 0, 0)),
None => RenderableItem::Owned(Box::new(())),
};
let mut flex = FlexRenderable::new();
flex.push(1, active_cell_renderable);
flex.push(
0,
RenderableItem::Borrowed(&self.bottom_pane).inset(Insets::tlbr(1, 0, 0, 0)),
);
RenderableItem::Owned(Box::new(flex))
}
}
impl WidgetRef for &ChatWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let [_, active_cell_area, bottom_pane_area] = self.layout_areas(area);
(&self.bottom_pane).render(bottom_pane_area, buf);
if !active_cell_area.is_empty()
&& let Some(cell) = &self.active_cell
{
let mut area = active_cell_area;
area.y = area.y.saturating_add(1);
area.height = area.height.saturating_sub(1);
if let Some(exec) = cell.as_any().downcast_ref::<ExecCell>() {
exec.render_ref(area, buf);
} else if let Some(tool) = cell.as_any().downcast_ref::<McpToolCallCell>() {
tool.render_ref(area, buf);
}
}
impl Renderable for ChatWidget {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.as_renderable().render(area, buf);
self.last_rendered_width.set(Some(area.width as usize));
}
fn desired_height(&self, width: u16) -> u16 {
self.as_renderable().desired_height(width)
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.as_renderable().cursor_pos(area)
}
}
enum Notification {

View File

@@ -2,4 +2,4 @@
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" Ask Codex to do anything "
" "

View File

@@ -3,4 +3,4 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
" Ask Codex to do anything "
" "

View File

@@ -1,8 +1,7 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 1470
expression: terminal.backend()
---
" "
" "
" Ask Codex to do anything "
" "

View File

@@ -2,4 +2,4 @@
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" Ask Codex to do anything "
" "

View File

@@ -1,7 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 1500
expression: terminal.backend()
---
" "
" Ask Codex to do anything "
" "

View File

@@ -1,8 +1,7 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 1500
expression: terminal.backend()
---
" "
"• Thinking (0s • esc to interrupt) "
" Ask Codex to do anything "
" "
" "

View File

@@ -0,0 +1,27 @@
---
source: tui/src/chatwidget/tests.rs
expression: term.backend().vt100().screen().contents()
---
• Working (0s • esc to interrupt)
↳ Hello, world! 0
↳ Hello, world! 1
↳ Hello, world! 2
↳ Hello, world! 3
↳ Hello, world! 4
↳ Hello, world! 5
↳ Hello, world! 6
↳ Hello, world! 7
↳ Hello, world! 8
↳ Hello, world! 9
↳ Hello, world! 10
↳ Hello, world! 11
↳ Hello, world! 12
↳ Hello, world! 13
↳ Hello, world! 14
↳ Hello, world! 15
↳ Hello, world! 16
Ask Codex to do anything
100% context left · ? for shortcuts

View File

@@ -424,7 +424,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() {
// The approval modal should display the command snippet for user confirmation.
let area = Rect::new(0, 0, 80, chat.desired_height(80));
let mut buf = ratatui::buffer::Buffer::empty(area);
(&chat).render_ref(area, &mut buf);
chat.render(area, &mut buf);
assert_snapshot!("exec_approval_modal_exec", format!("{buf:?}"));
// Approve via keyboard and verify a concise decision history line is added
@@ -465,7 +465,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
let area = Rect::new(0, 0, 80, chat.desired_height(80));
let mut buf = ratatui::buffer::Buffer::empty(area);
(&chat).render_ref(area, &mut buf);
chat.render(area, &mut buf);
let mut saw_first_line = false;
for y in 0..area.height {
let mut row = String::new();
@@ -1039,7 +1039,7 @@ fn review_commit_picker_shows_subjects_without_timestamps() {
let height = chat.desired_height(width);
let area = ratatui::layout::Rect::new(0, 0, width, height);
let mut buf = ratatui::buffer::Buffer::empty(area);
(&chat).render_ref(area, &mut buf);
chat.render(area, &mut buf);
let mut blob = String::new();
for y in 0..area.height {
@@ -1267,7 +1267,7 @@ fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String {
let height = chat.desired_height(width);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
(chat).render_ref(area, &mut buf);
chat.render(area, &mut buf);
for y in 0..area.height {
let mut row = String::new();
for x in 0..area.width {
@@ -1289,7 +1289,7 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String {
let height = chat.desired_height(width);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
(chat).render_ref(area, &mut buf);
chat.render(area, &mut buf);
let mut lines: Vec<String> = (0..area.height)
.map(|row| {
@@ -1701,7 +1701,7 @@ fn approval_modal_exec_snapshot() {
terminal.set_viewport_area(viewport);
terminal
.draw(|f| f.render_widget_ref(&chat, f.area()))
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw approval modal");
assert!(
terminal
@@ -1742,7 +1742,7 @@ fn approval_modal_exec_without_reason_snapshot() {
ratatui::Terminal::new(VT100Backend::new(80, height)).expect("create terminal");
terminal.set_viewport_area(Rect::new(0, 0, 80, height));
terminal
.draw(|f| f.render_widget_ref(&chat, f.area()))
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw approval modal (no reason)");
assert_snapshot!(
"approval_modal_exec_no_reason",
@@ -1781,7 +1781,7 @@ fn approval_modal_patch_snapshot() {
ratatui::Terminal::new(VT100Backend::new(80, height)).expect("create terminal");
terminal.set_viewport_area(Rect::new(0, 0, 80, height));
terminal
.draw(|f| f.render_widget_ref(&chat, f.area()))
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw patch approval modal");
assert_snapshot!(
"approval_modal_patch",
@@ -1873,7 +1873,7 @@ fn ui_snapshots_small_heights_idle() {
let name = format!("chat_small_idle_h{h}");
let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal");
terminal
.draw(|f| f.render_widget_ref(&chat, f.area()))
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw chat idle");
assert_snapshot!(name, terminal.backend());
}
@@ -1903,7 +1903,7 @@ fn ui_snapshots_small_heights_task_running() {
let name = format!("chat_small_running_h{h}");
let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal");
terminal
.draw(|f| f.render_widget_ref(&chat, f.area()))
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw chat running");
assert_snapshot!(name, terminal.backend());
}
@@ -1953,7 +1953,7 @@ fn status_widget_and_approval_modal_snapshot() {
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
.expect("create terminal");
terminal
.draw(|f| f.render_widget_ref(&chat, f.area()))
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw status + approval modal");
assert_snapshot!("status_widget_and_approval_modal", terminal.backend());
}
@@ -1982,7 +1982,7 @@ fn status_widget_active_snapshot() {
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
.expect("create terminal");
terminal
.draw(|f| f.render_widget_ref(&chat, f.area()))
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw status widget");
assert_snapshot!("status_widget_active", terminal.backend());
}
@@ -2017,7 +2017,7 @@ fn apply_patch_events_emit_history_cells() {
let area = Rect::new(0, 0, 80, chat.desired_height(80));
let mut buf = ratatui::buffer::Buffer::empty(area);
(&chat).render_ref(area, &mut buf);
chat.render(area, &mut buf);
let mut saw_summary = false;
for y in 0..area.height {
let mut row = String::new();
@@ -2304,7 +2304,7 @@ fn apply_patch_untrusted_shows_approval_modal() {
// Render and ensure the approval modal title is present
let area = Rect::new(0, 0, 80, 12);
let mut buf = Buffer::empty(area);
(&chat).render_ref(area, &mut buf);
chat.render(area, &mut buf);
let mut contains_title = false;
for y in 0..area.height {
@@ -2358,7 +2358,7 @@ fn apply_patch_request_shows_diff_summary() {
let area = Rect::new(0, 0, 80, chat.desired_height(80));
let mut buf = ratatui::buffer::Buffer::empty(area);
(&chat).render_ref(area, &mut buf);
chat.render(area, &mut buf);
let mut saw_header = false;
let mut saw_line1 = false;
@@ -2683,7 +2683,7 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() {
}
term.draw(|f| {
(&chat).render_ref(f.area(), f.buffer_mut());
chat.render(f.area(), f.buffer_mut());
})
.unwrap();
@@ -2781,3 +2781,28 @@ printf 'fenced within fenced\n'
assert_snapshot!(term.backend().vt100().screen().contents());
}
#[test]
fn chatwidget_tall() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
chat.handle_codex_event(Event {
id: "t1".into(),
msg: EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: None,
}),
});
for i in 0..30 {
chat.queue_user_message(format!("Hello, world! {i}").into());
}
let width: u16 = 80;
let height: u16 = 24;
let backend = VT100Backend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
let desired_height = chat.desired_height(width).min(height);
term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height));
term.draw(|f| {
chat.render(f.area(), f.buffer_mut());
})
.unwrap();
assert_snapshot!(term.backend().vt100().screen().contents());
}

View File

@@ -67,7 +67,7 @@ impl From<DiffSummary> for Box<dyn Renderable> {
rows.push(Box::new(path));
rows.push(Box::new(RtLine::from("")));
rows.push(Box::new(InsetRenderable::new(
row.change,
Box::new(row.change) as Box<dyn Renderable>,
Insets::tlbr(0, 2, 0, 0),
)));
}

View File

@@ -19,9 +19,6 @@ use itertools::Itertools;
use ratatui::prelude::*;
use ratatui::style::Modifier;
use ratatui::style::Stylize;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use textwrap::WordSplitter;
use unicode_width::UnicodeWidthStr;
@@ -205,31 +202,6 @@ impl HistoryCell for ExecCell {
}
}
impl WidgetRef for &ExecCell {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 {
return;
}
let content_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height,
};
let lines = self.display_lines(area.width);
let max_rows = area.height as usize;
let rendered = if lines.len() > max_rows {
lines[lines.len() - max_rows..].to_vec()
} else {
lines
};
Paragraph::new(Text::from(rendered))
.wrap(Wrap { trim: false })
.render(content_area, buf);
}
}
impl ExecCell {
fn exploring_display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();

View File

@@ -11,6 +11,7 @@ use crate::markdown::append_markdown;
use crate::render::line_utils::line_to_static;
use crate::render::line_utils::prefix_lines;
use crate::render::line_utils::push_owned_lines;
use crate::render::renderable::Renderable;
use crate::style::user_message_style;
use crate::text_formatting::format_and_truncate_tool_result;
use crate::text_formatting::truncate_text;
@@ -45,7 +46,6 @@ use ratatui::style::Style;
use ratatui::style::Styled;
use ratatui::style::Stylize;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use std::any::Any;
use std::collections::HashMap;
@@ -99,6 +99,24 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
}
}
impl Renderable for Box<dyn HistoryCell> {
fn render(&self, area: Rect, buf: &mut Buffer) {
let lines = self.display_lines(area.width);
let y = if area.height == 0 {
0
} else {
let overflow = lines.len().saturating_sub(usize::from(area.height));
u16::try_from(overflow).unwrap_or(u16::MAX)
};
Paragraph::new(Text::from(lines))
.scroll((y, 0))
.render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
HistoryCell::desired_height(self.as_ref(), width)
}
}
impl dyn HistoryCell {
pub(crate) fn as_any(&self) -> &dyn Any {
self
@@ -929,23 +947,6 @@ impl HistoryCell for McpToolCallCell {
}
}
impl WidgetRef for &McpToolCallCell {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 {
return;
}
let lines = self.display_lines(area.width);
let max_rows = area.height as usize;
let rendered = if lines.len() > max_rows {
lines[lines.len() - max_rows..].to_vec()
} else {
lines
};
Text::from(rendered).render(area, buf);
}
}
pub(crate) fn new_active_mcp_tool_call(
call_id: String,
invocation: McpInvocation,

View File

@@ -7,13 +7,13 @@
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use std::time::Duration;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::InputResult;
use crate::render::renderable::Renderable;
/// Action returned from feeding a key event into the ComposerInput.
pub enum ComposerAction {
@@ -94,7 +94,7 @@ impl ComposerInput {
/// Render the input into the provided buffer at `area`.
pub fn render_ref(&self, area: Rect, buf: &mut Buffer) {
WidgetRef::render_ref(&self.inner, area, buf);
self.inner.render(area, buf);
}
/// Return true if a paste-burst detection is currently active.

View File

@@ -13,9 +13,49 @@ use crate::render::RectExt as _;
pub trait Renderable {
fn render(&self, area: Rect, buf: &mut Buffer);
fn desired_height(&self, width: u16) -> u16;
fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> {
None
}
}
impl<R: Renderable + 'static> From<R> for Box<dyn Renderable> {
pub enum RenderableItem<'a> {
Owned(Box<dyn Renderable + 'a>),
Borrowed(&'a dyn Renderable),
}
impl<'a> Renderable for RenderableItem<'a> {
fn render(&self, area: Rect, buf: &mut Buffer) {
match self {
RenderableItem::Owned(child) => child.render(area, buf),
RenderableItem::Borrowed(child) => child.render(area, buf),
}
}
fn desired_height(&self, width: u16) -> u16 {
match self {
RenderableItem::Owned(child) => child.desired_height(width),
RenderableItem::Borrowed(child) => child.desired_height(width),
}
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
match self {
RenderableItem::Owned(child) => child.cursor_pos(area),
RenderableItem::Borrowed(child) => child.cursor_pos(area),
}
}
}
impl<'a> From<Box<dyn Renderable + 'a>> for RenderableItem<'a> {
fn from(value: Box<dyn Renderable + 'a>) -> Self {
RenderableItem::Owned(value)
}
}
impl<'a, R> From<R> for Box<dyn Renderable + 'a>
where
R: Renderable + 'a,
{
fn from(value: R) -> Self {
Box::new(value)
}
@@ -98,11 +138,11 @@ impl<R: Renderable> Renderable for Arc<R> {
}
}
pub struct ColumnRenderable {
children: Vec<Box<dyn Renderable>>,
pub struct ColumnRenderable<'a> {
children: Vec<RenderableItem<'a>>,
}
impl Renderable for ColumnRenderable {
impl Renderable for ColumnRenderable<'_> {
fn render(&self, area: Rect, buf: &mut Buffer) {
let mut y = area.y;
for child in &self.children {
@@ -121,29 +161,166 @@ impl Renderable for ColumnRenderable {
.map(|child| child.desired_height(width))
.sum()
}
/// Returns the cursor position of the first child that has a cursor position, offset by the
/// child's position in the column.
///
/// It is generally assumed that either zero or one child will have a cursor position.
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let mut y = area.y;
for child in &self.children {
let child_area = Rect::new(area.x, y, area.width, child.desired_height(area.width))
.intersection(area);
if !child_area.is_empty()
&& let Some((px, py)) = child.cursor_pos(child_area)
{
return Some((px, py));
}
y += child_area.height;
}
None
}
}
impl ColumnRenderable {
impl<'a> ColumnRenderable<'a> {
pub fn new() -> Self {
Self::with(vec![])
Self { children: vec![] }
}
pub fn with(children: impl IntoIterator<Item = Box<dyn Renderable>>) -> Self {
pub fn with<I, T>(children: I) -> Self
where
I: IntoIterator<Item = T>,
T: Into<RenderableItem<'a>>,
{
Self {
children: children.into_iter().collect(),
children: children.into_iter().map(Into::into).collect(),
}
}
pub fn push(&mut self, child: impl Into<Box<dyn Renderable>>) {
self.children.push(child.into());
pub fn push(&mut self, child: impl Into<Box<dyn Renderable + 'a>>) {
self.children.push(RenderableItem::Owned(child.into()));
}
#[allow(dead_code)]
pub fn push_ref<R>(&mut self, child: &'a R)
where
R: Renderable + 'a,
{
self.children
.push(RenderableItem::Borrowed(child as &'a dyn Renderable));
}
}
pub struct RowRenderable {
children: Vec<(u16, Box<dyn Renderable>)>,
pub struct FlexChild<'a> {
flex: i32,
child: RenderableItem<'a>,
}
impl Renderable for RowRenderable {
pub struct FlexRenderable<'a> {
children: Vec<FlexChild<'a>>,
}
/// Lays out children in a column, with the ability to specify a flex factor for each child.
///
/// Children with flex factor > 0 will be allocated the remaining space after the non-flex children,
/// proportional to the flex factor.
impl<'a> FlexRenderable<'a> {
pub fn new() -> Self {
Self { children: vec![] }
}
pub fn push(&mut self, flex: i32, child: impl Into<RenderableItem<'a>>) {
self.children.push(FlexChild {
flex,
child: child.into(),
});
}
/// Loosely inspired by Flutter's Flex widget.
///
/// Ref https://github.com/flutter/flutter/blob/3fd81edbf1e015221e143c92b2664f4371bdc04a/packages/flutter/lib/src/rendering/flex.dart#L1205-L1209
fn allocate(&self, area: Rect) -> Vec<Rect> {
let mut allocated_rects = Vec::with_capacity(self.children.len());
let mut child_sizes = vec![0; self.children.len()];
let mut allocated_size = 0;
let mut total_flex = 0;
// 1. Allocate space to non-flex children.
let max_size = area.height;
let mut last_flex_child_idx = 0;
for (i, FlexChild { flex, child }) in self.children.iter().enumerate() {
if *flex > 0 {
total_flex += flex;
last_flex_child_idx = i;
} else {
child_sizes[i] = child
.desired_height(area.width)
.min(max_size.saturating_sub(allocated_size));
allocated_size += child_sizes[i];
}
}
let free_space = max_size.saturating_sub(allocated_size);
// 2. Allocate space to flex children, proportional to their flex factor.
let mut allocated_flex_space = 0;
if total_flex > 0 {
let space_per_flex = free_space / total_flex as u16;
for (i, FlexChild { flex, child }) in self.children.iter().enumerate() {
if *flex > 0 {
// Last flex child gets all the remaining space, to prevent a rounding error
// from not allocating all the space.
let max_child_extent = if i == last_flex_child_idx {
free_space - allocated_flex_space
} else {
space_per_flex * *flex as u16
};
let child_size = child.desired_height(area.width).min(max_child_extent);
child_sizes[i] = child_size;
allocated_size += child_size;
allocated_flex_space += child_size;
}
}
}
let mut y = area.y;
for size in child_sizes {
let child_area = Rect::new(area.x, y, area.width, size);
allocated_rects.push(child_area);
y += child_area.height;
}
allocated_rects
}
}
impl<'a> Renderable for FlexRenderable<'a> {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.allocate(area)
.into_iter()
.zip(self.children.iter())
.for_each(|(rect, child)| {
child.child.render(rect, buf);
});
}
fn desired_height(&self, width: u16) -> u16 {
self.allocate(Rect::new(0, 0, width, u16::MAX))
.last()
.map(|rect| rect.bottom())
.unwrap_or(0)
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.allocate(area)
.into_iter()
.zip(self.children.iter())
.find_map(|(rect, child)| child.child.cursor_pos(rect))
}
}
pub struct RowRenderable<'a> {
children: Vec<(u16, RenderableItem<'a>)>,
}
impl Renderable for RowRenderable<'_> {
fn render(&self, area: Rect, buf: &mut Buffer) {
let mut x = area.x;
for (width, child) in &self.children {
@@ -172,24 +349,49 @@ impl Renderable for RowRenderable {
}
max_height
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let mut x = area.x;
for (width, child) in &self.children {
let available_width = area.width.saturating_sub(x - area.x);
let child_area = Rect::new(x, area.y, (*width).min(available_width), area.height);
if !child_area.is_empty()
&& let Some(pos) = child.cursor_pos(child_area)
{
return Some(pos);
}
x = x.saturating_add(*width);
}
None
}
}
impl RowRenderable {
impl<'a> RowRenderable<'a> {
pub fn new() -> Self {
Self { children: vec![] }
}
pub fn push(&mut self, width: u16, child: impl Into<Box<dyn Renderable>>) {
self.children.push((width, child.into()));
self.children
.push((width, RenderableItem::Owned(child.into())));
}
#[allow(dead_code)]
pub fn push_ref<R>(&mut self, width: u16, child: &'a R)
where
R: Renderable + 'a,
{
self.children
.push((width, RenderableItem::Borrowed(child as &'a dyn Renderable)));
}
}
pub struct InsetRenderable {
child: Box<dyn Renderable>,
pub struct InsetRenderable<'a> {
child: RenderableItem<'a>,
insets: Insets,
}
impl Renderable for InsetRenderable {
impl<'a> Renderable for InsetRenderable<'a> {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.child.render(area.inset(self.insets), buf);
}
@@ -199,10 +401,13 @@ impl Renderable for InsetRenderable {
+ self.insets.top
+ self.insets.bottom
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.child.cursor_pos(area.inset(self.insets))
}
}
impl InsetRenderable {
pub fn new(child: impl Into<Box<dyn Renderable>>, insets: Insets) -> Self {
impl<'a> InsetRenderable<'a> {
pub fn new(child: impl Into<RenderableItem<'a>>, insets: Insets) -> Self {
Self {
child: child.into(),
insets,
@@ -210,15 +415,17 @@ impl InsetRenderable {
}
}
pub trait RenderableExt {
fn inset(self, insets: Insets) -> Box<dyn Renderable>;
pub trait RenderableExt<'a> {
fn inset(self, insets: Insets) -> RenderableItem<'a>;
}
impl<R: Into<Box<dyn Renderable>>> RenderableExt for R {
fn inset(self, insets: Insets) -> Box<dyn Renderable> {
Box::new(InsetRenderable {
child: self.into(),
insets,
})
impl<'a, R> RenderableExt<'a> for R
where
R: Renderable + 'a,
{
fn inset(self, insets: Insets) -> RenderableItem<'a> {
let child: RenderableItem<'a> =
RenderableItem::Owned(Box::new(self) as Box<dyn Renderable + 'a>);
RenderableItem::Owned(Box::new(InsetRenderable { child, insets }))
}
}

View File

@@ -16,6 +16,7 @@ use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::exec_cell::spinner;
use crate::key_hint;
use crate::render::renderable::Renderable;
use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
@@ -62,10 +63,6 @@ impl StatusIndicatorWidget {
}
}
pub fn desired_height(&self, _width: u16) -> u16 {
1
}
pub(crate) fn interrupt(&self) {
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
}
@@ -75,15 +72,15 @@ impl StatusIndicatorWidget {
self.header = header;
}
pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) {
self.show_interrupt_hint = visible;
}
#[cfg(test)]
pub(crate) fn header(&self) -> &str {
&self.header
}
pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) {
self.show_interrupt_hint = visible;
}
#[cfg(test)]
pub(crate) fn interrupt_hint_visible(&self) -> bool {
self.show_interrupt_hint
@@ -131,8 +128,12 @@ impl StatusIndicatorWidget {
}
}
impl WidgetRef for StatusIndicatorWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
impl Renderable for StatusIndicatorWidget {
fn desired_height(&self, _width: u16) -> u16 {
1
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
@@ -200,7 +201,7 @@ mod tests {
// Render into a fixed-size test terminal and snapshot the backend.
let mut terminal = Terminal::new(TestBackend::new(80, 2)).expect("terminal");
terminal
.draw(|f| w.render_ref(f.area(), f.buffer_mut()))
.draw(|f| w.render(f.area(), f.buffer_mut()))
.expect("draw");
insta::assert_snapshot!(terminal.backend());
}
@@ -214,7 +215,7 @@ mod tests {
// Render into a fixed-size test terminal and snapshot the backend.
let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal");
terminal
.draw(|f| w.render_ref(f.area(), f.buffer_mut()))
.draw(|f| w.render(f.area(), f.buffer_mut()))
.expect("draw");
insta::assert_snapshot!(terminal.backend());
}