TUI: fix request_user_input wrapping for long option labels (#11123)

## Summary

This PR fixes long-text rendering in the `request_user_input` TUI
overlay while preserving a clear two-column option layout. (Issue
https://github.com/openai/codex/issues/11093)

Before:
- very long option labels could push description text into a narrow
right-edge strip
- option labels were effectively single-line when descriptions were
present, causing truncation/poor readability
- label and description wrapping interacted in one combined wrapped line

<img width="504" height="409" alt="Screenshot 2026-02-08 at 2 27 25 PM"
src="https://github.com/user-attachments/assets/a9afd108-d792-4522-bce1-e43b3cce882b"
/>

After:
- option labels wrap inside the left column
- descriptions wrap independently inside the right column
- row measurement and row rendering use the same wrapping path, so
layout stays stable

<img width="582" height="211" alt="Screenshot 2026-02-09 at 10 28 02 AM"
src="https://github.com/user-attachments/assets/47885a1c-07e5-4b0f-b992-032b149f1b0d"
/>

## Problem

`request_user_input` needs to handle verbose prompts/options. With
oversized labels:
- descriptions could collapse into a thin, hard-to-read column
- important label context was lost

## Root Cause

In shared row rendering (`selection_popup_common`):
- rows were wrapped as a single combined line
- auto column sizing could still place `desc_col` too far right for long
labels
- `request_user_input` rows did not provide wrap metadata to align
continuation lines after the option prefix

## What Changed

### 1) `request_user_input` rows opt into wrapped labels
File: `codex-rs/tui/src/bottom_pane/request_user_input/mod.rs`

- In `option_rows()`, compute the rendered option prefix (`› 1. ` / ` 2.
`) and set `wrap_indent` from its display width.
- Apply the same behavior to the synthetic “None of the above” row.
- Add long-text snapshot test coverage
(`question_with_very_long_option_text` +
`request_user_input_long_option_text_snapshot`).

### 2) Shared renderer now has an opt-in two-column wrapping path
File: `codex-rs/tui/src/bottom_pane/selection_popup_common.rs`

- Add focused helpers:
  - `should_wrap_name_in_column`
  - `wrap_two_column_row`
  - `wrap_standard_row`
  - `wrap_row_lines`
  - `apply_row_state_style`
- For opted-in rows (plain option rows with `wrap_indent` +
description), wrap label and description independently in their own
columns.
- Keep the legacy standard wrapping path for non-opted rows.
- Use the same `wrap_row_lines` function in both rendering and height
measurement to keep them in sync.

### 3) Keep column sizing simple and derived from existing fixed split
constants
File: `codex-rs/tui/src/bottom_pane/selection_popup_common.rs`

- Keep fixed mode at `3/10` left column (`30/70` split).
- In auto modes, cap label width using those same fixed constants (max
70% label, min 30% description), instead of extra special-case
constants/branches.
- Add/keep narrow-width safety guard in `wrap_two_column_row` so
extremely small widths do not panic.

### 4) Snapshot coverage
File: `codex-rs/tui/src/bottom_pane/request_user_input/snapshots/

codex_tui__bottom_pane__request_user_input__tests__request_user_input_long_option_text.snap`

- Add snapshot for long-label/long-description two-column rendering
behavior.
This commit is contained in:
Charley Cunningham
2026-02-09 12:23:31 -08:00
committed by GitHub
Unverified
parent c2bfd1e473
commit f88667042e
5 changed files with 473 additions and 73 deletions
@@ -631,7 +631,7 @@ impl Renderable for ListSelectionView {
"no matches",
ColumnWidthMode::Fixed,
),
}
};
}
if footer_area.height > 0 {
@@ -280,9 +280,12 @@ impl RequestUserInputOverlay {
let prefix = if selected { '' } else { ' ' };
let label = opt.label.as_str();
let number = idx + 1;
let prefix_label = format!("{prefix} {number}. ");
let wrap_indent = UnicodeWidthStr::width(prefix_label.as_str());
GenericDisplayRow {
name: format!("{prefix} {number}. {label}"),
name: format!("{prefix_label}{label}"),
description: Some(opt.description.clone()),
wrap_indent: Some(wrap_indent),
..Default::default()
}
})
@@ -293,9 +296,12 @@ impl RequestUserInputOverlay {
let selected = selected_idx.is_some_and(|sel| sel == idx);
let prefix = if selected { '' } else { ' ' };
let number = idx + 1;
let prefix_label = format!("{prefix} {number}. ");
let wrap_indent = UnicodeWidthStr::width(prefix_label.as_str());
rows.push(GenericDisplayRow {
name: format!("{prefix} {number}. {OTHER_OPTION_LABEL}"),
name: format!("{prefix_label}{OTHER_OPTION_LABEL}"),
description: Some(OTHER_OPTION_DESCRIPTION.to_string()),
wrap_indent: Some(wrap_indent),
..Default::default()
});
}
@@ -1382,6 +1388,57 @@ mod tests {
}
}
fn question_with_very_long_option_text(id: &str, header: &str) -> RequestUserInputQuestion {
RequestUserInputQuestion {
id: id.to_string(),
header: header.to_string(),
question: "Choose one option.".to_string(),
is_other: false,
is_secret: false,
options: Some(vec![
RequestUserInputQuestionOption {
label: "Job: running/completed/failed/expired; Run/Experiment: succeeded/failed/unknown (Recommended when triaging long-running background work and status transitions)".to_string(),
description: "Keep async job statuses for progress tracking and include enough context for debugging retries, stale workers, and unexpected expiration paths.".to_string(),
},
RequestUserInputQuestionOption {
label: "Add a short status model".to_string(),
description: "Simpler labels with less detail for quick rollouts.".to_string(),
},
]),
}
}
fn question_with_long_scroll_options(id: &str, header: &str) -> RequestUserInputQuestion {
RequestUserInputQuestion {
id: id.to_string(),
header: header.to_string(),
question:
"Choose one option; each hint is intentionally very long to test wrapped scrolling."
.to_string(),
is_other: false,
is_secret: false,
options: Some(vec![
RequestUserInputQuestionOption {
label: "Use Detailed Hint A (Recommended)".to_string(),
description: "Select this if you want a deliberately overextended explanatory hint that reads like a miniature specification, including context, rationale, expected behavior, and an explicit statement that this choice is mainly for testing how gracefully the interface wraps, truncates, and preserves readability under unusually verbose helper text conditions.".to_string(),
},
RequestUserInputQuestionOption {
label: "Use Detailed Hint B".to_string(),
description: "Select this if you want an equally verbose but differently phrased guidance block that emphasizes user-facing clarity, spacing tolerance, multiline wrapping, visual hierarchy interactions, and whether long descriptive metadata remains understandable when scanned quickly in a constrained layout where cognitive load is already high.".to_string(),
},
RequestUserInputQuestionOption {
label: "Use Detailed Hint C".to_string(),
description: "Select this when you specifically want to verify that navigating downward will keep the currently highlighted option visible, even when previous options consume many wrapped lines and would otherwise push the selection out of the viewport.".to_string(),
},
RequestUserInputQuestionOption {
label: "None of the above".to_string(),
description:
"Use this only if the previous long-form options do not apply.".to_string(),
},
]),
}
}
fn question_without_options(id: &str, header: &str) -> RequestUserInputQuestion {
RequestUserInputQuestion {
id: id.to_string(),
@@ -2575,6 +2632,49 @@ mod tests {
);
}
#[test]
fn request_user_input_long_option_text_snapshot() {
let (tx, _rx) = test_sender();
let overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![question_with_very_long_option_text("q1", "Status")],
),
tx,
true,
false,
false,
);
let area = Rect::new(0, 0, 120, 18);
insta::assert_snapshot!(
"request_user_input_long_option_text",
render_snapshot(&overlay, area)
);
}
#[test]
fn selected_long_wrapped_option_stays_visible() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![question_with_long_scroll_options("q1", "Scroll")],
),
tx,
true,
false,
false,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(2);
let rendered = render_snapshot(&overlay, Rect::new(0, 0, 80, 20));
assert!(
rendered.contains(" 3. Use Detailed Hint C"),
"expected selected option to be visible in viewport\n{rendered}"
);
}
#[test]
fn request_user_input_footer_wrap_snapshot() {
let (tx, _rx) = test_sender();
@@ -316,7 +316,7 @@ impl RequestUserInputOverlay {
// Ensure the selected option is visible in the scroll window.
options_state
.ensure_visible(option_rows.len(), sections.options_area.height as usize);
render_rows(
render_rows_bottom_aligned(
sections.options_area,
buf,
&option_rows,
@@ -431,6 +431,48 @@ fn line_width(line: &Line<'_>) -> usize {
.sum()
}
/// Render rows into `area`, bottom-aligning the visible rows when fewer than
/// `area.height` lines are produced.
///
/// This keeps footer spacing stable by anchoring the options block to the
/// bottom of its allocated region.
fn render_rows_bottom_aligned(
area: Rect,
buf: &mut Buffer,
rows: &[crate::bottom_pane::selection_popup_common::GenericDisplayRow],
state: &ScrollState,
max_results: usize,
empty_message: &str,
) {
if area.width == 0 || area.height == 0 {
return;
}
let scratch_area = Rect::new(0, 0, area.width, area.height);
let mut scratch = Buffer::empty(scratch_area);
for y in 0..area.height {
for x in 0..area.width {
scratch[(x, y)] = buf[(area.x + x, area.y + y)].clone();
}
}
let rendered_height = render_rows(
scratch_area,
&mut scratch,
rows,
state,
max_results,
empty_message,
);
let visible_height = rendered_height.min(area.height);
let y_offset = area.height.saturating_sub(visible_height);
for y in 0..visible_height {
for x in 0..area.width {
buf[(area.x + x, area.y + y_offset + y)] = scratch[(x, y)].clone();
}
}
}
/// Truncate a styled line to `max_width`, preferring a word boundary, and append an ellipsis.
///
/// This walks spans character-by-character, tracking the last width-safe position and the last
@@ -0,0 +1,17 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Choose one option.
1. Job: running/completed/failed/expired; Run/Experiment: succeeded/failed/ Keep async job statuses for
unknown (Recommended when triaging long-running background work and status progress tracking and include
transitions) enough context for debugging
retries, stale workers, and
unexpected expiration paths.
2. Add a short status model Simpler labels with less detail for
quick rollouts.
tab to add notes | enter to submit answer | esc to interrupt
@@ -9,6 +9,7 @@ use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Widget;
use std::borrow::Cow;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
@@ -52,6 +53,8 @@ pub(crate) enum ColumnWidthMode {
Fixed,
}
// Fixed split used by explicitly fixed column mode: 30% label, 70%
// description.
const FIXED_LEFT_COLUMN_NUMERATOR: usize = 3;
const FIXED_LEFT_COLUMN_DENOMINATOR: usize = 10;
@@ -107,6 +110,21 @@ fn line_width(line: &Line<'_>) -> usize {
.sum()
}
fn line_to_owned(line: Line<'_>) -> Line<'static> {
Line {
style: line.style,
alignment: line.alignment,
spans: line
.spans
.into_iter()
.map(|span| Span {
style: span.style,
content: Cow::Owned(span.content.into_owned()),
})
.collect(),
}
}
pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> Line<'static> {
if max_width == 0 {
return Line::from(Vec::<Span<'static>>::new());
@@ -192,11 +210,6 @@ pub(crate) fn truncate_line_with_ellipsis_if_overflow(
}
}
/// Computes the shared start column used for descriptions in selection rows.
/// The column is derived from the widest row name plus two spaces of padding
/// while always leaving at least one terminal cell for description content.
/// [`ColumnWidthMode::AutoAllRows`] computes width across the full dataset so
/// the description column does not shift as the user scrolls.
fn compute_desc_col(
rows_all: &[GenericDisplayRow],
start_idx: usize,
@@ -209,6 +222,14 @@ fn compute_desc_col(
}
let max_desc_col = content_width.saturating_sub(1) as usize;
// Reuse the existing fixed split constants to derive the auto cap:
// if fixed mode is 30/70 (label/description), auto mode caps label width
// at 70% to keep at least 30% available for descriptions.
let max_auto_desc_col = max_desc_col.min(
((content_width as usize * (FIXED_LEFT_COLUMN_DENOMINATOR - FIXED_LEFT_COLUMN_NUMERATOR))
/ FIXED_LEFT_COLUMN_DENOMINATOR)
.max(1),
);
match col_width_mode {
ColumnWidthMode::Fixed => ((content_width as usize * FIXED_LEFT_COLUMN_NUMERATOR)
/ FIXED_LEFT_COLUMN_DENOMINATOR)
@@ -243,7 +264,7 @@ fn compute_desc_col(
ColumnWidthMode::Fixed => 0,
};
max_name_width.saturating_add(2).min(max_desc_col)
max_name_width.saturating_add(2).min(max_auto_desc_col)
}
}
}
@@ -261,6 +282,219 @@ fn wrap_indent(row: &GenericDisplayRow, desc_col: usize, max_width: u16) -> usiz
indent.min(max_indent)
}
fn should_wrap_name_in_column(row: &GenericDisplayRow) -> bool {
// This path intentionally targets plain option rows that opt into wrapped
// labels. Styled/fuzzy-matched rows keep the legacy combined-line path.
row.wrap_indent.is_some()
&& row.description.is_some()
&& row.disabled_reason.is_none()
&& row.match_indices.is_none()
&& row.display_shortcut.is_none()
&& row.category_tag.is_none()
}
fn wrap_two_column_row(row: &GenericDisplayRow, desc_col: usize, width: u16) -> Vec<Line<'static>> {
let Some(description) = row.description.as_deref() else {
return Vec::new();
};
let width = width.max(1);
let max_desc_col = width.saturating_sub(1) as usize;
if max_desc_col == 0 {
// No valid description column exists at this width; let callers fall
// back to single-line wrapping path.
return Vec::new();
}
let desc_col = desc_col.clamp(1, max_desc_col);
let left_width = desc_col.saturating_sub(2).max(1);
let right_width = width.saturating_sub(desc_col as u16).max(1) as usize;
let name_wrap_indent = row
.wrap_indent
.unwrap_or(0)
.min(left_width.saturating_sub(1));
let name_subsequent_indent = " ".repeat(name_wrap_indent);
let name_options = textwrap::Options::new(left_width)
.initial_indent("")
.subsequent_indent(name_subsequent_indent.as_str());
let name_lines = textwrap::wrap(row.name.as_str(), name_options);
let desc_options = textwrap::Options::new(right_width).initial_indent("");
let desc_lines = textwrap::wrap(description, desc_options);
let rows = name_lines.len().max(desc_lines.len()).max(1);
let mut out = Vec::with_capacity(rows);
for idx in 0..rows {
let mut spans: Vec<Span<'static>> = Vec::new();
if let Some(name) = name_lines.get(idx) {
spans.push(name.to_string().into());
}
if let Some(desc) = desc_lines.get(idx) {
let left_used = spans
.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.sum::<usize>();
let gap = if left_used == 0 {
desc_col
} else {
desc_col.saturating_sub(left_used).max(2)
};
if gap > 0 {
spans.push(" ".repeat(gap).into());
}
spans.push(desc.to_string().dim());
}
out.push(Line::from(spans));
}
out
}
fn wrap_standard_row(row: &GenericDisplayRow, desc_col: usize, width: u16) -> Vec<Line<'static>> {
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
let full_line = build_full_line(row, desc_col);
let continuation_indent = wrap_indent(row, desc_col, width);
let options = RtOptions::new(width.max(1) as usize)
.initial_indent(Line::from(""))
.subsequent_indent(Line::from(" ".repeat(continuation_indent)));
word_wrap_line(&full_line, options)
.into_iter()
.map(line_to_owned)
.collect()
}
fn wrap_row_lines(row: &GenericDisplayRow, desc_col: usize, width: u16) -> Vec<Line<'static>> {
if should_wrap_name_in_column(row) {
let wrapped = wrap_two_column_row(row, desc_col, width);
if !wrapped.is_empty() {
return wrapped;
}
}
wrap_standard_row(row, desc_col, width)
}
fn apply_row_state_style(lines: &mut [Line<'static>], selected: bool, is_disabled: bool) {
if selected {
for line in lines.iter_mut() {
line.spans.iter_mut().for_each(|span| {
span.style = Style::default().fg(Color::Cyan).bold();
});
}
}
if is_disabled {
for line in lines.iter_mut() {
line.spans.iter_mut().for_each(|span| {
span.style = span.style.dim();
});
}
}
}
fn compute_item_window_start(
rows_all: &[GenericDisplayRow],
state: &ScrollState,
max_items: usize,
) -> usize {
if rows_all.is_empty() || max_items == 0 {
return 0;
}
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
if let Some(sel) = state.selected_idx {
if sel < start_idx {
start_idx = sel;
} else {
let bottom = start_idx.saturating_add(max_items.saturating_sub(1));
if sel > bottom {
start_idx = sel + 1 - max_items;
}
}
}
start_idx
}
fn is_selected_visible_in_wrapped_viewport(
rows_all: &[GenericDisplayRow],
start_idx: usize,
max_items: usize,
selected_idx: usize,
desc_col: usize,
width: u16,
viewport_height: u16,
) -> bool {
if viewport_height == 0 {
return false;
}
let mut used_lines = 0usize;
let viewport_height = viewport_height as usize;
for (idx, row) in rows_all.iter().enumerate().skip(start_idx).take(max_items) {
let row_lines = wrap_row_lines(row, desc_col, width).len().max(1);
// Keep rendering semantics in sync: always show the first row, even if
// it overflows the viewport.
if used_lines > 0 && used_lines.saturating_add(row_lines) > viewport_height {
break;
}
if idx == selected_idx {
return true;
}
used_lines = used_lines.saturating_add(row_lines);
if used_lines >= viewport_height {
break;
}
}
false
}
fn adjust_start_for_wrapped_selection_visibility(
rows_all: &[GenericDisplayRow],
state: &ScrollState,
max_items: usize,
desc_measure_items: usize,
width: u16,
viewport_height: u16,
col_width_mode: ColumnWidthMode,
) -> usize {
let mut start_idx = compute_item_window_start(rows_all, state, max_items);
let Some(sel) = state.selected_idx else {
return start_idx;
};
if viewport_height == 0 {
return start_idx;
}
// If wrapped row heights push the selected item out of view, advance the
// item window until the selected row is visible.
while start_idx < sel {
let desc_col = compute_desc_col(
rows_all,
start_idx,
desc_measure_items,
width,
col_width_mode,
);
if is_selected_visible_in_wrapped_viewport(
rows_all,
start_idx,
max_items,
sel,
desc_col,
width,
viewport_height,
) {
break;
}
start_idx = start_idx.saturating_add(1);
}
start_idx
}
/// Build the full display line for a row with the description padded to start
/// at `desc_col`. Applies fuzzy-match bolding when indices are present and
/// dims the description.
@@ -347,6 +581,8 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
/// Render a list of rows using the provided ScrollState, with shared styling
/// and behavior for selection popups.
/// Returns the number of terminal lines actually rendered (including the
/// single-line empty placeholder when shown).
fn render_rows_inner(
area: Rect,
buf: &mut Buffer,
@@ -355,36 +591,37 @@ fn render_rows_inner(
max_results: usize,
empty_message: &str,
col_width_mode: ColumnWidthMode,
) {
) -> u16 {
if rows_all.is_empty() {
if area.height > 0 {
Line::from(empty_message.dim().italic()).render(area, buf);
}
return;
// Count the placeholder line only when there is vertical space to draw it.
return u16::from(area.height > 0);
}
// Determine which logical rows (items) are visible given the selection and
// the max_results clamp. Scrolling is still item-based for simplicity.
let visible_items = max_results
.min(rows_all.len())
.min(area.height.max(1) as usize);
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
if let Some(sel) = state.selected_idx {
if sel < start_idx {
start_idx = sel;
} else if visible_items > 0 {
let bottom = start_idx + visible_items - 1;
if sel > bottom {
start_idx = sel + 1 - visible_items;
}
}
let max_items = max_results.min(rows_all.len());
if max_items == 0 {
return 0;
}
let desc_measure_items = max_items.min(area.height.max(1) as usize);
// Keep item-window semantics, then correct for wrapped row heights so the
// selected row remains visible in a line-based viewport.
let start_idx = adjust_start_for_wrapped_selection_visibility(
rows_all,
state,
max_items,
desc_measure_items,
area.width,
area.height,
col_width_mode,
);
let desc_col = compute_desc_col(
rows_all,
start_idx,
visible_items,
desc_measure_items,
area.width,
col_width_mode,
);
@@ -392,38 +629,18 @@ fn render_rows_inner(
// Render items, wrapping descriptions and aligning wrapped lines under the
// shared description column. Stop when we run out of vertical space.
let mut cur_y = area.y;
for (i, row) in rows_all
.iter()
.enumerate()
.skip(start_idx)
.take(visible_items)
{
let mut rendered_lines: u16 = 0;
for (i, row) in rows_all.iter().enumerate().skip(start_idx).take(max_items) {
if cur_y >= area.y + area.height {
break;
}
let mut full_line = build_full_line(row, desc_col);
if Some(i) == state.selected_idx && !row.is_disabled {
// Match previous behavior: cyan + bold for the selected row.
// Reset the style first to avoid inheriting dim from keyboard shortcuts.
full_line.spans.iter_mut().for_each(|span| {
span.style = Style::default().fg(Color::Cyan).bold();
});
}
if row.is_disabled {
full_line.spans.iter_mut().for_each(|span| {
span.style = span.style.dim();
});
}
// Wrap with subsequent indent aligned to the description column.
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
let continuation_indent = wrap_indent(row, desc_col, area.width);
let options = RtOptions::new(area.width as usize)
.initial_indent(Line::from(""))
.subsequent_indent(Line::from(" ".repeat(continuation_indent)));
let wrapped = word_wrap_line(&full_line, options);
let mut wrapped = wrap_row_lines(row, desc_col, area.width);
apply_row_state_style(
&mut wrapped,
Some(i) == state.selected_idx && !row.is_disabled,
row.is_disabled,
);
// Render the wrapped lines.
for line in wrapped {
@@ -440,8 +657,11 @@ fn render_rows_inner(
buf,
);
cur_y = cur_y.saturating_add(1);
rendered_lines = rendered_lines.saturating_add(1);
}
}
rendered_lines
}
/// Render a list of rows using the provided ScrollState, with shared styling
@@ -451,6 +671,7 @@ fn render_rows_inner(
///
/// This function should be paired with [`measure_rows_height`] when reserving
/// space; pairing it with a different measurement mode can cause clipping.
/// Returns the number of terminal lines actually rendered.
pub(crate) fn render_rows(
area: Rect,
buf: &mut Buffer,
@@ -458,7 +679,7 @@ pub(crate) fn render_rows(
state: &ScrollState,
max_results: usize,
empty_message: &str,
) {
) -> u16 {
render_rows_inner(
area,
buf,
@@ -467,7 +688,7 @@ pub(crate) fn render_rows(
max_results,
empty_message,
ColumnWidthMode::AutoVisible,
);
)
}
/// Render a list of rows using the provided ScrollState, with shared styling
@@ -478,6 +699,7 @@ pub(crate) fn render_rows(
/// This function should be paired with
/// [`measure_rows_height_stable_col_widths`] so reserved and rendered heights
/// stay in sync.
/// Returns the number of terminal lines actually rendered.
pub(crate) fn render_rows_stable_col_widths(
area: Rect,
buf: &mut Buffer,
@@ -485,7 +707,7 @@ pub(crate) fn render_rows_stable_col_widths(
state: &ScrollState,
max_results: usize,
empty_message: &str,
) {
) -> u16 {
render_rows_inner(
area,
buf,
@@ -494,7 +716,7 @@ pub(crate) fn render_rows_stable_col_widths(
max_results,
empty_message,
ColumnWidthMode::AutoAllRows,
);
)
}
/// Render a list of rows using the provided ScrollState and explicit
@@ -502,6 +724,7 @@ pub(crate) fn render_rows_stable_col_widths(
///
/// This is the low-level entry point for callers that need to thread a mode
/// through higher-level configuration.
/// Returns the number of terminal lines actually rendered.
pub(crate) fn render_rows_with_col_width_mode(
area: Rect,
buf: &mut Buffer,
@@ -510,7 +733,7 @@ pub(crate) fn render_rows_with_col_width_mode(
max_results: usize,
empty_message: &str,
col_width_mode: ColumnWidthMode,
) {
) -> u16 {
render_rows_inner(
area,
buf,
@@ -519,13 +742,14 @@ pub(crate) fn render_rows_with_col_width_mode(
max_results,
empty_message,
col_width_mode,
);
)
}
/// Render rows as a single line each (no wrapping), truncating overflow with an ellipsis.
///
/// This path always uses viewport-local width alignment and is best for dense
/// list UIs where multi-line descriptions would add too much vertical churn.
/// Returns the number of terminal lines actually rendered.
pub(crate) fn render_rows_single_line(
area: Rect,
buf: &mut Buffer,
@@ -533,12 +757,13 @@ pub(crate) fn render_rows_single_line(
state: &ScrollState,
max_results: usize,
empty_message: &str,
) {
) -> u16 {
if rows_all.is_empty() {
if area.height > 0 {
Line::from(empty_message.dim().italic()).render(area, buf);
}
return;
// Count the placeholder line only when there is vertical space to draw it.
return u16::from(area.height > 0);
}
let visible_items = max_results
@@ -566,6 +791,7 @@ pub(crate) fn render_rows_single_line(
);
let mut cur_y = area.y;
let mut rendered_lines: u16 = 0;
for (i, row) in rows_all
.iter()
.enumerate()
@@ -599,7 +825,10 @@ pub(crate) fn render_rows_single_line(
buf,
);
cur_y = cur_y.saturating_add(1);
rendered_lines = rendered_lines.saturating_add(1);
}
rendered_lines
}
/// Compute the number of terminal rows required to render up to `max_results`
@@ -690,8 +919,6 @@ fn measure_rows_height_inner(
col_width_mode,
);
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
let mut total: u16 = 0;
for row in rows_all
.iter()
@@ -700,13 +927,27 @@ fn measure_rows_height_inner(
.take(visible_items)
.map(|(_, r)| r)
{
let full_line = build_full_line(row, desc_col);
let continuation_indent = wrap_indent(row, desc_col, content_width);
let opts = RtOptions::new(content_width as usize)
.initial_indent(Line::from(""))
.subsequent_indent(Line::from(" ".repeat(continuation_indent)));
let wrapped_lines = word_wrap_line(&full_line, opts).len();
let wrapped_lines = wrap_row_lines(row, desc_col, content_width).len();
total = total.saturating_add(wrapped_lines as u16);
}
total.max(1)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn one_cell_width_falls_back_without_panic_for_wrapped_two_column_rows() {
let row = GenericDisplayRow {
name: "1. Very long option label".to_string(),
description: Some("Very long description".to_string()),
wrap_indent: Some(4),
..Default::default()
};
let two_col = wrap_two_column_row(&row, 0, 1);
assert_eq!(two_col.len(), 0);
}
}