use ratatui::{
style::Style,
text::{Line, Span},
};
use super::{
display_width, styled_line, truncate_one_line, LineFill, PickerBadgeTone, PickerItem, Theme,
};
const MAX_LABEL_COLUMN: usize = 60;
const MIN_LABEL_COLUMN: usize = 12;
const NAV_BADGE_CAP: usize = 16;
pub(super) fn picker_badge_style(tone: PickerBadgeTone) -> Style {
match tone {
PickerBadgeTone::Internal | PickerBadgeTone::Editable => Theme::accent(),
PickerBadgeTone::Selected => Theme::warning(),
PickerBadgeTone::Favorite | PickerBadgeTone::Healthy => Theme::success(),
PickerBadgeTone::Warning => Theme::warning(),
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum RowWidthMode {
FillPane,
AlignedColumn(usize),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct RowLayout {
pub(super) width: usize,
pub(super) width_mode: RowWidthMode,
pub(super) show_badges: bool,
pub(super) show_preview: bool,
pub(super) fill: LineFill,
}
pub(super) struct PickerRows {
pub(super) rows: Vec<Line<'static>>,
pub(super) selected_row: usize,
}
pub(super) fn label_column_width(items: &[PickerItem], width: usize) -> usize {
let reserved_preview_width = width.saturating_sub(18);
let available_width = if reserved_preview_width >= MIN_LABEL_COLUMN {
reserved_preview_width
} else {
width.saturating_sub(2).max(1)
};
let max_label_width = MAX_LABEL_COLUMN.min(available_width);
let min_label_width = MIN_LABEL_COLUMN.min(max_label_width).max(1);
items
.iter()
.map(|item| display_width(&item.label))
.max()
.unwrap_or(min_label_width)
.clamp(min_label_width, max_label_width)
}
pub(super) enum PickerRow<'a> {
Header(&'a str),
Item {
index: usize,
item: &'a PickerItem,
},
}
pub(super) fn rows<'a>(
items: &'a [PickerItem],
matching: impl IntoIterator<Item = usize> + 'a,
) -> impl Iterator<Item = PickerRow<'a>> + 'a {
let mut current_section: Option<&'a str> = None;
matching.into_iter().flat_map(move |index| {
let Some(item) = items.get(index) else {
return Option::<PickerRow<'a>>::None.into_iter().chain(None);
};
let mut header = None;
if item.section.as_deref() != current_section {
current_section = item.section.as_deref();
header = current_section.map(PickerRow::Header);
}
header
.into_iter()
.chain(Some(PickerRow::Item { index, item }))
})
}
pub(super) fn picker_row_count(items: &[PickerItem], matching: &[usize]) -> usize {
rows(items, matching.iter().copied()).count()
}
pub(super) fn selected_row_index(
items: &[PickerItem],
matching: &[usize],
selected: usize,
) -> usize {
rows(items, matching.iter().copied())
.position(|row| matches!(row, PickerRow::Item { index, .. } if index == selected))
.unwrap_or(0)
}
pub(super) fn item_index_at_row(
items: &[PickerItem],
matching: &[usize],
row_index: usize,
) -> Option<usize> {
match rows(items, matching.iter().copied()).nth(row_index)? {
PickerRow::Header(_) => None,
PickerRow::Item { index, .. } => Some(index),
}
}
pub(super) fn picker_item_rows(
items: &[PickerItem],
matching: &[usize],
selected: usize,
layout: RowLayout,
hovered_row: Option<usize>,
) -> PickerRows {
let mut lines = Vec::with_capacity(matching.len());
let mut selected_row = 0;
for row in rows(items, matching.iter().copied()) {
match row {
PickerRow::Header(section) => lines.push(section_header_line(section, layout)),
PickerRow::Item { index, item } => {
if index == selected {
selected_row = lines.len();
}
let hovered = hovered_row == Some(lines.len());
lines.push(item_line(item, index == selected, hovered, layout));
}
}
}
PickerRows {
rows: lines,
selected_row,
}
}
pub(super) fn scroll_window_start(selected_row: usize, viewport_rows: usize) -> usize {
selected_row
.saturating_add(1)
.saturating_sub(viewport_rows.max(1))
}
fn section_header_line(section: &str, layout: RowLayout) -> Line<'static> {
let label = truncate_one_line(section, layout.width.saturating_sub(2));
styled_line(
format!(" {label}"),
layout.width,
Theme::dim(),
layout.fill,
)
}
fn item_line(item: &PickerItem, selected: bool, hovered: bool, layout: RowLayout) -> Line<'static> {
let width = layout.width;
if width == 0 {
return Line::raw("");
}
let marker = if selected {
super::composer_chrome::SELECTION_MARKER_ACTIVE
} else {
super::composer_chrome::SELECTION_MARKER_INACTIVE
};
let style = if selected {
Theme::accent()
} else if hovered {
Theme::text_strong()
} else {
Theme::text()
};
if width == 1 {
return Line::from(Span::styled(marker.to_string(), style));
}
let available = width.saturating_sub(2);
match layout.width_mode {
RowWidthMode::FillPane => fill_pane_line(item, marker, style, available, layout),
RowWidthMode::AlignedColumn(column) => {
aligned_column_line(item, marker, style, column, layout)
}
}
}
fn fill_pane_line(
item: &PickerItem,
marker: &str,
style: Style,
available: usize,
layout: RowLayout,
) -> Line<'static> {
let badge = layout
.show_badges
.then_some(item.badge.as_ref())
.flatten()
.and_then(|badge| {
let budget = display_width(&badge.text)
.min(NAV_BADGE_CAP)
.min(available.saturating_sub(2));
(budget > 0).then(|| (truncate_one_line(&badge.text, budget), badge.tone))
});
let badge_width = badge
.as_ref()
.map_or(0, |(text, _)| display_width(text).saturating_add(1));
let label_budget = available.saturating_sub(badge_width);
let label = truncate_one_line(&item.label, label_budget);
let mut spans = vec![Span::styled(
format!(
"{marker} {label}{}",
" ".repeat(label_budget.saturating_sub(display_width(&label)))
),
style,
)];
if let Some((text, tone)) = badge {
spans.push(Span::raw(" "));
spans.push(Span::styled(text, picker_badge_style(tone)));
}
Line::from(spans)
}
fn aligned_column_line(
item: &PickerItem,
marker: &str,
style: Style,
column: usize,
layout: RowLayout,
) -> Line<'static> {
let width = layout.width;
let label_width = column.min(width.saturating_sub(2));
let label = truncate_one_line(&item.label, label_width);
let mut used_width = 2 + label_width;
let mut spans = vec![Span::styled(
format!(
"{marker} {label}{}",
" ".repeat(label_width.saturating_sub(display_width(&label)))
),
style,
)];
if layout.show_badges {
if let Some(badge) = &item.badge {
let remaining = width.saturating_sub(used_width.saturating_add(2));
if remaining > 1 {
let badge_text = truncate_one_line(&badge.text, remaining);
used_width += 2 + display_width(&badge_text);
spans.push(Span::raw(" "));
spans.push(Span::styled(badge_text, picker_badge_style(badge.tone)));
}
}
}
if layout.show_preview {
if let Some(preview) = &item.preview {
let remaining = width.saturating_sub(used_width.saturating_add(2));
if remaining > 1 {
spans.push(Span::raw(" "));
spans.push(Span::styled(
truncate_one_line(preview, remaining),
Theme::dim(),
));
}
}
}
if layout.fill.pads_to_width() {
let content_width = spans
.iter()
.map(|span| display_width(span.content.as_ref()))
.sum::<usize>();
if content_width < width {
spans.push(Span::raw(" ".repeat(width - content_width)));
}
}
Line::from(spans)
}
#[cfg(test)]
#[path = "picker_rows_tests.rs"]
mod tests;