use crate::buffer::ScreenBuffer;
use crate::cell::Cell;
use crate::event::{Event, KeyCode, KeyEvent};
use crate::geometry::Rect;
use crate::style::Style;
use crate::text::truncate_to_display_width;
use unicode_width::UnicodeWidthStr;
use super::{BorderStyle, EventResult, InteractiveWidget, Widget};
pub struct OptionList {
options: Vec<String>,
selected: usize,
scroll_offset: usize,
option_style: Style,
selected_style: Style,
border: BorderStyle,
prefix: Option<String>,
}
impl OptionList {
pub fn new(options: Vec<String>) -> Self {
Self {
options,
selected: 0,
scroll_offset: 0,
option_style: Style::default(),
selected_style: Style::default().reverse(true),
border: BorderStyle::None,
prefix: None,
}
}
#[must_use]
pub fn with_selected_style(mut self, style: Style) -> Self {
self.selected_style = style;
self
}
#[must_use]
pub fn with_option_style(mut self, style: Style) -> Self {
self.option_style = style;
self
}
#[must_use]
pub fn with_border(mut self, border: BorderStyle) -> Self {
self.border = border;
self
}
#[must_use]
pub fn with_prefix(mut self, prefix: &str) -> Self {
self.prefix = Some(prefix.to_string());
self
}
pub fn options(&self) -> &[String] {
&self.options
}
pub fn set_options(&mut self, options: Vec<String>) {
self.options = options;
self.selected = 0;
self.scroll_offset = 0;
}
pub fn selected(&self) -> usize {
self.selected
}
pub fn set_selected(&mut self, idx: usize) {
if self.options.is_empty() {
self.selected = 0;
} else {
self.selected = idx.min(self.options.len().saturating_sub(1));
}
}
pub fn selected_option(&self) -> Option<&str> {
self.options.get(self.selected).map(|s| s.as_str())
}
fn ensure_visible(&mut self, height: usize) {
if height == 0 {
return;
}
if self.selected < self.scroll_offset {
self.scroll_offset = self.selected;
} else if self.selected >= self.scroll_offset + height {
self.scroll_offset = self.selected.saturating_sub(height.saturating_sub(1));
}
}
}
impl Widget for OptionList {
fn render(&self, area: Rect, buf: &mut ScreenBuffer) {
if area.size.width == 0 || area.size.height == 0 {
return;
}
super::border::render_border(area, self.border, self.option_style.clone(), buf);
let inner = super::border::inner_area(area, self.border);
if inner.size.width == 0 || inner.size.height == 0 {
return;
}
let w = inner.size.width as usize;
let h = inner.size.height as usize;
let x0 = inner.position.x;
let visible_end = (self.scroll_offset + h).min(self.options.len());
for (row, opt_idx) in (self.scroll_offset..visible_end).enumerate() {
let y = inner.position.y + row as u16;
if let Some(option) = self.options.get(opt_idx) {
let is_selected = opt_idx == self.selected;
let style = if is_selected {
&self.selected_style
} else {
&self.option_style
};
let text = match &self.prefix {
Some(pfx) => format!("{pfx}{option}"),
None => option.clone(),
};
let truncated = truncate_to_display_width(&text, w);
let mut col: u16 = 0;
for ch in truncated.chars() {
if col as usize >= w {
break;
}
let char_w = UnicodeWidthStr::width(ch.encode_utf8(&mut [0; 4]) as &str);
if col as usize + char_w > w {
break;
}
buf.set(x0 + col, y, Cell::new(ch.to_string(), style.clone()));
col += char_w as u16;
}
if is_selected {
while (col as usize) < w {
buf.set(x0 + col, y, Cell::new(" ", style.clone()));
col += 1;
}
}
}
}
}
}
impl InteractiveWidget for OptionList {
fn handle_event(&mut self, event: &Event) -> EventResult {
let Event::Key(KeyEvent { code, .. }) = event else {
return EventResult::Ignored;
};
if self.options.is_empty() {
return EventResult::Ignored;
}
match code {
KeyCode::Up => {
if self.selected > 0 {
self.selected -= 1;
}
self.ensure_visible(20);
EventResult::Consumed
}
KeyCode::Down => {
if self.selected < self.options.len().saturating_sub(1) {
self.selected += 1;
}
self.ensure_visible(20);
EventResult::Consumed
}
KeyCode::Home => {
self.selected = 0;
self.scroll_offset = 0;
EventResult::Consumed
}
KeyCode::End => {
self.selected = self.options.len().saturating_sub(1);
self.ensure_visible(20);
EventResult::Consumed
}
KeyCode::PageUp => {
self.selected = self.selected.saturating_sub(20);
self.ensure_visible(20);
EventResult::Consumed
}
KeyCode::PageDown => {
self.selected = (self.selected + 20).min(self.options.len().saturating_sub(1));
self.ensure_visible(20);
EventResult::Consumed
}
KeyCode::Enter => EventResult::Consumed,
_ => EventResult::Ignored,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::geometry::Size;
fn opts(labels: &[&str]) -> Vec<String> {
labels.iter().map(|s| s.to_string()).collect()
}
fn row_text(buf: &ScreenBuffer, y: u16, w: u16) -> String {
(0..w)
.map(|x| buf.get(x, y).map(|c| c.grapheme.as_str()).unwrap_or(" "))
.collect()
}
#[test]
fn create_with_options() {
let ol = OptionList::new(opts(&["A", "B", "C"]));
assert_eq!(ol.options().len(), 3);
assert_eq!(ol.selected(), 0);
}
#[test]
fn render_highlights_selected() {
let ol = OptionList::new(opts(&["Alpha", "Beta", "Gamma"]));
let mut buf = ScreenBuffer::new(Size::new(20, 5));
ol.render(Rect::new(0, 0, 20, 5), &mut buf);
let row0 = row_text(&buf, 0, 20);
assert!(row0.contains("Alpha"));
}
#[test]
fn navigate_up_down() {
let mut ol = OptionList::new(opts(&["A", "B", "C"]));
assert_eq!(ol.selected(), 0);
ol.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Down)));
assert_eq!(ol.selected(), 1);
ol.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Down)));
assert_eq!(ol.selected(), 2);
ol.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Down)));
assert_eq!(ol.selected(), 2);
ol.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Up)));
assert_eq!(ol.selected(), 1);
}
#[test]
fn home_end_navigation() {
let mut ol = OptionList::new(opts(&["A", "B", "C", "D"]));
ol.handle_event(&Event::Key(KeyEvent::plain(KeyCode::End)));
assert_eq!(ol.selected(), 3);
ol.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Home)));
assert_eq!(ol.selected(), 0);
}
#[test]
fn set_selected_clamped() {
let mut ol = OptionList::new(opts(&["A", "B"]));
ol.set_selected(100);
assert_eq!(ol.selected(), 1);
}
#[test]
fn selected_option_text() {
let ol = OptionList::new(opts(&["Foo", "Bar"]));
assert_eq!(ol.selected_option(), Some("Foo"));
}
#[test]
fn empty_list() {
let ol = OptionList::new(vec![]);
assert_eq!(ol.selected(), 0);
assert!(ol.selected_option().is_none());
let mut buf = ScreenBuffer::new(Size::new(20, 5));
ol.render(Rect::new(0, 0, 20, 5), &mut buf);
}
#[test]
fn single_option() {
let ol = OptionList::new(opts(&["Only"]));
assert_eq!(ol.selected_option(), Some("Only"));
}
#[test]
fn custom_prefix() {
let ol = OptionList::new(opts(&["Item"])).with_prefix("> ");
let mut buf = ScreenBuffer::new(Size::new(20, 3));
ol.render(Rect::new(0, 0, 20, 3), &mut buf);
let row = row_text(&buf, 0, 20);
assert!(row.contains("> Item"));
}
#[test]
fn set_options_resets_selection() {
let mut ol = OptionList::new(opts(&["A", "B", "C"]));
ol.set_selected(2);
ol.set_options(opts(&["X", "Y"]));
assert_eq!(ol.selected(), 0);
}
#[test]
fn border_rendering() {
let ol = OptionList::new(opts(&["T"])).with_border(BorderStyle::Single);
let mut buf = ScreenBuffer::new(Size::new(20, 5));
ol.render(Rect::new(0, 0, 20, 5), &mut buf);
assert_eq!(buf.get(0, 0).unwrap().grapheme, "┌");
}
#[test]
fn enter_consumed() {
let mut ol = OptionList::new(opts(&["A"]));
let result = ol.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Enter)));
assert_eq!(result, EventResult::Consumed);
}
}