clockify 0.1.1

Basic TUI for Clockify time tracking application
Documentation
use tui::{
    backend::Backend, 
    layout::{Constraint, Direction, Layout, Rect}, 
    style::{Modifier, Style}, 
    text::{Span, Spans},
    widgets::{Block, Borders, List, ListItem, Paragraph, ListState},
    Frame
}; 
use std::fmt::Display;
use crossterm::event::{KeyCode, KeyEvent};
use aho_corasick::AhoCorasickBuilder;

use crate::clockify::AppMode;

pub trait Component {
   fn render<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect);
   fn key_event(&mut self, key: KeyEvent, mode: &AppMode);
}

#[derive(Debug, Clone)]
pub struct InputBox {
    pub prompt: String,
    pub text: String, 
}

impl Component for InputBox {
    fn render<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect) {
        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .margin(5)
            .constraints(
                [
                Constraint::Min(1), 
                Constraint::Min(0),
                ].as_ref()
            ).split(area); 
        f.render_widget(Paragraph::new(self.prompt.clone()), chunks[0]); 
        f.render_widget(Paragraph::new(self.text.clone()), chunks[1]); 
    }

    fn key_event(&mut self, key: KeyEvent, mode: &AppMode) {
        match mode {
            AppMode::Edit => {
                match key.code {
                    KeyCode::Char(c) => {
                        self.text = format!("{}{}", self.text, c); 
                    },
                    KeyCode::Backspace => {
                        self.text.pop();
                    }, 
                    _ => {}
                }
            },
            AppMode::Navigation => {
                if let KeyCode::Char(c) = key.code {
                    if c == 'c' { self.text = String::new() }
                }
            }, 
            _ => {}
        }
    }
}

impl From<&str> for InputBox {
    fn from(s: &str) -> InputBox {
        InputBox {
            prompt: s.to_owned(), 
            text: String::new()
        }
    }
}

impl From<String> for InputBox {
    fn from(s: String) -> InputBox {
        InputBox {
            prompt: s, 
            text: String::new()
        }
    }
}

#[derive(Debug, Clone)]
pub struct StatefulList<T: Display + Id + Clone> {
    pub search_text: String,
    pub selected: Vec<String>, 
    pub multiselect: bool, 
    pub title: String, 
    pub state: ListState, 
    pub items: Vec<T>
}

pub trait Id {
    fn id(&self) -> String; 
}

#[derive(Debug, Copy, Clone)]
struct Match<T> {
    obj: T,
    count: usize
}

impl<T: Display + Id + Clone> StatefulList<T> {
    pub fn with_items(items: Vec<T>, title: String, multiselect: bool) -> StatefulList<T> {
        StatefulList {
            search_text: String::new(),
            selected: vec![],
            multiselect, 
            state: ListState::default(), 
            items,
            title
        }
    }

    pub fn search(&self, query: &str) -> Vec<T> {
        let vec_string: Vec<String> = self.items
            .iter()
            .map(|x| x.to_string())
            .collect::<Vec<String>>();
        let matches = Self::find_matches(query.to_owned(), &vec_string);
        
        Self::filter_by_matches(&self.items, &matches)
    }

    fn find_matches(query: String, vector: &[String]) -> Vec<usize> {
        let patterns = query.split_whitespace();
        let ac = AhoCorasickBuilder::new()
            .ascii_case_insensitive(true)
            .build(patterns);
        vector
            .iter()
            .map(|string| {
                ac.find_iter(string).count()
            }).collect::<Vec<usize>>()
    }

    fn filter_by_matches(filter_vec: &[T], match_vec: &[usize]) -> Vec<T> {
        let zip = filter_vec.iter().zip(match_vec.iter());
        let mut matches: Vec<Match<T>> = zip
            .map(|(x,m)| Match {
                obj: x.clone(), 
                count: *m,
            }).collect::<Vec<Match<T>>>();
        matches.sort_by(|a, b| b.count.cmp(&a.count));
        return matches
            .iter()
            .filter(|m| m.count > 0)
            .map(|m| m.obj.clone())
            .collect::<Vec<T>>();
    }

    pub fn clear_selected(&mut self) {
        self.selected = vec![];
    }

    pub fn get_by_id(&self, id: String) -> Option<&T> {
        return self.items.iter().find(|x| x.id() == id);
    }

    pub fn toggle_highlighted(&mut self) {
        let highlighted_item : Option<T> = self.get_highlighted_item().cloned();
        if let Some(item) = highlighted_item {
            if let Some(idx) = self.selected.iter().position(|x| *x == item.id()) {
                self.selected.remove(idx);
            } else {
                if !self.multiselect && !self.selected.is_empty() {
                    self.selected = vec![]; 
                }
                self.selected.push(item.id()); 
            }
        }
    }

    pub fn next(&mut self) {
        let i = match self.state.selected() {
            Some(i) => {
                if i >= self.items.len() - 1 {
                    0
                } else {
                    i + 1
                }
            }
            None => 0, 
        }; 
        self.state.select(Some(i));
    }

    pub fn previous(&mut self) {
        let i = match self.state.selected() {
            Some(i) => {
                if i == 0 {
                    self.items.len() - 1 
                } else {
                    i - 1
                }
            }
            None => 0, 
        }; 
        self.state.select(Some(i))
    }

    pub fn get_highlighted_item(&self) -> Option<&T> {
        match self.state.selected() {
            Some(x) => {
                let items : Vec<T> = if self.search_text.is_empty() {
                    self.items.to_vec()
                } else {
                    self.search(&self.search_text)
                };
                let highlighted_item = items.get(x).unwrap();
                return self.items.iter().find(|x| x.id() == highlighted_item.id());
            }, 
            None => {
                None
            },
        }
    }

    pub fn get_selected_item(&self) -> Option<&T> {
        return self.items.iter().filter(|x| self.selected.contains(&x.id())).collect::<Vec<&T>>().get(0).cloned();
    }

    pub fn get_selected_items(&self) -> Vec<&T> {
        return self.items.iter().filter(|x| self.selected.contains(&x.id())).collect::<Vec<&T>>();
    }
}

impl<T: Display + Id + Clone> Component for StatefulList<T> {
    fn render<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect) {
        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .margin(5)
            .constraints(
                [
                Constraint::Min(1), 
                Constraint::Min(0),
                ].as_ref()
            ).split(area); 
        let mut title = self.title.clone(); 
        if !self.search_text.is_empty() {
            title = format!("{}{}", title, self.search_text);
        }
        f.render_widget(Paragraph::new(title), chunks[0]); 
        let items : Vec<T> = if self.search_text.is_empty() {
            self.items.to_vec()
        } else {
            self.search(&self.search_text)
        };
        let list_item : Vec<ListItem> = items.iter()
            .map(|i| {
                if self.selected.contains(&i.id()) {
                    ListItem::new(vec![Spans::from(Span::raw(format!(">> {}", i)))])
                } else {
                    ListItem::new(vec![Spans::from(Span::raw(format!("{}", i)))])
                }
            })
            .collect();
        let list_item = List::new(list_item)
            .block(Block::default().borders(Borders::ALL))
            .highlight_style(Style::default().add_modifier(Modifier::BOLD).add_modifier(Modifier::UNDERLINED).add_modifier(Modifier::ITALIC));
        f.render_stateful_widget(list_item, chunks[1], &mut self.state)
    }

    fn key_event(&mut self, key: KeyEvent, mode: &AppMode) {
        match mode {
            AppMode::Search => {
                self.state.select(Some(0));
                match key.code {
                    KeyCode::Char(c) => {
                        self.search_text = format!("{}{}", self.search_text, c);
                    }, 
                    KeyCode::Backspace => {
                        self.search_text.pop();
                    }, 
                   _ => {}
                }
            },
            AppMode::Navigation => {
                match key.code {
                    KeyCode::Char(c) => {
                        match c {
                            'j' => self.next(),
                            'k' => self.previous(),
                            'c' => self.clear_selected(),
                            _ => {}
                        }
                    },
                    KeyCode::Up => {
                        self.previous()
                    }, 
                    KeyCode::Down => {
                        self.next()
                    }, 
                    KeyCode::Enter => {
                        self.toggle_highlighted();
                        self.search_text = String::new();
                    }
                    _ => {}
                }
            },
            _ => {}
        }
    }
}