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::Modifier; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; use ratatui::widgets::Block; use ratatui::widgets::BorderType; use ratatui::widgets::Borders; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use super::scroll_state::ScrollState; use crate::ui_consts::LIVE_PREFIX_COLS; /// A generic representation of a display row for selection popups. pub(crate) struct GenericDisplayRow { pub name: String, pub match_indices: Option>, // indices to bold (char positions) pub is_current: bool, pub description: Option, // optional grey text after the name } impl GenericDisplayRow {} /// Compute a shared description-column start based on the widest visible name /// plus two spaces of padding. Ensures at least one column is left for the /// description. fn compute_desc_col( rows_all: &[GenericDisplayRow], start_idx: usize, visible_items: usize, content_width: u16, ) -> usize { let visible_range = start_idx..(start_idx + visible_items); let max_name_width = rows_all .iter() .enumerate() .filter(|(i, _)| visible_range.contains(i)) .map(|(_, r)| Line::from(r.name.clone()).width()) .max() .unwrap_or(0); let mut desc_col = max_name_width.saturating_add(2); if (desc_col as u16) >= content_width { desc_col = content_width.saturating_sub(1) as usize; } desc_col } /// 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 mut name_spans: Vec = Vec::with_capacity(row.name.len()); 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() { 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 { name_spans.push(row.name.clone().into()); } let this_name_width = Line::from(name_spans.clone()).width(); let mut full_spans: Vec = name_spans; if let Some(desc) = row.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()); } Line::from(full_spans) } /// Render a list of rows using the provided ScrollState, with shared styling /// and behavior for selection popups. pub(crate) fn render_rows( area: Rect, buf: &mut Buffer, rows_all: &[GenericDisplayRow], state: &ScrollState, max_results: usize, _dim_non_selected: bool, empty_message: &str, ) { // Always draw a dim left border to match other popups. let block = Block::default() .borders(Borders::LEFT) .border_type(BorderType::QuadrantOutside) .border_style(Style::default().add_modifier(Modifier::DIM)); block.render(area, buf); // Content renders to the right of the border with the same live prefix // padding used by the composer so the popup aligns with the input text. let prefix_cols = LIVE_PREFIX_COLS; let content_area = Rect { x: area.x.saturating_add(prefix_cols), y: area.y, width: area.width.saturating_sub(prefix_cols), height: area.height, }; // Clear the padding column(s) so stale characters never peek between the // border and the popup contents. let padding_cols = prefix_cols.saturating_sub(1); if padding_cols > 0 { let pad_start = area.x.saturating_add(1); let pad_end = pad_start .saturating_add(padding_cols) .min(area.x.saturating_add(area.width)); let pad_bottom = area.y.saturating_add(area.height); for x in pad_start..pad_end { for y in area.y..pad_bottom { if let Some(cell) = buf.cell_mut((x, y)) { cell.set_symbol(" "); } } } } if rows_all.is_empty() { if content_area.height > 0 { let para = Paragraph::new(Line::from(empty_message.dim().italic())); para.render( Rect { x: content_area.x, y: content_area.y, width: content_area.width, height: 1, }, 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 max_rows_from_area = content_area.height as usize; let visible_items = max_results .min(rows_all.len()) .min(max_rows_from_area.max(1)); 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_area.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 = content_area.y; for (i, row) in rows_all .iter() .enumerate() .skip(start_idx) .take(visible_items) { if cur_y >= content_area.y + content_area.height { break; } let GenericDisplayRow { name, match_indices, is_current: _is_current, description, } = row; let full_line = build_full_line( &GenericDisplayRow { name: name.clone(), match_indices: match_indices.clone(), is_current: *_is_current, description: description.clone(), }, desc_col, ); // Wrap with subsequent indent aligned to the description column. use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; let options = RtOptions::new(content_area.width as usize) .initial_indent(Line::from("")) .subsequent_indent(Line::from(" ".repeat(desc_col))); let wrapped = word_wrap_line(&full_line, options); // Render the wrapped lines. for mut line in wrapped { if cur_y >= content_area.y + content_area.height { break; } if Some(i) == state.selected_idx { // Match previous behavior: cyan + bold for the selected row. line.style = Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD); } let para = Paragraph::new(line); para.render( Rect { x: content_area.x, y: cur_y, width: content_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. pub(crate) fn measure_rows_height( rows_all: &[GenericDisplayRow], state: &ScrollState, max_results: usize, width: u16, ) -> 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); 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 opts = RtOptions::new(content_width as usize) .initial_indent(Line::from("")) .subsequent_indent(Line::from(" ".repeat(desc_col))); total = total.saturating_add(word_wrap_line(&full_line, opts).len() as u16); } total.max(1) }