use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::{
Frame,
prelude::*,
widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use crate::app::{
keymap::Input,
sorter::{SortCriteria, SortOrder, Sorter},
};
use super::{PopupReturn, Styles, ui_functions::centered_rect};
type SortReturn = PopupReturn<SortResult>;
const FOOTER_MARGIN: usize = 8;
const LIST_HIGHLIGHT_SYMBOL: &str = ">> ";
pub struct SortPopup {
available_criteria: Vec<SortCriteria>,
applied_criteria: Vec<SortCriteria>,
sort_order: SortOrder,
active_control: SortControl,
available_state: ListState,
applied_state: ListState,
is_valid: bool,
}
pub struct SortResult {
pub applied_criteria: Vec<SortCriteria>,
pub order: SortOrder,
}
#[derive(Debug, Clone, Copy)]
enum SortControl {
AvailableList,
AppliedList,
}
impl SortPopup {
pub fn new(sorter: &Sorter) -> Self {
let active_control = SortControl::AvailableList;
let sort_order = sorter.order;
let mut sort_popup = Self {
available_criteria: Default::default(),
applied_criteria: Default::default(),
sort_order,
active_control,
available_state: Default::default(),
applied_state: Default::default(),
is_valid: true,
};
sort_popup.load_form_sorter(sorter);
sort_popup.validate();
sort_popup
}
fn load_form_sorter(&mut self, sorter: &Sorter) {
self.applied_criteria = sorter.get_criteria().to_vec();
self.available_criteria = SortCriteria::iterator()
.filter(|c| !self.applied_criteria.contains(c))
.collect();
self.available_state = ListState::default();
self.applied_state = ListState::default();
self.sort_order = sorter.order;
if !self.applied_criteria.is_empty() {
self.applied_state.select(Some(0));
}
if !self.available_criteria.is_empty() {
self.available_state.select(Some(0));
}
}
pub fn render_widget(&mut self, frame: &mut Frame, area: Rect, styles: &Styles) {
let area = centered_rect(70, 80, area);
let block = Block::default().borders(Borders::ALL).title("Sort");
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let footer_text = self.get_footer_text();
let footer_height = textwrap::fill(&footer_text, (area.width as usize) - FOOTER_MARGIN)
.lines()
.count() as u16;
let horizontal_margin = 4;
let lists_height = area.height - 3 - horizontal_margin - footer_height;
let chunks = Layout::default()
.direction(Direction::Vertical)
.horizontal_margin(horizontal_margin)
.vertical_margin(2)
.constraints(
[
Constraint::Length(3),
Constraint::Length((lists_height / 2).max(3)),
Constraint::Length((lists_height - lists_height / 2).max(3)),
Constraint::Length(footer_height),
]
.as_ref(),
)
.split(area);
self.render_sort_order(frame, chunks[0]);
self.render_available_items(frame, chunks[1], styles);
self.render_applied_items(frame, chunks[2], styles);
self.render_footer(footer_text, frame, chunks[3]);
}
fn render_sort_order(&self, frame: &mut Frame, area: Rect) {
let order_text = self.sort_order.to_string();
let order = Paragraph::new(order_text)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("Sort Order"));
frame.render_widget(order, area);
}
fn render_available_items(&mut self, frame: &mut Frame, area: Rect, styles: &Styles) {
let items: Vec<ListItem> = self
.available_criteria
.iter()
.map(|cr| ListItem::new(cr.to_string()).style(Style::reset()))
.collect();
let block_style = match self.active_control {
SortControl::AvailableList => Style::from(styles.general.input_block_active),
_ => Style::default(),
};
let list_block = Block::default()
.borders(Borders::ALL)
.title("Available Criteria")
.border_type(BorderType::Rounded)
.style(block_style);
let list = List::new(items)
.block(list_block)
.highlight_style(Self::get_list_highlight_style(
matches!(self.active_control, SortControl::AvailableList),
styles,
))
.highlight_symbol(LIST_HIGHLIGHT_SYMBOL);
frame.render_stateful_widget(list, area, &mut self.available_state);
}
fn render_applied_items(&mut self, frame: &mut Frame, area: Rect, styles: &Styles) {
let items: Vec<ListItem> = self
.applied_criteria
.iter()
.map(|cr| ListItem::new(cr.to_string()).style(Style::reset()))
.collect();
let block_style = match (self.is_valid, self.active_control) {
(false, _) => Style::from(styles.general.input_block_invalid),
(true, SortControl::AppliedList) => Style::from(styles.general.input_block_active),
_ => Style::default(),
};
let title = if self.is_valid {
"Applied Criteria"
} else {
"Applied criteria can't be empty"
};
let list_block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_type(BorderType::Rounded)
.style(block_style);
let list = List::new(items)
.block(list_block)
.highlight_style(Self::get_list_highlight_style(
matches!(self.active_control, SortControl::AppliedList),
styles,
))
.highlight_symbol(LIST_HIGHLIGHT_SYMBOL);
frame.render_stateful_widget(list, area, &mut self.applied_state);
}
fn render_footer(&self, footer_text: String, frame: &mut Frame, area: Rect) {
let footer = Paragraph::new(footer_text)
.alignment(Alignment::Center)
.wrap(Wrap { trim: false })
.block(Block::default().borders(Borders::NONE));
frame.render_widget(footer, area);
}
fn get_list_highlight_style(is_focused: bool, styles: &Styles) -> Style {
if is_focused {
styles.general.list_highlight_active.into()
} else {
styles.general.list_highlight_inactive.into()
}
}
pub fn handle_input(&mut self, input: &Input) -> SortReturn {
let has_control = input.modifiers.contains(KeyModifiers::CONTROL);
match input.key_code {
KeyCode::Esc | KeyCode::Char('q') => return PopupReturn::Cancel,
KeyCode::Char('c') if has_control => return PopupReturn::Cancel,
KeyCode::Tab => self.cycle_next_control(),
KeyCode::Char('i') if has_control => self.cycle_next_control(),
KeyCode::Char('o') => self.toggle_sort_order(),
KeyCode::Char(' ') => {
match self.active_control {
SortControl::AvailableList => Self::move_criteria(
&mut self.available_criteria,
&mut self.applied_criteria,
&mut self.available_state,
&mut self.applied_state,
),
SortControl::AppliedList => Self::move_criteria(
&mut self.applied_criteria,
&mut self.available_criteria,
&mut self.applied_state,
&mut self.available_state,
),
};
self.validate();
}
KeyCode::Char('k') | KeyCode::Up
if has_control && matches!(self.active_control, SortControl::AppliedList) =>
{
self.move_criteria_up()
}
KeyCode::Char('j') | KeyCode::Down
if has_control && matches!(self.active_control, SortControl::AppliedList) =>
{
self.move_criteria_down()
}
KeyCode::Char('k') | KeyCode::Up => match self.active_control {
SortControl::AvailableList => {
Self::cycle_prev_criteria(&self.available_criteria, &mut self.available_state)
}
SortControl::AppliedList => {
Self::cycle_prev_criteria(&self.applied_criteria, &mut self.applied_state)
}
},
KeyCode::Char('j') | KeyCode::Down => match self.active_control {
SortControl::AvailableList => {
Self::cycle_next_criteria(&self.available_criteria, &mut self.available_state)
}
SortControl::AppliedList => {
Self::cycle_next_criteria(&self.applied_criteria, &mut self.applied_state)
}
},
KeyCode::Enter => return self.confirm(),
KeyCode::Char('m') if has_control => return self.confirm(),
KeyCode::Char('d') if has_control => self.load_form_sorter(&Sorter::default()),
_ => {}
};
PopupReturn::KeepPopup
}
fn cycle_next_control(&mut self) {
self.active_control = match self.active_control {
SortControl::AvailableList => SortControl::AppliedList,
SortControl::AppliedList => SortControl::AvailableList,
}
}
fn toggle_sort_order(&mut self) {
self.sort_order = match self.sort_order {
SortOrder::Ascending => SortOrder::Descending,
SortOrder::Descending => SortOrder::Ascending,
}
}
fn move_criteria(
source: &mut Vec<SortCriteria>,
dest: &mut Vec<SortCriteria>,
source_state: &mut ListState,
dest_state: &mut ListState,
) {
if let Some(cr_idx) = source_state.selected() {
let criteria = source.remove(cr_idx);
dest.push(criteria);
source_state.select(cr_idx.checked_sub(1).or(if source.is_empty() {
None
} else {
Some(0)
}));
if dest_state.selected().is_none() {
dest_state.select(Some(0));
}
}
}
fn validate(&mut self) {
self.is_valid = !self.applied_criteria.is_empty();
}
fn cycle_next_criteria(criteria: &[SortCriteria], state: &mut ListState) {
if criteria.is_empty() {
return;
}
let new_idx = state.selected().map(|idx| {
if idx >= criteria.len() - 1 {
0
} else {
idx + 1
}
});
state.select(new_idx);
}
fn cycle_prev_criteria(criteria: &[SortCriteria], state: &mut ListState) {
if criteria.is_empty() {
return;
}
let new_idx = state
.selected()
.map(|idx| idx.checked_sub(1).unwrap_or(criteria.len() - 1));
state.select(new_idx);
}
fn move_criteria_up(&mut self) {
if self.applied_criteria.is_empty() || self.applied_state.selected().is_none() {
return;
}
let curr_idx = self.applied_state.selected().unwrap();
if curr_idx == 0 {
return;
}
let new_idx = curr_idx.checked_sub(1).unwrap();
self.applied_criteria.swap(curr_idx, new_idx);
self.applied_state.select(Some(new_idx));
}
fn move_criteria_down(&mut self) {
if self.applied_criteria.is_empty() || self.applied_state.selected().is_none() {
return;
}
let curr_idx = self.applied_state.selected().unwrap();
let last_idx = self.applied_criteria.len() - 1;
if curr_idx == last_idx {
return;
}
let new_idx = curr_idx + 1;
self.applied_criteria.swap(curr_idx, new_idx);
self.applied_state.select(Some(new_idx));
}
fn confirm(&mut self) -> SortReturn {
self.validate();
if !self.is_valid {
return PopupReturn::KeepPopup;
}
let applied_criteria = self.applied_criteria.clone();
let order = self.sort_order;
let result = SortResult {
applied_criteria,
order,
};
PopupReturn::Apply(result)
}
fn get_footer_text(&self) -> String {
let move_mapping = match self.active_control {
SortControl::AvailableList => "",
SortControl::AppliedList => " <Ctrl-j/k> or <Ctrl-Up/Down> Move criteria up/down |",
};
format!(
"Tab: Change focused control | Enter or <Ctrl-m>: Confirm | Esc or <Ctrl-c>: Cancel |\
<o>: Change Sort Order | <Space>: Move criteria to other list |\
<j/k> or <up/down> cycle between criteria |{move_mapping} <Ctrl-d> Load default"
)
}
}