mirror of
https://github.com/openai/codex.git
synced 2026-05-01 01:47:18 +00:00
## What changed
- In `codex-rs/core/src/skills/injection.rs`, we now honor explicit
`UserInput::Skill { name, path }` first, then fall back to text mentions
only when safe.
- In `codex-rs/tui/src/bottom_pane/chat_composer.rs`, mention selection
is now token-bound (selected mention is tied to the specific inserted
`$token`), and we snapshot bindings at submit time so selection is not
lost.
- In `codex-rs/tui/src/chatwidget.rs` and
`codex-rs/tui/src/bottom_pane/mod.rs`, submit/queue paths now consume
the submit-time mention snapshot (instead of rereading cleared composer
state).
- In `codex-rs/tui/src/mention_codec.rs` and
`codex-rs/tui/src/bottom_pane/chat_composer_history.rs`, history now
round-trips mention targets so resume restores the same selected
duplicate.
- In `codex-rs/tui/src/bottom_pane/skill_popup.rs` and
`codex-rs/tui/src/bottom_pane/chat_composer.rs`, duplicate labels are
normalized to `[Repo]` / `[App]`, app rows no longer show `Connected -`,
and description space is a bit wider.
<img width="550" height="163" alt="Screenshot 2026-02-05 at 9 56 56 PM"
src="https://github.com/user-attachments/assets/346a7eb2-a342-4a49-aec8-68dfec0c7d89"
/>
<img width="550" height="163" alt="Screenshot 2026-02-05 at 9 57 09 PM"
src="https://github.com/user-attachments/assets/5e04d9af-cccf-4932-98b3-c37183e445ed"
/>
## Before vs now
- Before: selecting a duplicate could still submit the default/repo
match, and resume could lose which duplicate was originally selected.
- Now: the exact selected target (skill path or app id) is preserved
through submit, queue/restore, and resume.
## Manual test
1. Build and run this branch locally:
- `cd /Users/daniels/code/codex/codex-rs`
- `cargo build -p codex-cli --bin codex`
- `./target/debug/codex`
2. Open mention picker with `$` and pick a duplicate entry (not the
first one).
3. Confirm duplicate UI:
- repo duplicate rows show `[Repo]`
- app duplicate rows show `[App]`
- app description does **not** start with `Connected -`
4. Submit the prompt, then press Up to restore draft and submit again.
Expected: it keeps the same selected duplicate target.
5. Use `/resume` to reopen the session and send again.
Expected: restored mention still resolves to the same duplicate target.
713 lines
22 KiB
Rust
713 lines
22 KiB
Rust
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
// Note: Table-based layout previously used Constraint; the manual renderer
|
|
// below no longer requires it.
|
|
use ratatui::style::Color;
|
|
use ratatui::style::Style;
|
|
use ratatui::style::Stylize;
|
|
use ratatui::text::Line;
|
|
use ratatui::text::Span;
|
|
use ratatui::widgets::Block;
|
|
use ratatui::widgets::Widget;
|
|
use unicode_width::UnicodeWidthChar;
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
use crate::key_hint::KeyBinding;
|
|
use crate::render::Insets;
|
|
use crate::render::RectExt as _;
|
|
use crate::style::user_message_style;
|
|
|
|
use super::scroll_state::ScrollState;
|
|
|
|
/// Render-ready representation of one row in a selection popup.
|
|
///
|
|
/// This type contains presentation-focused fields that are intentionally more
|
|
/// concrete than source domain models. `match_indices` are character offsets
|
|
/// into `name`, and `wrap_indent` is interpreted in terminal cell columns.
|
|
#[derive(Default)]
|
|
pub(crate) struct GenericDisplayRow {
|
|
pub name: String,
|
|
pub display_shortcut: Option<KeyBinding>,
|
|
pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
|
|
pub description: Option<String>, // optional grey text after the name
|
|
pub category_tag: Option<String>, // optional right-side category label
|
|
pub disabled_reason: Option<String>, // optional disabled message
|
|
pub is_disabled: bool,
|
|
pub wrap_indent: Option<usize>, // optional indent for wrapped lines
|
|
}
|
|
|
|
/// Controls how selection rows choose the split between left/right name/description columns.
|
|
///
|
|
/// Callers should use the same mode for both measurement and rendering, or the
|
|
/// popup can reserve the wrong number of lines and clip content.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
#[cfg_attr(not(test), allow(dead_code))]
|
|
pub(crate) enum ColumnWidthMode {
|
|
/// Derive column placement from only the visible viewport rows.
|
|
#[default]
|
|
AutoVisible,
|
|
/// Derive column placement from all rows so scrolling does not shift columns.
|
|
AutoAllRows,
|
|
/// Use a fixed two-column split: 30% left (name), 70% right (description).
|
|
Fixed,
|
|
}
|
|
|
|
const FIXED_LEFT_COLUMN_NUMERATOR: usize = 3;
|
|
const FIXED_LEFT_COLUMN_DENOMINATOR: usize = 10;
|
|
|
|
const MENU_SURFACE_INSET_V: u16 = 1;
|
|
const MENU_SURFACE_INSET_H: u16 = 2;
|
|
|
|
/// Apply the shared "menu surface" padding used by bottom-pane overlays.
|
|
///
|
|
/// Rendering code should generally call [`render_menu_surface`] and then lay
|
|
/// out content inside the returned inset rect.
|
|
pub(crate) fn menu_surface_inset(area: Rect) -> Rect {
|
|
area.inset(Insets::vh(MENU_SURFACE_INSET_V, MENU_SURFACE_INSET_H))
|
|
}
|
|
|
|
/// Total vertical padding introduced by the menu surface treatment.
|
|
pub(crate) const fn menu_surface_padding_height() -> u16 {
|
|
MENU_SURFACE_INSET_V * 2
|
|
}
|
|
|
|
/// Paint the shared menu background and return the inset content area.
|
|
///
|
|
/// This keeps the surface treatment consistent across selection-style overlays
|
|
/// (for example `/model`, approvals, and request-user-input). Callers should
|
|
/// render all inner content in the returned rect, not the original area.
|
|
pub(crate) fn render_menu_surface(area: Rect, buf: &mut Buffer) -> Rect {
|
|
if area.is_empty() {
|
|
return area;
|
|
}
|
|
Block::default()
|
|
.style(user_message_style())
|
|
.render(area, buf);
|
|
menu_surface_inset(area)
|
|
}
|
|
|
|
/// Wrap a styled line while preserving span styles.
|
|
///
|
|
/// The function clamps `width` to at least one terminal cell so callers can use
|
|
/// it safely with narrow layouts.
|
|
pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec<Line<'a>> {
|
|
use crate::wrapping::RtOptions;
|
|
use crate::wrapping::word_wrap_line;
|
|
|
|
let width = width.max(1) as usize;
|
|
let opts = RtOptions::new(width)
|
|
.initial_indent(Line::from(""))
|
|
.subsequent_indent(Line::from(""));
|
|
word_wrap_line(line, opts)
|
|
}
|
|
|
|
fn line_width(line: &Line<'_>) -> usize {
|
|
line.iter()
|
|
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
|
|
.sum()
|
|
}
|
|
|
|
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());
|
|
}
|
|
|
|
let Line {
|
|
style,
|
|
alignment,
|
|
spans,
|
|
} = line;
|
|
let mut used = 0usize;
|
|
let mut spans_out: Vec<Span<'static>> = Vec::new();
|
|
|
|
for span in spans {
|
|
let text = span.content.into_owned();
|
|
let style = span.style;
|
|
let span_width = UnicodeWidthStr::width(text.as_str());
|
|
|
|
if span_width == 0 {
|
|
spans_out.push(Span::styled(text, style));
|
|
continue;
|
|
}
|
|
|
|
if used >= max_width {
|
|
break;
|
|
}
|
|
|
|
if used + span_width <= max_width {
|
|
used += span_width;
|
|
spans_out.push(Span::styled(text, style));
|
|
continue;
|
|
}
|
|
|
|
let mut truncated = String::new();
|
|
for ch in text.chars() {
|
|
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
|
|
if used + ch_width > max_width {
|
|
break;
|
|
}
|
|
truncated.push(ch);
|
|
used += ch_width;
|
|
}
|
|
|
|
if !truncated.is_empty() {
|
|
spans_out.push(Span::styled(truncated, style));
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
Line {
|
|
style,
|
|
alignment,
|
|
spans: spans_out,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn truncate_line_with_ellipsis_if_overflow(
|
|
line: Line<'static>,
|
|
max_width: usize,
|
|
) -> Line<'static> {
|
|
if max_width == 0 {
|
|
return Line::from(Vec::<Span<'static>>::new());
|
|
}
|
|
|
|
let width = line_width(&line);
|
|
if width <= max_width {
|
|
return line;
|
|
}
|
|
|
|
let truncated = truncate_line_to_width(line, max_width.saturating_sub(1));
|
|
let Line {
|
|
style,
|
|
alignment,
|
|
mut spans,
|
|
} = truncated;
|
|
let ellipsis_style = spans.last().map(|span| span.style).unwrap_or_default();
|
|
spans.push(Span::styled("…", ellipsis_style));
|
|
Line {
|
|
style,
|
|
alignment,
|
|
spans,
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
visible_items: usize,
|
|
content_width: u16,
|
|
col_width_mode: ColumnWidthMode,
|
|
) -> usize {
|
|
if content_width <= 1 {
|
|
return 0;
|
|
}
|
|
|
|
let max_desc_col = content_width.saturating_sub(1) as usize;
|
|
match col_width_mode {
|
|
ColumnWidthMode::Fixed => ((content_width as usize * FIXED_LEFT_COLUMN_NUMERATOR)
|
|
/ FIXED_LEFT_COLUMN_DENOMINATOR)
|
|
.clamp(1, max_desc_col),
|
|
ColumnWidthMode::AutoVisible | ColumnWidthMode::AutoAllRows => {
|
|
let max_name_width = match col_width_mode {
|
|
ColumnWidthMode::AutoVisible => rows_all
|
|
.iter()
|
|
.enumerate()
|
|
.skip(start_idx)
|
|
.take(visible_items)
|
|
.map(|(_, row)| {
|
|
let mut spans: Vec<Span> = vec![row.name.clone().into()];
|
|
if row.disabled_reason.is_some() {
|
|
spans.push(" (disabled)".dim());
|
|
}
|
|
Line::from(spans).width()
|
|
})
|
|
.max()
|
|
.unwrap_or(0),
|
|
ColumnWidthMode::AutoAllRows => rows_all
|
|
.iter()
|
|
.map(|row| {
|
|
let mut spans: Vec<Span> = vec![row.name.clone().into()];
|
|
if row.disabled_reason.is_some() {
|
|
spans.push(" (disabled)".dim());
|
|
}
|
|
Line::from(spans).width()
|
|
})
|
|
.max()
|
|
.unwrap_or(0),
|
|
ColumnWidthMode::Fixed => 0,
|
|
};
|
|
|
|
max_name_width.saturating_add(2).min(max_desc_col)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Determine how many spaces to indent wrapped lines for a row.
|
|
fn wrap_indent(row: &GenericDisplayRow, desc_col: usize, max_width: u16) -> usize {
|
|
let max_indent = max_width.saturating_sub(1) as usize;
|
|
let indent = row.wrap_indent.unwrap_or_else(|| {
|
|
if row.description.is_some() || row.disabled_reason.is_some() {
|
|
desc_col
|
|
} else {
|
|
0
|
|
}
|
|
});
|
|
indent.min(max_indent)
|
|
}
|
|
|
|
/// 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.
|
|
fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
|
|
let combined_description = match (&row.description, &row.disabled_reason) {
|
|
(Some(desc), Some(reason)) => Some(format!("{desc} (disabled: {reason})")),
|
|
(Some(desc), None) => Some(desc.clone()),
|
|
(None, Some(reason)) => Some(format!("disabled: {reason}")),
|
|
(None, None) => None,
|
|
};
|
|
|
|
// Enforce single-line name: allow at most desc_col - 2 cells for name,
|
|
// reserving two spaces before the description column.
|
|
let name_limit = combined_description
|
|
.as_ref()
|
|
.map(|_| desc_col.saturating_sub(2))
|
|
.unwrap_or(usize::MAX);
|
|
|
|
let mut name_spans: Vec<Span> = Vec::with_capacity(row.name.len());
|
|
let mut used_width = 0usize;
|
|
let mut truncated = false;
|
|
|
|
if let Some(idxs) = row.match_indices.as_ref() {
|
|
let mut idx_iter = idxs.iter().peekable();
|
|
for (char_idx, ch) in row.name.chars().enumerate() {
|
|
let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
|
|
let next_width = used_width.saturating_add(ch_w);
|
|
if next_width > name_limit {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
used_width = next_width;
|
|
|
|
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
|
|
idx_iter.next();
|
|
name_spans.push(ch.to_string().bold());
|
|
} else {
|
|
name_spans.push(ch.to_string().into());
|
|
}
|
|
}
|
|
} else {
|
|
for ch in row.name.chars() {
|
|
let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
|
|
let next_width = used_width.saturating_add(ch_w);
|
|
if next_width > name_limit {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
used_width = next_width;
|
|
name_spans.push(ch.to_string().into());
|
|
}
|
|
}
|
|
|
|
if truncated {
|
|
// If there is at least one cell available, add an ellipsis.
|
|
// When name_limit is 0, we still show an ellipsis to indicate truncation.
|
|
name_spans.push("…".into());
|
|
}
|
|
|
|
if row.disabled_reason.is_some() {
|
|
name_spans.push(" (disabled)".dim());
|
|
}
|
|
|
|
let this_name_width = Line::from(name_spans.clone()).width();
|
|
let mut full_spans: Vec<Span> = name_spans;
|
|
if let Some(display_shortcut) = row.display_shortcut {
|
|
full_spans.push(" (".into());
|
|
full_spans.push(display_shortcut.into());
|
|
full_spans.push(")".into());
|
|
}
|
|
if let Some(desc) = combined_description.as_ref() {
|
|
let gap = desc_col.saturating_sub(this_name_width);
|
|
if gap > 0 {
|
|
full_spans.push(" ".repeat(gap).into());
|
|
}
|
|
full_spans.push(desc.clone().dim());
|
|
}
|
|
if let Some(tag) = row.category_tag.as_deref().filter(|tag| !tag.is_empty()) {
|
|
full_spans.push(" ".into());
|
|
full_spans.push(tag.to_string().dim());
|
|
}
|
|
Line::from(full_spans)
|
|
}
|
|
|
|
/// Render a list of rows using the provided ScrollState, with shared styling
|
|
/// and behavior for selection popups.
|
|
fn render_rows_inner(
|
|
area: Rect,
|
|
buf: &mut Buffer,
|
|
rows_all: &[GenericDisplayRow],
|
|
state: &ScrollState,
|
|
max_results: usize,
|
|
empty_message: &str,
|
|
col_width_mode: ColumnWidthMode,
|
|
) {
|
|
if rows_all.is_empty() {
|
|
if area.height > 0 {
|
|
Line::from(empty_message.dim().italic()).render(area, buf);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 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 desc_col = compute_desc_col(
|
|
rows_all,
|
|
start_idx,
|
|
visible_items,
|
|
area.width,
|
|
col_width_mode,
|
|
);
|
|
|
|
// 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)
|
|
{
|
|
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);
|
|
|
|
// Render the wrapped lines.
|
|
for line in wrapped {
|
|
if cur_y >= area.y + area.height {
|
|
break;
|
|
}
|
|
line.render(
|
|
Rect {
|
|
x: area.x,
|
|
y: cur_y,
|
|
width: area.width,
|
|
height: 1,
|
|
},
|
|
buf,
|
|
);
|
|
cur_y = cur_y.saturating_add(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Render a list of rows using the provided ScrollState, with shared styling
|
|
/// and behavior for selection popups.
|
|
/// Description alignment is computed from visible rows only, which allows the
|
|
/// layout to adapt tightly to the current viewport.
|
|
///
|
|
/// This function should be paired with [`measure_rows_height`] when reserving
|
|
/// space; pairing it with a different measurement mode can cause clipping.
|
|
pub(crate) fn render_rows(
|
|
area: Rect,
|
|
buf: &mut Buffer,
|
|
rows_all: &[GenericDisplayRow],
|
|
state: &ScrollState,
|
|
max_results: usize,
|
|
empty_message: &str,
|
|
) {
|
|
render_rows_inner(
|
|
area,
|
|
buf,
|
|
rows_all,
|
|
state,
|
|
max_results,
|
|
empty_message,
|
|
ColumnWidthMode::AutoVisible,
|
|
);
|
|
}
|
|
|
|
/// Render a list of rows using the provided ScrollState, with shared styling
|
|
/// and behavior for selection popups.
|
|
/// This mode keeps column placement stable while scrolling by sizing the
|
|
/// description column against the full dataset.
|
|
///
|
|
/// This function should be paired with
|
|
/// [`measure_rows_height_stable_col_widths`] so reserved and rendered heights
|
|
/// stay in sync.
|
|
pub(crate) fn render_rows_stable_col_widths(
|
|
area: Rect,
|
|
buf: &mut Buffer,
|
|
rows_all: &[GenericDisplayRow],
|
|
state: &ScrollState,
|
|
max_results: usize,
|
|
empty_message: &str,
|
|
) {
|
|
render_rows_inner(
|
|
area,
|
|
buf,
|
|
rows_all,
|
|
state,
|
|
max_results,
|
|
empty_message,
|
|
ColumnWidthMode::AutoAllRows,
|
|
);
|
|
}
|
|
|
|
/// Render a list of rows using the provided ScrollState and explicit
|
|
/// [`ColumnWidthMode`] behavior.
|
|
///
|
|
/// This is the low-level entry point for callers that need to thread a mode
|
|
/// through higher-level configuration.
|
|
pub(crate) fn render_rows_with_col_width_mode(
|
|
area: Rect,
|
|
buf: &mut Buffer,
|
|
rows_all: &[GenericDisplayRow],
|
|
state: &ScrollState,
|
|
max_results: usize,
|
|
empty_message: &str,
|
|
col_width_mode: ColumnWidthMode,
|
|
) {
|
|
render_rows_inner(
|
|
area,
|
|
buf,
|
|
rows_all,
|
|
state,
|
|
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.
|
|
pub(crate) fn render_rows_single_line(
|
|
area: Rect,
|
|
buf: &mut Buffer,
|
|
rows_all: &[GenericDisplayRow],
|
|
state: &ScrollState,
|
|
max_results: usize,
|
|
empty_message: &str,
|
|
) {
|
|
if rows_all.is_empty() {
|
|
if area.height > 0 {
|
|
Line::from(empty_message.dim().italic()).render(area, buf);
|
|
}
|
|
return;
|
|
}
|
|
|
|
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 desc_col = compute_desc_col(
|
|
rows_all,
|
|
start_idx,
|
|
visible_items,
|
|
area.width,
|
|
ColumnWidthMode::AutoVisible,
|
|
);
|
|
|
|
let mut cur_y = area.y;
|
|
for (i, row) in rows_all
|
|
.iter()
|
|
.enumerate()
|
|
.skip(start_idx)
|
|
.take(visible_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 {
|
|
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();
|
|
});
|
|
}
|
|
|
|
let full_line = truncate_line_with_ellipsis_if_overflow(full_line, area.width as usize);
|
|
full_line.render(
|
|
Rect {
|
|
x: area.x,
|
|
y: cur_y,
|
|
width: area.width,
|
|
height: 1,
|
|
},
|
|
buf,
|
|
);
|
|
cur_y = cur_y.saturating_add(1);
|
|
}
|
|
}
|
|
|
|
/// Compute the number of terminal rows required to render up to `max_results`
|
|
/// items from `rows_all` given the current scroll/selection state and the
|
|
/// available `width`. Accounts for description wrapping and alignment so the
|
|
/// caller can allocate sufficient vertical space.
|
|
///
|
|
/// This function matches [`render_rows`] semantics (`AutoVisible` column
|
|
/// sizing). Mixing it with stable or fixed render modes can under- or
|
|
/// over-estimate required height.
|
|
pub(crate) fn measure_rows_height(
|
|
rows_all: &[GenericDisplayRow],
|
|
state: &ScrollState,
|
|
max_results: usize,
|
|
width: u16,
|
|
) -> u16 {
|
|
measure_rows_height_inner(
|
|
rows_all,
|
|
state,
|
|
max_results,
|
|
width,
|
|
ColumnWidthMode::AutoVisible,
|
|
)
|
|
}
|
|
|
|
/// Measures selection-row height while using full-dataset column alignment.
|
|
/// This should be paired with [`render_rows_stable_col_widths`] so layout
|
|
/// reservation matches rendering behavior.
|
|
pub(crate) fn measure_rows_height_stable_col_widths(
|
|
rows_all: &[GenericDisplayRow],
|
|
state: &ScrollState,
|
|
max_results: usize,
|
|
width: u16,
|
|
) -> u16 {
|
|
measure_rows_height_inner(
|
|
rows_all,
|
|
state,
|
|
max_results,
|
|
width,
|
|
ColumnWidthMode::AutoAllRows,
|
|
)
|
|
}
|
|
|
|
/// Measure selection-row height using explicit [`ColumnWidthMode`] behavior.
|
|
///
|
|
/// This is the low-level companion to [`render_rows_with_col_width_mode`].
|
|
pub(crate) fn measure_rows_height_with_col_width_mode(
|
|
rows_all: &[GenericDisplayRow],
|
|
state: &ScrollState,
|
|
max_results: usize,
|
|
width: u16,
|
|
col_width_mode: ColumnWidthMode,
|
|
) -> u16 {
|
|
measure_rows_height_inner(rows_all, state, max_results, width, col_width_mode)
|
|
}
|
|
|
|
fn measure_rows_height_inner(
|
|
rows_all: &[GenericDisplayRow],
|
|
state: &ScrollState,
|
|
max_results: usize,
|
|
width: u16,
|
|
col_width_mode: ColumnWidthMode,
|
|
) -> u16 {
|
|
if rows_all.is_empty() {
|
|
return 1; // placeholder "no matches" line
|
|
}
|
|
|
|
let content_width = width.saturating_sub(1).max(1);
|
|
|
|
let visible_items = max_results.min(rows_all.len());
|
|
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 desc_col = compute_desc_col(
|
|
rows_all,
|
|
start_idx,
|
|
visible_items,
|
|
content_width,
|
|
col_width_mode,
|
|
);
|
|
|
|
use crate::wrapping::RtOptions;
|
|
use crate::wrapping::word_wrap_line;
|
|
let mut total: u16 = 0;
|
|
for row in rows_all
|
|
.iter()
|
|
.enumerate()
|
|
.skip(start_idx)
|
|
.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();
|
|
total = total.saturating_add(wrapped_lines as u16);
|
|
}
|
|
total.max(1)
|
|
}
|