mod input;
mod render;
use ratatui::layout::Rect;
use ratatui::style::Color;
pub use input::TextListEvent;
pub use render::render_text_list;
use super::FocusState;
#[derive(Debug, Clone)]
pub struct TextListState {
pub items: Vec<String>,
pub focused_item: Option<usize>,
pub cursor: usize,
pub new_item_text: String,
pub label: String,
pub focus: FocusState,
}
impl TextListState {
pub fn new(label: impl Into<String>) -> Self {
Self {
items: Vec::new(),
focused_item: None,
cursor: 0,
new_item_text: String::new(),
label: label.into(),
focus: FocusState::Normal,
}
}
pub fn with_items(mut self, items: Vec<String>) -> Self {
self.items = items;
self
}
pub fn with_focus(mut self, focus: FocusState) -> Self {
self.focus = focus;
self
}
pub fn is_enabled(&self) -> bool {
self.focus != FocusState::Disabled
}
pub fn add_item(&mut self) {
if !self.is_enabled() || self.new_item_text.is_empty() {
return;
}
self.items.push(std::mem::take(&mut self.new_item_text));
self.cursor = 0;
}
pub fn insert_str(&mut self, s: &str) {
if !self.is_enabled() {
return;
}
if let Some(index) = self.focused_item {
if let Some(item) = self.items.get_mut(index) {
if self.cursor <= item.len() {
item.insert_str(self.cursor, s);
self.cursor += s.len();
}
}
} else if self.cursor <= self.new_item_text.len() {
self.new_item_text.insert_str(self.cursor, s);
self.cursor += s.len();
}
}
pub fn remove_item(&mut self, index: usize) {
if !self.is_enabled() || index >= self.items.len() {
return;
}
self.items.remove(index);
if let Some(focused) = self.focused_item {
if focused >= self.items.len() {
self.focused_item = if self.items.is_empty() {
None
} else {
Some(self.items.len() - 1)
};
}
}
}
pub fn focus_item(&mut self, index: usize) {
if index < self.items.len() {
self.focused_item = Some(index);
self.cursor = self.items[index].len();
}
}
pub fn focus_new_item(&mut self) {
self.focused_item = None;
self.cursor = self.new_item_text.len();
}
pub fn insert(&mut self, c: char) {
if !self.is_enabled() {
return;
}
match self.focused_item {
Some(idx) if idx < self.items.len() => {
self.items[idx].insert(self.cursor, c);
self.cursor += 1;
}
None => {
self.new_item_text.insert(self.cursor, c);
self.cursor += 1;
}
_ => {}
}
}
pub fn backspace(&mut self) {
if !self.is_enabled() || self.cursor == 0 {
return;
}
self.cursor -= 1;
match self.focused_item {
Some(idx) if idx < self.items.len() => {
self.items[idx].remove(self.cursor);
}
None => {
self.new_item_text.remove(self.cursor);
}
_ => {}
}
}
pub fn move_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
}
pub fn move_right(&mut self) {
let max = match self.focused_item {
Some(idx) if idx < self.items.len() => self.items[idx].len(),
None => self.new_item_text.len(),
_ => 0,
};
if self.cursor < max {
self.cursor += 1;
}
}
pub fn move_home(&mut self) {
self.cursor = 0;
}
pub fn move_end(&mut self) {
self.cursor = match self.focused_item {
Some(idx) if idx < self.items.len() => self.items[idx].len(),
None => self.new_item_text.len(),
_ => 0,
};
}
pub fn delete(&mut self) {
if !self.is_enabled() {
return;
}
let max = match self.focused_item {
Some(idx) if idx < self.items.len() => self.items[idx].len(),
None => self.new_item_text.len(),
_ => return,
};
if self.cursor >= max {
return;
}
match self.focused_item {
Some(idx) if idx < self.items.len() => {
self.items[idx].remove(self.cursor);
}
None => {
self.new_item_text.remove(self.cursor);
}
_ => {}
}
}
pub fn focus_prev(&mut self) {
match self.focused_item {
Some(0) => {}
Some(idx) => {
self.focused_item = Some(idx - 1);
self.cursor = self.items[idx - 1].len();
}
None if !self.items.is_empty() => {
self.focused_item = Some(self.items.len() - 1);
self.cursor = self.items.last().map(|s| s.len()).unwrap_or(0);
}
None => {}
}
}
pub fn focus_next(&mut self) {
match self.focused_item {
Some(idx) if idx + 1 < self.items.len() => {
self.focused_item = Some(idx + 1);
self.cursor = self.items[idx + 1].len();
}
Some(_) => {
self.focused_item = None;
self.cursor = self.new_item_text.len();
}
None => {}
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct TextListColors {
pub label: Color,
pub text: Color,
pub border: Color,
pub remove_button: Color,
pub add_button: Color,
pub focused: Color,
pub focused_fg: Color,
pub cursor: Color,
pub disabled: Color,
}
impl Default for TextListColors {
fn default() -> Self {
Self {
label: Color::White,
text: Color::White,
border: Color::Gray,
remove_button: Color::Red,
add_button: Color::Green,
focused: Color::Cyan,
focused_fg: Color::Black,
cursor: Color::Yellow,
disabled: Color::DarkGray,
}
}
}
impl TextListColors {
pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
Self {
label: theme.editor_fg,
text: theme.editor_fg,
border: theme.line_number_fg,
remove_button: theme.diagnostic_error_fg,
add_button: theme.diagnostic_info_fg,
focused: theme.settings_selected_bg,
focused_fg: theme.settings_selected_fg,
cursor: theme.cursor,
disabled: theme.line_number_fg,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct TextListRowLayout {
pub text_area: Rect,
pub button_area: Rect,
pub index: Option<usize>,
}
#[derive(Debug, Clone, Default)]
pub struct TextListLayout {
pub rows: Vec<TextListRowLayout>,
pub full_area: Rect,
}
impl TextListLayout {
pub fn hit_test(&self, x: u16, y: u16) -> Option<TextListHit> {
for row in &self.rows {
if y >= row.text_area.y
&& y < row.text_area.y + row.text_area.height
&& x >= row.button_area.x
&& x < row.button_area.x + row.button_area.width
{
return Some(TextListHit::Button(row.index));
}
if y >= row.text_area.y
&& y < row.text_area.y + row.text_area.height
&& x >= row.text_area.x
&& x < row.text_area.x + row.text_area.width
{
return Some(TextListHit::TextField(row.index));
}
}
None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextListHit {
TextField(Option<usize>),
Button(Option<usize>),
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn test_frame<F>(width: u16, height: u16, f: F)
where
F: FnOnce(&mut ratatui::Frame, Rect),
{
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = Rect::new(0, 0, width, height);
f(frame, area);
})
.unwrap();
}
#[test]
fn test_text_list_empty() {
test_frame(40, 5, |frame, area| {
let state = TextListState::new("Items");
let colors = TextListColors::default();
let layout = render_text_list(frame, area, &state, &colors, 20);
assert_eq!(layout.rows.len(), 1);
assert!(layout.rows[0].index.is_none());
});
}
#[test]
fn test_text_list_with_items() {
test_frame(40, 5, |frame, area| {
let state =
TextListState::new("Items").with_items(vec!["one".to_string(), "two".to_string()]);
let colors = TextListColors::default();
let layout = render_text_list(frame, area, &state, &colors, 20);
assert_eq!(layout.rows.len(), 3);
assert_eq!(layout.rows[0].index, Some(0));
assert_eq!(layout.rows[1].index, Some(1));
assert!(layout.rows[2].index.is_none());
});
}
#[test]
fn test_text_list_add_item() {
let mut state = TextListState::new("Items");
state.new_item_text = "new item".to_string();
state.add_item();
assert_eq!(state.items.len(), 1);
assert_eq!(state.items[0], "new item");
assert!(state.new_item_text.is_empty());
}
#[test]
fn test_text_list_remove_item() {
let mut state =
TextListState::new("Items").with_items(vec!["a".to_string(), "b".to_string()]);
state.remove_item(0);
assert_eq!(state.items.len(), 1);
assert_eq!(state.items[0], "b");
}
#[test]
fn test_text_list_edit_item() {
let mut state = TextListState::new("Items").with_items(vec!["hello".to_string()]);
state.focus_item(0);
state.insert('!');
assert_eq!(state.items[0], "hello!");
}
#[test]
fn test_text_list_navigation() {
let mut state = TextListState::new("Items")
.with_items(vec!["a".to_string(), "b".to_string()])
.with_focus(FocusState::Focused);
assert!(state.focused_item.is_none());
state.focus_prev();
assert_eq!(state.focused_item, Some(1));
state.focus_prev();
assert_eq!(state.focused_item, Some(0));
state.focus_prev();
assert_eq!(state.focused_item, Some(0));
state.focus_next();
assert_eq!(state.focused_item, Some(1));
state.focus_next();
assert!(state.focused_item.is_none());
}
#[test]
fn test_text_list_hit_test() {
test_frame(40, 5, |frame, area| {
let state = TextListState::new("Items").with_items(vec!["one".to_string()]);
let colors = TextListColors::default();
let layout = render_text_list(frame, area, &state, &colors, 20);
let btn = &layout.rows[0].button_area;
let hit = layout.hit_test(btn.x, btn.y);
assert_eq!(hit, Some(TextListHit::Button(Some(0))));
let add_btn = &layout.rows[1].button_area;
let hit = layout.hit_test(add_btn.x, add_btn.y);
assert_eq!(hit, Some(TextListHit::Button(None)));
});
}
}