mod render;
use ratatui::layout::Rect;
use ratatui::style::Color;
pub use render::render_dual_list_partial;
use super::FocusState;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DualListColumn {
#[default]
Available,
Included,
}
#[derive(Debug, Clone)]
pub struct DualListState {
pub all_options: Vec<(String, String)>,
pub included: Vec<String>,
pub excluded: Vec<String>,
pub active_column: DualListColumn,
pub available_cursor: usize,
pub included_cursor: usize,
pub label: String,
pub focus: FocusState,
pub editing: bool,
}
impl DualListState {
pub fn new(label: impl Into<String>, all_options: Vec<(String, String)>) -> Self {
Self {
all_options,
included: Vec::new(),
excluded: Vec::new(),
active_column: DualListColumn::Available,
available_cursor: 0,
included_cursor: 0,
label: label.into(),
focus: FocusState::Normal,
editing: false,
}
}
pub fn with_included(mut self, included: Vec<String>) -> Self {
self.included = included;
self
}
pub fn with_excluded(mut self, excluded: Vec<String>) -> Self {
self.excluded = excluded;
self
}
pub fn available_items(&self) -> Vec<&(String, String)> {
self.all_options
.iter()
.filter(|(value, _)| !self.included.contains(value) && !self.excluded.contains(value))
.collect()
}
pub fn included_items(&self) -> Vec<(&String, &String)> {
self.included
.iter()
.filter_map(|value| {
self.all_options
.iter()
.find(|(v, _)| v == value)
.map(|(v, name)| (v, name))
})
.collect()
}
pub fn add_selected(&mut self) {
let available = self.available_items();
if self.available_cursor < available.len() {
let value = available[self.available_cursor].0.clone();
self.included.push(value);
let new_len = self.available_items().len();
if self.available_cursor >= new_len && new_len > 0 {
self.available_cursor = new_len - 1;
}
}
}
pub fn remove_selected(&mut self) {
if self.included_cursor < self.included.len() {
self.included.remove(self.included_cursor);
if self.included_cursor >= self.included.len() && !self.included.is_empty() {
self.included_cursor = self.included.len() - 1;
}
}
}
pub fn move_up(&mut self) {
if self.active_column != DualListColumn::Included {
return;
}
if self.included_cursor > 0 && self.included_cursor < self.included.len() {
self.included
.swap(self.included_cursor, self.included_cursor - 1);
self.included_cursor -= 1;
}
}
pub fn move_down(&mut self) {
if self.active_column != DualListColumn::Included {
return;
}
if self.included_cursor + 1 < self.included.len() {
self.included
.swap(self.included_cursor, self.included_cursor + 1);
self.included_cursor += 1;
}
}
pub fn cursor_up(&mut self) {
match self.active_column {
DualListColumn::Available => {
if self.available_cursor > 0 {
self.available_cursor -= 1;
}
}
DualListColumn::Included => {
if self.included_cursor > 0 {
self.included_cursor -= 1;
}
}
}
}
pub fn cursor_down(&mut self) {
match self.active_column {
DualListColumn::Available => {
let len = self.available_items().len();
if len > 0 && self.available_cursor + 1 < len {
self.available_cursor += 1;
}
}
DualListColumn::Included => {
if !self.included.is_empty() && self.included_cursor + 1 < self.included.len() {
self.included_cursor += 1;
}
}
}
}
pub fn switch_column(&mut self) {
self.active_column = match self.active_column {
DualListColumn::Available => DualListColumn::Included,
DualListColumn::Included => DualListColumn::Available,
};
}
pub fn body_rows(&self) -> usize {
let avail = self.available_items().len();
let incl = self.included.len();
avail.max(incl).max(5)
}
}
#[derive(Debug, Clone, Copy)]
pub struct DualListColors {
pub label: Color,
pub text: Color,
pub header: Color,
pub button: Color,
pub focused_bg: Color,
pub focused_fg: Color,
pub disabled: Color,
}
impl Default for DualListColors {
fn default() -> Self {
Self {
label: Color::White,
text: Color::White,
header: Color::Gray,
button: Color::Cyan,
focused_bg: Color::Cyan,
focused_fg: Color::Black,
disabled: Color::DarkGray,
}
}
}
impl DualListColors {
pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
Self {
label: theme.editor_fg,
text: theme.editor_fg,
header: theme.line_number_fg,
button: theme.help_key_fg,
focused_bg: theme.settings_selected_bg,
focused_fg: theme.settings_selected_fg,
disabled: theme.line_number_fg,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct DualListRowArea {
pub area: Rect,
pub index: usize,
}
#[derive(Debug, Clone, Default)]
pub struct DualListLayout {
pub available_rows: Vec<DualListRowArea>,
pub included_rows: Vec<DualListRowArea>,
pub add_button: Option<Rect>,
pub remove_button: Option<Rect>,
pub move_up_button: Option<Rect>,
pub move_down_button: Option<Rect>,
pub full_area: Rect,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DualListHit {
AvailableRow(usize),
IncludedRow(usize),
AddButton,
RemoveButton,
MoveUpButton,
MoveDownButton,
}
impl DualListLayout {
pub fn hit_test(&self, x: u16, y: u16) -> Option<DualListHit> {
if let Some(ref area) = self.add_button {
if point_in_rect(*area, x, y) {
return Some(DualListHit::AddButton);
}
}
if let Some(ref area) = self.remove_button {
if point_in_rect(*area, x, y) {
return Some(DualListHit::RemoveButton);
}
}
if let Some(ref area) = self.move_up_button {
if point_in_rect(*area, x, y) {
return Some(DualListHit::MoveUpButton);
}
}
if let Some(ref area) = self.move_down_button {
if point_in_rect(*area, x, y) {
return Some(DualListHit::MoveDownButton);
}
}
for row in &self.available_rows {
if point_in_rect(row.area, x, y) {
return Some(DualListHit::AvailableRow(row.index));
}
}
for row in &self.included_rows {
if point_in_rect(row.area, x, y) {
return Some(DualListHit::IncludedRow(row.index));
}
}
None
}
}
fn point_in_rect(rect: Rect, x: u16, y: u16) -> bool {
x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
}
#[cfg(test)]
mod tests {
use super::*;
fn test_options() -> Vec<(String, String)> {
vec![
("filename".into(), "Filename".into()),
("cursor".into(), "Cursor".into()),
("cursor:compact".into(), "Cursor (compact)".into()),
("diagnostics".into(), "Diagnostics".into()),
("cursor_count".into(), "Cursor Count".into()),
("messages".into(), "Messages".into()),
("chord".into(), "Chord".into()),
]
}
#[test]
fn test_available_items_excludes_included_and_excluded() {
let state = DualListState::new("Test", test_options())
.with_included(vec!["filename".into(), "cursor".into()])
.with_excluded(vec!["chord".into()]);
let available = state.available_items();
assert_eq!(available.len(), 4); assert!(available
.iter()
.all(|(v, _)| v != "filename" && v != "cursor" && v != "chord"));
}
#[test]
fn test_add_selected() {
let mut state = DualListState::new("Test", test_options());
state.available_cursor = 1; state.add_selected();
assert_eq!(state.included, vec!["cursor"]);
assert_eq!(state.available_items().len(), 6);
}
#[test]
fn test_remove_selected() {
let mut state = DualListState::new("Test", test_options())
.with_included(vec!["filename".into(), "cursor".into()]);
state.included_cursor = 0;
state.remove_selected();
assert_eq!(state.included, vec!["cursor"]);
}
#[test]
fn test_move_up_down() {
let mut state = DualListState::new("Test", test_options()).with_included(vec![
"filename".into(),
"cursor".into(),
"diagnostics".into(),
]);
state.active_column = DualListColumn::Included;
state.included_cursor = 2;
state.move_up();
assert_eq!(state.included, vec!["filename", "diagnostics", "cursor"]);
assert_eq!(state.included_cursor, 1);
state.move_down();
assert_eq!(state.included, vec!["filename", "cursor", "diagnostics"]);
assert_eq!(state.included_cursor, 2);
}
#[test]
fn test_switch_column() {
let mut state = DualListState::new("Test", test_options());
assert_eq!(state.active_column, DualListColumn::Available);
state.switch_column();
assert_eq!(state.active_column, DualListColumn::Included);
state.switch_column();
assert_eq!(state.active_column, DualListColumn::Available);
}
fn shift_right(state: &mut DualListState) {
assert_eq!(state.active_column, DualListColumn::Available);
state.add_selected();
state.active_column = DualListColumn::Included;
state.included_cursor = state.included.len().saturating_sub(1);
}
fn shift_left(state: &mut DualListState) {
assert_eq!(state.active_column, DualListColumn::Included);
let value = state.included[state.included_cursor].clone();
state.remove_selected();
state.active_column = DualListColumn::Available;
let avail = state.available_items();
if let Some(pos) = avail.iter().position(|(v, _)| *v == value) {
state.available_cursor = pos;
}
}
#[test]
fn test_shift_arrows_focus_follows_item() {
let mut state = DualListState::new("Test", test_options());
state.active_column = DualListColumn::Available;
state.available_cursor = 1; shift_right(&mut state);
assert_eq!(state.included, vec!["cursor"]);
assert_eq!(state.active_column, DualListColumn::Included);
assert_eq!(state.included_cursor, 0);
state.active_column = DualListColumn::Available;
state.available_cursor = 0; shift_right(&mut state);
assert_eq!(state.included, vec!["cursor", "filename"]);
assert_eq!(state.active_column, DualListColumn::Included);
assert_eq!(state.included_cursor, 1);
state.move_up();
assert_eq!(state.included, vec!["filename", "cursor"]);
assert_eq!(state.included_cursor, 0);
state.move_up();
assert_eq!(state.included, vec!["filename", "cursor"]);
assert_eq!(state.included_cursor, 0);
state.move_down();
assert_eq!(state.included, vec!["cursor", "filename"]);
assert_eq!(state.included_cursor, 1);
state.move_down();
assert_eq!(state.included, vec!["cursor", "filename"]);
assert_eq!(state.included_cursor, 1);
shift_left(&mut state);
assert_eq!(state.included, vec!["cursor"]);
assert_eq!(state.active_column, DualListColumn::Available);
let avail = state.available_items();
assert_eq!(avail[state.available_cursor].0, "filename");
shift_right(&mut state);
assert_eq!(state.included, vec!["cursor", "filename"]);
assert_eq!(state.active_column, DualListColumn::Included);
assert_eq!(state.included_cursor, 1);
state.included_cursor = 0;
shift_left(&mut state);
assert_eq!(state.included, vec!["filename"]);
assert_eq!(state.active_column, DualListColumn::Available);
let avail = state.available_items();
assert_eq!(avail[state.available_cursor].0, "cursor");
state.available_cursor = 0; shift_right(&mut state);
state.active_column = DualListColumn::Available;
state.available_cursor = 1; shift_right(&mut state);
assert_eq!(state.included, vec!["filename", "cursor", "diagnostics"]);
assert_eq!(state.included_cursor, 2);
state.move_up();
assert_eq!(state.included, vec!["filename", "diagnostics", "cursor"]);
assert_eq!(state.included_cursor, 1);
state.move_up();
assert_eq!(state.included, vec!["diagnostics", "filename", "cursor"]);
assert_eq!(state.included_cursor, 0);
state.move_down();
assert_eq!(state.included, vec!["filename", "diagnostics", "cursor"]);
assert_eq!(state.included_cursor, 1);
shift_left(&mut state);
assert_eq!(state.included, vec!["filename", "cursor"]);
assert_eq!(state.active_column, DualListColumn::Available);
let avail = state.available_items();
assert_eq!(avail[state.available_cursor].0, "diagnostics");
let avail_before: Vec<String> = state
.available_items()
.iter()
.map(|(v, _)| v.clone())
.collect();
let included_before = state.included.clone();
state.move_up();
assert_eq!(
included_before, state.included,
"Included should not change after move_up in Available column"
);
state.move_down();
assert_eq!(
included_before, state.included,
"Included should not change after move_down in Available column"
);
let avail_after: Vec<String> = state
.available_items()
.iter()
.map(|(v, _)| v.clone())
.collect();
assert_eq!(
avail_before, avail_after,
"Available order should not change"
);
}
#[test]
fn test_cursor_bounds() {
let mut state = DualListState::new("Test", test_options());
state.cursor_up();
assert_eq!(state.available_cursor, 0);
for _ in 0..10 {
state.cursor_down();
}
assert_eq!(state.available_cursor, 6);
state.available_cursor = 6;
state.add_selected();
assert!(state.available_cursor < state.available_items().len());
}
}