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::Stylize;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;use crate::key_hint::KeyBinding;use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;use crate::render::Insets;use crate::render::RectExt as _;use crate::style::accent_style;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 name_prefix_spans: Vec<Span<'static>>, 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,}/// Column-width behavior plus an optional shared left-column width override.#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]pub(crate) struct ColumnWidthConfig { pub mode: ColumnWidthMode, pub name_column_width: Option<usize>,}impl ColumnWidthConfig { pub(crate) const fn new(mode: ColumnWidthMode, name_column_width: Option<usize>) -> Self { Self { mode, name_column_width, } }}// 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;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_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(), }}fn compute_desc_col( rows_all: &[GenericDisplayRow], start_idx: usize, visible_items: usize, content_width: u16, column_width: ColumnWidthConfig,) -> usize { if content_width <= 1 { return 0; } 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 column_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 column_width.mode { ColumnWidthMode::AutoVisible => rows_all .iter() .enumerate() .skip(start_idx) .take(visible_items) .map(|(_, row)| { let mut spans = row.name_prefix_spans.clone(); spans.push(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 = row.name_prefix_spans.clone(); spans.push(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, }; column_width .name_column_width .map(|width| width.max(max_name_width)) .unwrap_or(max_name_width) .saturating_add(2) .min(max_auto_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)}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() && row.name_prefix_spans.is_empty()}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 = accent_style(); }); } } 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, column_width: ColumnWidthConfig,) -> 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, column_width); 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.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_prefix_width = Line::from(row.name_prefix_spans.clone()).width(); let name_limit = combined_description .as_ref() .map(|_| desc_col.saturating_sub(2).saturating_sub(name_prefix_width)) .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 = name_prefix_width + Line::from(name_spans.clone()).width(); let mut full_spans: Vec<Span> = row.name_prefix_spans.clone(); full_spans.extend(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./// 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, rows_all: &[GenericDisplayRow], state: &ScrollState, max_results: usize, empty_message: &str, column_width: ColumnWidthConfig,) -> u16 { if rows_all.is_empty() { if area.height > 0 { Line::from(empty_message.dim().italic()).render(area, buf); } // Count the placeholder line only when there is vertical space to draw it. return u16::from(area.height > 0); } 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, column_width, ); let desc_col = compute_desc_col( rows_all, start_idx, desc_measure_items, area.width, column_width, ); // 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; 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 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 { 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); rendered_lines = rendered_lines.saturating_add(1); } } rendered_lines}/// 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./// Returns the number of terminal lines actually rendered.pub(crate) fn render_rows( area: Rect, buf: &mut Buffer, rows_all: &[GenericDisplayRow], state: &ScrollState, max_results: usize, empty_message: &str,) -> u16 { render_rows_inner( area, buf, rows_all, state, max_results, empty_message, ColumnWidthConfig::default(), )}/// 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./// Returns the number of terminal lines actually rendered.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, column_width: ColumnWidthConfig,) -> u16 { render_rows_inner( area, buf, rows_all, state, max_results, empty_message, column_width, )}/// 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, rows_all: &[GenericDisplayRow], state: &ScrollState, max_results: usize, empty_message: &str,) -> u16 { render_rows_single_line_with_col_width_mode( area, buf, rows_all, state, max_results, empty_message, ColumnWidthConfig::default(), )}/// Render a list of rows as a single line each (no wrapping), truncating overflow with an/// ellipsis while honoring the configured column width behavior.pub(crate) fn render_rows_single_line_with_col_width_mode( area: Rect, buf: &mut Buffer, rows_all: &[GenericDisplayRow], state: &ScrollState, max_results: usize, empty_message: &str, column_width: ColumnWidthConfig,) -> u16 { if rows_all.is_empty() { if area.height > 0 { Line::from(empty_message.dim().italic()).render(area, buf); } // Count the placeholder line only when there is vertical space to draw it. return u16::from(area.height > 0); } 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, column_width); let mut cur_y = area.y; let mut rendered_lines: u16 = 0; 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 = accent_style(); }); } 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); rendered_lines = rendered_lines.saturating_add(1); } rendered_lines}/// 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, ColumnWidthConfig::default(), )}/// 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, column_width: ColumnWidthConfig,) -> u16 { measure_rows_height_inner(rows_all, state, max_results, width, column_width)}fn measure_rows_height_inner( rows_all: &[GenericDisplayRow], state: &ScrollState, max_results: usize, width: u16, column_width: ColumnWidthConfig,) -> 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, column_width, ); let mut total: u16 = 0; for row in rows_all .iter() .enumerate() .skip(start_idx) .take(visible_items) .map(|(_, r)| r) { 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; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Modifier; #[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, /*desc_col*/ 0, /*width*/ 1); assert_eq!(two_col.len(), 0); } #[test] fn selected_rows_use_the_shared_accent_style() { let rows = vec![GenericDisplayRow { name: "selected".to_string(), ..Default::default() }]; let state = ScrollState { selected_idx: Some(0), ..Default::default() }; let area = Rect::new(0, 0, 16, 1); let mut buf = Buffer::empty(area); render_rows( area, &mut buf, &rows, &state, /*max_results*/ 1, "no rows", ); let style = buf[(0, 0)].style(); let expected = accent_style(); assert_eq!(style.fg, expected.fg); assert!(style.add_modifier.contains(Modifier::BOLD)); }}