Cache TUI flex heights across frame passes (#34348)

## Why

Sizing, rendering, and cursor placement can query the same chat widget layout
multiple times in one frame, repeatedly measuring active transcript cells.

## What changed

- Build one chat widget renderable tree per frame and reuse it for sizing,
  rendering, and cursor placement.
- Cache each flex child's desired height by width for the lifetime of that tree.
- Reuse the bottom pane's renderable directly instead of forwarding each
  renderable operation through a wrapper.

## Testing

- Verify flex layouts measure a child once across frame passes and remeasure it
  when the width changes.
- Verify a chat widget frame measures its active transcript cell once.

GitOrigin-RevId: 5ad1a6711f4011c699b5d002b13dc3319cb4db8e
This commit is contained in:
Charlie Marsh authored and copyberry committed 2026-07-20 13:58:17 +00:00
1 parent ada5a79ddf
commit eceb3eeaf3
7 files changed
+141 -90

No files matched your search

+9 -34
View File
@@ -3,7 +3,7 @@
use super::*;
impl ChatWidget {
pub(super) fn as_renderable(&self) -> RenderableItem<'_> {
pub(crate) fn as_renderable(&self) -> RenderableItem<'_> {
let active_cell_right_reserve = self.ambient_pet_wrap_reserved_cols();
let active_cell_renderable = match &self.transcript.active_cell {
Some(cell) => RenderableItem::Owned(Box::new(TranscriptAreaRenderable {
@@ -48,42 +48,17 @@ impl ChatWidget {
}
flex.push(
/*flex*/ 0,
RenderableItem::Owned(Box::new(BottomPaneComposerReserveRenderable {
bottom_pane: &self.bottom_pane,
right_reserve: active_cell_right_reserve,
}))
.inset(Insets::tlbr(
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
)),
self.bottom_pane
.as_renderable_with_composer_right_reserve(active_cell_right_reserve)
.inset(Insets::tlbr(
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
)),
);
RenderableItem::Owned(Box::new(flex))
}
}
struct BottomPaneComposerReserveRenderable<'a> {
bottom_pane: &'a BottomPane,
right_reserve: u16,
}
impl Renderable for BottomPaneComposerReserveRenderable<'_> {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.bottom_pane
.render_with_composer_right_reserve(area, buf, self.right_reserve);
}
fn desired_height(&self, width: u16) -> u16 {
self.bottom_pane
.desired_height_with_composer_right_reserve(width, self.right_reserve)
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.bottom_pane
.cursor_pos_with_composer_right_reserve(area, self.right_reserve)
}
fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle {
self.bottom_pane
.cursor_style_with_composer_right_reserve(area, self.right_reserve)
pub(crate) fn note_rendered_width(&self, width: u16) {
self.last_rendered_width.set(Some(width as usize));
}
}
@@ -132,7 +107,7 @@ impl TranscriptAreaRenderable<'_> {
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));
self.note_rendered_width(area.width);
}
fn desired_height(&self, width: u16) -> u16 {
@@ -215,6 +215,10 @@ pub(super) async fn make_chatwidget_manual_with_auth(
(widget, rx, op_rx)
}
pub(crate) fn set_active_cell(chat: &mut ChatWidget, cell: Box<dyn HistoryCell>) {
chat.transcript.active_cell = Some(cell);
}
// ChatWidget may emit other `Op`s (e.g. history/logging updates) on the same channel; this helper
// filters until we see a submission op.
pub(super) fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) -> Op {