Fix jitter in TUI apps/connectors picker (#10593)
This PR fixes jitter in the TUI apps menu by making the description column stable during rendering and height measurement. Added a `stable_desc_col` option to `SelectionViewParams`/`ListSelectionView`, introduced stable variants of the shared row render/measure helpers in `selection_popup_common`, and enabled the stable mode for the apps/connectors picker in `chatwidget`. With these changes, only the apps/connectors picker uses this new option, though it could be used elsewhere in the future. Why: previously, the description column was computed from only currently visible rows, so as you scrolled or filtered, the column could shift and cause wrapping/height changes that looked jumpy. Computing it from all rows in this popup keeps alignment and layout consistent as users scroll through avaialble apps. **Before:** https://github.com/user-attachments/assets/3856cb72-5465-4b90-a993-65a2ffb09113 **After:** https://github.com/user-attachments/assets/37b9d626-0b21-4c0f-8bb8-244c9ef971ff
This commit is contained in:
parent
4922b3e571
commit
d589ee05b1
7 changed files with 577 additions and 47 deletions
|
|
@ -23,14 +23,25 @@ use super::CancellationEvent;
|
|||
use super::bottom_pane_view::BottomPaneView;
|
||||
use super::popup_consts::MAX_POPUP_ROWS;
|
||||
use super::scroll_state::ScrollState;
|
||||
pub(crate) use super::selection_popup_common::ColumnWidthMode;
|
||||
use super::selection_popup_common::GenericDisplayRow;
|
||||
use super::selection_popup_common::measure_rows_height;
|
||||
use super::selection_popup_common::measure_rows_height_stable_col_widths;
|
||||
use super::selection_popup_common::measure_rows_height_with_col_width_mode;
|
||||
use super::selection_popup_common::render_rows;
|
||||
use super::selection_popup_common::render_rows_stable_col_widths;
|
||||
use super::selection_popup_common::render_rows_with_col_width_mode;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// One selectable item in the generic selection list.
|
||||
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
|
||||
|
||||
/// One row in a [`ListSelectionView`] selection list.
|
||||
///
|
||||
/// This is the source-of-truth model for row state before filtering and
|
||||
/// formatting into render rows. A row is treated as disabled when either
|
||||
/// `is_disabled` is true or `disabled_reason` is present; disabled rows cannot
|
||||
/// be accepted and are skipped by keyboard navigation.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SelectionItem {
|
||||
pub name: String,
|
||||
|
|
@ -46,6 +57,16 @@ pub(crate) struct SelectionItem {
|
|||
pub disabled_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Construction-time configuration for [`ListSelectionView`].
|
||||
///
|
||||
/// This config is consumed once by [`ListSelectionView::new`]. After
|
||||
/// construction, mutable interaction state (filtering, scrolling, and selected
|
||||
/// row) lives on the view itself.
|
||||
///
|
||||
/// `col_width_mode` controls column width mode in selection lists:
|
||||
/// `AutoVisible` (default) measures only rows visible in the viewport
|
||||
/// `AutoAllRows` measures all rows to ensure stable column widths as the user scrolls
|
||||
/// `Fixed` used a fixed 30/70 split between columns
|
||||
pub(crate) struct SelectionViewParams {
|
||||
pub title: Option<String>,
|
||||
pub subtitle: Option<String>,
|
||||
|
|
@ -54,6 +75,7 @@ pub(crate) struct SelectionViewParams {
|
|||
pub items: Vec<SelectionItem>,
|
||||
pub is_searchable: bool,
|
||||
pub search_placeholder: Option<String>,
|
||||
pub col_width_mode: ColumnWidthMode,
|
||||
pub header: Box<dyn Renderable>,
|
||||
pub initial_selected_idx: Option<usize>,
|
||||
}
|
||||
|
|
@ -68,12 +90,18 @@ impl Default for SelectionViewParams {
|
|||
items: Vec::new(),
|
||||
is_searchable: false,
|
||||
search_placeholder: None,
|
||||
col_width_mode: ColumnWidthMode::AutoVisible,
|
||||
header: Box::new(()),
|
||||
initial_selected_idx: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime state for rendering and interacting with a list-based selection popup.
|
||||
///
|
||||
/// This type is the single authority for filtered index mapping between
|
||||
/// visible rows and source items and for preserving selection while filters
|
||||
/// change.
|
||||
pub(crate) struct ListSelectionView {
|
||||
footer_note: Option<Line<'static>>,
|
||||
footer_hint: Option<Line<'static>>,
|
||||
|
|
@ -84,6 +112,7 @@ pub(crate) struct ListSelectionView {
|
|||
is_searchable: bool,
|
||||
search_query: String,
|
||||
search_placeholder: Option<String>,
|
||||
col_width_mode: ColumnWidthMode,
|
||||
filtered_indices: Vec<usize>,
|
||||
last_selected_actual_idx: Option<usize>,
|
||||
header: Box<dyn Renderable>,
|
||||
|
|
@ -91,6 +120,13 @@ pub(crate) struct ListSelectionView {
|
|||
}
|
||||
|
||||
impl ListSelectionView {
|
||||
/// Create a selection popup view with filtering, scrolling, and callbacks wired.
|
||||
///
|
||||
/// The constructor normalizes header/title composition and immediately
|
||||
/// applies filtering so `ScrollState` starts in a valid visible range.
|
||||
/// When search is enabled, rows without `search_value` will disappear as
|
||||
/// soon as the query is non-empty, which can look like dropped data unless
|
||||
/// callers intentionally populate that field.
|
||||
pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self {
|
||||
let mut header = params.header;
|
||||
if params.title.is_some() || params.subtitle.is_some() {
|
||||
|
|
@ -116,6 +152,7 @@ impl ListSelectionView {
|
|||
} else {
|
||||
None
|
||||
},
|
||||
col_width_mode: params.col_width_mode,
|
||||
filtered_indices: Vec::new(),
|
||||
last_selected_actual_idx: None,
|
||||
header,
|
||||
|
|
@ -434,12 +471,27 @@ impl Renderable for ListSelectionView {
|
|||
// Build the same display rows used by the renderer so wrapping math matches.
|
||||
let rows = self.build_rows();
|
||||
let rows_width = Self::rows_width(width);
|
||||
let rows_height = measure_rows_height(
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
rows_width.saturating_add(1),
|
||||
);
|
||||
let rows_height = match self.col_width_mode {
|
||||
ColumnWidthMode::AutoVisible => measure_rows_height(
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
rows_width.saturating_add(1),
|
||||
),
|
||||
ColumnWidthMode::AutoAllRows => measure_rows_height_stable_col_widths(
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
rows_width.saturating_add(1),
|
||||
),
|
||||
ColumnWidthMode::Fixed => measure_rows_height_with_col_width_mode(
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
rows_width.saturating_add(1),
|
||||
ColumnWidthMode::Fixed,
|
||||
),
|
||||
};
|
||||
|
||||
// Subtract 4 for the padding on the left and right of the header.
|
||||
let mut height = self.header.desired_height(width.saturating_sub(4));
|
||||
|
|
@ -483,12 +535,27 @@ impl Renderable for ListSelectionView {
|
|||
.desired_height(outer_content_area.width.saturating_sub(4));
|
||||
let rows = self.build_rows();
|
||||
let rows_width = Self::rows_width(outer_content_area.width);
|
||||
let rows_height = measure_rows_height(
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
rows_width.saturating_add(1),
|
||||
);
|
||||
let rows_height = match self.col_width_mode {
|
||||
ColumnWidthMode::AutoVisible => measure_rows_height(
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
rows_width.saturating_add(1),
|
||||
),
|
||||
ColumnWidthMode::AutoAllRows => measure_rows_height_stable_col_widths(
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
rows_width.saturating_add(1),
|
||||
),
|
||||
ColumnWidthMode::Fixed => measure_rows_height_with_col_width_mode(
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
rows_width.saturating_add(1),
|
||||
ColumnWidthMode::Fixed,
|
||||
),
|
||||
};
|
||||
let [header_area, _, search_area, list_area] = Layout::vertical([
|
||||
Constraint::Max(header_height),
|
||||
Constraint::Max(1),
|
||||
|
|
@ -529,14 +596,33 @@ impl Renderable for ListSelectionView {
|
|||
width: rows_width.max(1),
|
||||
height: list_area.height,
|
||||
};
|
||||
render_rows(
|
||||
render_area,
|
||||
buf,
|
||||
&rows,
|
||||
&self.state,
|
||||
render_area.height as usize,
|
||||
"no matches",
|
||||
);
|
||||
match self.col_width_mode {
|
||||
ColumnWidthMode::AutoVisible => render_rows(
|
||||
render_area,
|
||||
buf,
|
||||
&rows,
|
||||
&self.state,
|
||||
render_area.height as usize,
|
||||
"no matches",
|
||||
),
|
||||
ColumnWidthMode::AutoAllRows => render_rows_stable_col_widths(
|
||||
render_area,
|
||||
buf,
|
||||
&rows,
|
||||
&self.state,
|
||||
render_area.height as usize,
|
||||
"no matches",
|
||||
),
|
||||
ColumnWidthMode::Fixed => render_rows_with_col_width_mode(
|
||||
render_area,
|
||||
buf,
|
||||
&rows,
|
||||
&self.state,
|
||||
render_area.height as usize,
|
||||
"no matches",
|
||||
ColumnWidthMode::Fixed,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if footer_area.height > 0 {
|
||||
|
|
@ -585,7 +671,9 @@ mod tests {
|
|||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
|
||||
use crossterm::event::KeyCode;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::layout::Rect;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
|
|
@ -647,6 +735,55 @@ mod tests {
|
|||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn description_col(rendered: &str, item_marker: &str, description: &str) -> usize {
|
||||
let line = rendered
|
||||
.lines()
|
||||
.find(|line| line.contains(item_marker) && line.contains(description))
|
||||
.expect("expected rendered line to contain row marker and description");
|
||||
line.find(description)
|
||||
.expect("expected rendered line to contain description")
|
||||
}
|
||||
|
||||
fn make_scrolling_width_items() -> Vec<SelectionItem> {
|
||||
let mut items: Vec<SelectionItem> = (1..=8)
|
||||
.map(|idx| SelectionItem {
|
||||
name: format!("Item {idx}"),
|
||||
description: Some(format!("desc {idx}")),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
items.push(SelectionItem {
|
||||
name: "Item 9 with an intentionally much longer name".to_string(),
|
||||
description: Some("desc 9".to_string()),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
});
|
||||
items
|
||||
}
|
||||
|
||||
fn render_before_after_scroll_snapshot(col_width_mode: ColumnWidthMode, width: u16) -> String {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
title: Some("Debug".to_string()),
|
||||
items: make_scrolling_width_items(),
|
||||
col_width_mode,
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
let before_scroll = render_lines_with_width(&view, width);
|
||||
for _ in 0..8 {
|
||||
view.handle_key_event(KeyEvent::from(KeyCode::Down));
|
||||
}
|
||||
let after_scroll = render_lines_with_width(&view, width);
|
||||
|
||||
format!("before scroll:\n{before_scroll}\n\nafter scroll:\n{after_scroll}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_blank_line_between_title_and_items_without_subtitle() {
|
||||
let view = make_selection_view(None);
|
||||
|
|
@ -921,4 +1058,96 @@ mod tests {
|
|||
render_lines_with_width(&view, 24)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_auto_visible_col_width_mode_scroll_behavior() {
|
||||
assert_snapshot!(
|
||||
"list_selection_col_width_mode_auto_visible_scroll",
|
||||
render_before_after_scroll_snapshot(ColumnWidthMode::AutoVisible, 96)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_auto_all_rows_col_width_mode_scroll_behavior() {
|
||||
assert_snapshot!(
|
||||
"list_selection_col_width_mode_auto_all_rows_scroll",
|
||||
render_before_after_scroll_snapshot(ColumnWidthMode::AutoAllRows, 96)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_fixed_col_width_mode_scroll_behavior() {
|
||||
assert_snapshot!(
|
||||
"list_selection_col_width_mode_fixed_scroll",
|
||||
render_before_after_scroll_snapshot(ColumnWidthMode::Fixed, 96)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_all_rows_col_width_does_not_shift_when_scrolling() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
|
||||
let mut view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
title: Some("Debug".to_string()),
|
||||
items: make_scrolling_width_items(),
|
||||
col_width_mode: ColumnWidthMode::AutoAllRows,
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
let before_scroll = render_lines_with_width(&view, 96);
|
||||
for _ in 0..8 {
|
||||
view.handle_key_event(KeyEvent::from(KeyCode::Down));
|
||||
}
|
||||
let after_scroll = render_lines_with_width(&view, 96);
|
||||
|
||||
assert!(
|
||||
after_scroll.contains("9. Item 9 with an intentionally much longer name"),
|
||||
"expected the scrolled view to include the longer row:\n{after_scroll}"
|
||||
);
|
||||
|
||||
let before_col = description_col(&before_scroll, "8. Item 8", "desc 8");
|
||||
let after_col = description_col(&after_scroll, "8. Item 8", "desc 8");
|
||||
assert_eq!(
|
||||
before_col, after_col,
|
||||
"description column changed across scroll:\nbefore:\n{before_scroll}\nafter:\n{after_scroll}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_col_width_is_30_70_and_does_not_shift_when_scrolling() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let width = 96;
|
||||
let mut view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
title: Some("Debug".to_string()),
|
||||
items: make_scrolling_width_items(),
|
||||
col_width_mode: ColumnWidthMode::Fixed,
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
let before_scroll = render_lines_with_width(&view, width);
|
||||
let before_col = description_col(&before_scroll, "8. Item 8", "desc 8");
|
||||
let expected_desc_col = ((width.saturating_sub(2) as usize) * 3) / 10;
|
||||
assert_eq!(
|
||||
before_col, expected_desc_col,
|
||||
"fixed mode should place description column at a 30/70 split:\n{before_scroll}"
|
||||
);
|
||||
|
||||
for _ in 0..8 {
|
||||
view.handle_key_event(KeyEvent::from(KeyCode::Down));
|
||||
}
|
||||
let after_scroll = render_lines_with_width(&view, width);
|
||||
let after_col = description_col(&after_scroll, "8. Item 8", "desc 8");
|
||||
assert_eq!(
|
||||
before_col, after_col,
|
||||
"fixed description column changed across scroll:\nbefore:\n{before_scroll}\nafter:\n{after_scroll}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ mod skill_popup;
|
|||
mod skills_toggle_view;
|
||||
mod slash_commands;
|
||||
pub(crate) use footer::CollaborationModeIndicator;
|
||||
pub(crate) use list_selection_view::ColumnWidthMode;
|
||||
pub(crate) use list_selection_view::SelectionViewParams;
|
||||
mod feedback_view;
|
||||
pub(crate) use feedback_view::FeedbackAudience;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,11 @@ use crate::style::user_message_style;
|
|||
|
||||
use super::scroll_state::ScrollState;
|
||||
|
||||
/// A generic representation of a display row for selection popups.
|
||||
/// 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,
|
||||
|
|
@ -31,6 +35,25 @@ pub(crate) struct GenericDisplayRow {
|
|||
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;
|
||||
|
||||
|
|
@ -50,7 +73,8 @@ pub(crate) const fn menu_surface_padding_height() -> u16 {
|
|||
/// 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).
|
||||
/// (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;
|
||||
|
|
@ -61,6 +85,10 @@ pub(crate) fn render_menu_surface(area: Rect, buf: &mut Buffer) -> Rect {
|
|||
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;
|
||||
|
|
@ -143,34 +171,60 @@ fn truncate_line_with_ellipsis_if_overflow(line: Line<'static>, max_width: usize
|
|||
Line::from(spans)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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 {
|
||||
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)| {
|
||||
let mut spans: Vec<Span> = vec![r.name.clone().into()];
|
||||
if r.disabled_reason.is_some() {
|
||||
spans.push(" (disabled)".dim());
|
||||
}
|
||||
Line::from(spans).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;
|
||||
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)
|
||||
}
|
||||
}
|
||||
desc_col
|
||||
}
|
||||
|
||||
/// Determine how many spaces to indent wrapped lines for a row.
|
||||
|
|
@ -268,13 +322,14 @@ 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.
|
||||
pub(crate) fn render_rows(
|
||||
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 {
|
||||
|
|
@ -301,7 +356,13 @@ pub(crate) fn render_rows(
|
|||
}
|
||||
}
|
||||
|
||||
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, area.width);
|
||||
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.
|
||||
|
|
@ -358,7 +419,88 @@ pub(crate) fn render_rows(
|
|||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
|
|
@ -390,7 +532,13 @@ pub(crate) fn render_rows_single_line(
|
|||
}
|
||||
}
|
||||
|
||||
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, area.width);
|
||||
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
|
||||
|
|
@ -433,11 +581,62 @@ pub(crate) fn render_rows_single_line(
|
|||
/// 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
|
||||
|
|
@ -458,7 +657,13 @@ pub(crate) fn measure_rows_height(
|
|||
}
|
||||
}
|
||||
|
||||
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_width);
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
source: tui/src/bottom_pane/list_selection_view.rs
|
||||
assertion_line: 1054
|
||||
expression: "render_before_after_scroll_snapshot(ColumnWidthMode::AutoAllRows, 96)"
|
||||
---
|
||||
before scroll:
|
||||
|
||||
Debug
|
||||
|
||||
› 1. Item 1 desc 1
|
||||
2. Item 2 desc 2
|
||||
3. Item 3 desc 3
|
||||
4. Item 4 desc 4
|
||||
5. Item 5 desc 5
|
||||
6. Item 6 desc 6
|
||||
7. Item 7 desc 7
|
||||
8. Item 8 desc 8
|
||||
|
||||
|
||||
after scroll:
|
||||
|
||||
Debug
|
||||
|
||||
2. Item 2 desc 2
|
||||
3. Item 3 desc 3
|
||||
4. Item 4 desc 4
|
||||
5. Item 5 desc 5
|
||||
6. Item 6 desc 6
|
||||
7. Item 7 desc 7
|
||||
8. Item 8 desc 8
|
||||
› 9. Item 9 with an intentionally much longer name desc 9
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
source: tui/src/bottom_pane/list_selection_view.rs
|
||||
assertion_line: 1046
|
||||
expression: "render_before_after_scroll_snapshot(ColumnWidthMode::AutoVisible, 96)"
|
||||
---
|
||||
before scroll:
|
||||
|
||||
Debug
|
||||
|
||||
› 1. Item 1 desc 1
|
||||
2. Item 2 desc 2
|
||||
3. Item 3 desc 3
|
||||
4. Item 4 desc 4
|
||||
5. Item 5 desc 5
|
||||
6. Item 6 desc 6
|
||||
7. Item 7 desc 7
|
||||
8. Item 8 desc 8
|
||||
|
||||
|
||||
after scroll:
|
||||
|
||||
Debug
|
||||
|
||||
2. Item 2 desc 2
|
||||
3. Item 3 desc 3
|
||||
4. Item 4 desc 4
|
||||
5. Item 5 desc 5
|
||||
6. Item 6 desc 6
|
||||
7. Item 7 desc 7
|
||||
8. Item 8 desc 8
|
||||
› 9. Item 9 with an intentionally much longer name desc 9
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
source: tui/src/bottom_pane/list_selection_view.rs
|
||||
assertion_line: 1062
|
||||
expression: "render_before_after_scroll_snapshot(ColumnWidthMode::Fixed, 96)"
|
||||
---
|
||||
before scroll:
|
||||
|
||||
Debug
|
||||
|
||||
› 1. Item 1 desc 1
|
||||
2. Item 2 desc 2
|
||||
3. Item 3 desc 3
|
||||
4. Item 4 desc 4
|
||||
5. Item 5 desc 5
|
||||
6. Item 6 desc 6
|
||||
7. Item 7 desc 7
|
||||
8. Item 8 desc 8
|
||||
|
||||
|
||||
after scroll:
|
||||
|
||||
Debug
|
||||
|
||||
2. Item 2 desc 2
|
||||
3. Item 3 desc 3
|
||||
4. Item 4 desc 4
|
||||
5. Item 5 desc 5
|
||||
6. Item 6 desc 6
|
||||
7. Item 7 desc 7
|
||||
8. Item 8 desc 8
|
||||
› 9. Item 9 with an intent… desc 9
|
||||
|
|
@ -144,6 +144,7 @@ use crate::bottom_pane::BottomPane;
|
|||
use crate::bottom_pane::BottomPaneParams;
|
||||
use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::CollaborationModeIndicator;
|
||||
use crate::bottom_pane::ColumnWidthMode;
|
||||
use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED;
|
||||
use crate::bottom_pane::ExperimentalFeatureItem;
|
||||
use crate::bottom_pane::ExperimentalFeaturesView;
|
||||
|
|
@ -5742,6 +5743,7 @@ impl ChatWidget {
|
|||
items,
|
||||
is_searchable: true,
|
||||
search_placeholder: Some("Type to search apps".to_string()),
|
||||
col_width_mode: ColumnWidthMode::AutoAllRows,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue