Skip to content

Commit 62474a3

Browse files
tui: refactor ChatWidget and BottomPane to use Renderables (openai#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()
1 parent 9a10e80 commit 62474a3

27 files changed

+508
-436
lines changed

codex-rs/tui/src/app.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use crate::file_search::FileSearchManager;
99
use crate::history_cell::HistoryCell;
1010
use crate::pager_overlay::Overlay;
1111
use crate::render::highlight::highlight_bash_to_lines;
12+
use crate::render::renderable::Renderable;
1213
use crate::resume_picker::ResumeSelection;
1314
use crate::tui;
1415
use crate::tui::TuiEvent;
@@ -233,7 +234,7 @@ impl App {
233234
tui.draw(
234235
self.chat_widget.desired_height(tui.terminal.size()?.width),
235236
|frame| {
236-
frame.render_widget_ref(&self.chat_widget, frame.area());
237+
self.chat_widget.render(frame.area(), frame.buffer);
237238
if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) {
238239
frame.set_cursor_position((x, y));
239240
}

codex-rs/tui/src/bottom_pane/approval_overlay.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -260,10 +260,6 @@ impl BottomPaneView for ApprovalOverlay {
260260
self.enqueue_request(request);
261261
None
262262
}
263-
264-
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
265-
self.list.cursor_pos(area)
266-
}
267263
}
268264

269265
impl Renderable for ApprovalOverlay {
@@ -274,6 +270,10 @@ impl Renderable for ApprovalOverlay {
274270
fn render(&self, area: Rect, buf: &mut Buffer) {
275271
self.list.render(area, buf);
276272
}
273+
274+
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
275+
self.list.cursor_pos(area)
276+
}
277277
}
278278

279279
struct ApprovalRequestState {

codex-rs/tui/src/bottom_pane/bottom_pane_view.rs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
use crate::bottom_pane::ApprovalRequest;
22
use crate::render::renderable::Renderable;
33
use crossterm::event::KeyEvent;
4-
use ratatui::layout::Rect;
54

65
use super::CancellationEvent;
76

@@ -27,11 +26,6 @@ pub(crate) trait BottomPaneView: Renderable {
2726
false
2827
}
2928

30-
/// Cursor position when this view is active.
31-
fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> {
32-
None
33-
}
34-
3529
/// Try to handle approval request; return the original value if not
3630
/// consumed.
3731
fn try_consume_approval_request(

codex-rs/tui/src/bottom_pane/chat_composer.rs

Lines changed: 45 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ use crate::bottom_pane::prompt_args::parse_slash_name;
3535
use crate::bottom_pane::prompt_args::prompt_argument_names;
3636
use crate::bottom_pane::prompt_args::prompt_command_with_arg_placeholders;
3737
use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders;
38+
use crate::render::Insets;
39+
use crate::render::RectExt;
40+
use crate::render::renderable::Renderable;
3841
use crate::slash_command::SlashCommand;
3942
use crate::slash_command::built_in_slash_commands;
4043
use crate::style::user_message_style;
@@ -158,24 +161,6 @@ impl ChatComposer {
158161
this
159162
}
160163

161-
pub fn desired_height(&self, width: u16) -> u16 {
162-
let footer_props = self.footer_props();
163-
let footer_hint_height = self
164-
.custom_footer_height()
165-
.unwrap_or_else(|| footer_height(footer_props));
166-
let footer_spacing = Self::footer_spacing(footer_hint_height);
167-
let footer_total_height = footer_hint_height + footer_spacing;
168-
const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1;
169-
self.textarea
170-
.desired_height(width.saturating_sub(COLS_WITH_MARGIN))
171-
+ 2
172-
+ match &self.active_popup {
173-
ActivePopup::None => footer_total_height,
174-
ActivePopup::Command(c) => c.calculate_required_height(width),
175-
ActivePopup::File(c) => c.calculate_required_height(),
176-
}
177-
}
178-
179164
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
180165
let footer_props = self.footer_props();
181166
let footer_hint_height = self
@@ -190,18 +175,9 @@ impl ChatComposer {
190175
ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()),
191176
ActivePopup::None => Constraint::Max(footer_total_height),
192177
};
193-
let mut area = area;
194-
if area.height > 1 {
195-
area.height -= 1;
196-
area.y += 1;
197-
}
198178
let [composer_rect, popup_rect] =
199-
Layout::vertical([Constraint::Min(1), popup_constraint]).areas(area);
200-
let mut textarea_rect = composer_rect;
201-
textarea_rect.width = textarea_rect.width.saturating_sub(
202-
LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */
203-
);
204-
textarea_rect.x = textarea_rect.x.saturating_add(LIVE_PREFIX_COLS);
179+
Layout::vertical([Constraint::Min(3), popup_constraint]).areas(area);
180+
let textarea_rect = composer_rect.inset(Insets::tlbr(1, LIVE_PREFIX_COLS, 1, 1));
205181
[composer_rect, textarea_rect, popup_rect]
206182
}
207183

@@ -213,12 +189,6 @@ impl ChatComposer {
213189
}
214190
}
215191

216-
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
217-
let [_, textarea_rect, _] = self.layout_areas(area);
218-
let state = *self.textarea_state.borrow();
219-
self.textarea.cursor_pos_with_state(textarea_rect, state)
220-
}
221-
222192
/// Returns true if the composer currently contains no user input.
223193
pub(crate) fn is_empty(&self) -> bool {
224194
self.textarea.is_empty()
@@ -1541,8 +1511,32 @@ impl ChatComposer {
15411511
}
15421512
}
15431513

1544-
impl WidgetRef for ChatComposer {
1545-
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
1514+
impl Renderable for ChatComposer {
1515+
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
1516+
let [_, textarea_rect, _] = self.layout_areas(area);
1517+
let state = *self.textarea_state.borrow();
1518+
self.textarea.cursor_pos_with_state(textarea_rect, state)
1519+
}
1520+
1521+
fn desired_height(&self, width: u16) -> u16 {
1522+
let footer_props = self.footer_props();
1523+
let footer_hint_height = self
1524+
.custom_footer_height()
1525+
.unwrap_or_else(|| footer_height(footer_props));
1526+
let footer_spacing = Self::footer_spacing(footer_hint_height);
1527+
let footer_total_height = footer_hint_height + footer_spacing;
1528+
const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1;
1529+
self.textarea
1530+
.desired_height(width.saturating_sub(COLS_WITH_MARGIN))
1531+
+ 2
1532+
+ match &self.active_popup {
1533+
ActivePopup::None => footer_total_height,
1534+
ActivePopup::Command(c) => c.calculate_required_height(width),
1535+
ActivePopup::File(c) => c.calculate_required_height(),
1536+
}
1537+
}
1538+
1539+
fn render(&self, area: Rect, buf: &mut Buffer) {
15461540
let [composer_rect, textarea_rect, popup_rect] = self.layout_areas(area);
15471541
match &self.active_popup {
15481542
ActivePopup::Command(popup) => {
@@ -1591,16 +1585,15 @@ impl WidgetRef for ChatComposer {
15911585
}
15921586
}
15931587
let style = user_message_style();
1594-
let mut block_rect = composer_rect;
1595-
block_rect.y = composer_rect.y.saturating_sub(1);
1596-
block_rect.height = composer_rect.height.saturating_add(1);
1597-
Block::default().style(style).render_ref(block_rect, buf);
1598-
buf.set_span(
1599-
composer_rect.x,
1600-
composer_rect.y,
1601-
&"›".bold(),
1602-
composer_rect.width,
1603-
);
1588+
Block::default().style(style).render_ref(composer_rect, buf);
1589+
if !textarea_rect.is_empty() {
1590+
buf.set_span(
1591+
textarea_rect.x - LIVE_PREFIX_COLS,
1592+
textarea_rect.y,
1593+
&"›".bold(),
1594+
textarea_rect.width,
1595+
);
1596+
}
16041597

16051598
let mut state = self.textarea_state.borrow_mut();
16061599
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
@@ -1692,7 +1685,7 @@ mod tests {
16921685

16931686
let area = Rect::new(0, 0, 40, 6);
16941687
let mut buf = Buffer::empty(area);
1695-
composer.render_ref(area, &mut buf);
1688+
composer.render(area, &mut buf);
16961689

16971690
let row_to_string = |y: u16| {
16981691
let mut row = String::new();
@@ -1756,7 +1749,7 @@ mod tests {
17561749
let height = footer_lines + footer_spacing + 8;
17571750
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
17581751
terminal
1759-
.draw(|f| f.render_widget_ref(composer, f.area()))
1752+
.draw(|f| composer.render(f.area(), f.buffer_mut()))
17601753
.unwrap();
17611754
insta::assert_snapshot!(name, terminal.backend());
17621755
}
@@ -2276,7 +2269,7 @@ mod tests {
22762269
}
22772270

22782271
terminal
2279-
.draw(|f| f.render_widget_ref(composer, f.area()))
2272+
.draw(|f| composer.render(f.area(), f.buffer_mut()))
22802273
.unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}"));
22812274

22822275
insta::assert_snapshot!(name, terminal.backend());
@@ -2302,12 +2295,12 @@ mod tests {
23022295
// Type "/mo" humanlike so paste-burst doesn’t interfere.
23032296
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
23042297

2305-
let mut terminal = match Terminal::new(TestBackend::new(60, 4)) {
2298+
let mut terminal = match Terminal::new(TestBackend::new(60, 5)) {
23062299
Ok(t) => t,
23072300
Err(e) => panic!("Failed to create terminal: {e}"),
23082301
};
23092302
terminal
2310-
.draw(|f| f.render_widget_ref(composer, f.area()))
2303+
.draw(|f| composer.render(f.area(), f.buffer_mut()))
23112304
.unwrap_or_else(|e| panic!("Failed to draw composer: {e}"));
23122305

23132306
// Visual snapshot should show the slash popup with /model as the first entry.

codex-rs/tui/src/bottom_pane/custom_prompt_view.rs

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -103,26 +103,6 @@ impl BottomPaneView for CustomPromptView {
103103
self.textarea.insert_str(&pasted);
104104
true
105105
}
106-
107-
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
108-
if area.height < 2 || area.width <= 2 {
109-
return None;
110-
}
111-
let text_area_height = self.input_height(area.width).saturating_sub(1);
112-
if text_area_height == 0 {
113-
return None;
114-
}
115-
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
116-
let top_line_count = 1u16 + extra_offset;
117-
let textarea_rect = Rect {
118-
x: area.x.saturating_add(2),
119-
y: area.y.saturating_add(top_line_count).saturating_add(1),
120-
width: area.width.saturating_sub(2),
121-
height: text_area_height,
122-
};
123-
let state = *self.textarea_state.borrow();
124-
self.textarea.cursor_pos_with_state(textarea_rect, state)
125-
}
126106
}
127107

128108
impl Renderable for CustomPromptView {
@@ -232,6 +212,26 @@ impl Renderable for CustomPromptView {
232212
);
233213
}
234214
}
215+
216+
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
217+
if area.height < 2 || area.width <= 2 {
218+
return None;
219+
}
220+
let text_area_height = self.input_height(area.width).saturating_sub(1);
221+
if text_area_height == 0 {
222+
return None;
223+
}
224+
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
225+
let top_line_count = 1u16 + extra_offset;
226+
let textarea_rect = Rect {
227+
x: area.x.saturating_add(2),
228+
y: area.y.saturating_add(top_line_count).saturating_add(1),
229+
width: area.width.saturating_sub(2),
230+
height: text_area_height,
231+
};
232+
let state = *self.textarea_state.borrow();
233+
self.textarea.cursor_pos_with_state(textarea_rect, state)
234+
}
235235
}
236236

237237
impl CustomPromptView {

codex-rs/tui/src/bottom_pane/feedback_view.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,12 @@ impl BottomPaneView for FeedbackNoteView {
163163
self.textarea.insert_str(&pasted);
164164
true
165165
}
166+
}
167+
168+
impl Renderable for FeedbackNoteView {
169+
fn desired_height(&self, width: u16) -> u16 {
170+
1u16 + self.input_height(width) + 3u16
171+
}
166172

167173
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
168174
if area.height < 2 || area.width <= 2 {
@@ -182,12 +188,6 @@ impl BottomPaneView for FeedbackNoteView {
182188
let state = *self.textarea_state.borrow();
183189
self.textarea.cursor_pos_with_state(textarea_rect, state)
184190
}
185-
}
186-
187-
impl Renderable for FeedbackNoteView {
188-
fn desired_height(&self, width: u16) -> u16 {
189-
1u16 + self.input_height(width) + 3u16
190-
}
191191

192192
fn render(&self, area: Rect, buf: &mut Buffer) {
193193
if area.height == 0 || area.width == 0 {

0 commit comments

Comments
 (0)