use std::borrow::Cow;
use crate::style::Style;
use unicode_segmentation::UnicodeSegmentation;
use crate::config::Config;
use crate::picker::PickerState;
use crate::ui::layout::opts;
use crate::ui::{DASHES, UiTree, layout_cursor, layout_span, repeat_chars};
const MAX_ITEMS_DISPLAY: usize = 10;
pub(crate) fn layout_picker<'a>(
layout: &mut UiTree<'a>,
state: &'a PickerState,
config: &Config,
width: usize,
) {
if state.is_one_row() {
layout_one_row(layout, state, config);
return;
}
let separator_style = Style::from(&config.style.separator);
repeat_chars(layout, width, DASHES, separator_style);
if state.grid().is_some() {
layout_grid(layout, state, config);
return;
}
let prompt_style: Style = (&config.style.picker.prompt).into();
let info_style: Style = (&config.style.picker.info).into();
layout.row(opts(), |layout| {
if state.assigns_keys() {
let heading = format!(" {}", state.prompt_text);
layout_span(layout, (heading.into(), prompt_style));
return;
}
let status_text = format!(" {}/{} ", state.filtered_count(), state.total_items());
layout_span(layout, (status_text.into(), info_style));
layout_span(layout, (state.prompt_text.as_ref().into(), prompt_style));
layout_span(layout, (" › ".into(), prompt_style));
let (before, at_cursor, after) = state.input_state.split_at_cursor();
layout_span(layout, (before.into(), Style::new()));
layout_cursor(layout, at_cursor);
layout_span(layout, (after.into(), Style::new()));
});
let cursor = state.cursor();
let total_items = state.filtered_items().count();
let visible_count = MAX_ITEMS_DISPLAY.min(total_items);
let start = calculate_visible_range(cursor, total_items, MAX_ITEMS_DISPLAY);
let mut rendered_count = 0;
for (display_idx, (original_idx, item)) in state
.filtered_items()
.enumerate()
.skip(start)
.take(visible_count)
{
let is_selected = display_idx == cursor;
let style = if is_selected {
(&config.style.picker.selection_line).into()
} else {
Style::new()
};
layout.row(opts(), |layout| {
if is_selected {
let cursor_style: Style = (&config.style.cursor).into();
let indicator = format!("{}", config.style.cursor.symbol);
layout_span(layout, (indicator.into(), cursor_style));
} else {
layout_span(layout, (" ".into(), Style::new()));
}
if let Some(match_indices) = state.match_indices(original_idx) {
render_highlighted_text(layout, &item.display, &match_indices, style, config);
} else {
layout_span(layout, (item.display.as_ref().into(), style));
}
});
rendered_count += 1;
}
for _ in rendered_count..MAX_ITEMS_DISPLAY {
layout.row(opts(), |layout| {
layout_span(layout, (" ".into(), Style::new()));
});
}
}
fn layout_grid<'a>(layout: &mut UiTree<'a>, state: &'a PickerState, config: &Config) {
let Some(grid) = state.grid() else {
return;
};
let heading_style: Style = (&config.style.picker.prompt).into();
let columns: Vec<(usize, Vec<usize>)> = (0..grid.headings.len())
.map(|column| (column, state.column_positions(column)))
.filter(|(_, positions)| !positions.is_empty())
.collect();
let longest = columns
.iter()
.map(|(_, positions)| positions.len())
.max()
.unwrap_or(0);
let cursor_row = columns
.iter()
.find_map(|(_, positions)| positions.iter().position(|&p| p == state.cursor()))
.unwrap_or(0);
let start = calculate_visible_range(cursor_row, longest, MAX_ITEMS_DISPLAY);
let items: Vec<_> = state.filtered_items().map(|(_, item)| item).collect();
layout.row(opts().gap(3), |layout| {
for (column, positions) in &columns {
layout.col(opts(), |layout| {
layout.row(opts(), |layout| {
let heading = format!(" {}", grid.headings[*column]);
layout_span(layout, (heading.into(), heading_style));
});
for row in start..start + MAX_ITEMS_DISPLAY {
layout.row(opts(), |layout| match positions.get(row) {
Some(&position) => {
layout_item(layout, items[position], position == state.cursor(), config)
}
None => layout_span(layout, (" ".into(), Style::new())),
});
}
});
}
});
}
fn layout_item<'a>(
layout: &mut UiTree<'a>,
item: &'a crate::picker::PickerItem,
is_selected: bool,
config: &Config,
) {
if is_selected {
let cursor_style: Style = (&config.style.cursor).into();
let indicator = format!("{}", config.style.cursor.symbol);
layout_span(layout, (indicator.into(), cursor_style));
layout_span(
layout,
(
item.display.as_ref().into(),
(&config.style.picker.selection_line).into(),
),
);
} else {
layout_span(layout, (" ".into(), Style::new()));
layout_span(layout, (item.display.as_ref().into(), Style::new()));
}
}
fn layout_one_row<'a>(layout: &mut UiTree<'a>, state: &'a PickerState, config: &Config) {
let prompt_style: Style = (&config.style.picker.prompt).into();
let info_style: Style = (&config.style.picker.info).into();
let selection_style: Style = (&config.style.picker.selection_line).into();
layout.row(opts(), |layout| {
let (number, last) = state.one_row_number();
let number = number.map_or("-".to_owned(), |n| n.to_string());
layout_span(layout, (format!(" {number}/{last} ").into(), info_style));
layout_span(
layout,
(format!("{} ", state.prompt_text).into(), prompt_style),
);
if let Some(item) = state.selected() {
layout_span(layout, (item.display.as_ref().into(), selection_style));
}
if !state.pattern().is_empty() {
layout_span(
layout,
(format!(" › {}", state.pattern()).into(), info_style),
);
}
});
}
fn calculate_visible_range(cursor: usize, total: usize, max_items: usize) -> usize {
if total <= max_items {
return 0;
}
let half = max_items / 2;
if cursor < half {
0
} else if cursor >= total - half {
total - max_items
} else {
cursor - half
}
}
fn render_highlighted_text<'a>(
layout: &mut UiTree<'a>,
text: &'a str,
highlight_indices: &[usize],
base_style: Style,
config: &Config,
) {
let highlight_style: Style = (&config.style.picker.matched).into();
let mut buffer = String::new();
for (idx, grapheme) in text.graphemes(true).enumerate() {
let should_highlight = highlight_indices.contains(&idx);
if should_highlight {
if !buffer.is_empty() {
layout_span(layout, (Cow::Owned(buffer.clone()), base_style));
buffer.clear();
}
layout_span(layout, (Cow::Owned(grapheme.to_string()), highlight_style));
} else {
buffer.push_str(grapheme);
}
}
if !buffer.is_empty() {
layout_span(layout, (Cow::Owned(buffer), base_style));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::picker::{PickerData, PickerItem, PickerState};
use crate::ui::layout::LayoutTree;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use itertools::Itertools;
use std::collections::BTreeMap;
fn test_config() -> Config {
use crate::config::{GeneralConfig, PickerBindingsConfig, StyleConfig};
Config {
general: GeneralConfig::default(),
style: StyleConfig::default(),
bindings: BTreeMap::new().try_into().unwrap(),
picker_bindings: PickerBindingsConfig::default().try_into().unwrap(),
}
}
fn create_test_items() -> Vec<PickerItem> {
vec![
PickerItem::new("main", PickerData::Item("main".to_string())),
PickerItem::new("develop", PickerData::Item("develop".to_string())),
PickerItem::new("feature/test", PickerData::Item("feature/test".to_string())),
PickerItem::new("feature/new", PickerData::Item("feature/new".to_string())),
PickerItem::new("bugfix/123", PickerData::Item("bugfix/123".to_string())),
]
}
fn render_to_string(
computed: crate::ui::layout::Computed<'_, crate::ui::Span<'_>, Style>,
width: usize,
height: usize,
) -> String {
let mut grid = vec![' '; height * width];
for item in computed.iter() {
let x0 = item.pos[0] as usize;
let y0 = item.pos[1] as usize;
let item_width = item.size[0] as usize;
let text = match item.data {
crate::ui::layout::Payload::Leaf(crate::ui::Span(cow, _)) => cow.as_ref(),
crate::ui::layout::Payload::Container(_style) => "",
};
for (i, c) in text.chars().take(item_width).enumerate() {
if y0 < height && x0 + i < width {
grid[y0 * width + (x0 + i)] = c;
}
}
}
grid.chunks(width)
.map(|row| row.iter().collect::<String>().trim_end().to_string())
.join("\n")
}
#[test]
fn test_picker_empty_input() {
let items = create_test_items();
let state = PickerState::new("Select branch", items, false);
let config = test_config();
let mut layout = LayoutTree::new();
layout.col(crate::ui::layout::opts(), |layout| {
layout_picker(layout, &state, &config, 40);
});
insta::assert_snapshot!(render_to_string(layout.compute([40, 15]), 40, 15));
}
#[test]
fn test_picker_with_filter() {
let items = create_test_items();
let mut state = PickerState::new("Select branch", items, false);
state
.input_state
.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()));
state
.input_state
.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()));
state
.input_state
.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
state.update_filter();
let config = test_config();
let mut layout = LayoutTree::new();
layout.col(crate::ui::layout::opts(), |layout| {
layout_picker(layout, &state, &config, 40);
});
insta::assert_snapshot!(render_to_string(layout.compute([40, 15]), 40, 15));
}
#[test]
fn test_picker_cursor_movement() {
let items = create_test_items();
let mut state = PickerState::new("Select branch", items, false);
state.next();
state.next();
let config = test_config();
let mut layout = LayoutTree::new();
layout.col(crate::ui::layout::opts(), |layout| {
layout_picker(layout, &state, &config, 40);
});
insta::assert_snapshot!(render_to_string(layout.compute([40, 15]), 40, 15));
}
#[test]
fn test_picker_scroll_many_items() {
let items: Vec<_> = (0..20)
.map(|i| {
PickerItem::new(
format!("branch-{:02}", i),
PickerData::Item(format!("branch-{:02}", i)),
)
})
.collect();
let mut state = PickerState::new("Select branch", items, false);
let config = test_config();
for _ in 0..10 {
state.next();
}
let mut layout = LayoutTree::new();
layout.col(crate::ui::layout::opts(), |layout| {
layout_picker(layout, &state, &config, 40);
});
insta::assert_snapshot!(render_to_string(layout.compute([40, 15]), 40, 15));
}
#[test]
fn test_picker_scroll_near_end() {
let items: Vec<_> = (0..20)
.map(|i| {
PickerItem::new(
format!("branch-{:02}", i),
PickerData::Item(format!("branch-{:02}", i)),
)
})
.collect();
let mut state = PickerState::new("Select branch", items, false);
let config = test_config();
for _ in 0..18 {
state.next();
}
let mut layout = LayoutTree::new();
layout.col(crate::ui::layout::opts(), |layout| {
layout_picker(layout, &state, &config, 40);
});
insta::assert_snapshot!(render_to_string(layout.compute([40, 15]), 40, 15));
}
#[test]
fn test_picker_filtered_with_navigation() {
let items = create_test_items();
let mut state = PickerState::new("Select branch", items, false);
state
.input_state
.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()));
state
.input_state
.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()));
state
.input_state
.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
state.update_filter();
state.next();
let config = test_config();
let mut layout = LayoutTree::new();
layout.col(crate::ui::layout::opts(), |layout| {
layout_picker(layout, &state, &config, 40);
});
insta::assert_snapshot!(render_to_string(layout.compute([40, 15]), 40, 15));
}
#[test]
fn test_picker_with_custom_input() {
let items = create_test_items();
let mut state = PickerState::new("New branch", items, true);
state
.input_state
.handle_key_event(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::empty()));
state
.input_state
.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty()));
state.update_filter();
let config = test_config();
let mut layout = LayoutTree::new();
layout.col(crate::ui::layout::opts(), |layout| {
layout_picker(layout, &state, &config, 40);
});
insta::assert_snapshot!(render_to_string(layout.compute([40, 15]), 40, 15));
}
#[test]
fn test_picker_no_matches() {
let items = create_test_items();
let mut state = PickerState::new("Select branch", items, false);
state
.input_state
.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty()));
state
.input_state
.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty()));
state
.input_state
.handle_key_event(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::empty()));
state.update_filter();
let config = test_config();
let mut layout = LayoutTree::new();
layout.col(crate::ui::layout::opts(), |layout| {
layout_picker(layout, &state, &config, 40);
});
insta::assert_snapshot!(render_to_string(layout.compute([40, 15]), 40, 15));
}
#[test]
fn test_picker_narrow_width() {
let items = create_test_items();
let state = PickerState::new("Select", items, false);
let config = test_config();
let mut layout = LayoutTree::new();
layout.col(crate::ui::layout::opts(), |layout| {
layout_picker(layout, &state, &config, 20);
});
insta::assert_snapshot!(render_to_string(layout.compute([20, 15]), 20, 15));
}
}