mod handle_event_trait;
mod popup;
mod ui_event;
mod ui_state;
use popup::Popup;
pub use handle_event_trait::HandleEvent;
pub use ui_event::*;
pub use ui_state::UIState;
use crate::{
config::{Config, PasteBehavior, UiConfig, WidgetBorderType},
file_worker::{FileWorker, FileWorkerCommands},
layout::{Layout, Render},
todo::{autocomplete, ToDo},
};
use anyhow::Result;
use crossterm::{
event::{
self, read, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste,
EnableMouseCapture, Event, KeyCode, MouseEvent,
},
execute,
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, SetTitle,
},
ExecutableCommand,
};
use std::{
io,
panic::{set_hook, take_hook},
sync::{mpsc::Sender, Arc, Mutex},
};
use tui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout as tuiLayout, Position, Rect},
style::{Color, Style},
widgets::{Block, Borders, Paragraph},
Terminal,
};
use tui_input::{backend::crossterm::EventHandler, Input};
#[derive(Debug, PartialEq, Eq)]
enum Mode {
Input,
Edit,
Search,
Normal,
}
pub struct UI {
input_chunk: Rect,
tinput: Input,
layout: Layout,
mode: Mode,
data: Arc<Mutex<ToDo>>,
tx: Sender<FileWorkerCommands>,
quit: bool,
active_color: Color,
config: UiConfig,
border_type: WidgetBorderType,
popup: Popup,
help_text: String,
}
impl UI {
pub fn new(
layout: Layout,
data: Arc<Mutex<ToDo>>,
tx: Sender<FileWorkerCommands>,
config: &Config,
) -> UI {
UI {
input_chunk: Rect::default(),
tinput: Input::default(),
layout,
mode: Mode::Normal,
data,
tx,
quit: false,
active_color: *config.styles.active_color,
config: config.ui_config.clone(),
border_type: config.widget_base_config.border_type,
popup: Popup::new(config.widget_base_config.border_type),
help_text: Self::build_help_text(config),
}
}
fn build_help_text(config: &Config) -> String {
fn section(text: &mut String, title: &str, handler: &EventHandlerUI) {
if handler.is_empty() {
return;
}
text.push_str(&format!(" {title}:\n"));
for (key, event) in handler.entries() {
if *event != UIEvent::None {
text.push_str(&format!(" {:<12} {event}\n", key.to_string()));
}
}
text.push('\n');
}
let mut text = String::new();
section(&mut text, "Window", &config.ui_config.window_keybinds);
section(
&mut text,
"List navigation",
&config.list_config.list_keybind,
);
section(
&mut text,
"Task actions",
&config.widget_base_config.tasks_keybind,
);
section(
&mut text,
"Category actions",
&config.widget_base_config.category_keybind,
);
text
}
pub fn build(config: &Config) -> Result<UI> {
let mut todo = ToDo::new(
config.todo_config.clone(),
config.hook_paths.clone(),
config.styles.clone(),
);
let mut init_widget = config.ui_config.init_widget;
if let Some(path) = &config.ui_config.save_state_path {
match UIState::load(path) {
Ok(UIState { active, todo_state }) => {
todo.update_state(todo_state);
init_widget = active;
}
Err(e) => log::error!("Cannot load state: {e}"),
}
}
let mut layout = Layout::from_str(&config.ui_config.layout, &todo, config)?;
let todo = Arc::new(Mutex::new(todo));
let file_worker = FileWorker::new(config.file_worker_config.clone(), todo.clone())?;
file_worker.load()?;
let tx = file_worker.run()?;
layout.select_widget(init_widget, &todo.lock().unwrap());
Ok(UI::new(layout, todo, tx.clone(), config))
}
fn update_chunk(&mut self, main_chunk: Rect) {
let layout = tuiLayout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(main_chunk);
self.input_chunk = layout[0];
self.layout.update_chunk(layout[1]);
}
pub fn run(&mut self) -> Result<()> {
fn restore_tui(enable_mouse: bool, paste_behavior: PasteBehavior) -> io::Result<()> {
disable_raw_mode()?;
execute!(io::stdout(), LeaveAlternateScreen,)?;
if enable_mouse {
execute!(io::stdout(), DisableMouseCapture,)?;
}
if paste_behavior != PasteBehavior::AsKeys {
execute!(io::stdout(), DisableBracketedPaste,)?;
}
Ok(())
}
fn run_ui(this: &mut UI) -> Result<()> {
enable_raw_mode()?;
execute!(io::stdout(), EnterAlternateScreen,)?;
if this.config.enable_mouse {
execute!(io::stdout(), EnableMouseCapture,)?;
}
if this.config.paste_behavior != PasteBehavior::AsKeys {
execute!(io::stdout(), EnableBracketedPaste,)?;
}
let mut backend = CrosstermBackend::new(io::stdout());
backend.execute(SetTitle(this.config.window_title.clone()))?;
let mut terminal = Terminal::new(backend)?;
terminal.hide_cursor()?;
let size = terminal.size()?;
this.update_chunk(Rect::new(0, 0, size.width, size.height));
this.draw(&mut terminal)?;
this.main_loop(&mut terminal)?;
restore_tui(this.config.enable_mouse, this.config.paste_behavior)?;
terminal.show_cursor()?;
Ok(())
}
let orig_hook = take_hook();
let enable_mouse = self.config.enable_mouse;
let paste_behavior = self.config.paste_behavior;
set_hook(Box::new(move |panic_info| {
let _ = restore_tui(enable_mouse, paste_behavior);
orig_hook(panic_info);
}));
if let Err(e) = run_ui(self) {
self.tx.send(FileWorkerCommands::Exit).unwrap();
Err(e)
} else {
Ok(())
}
}
fn main_loop<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
let mut versions = self.data.lock().unwrap().get_version().get_version_all();
loop {
if event::poll(self.config.list_refresh_rate)? {
if self.process_event()? {
break;
}
versions = self.data.lock().unwrap().get_version().get_version_all();
self.draw(terminal)?;
} else if !self
.data
.lock()
.unwrap()
.get_version()
.is_actual_all(versions)
{
versions = self.data.lock().unwrap().get_version().get_version_all();
self.draw(terminal)?;
}
}
Ok(())
}
fn draw<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
let mut block = Block::default()
.borders(Borders::ALL)
.title("Input")
.border_type(self.border_type.into());
if self.mode == Mode::Input || self.mode == Mode::Edit || self.mode == Mode::Search {
block = block.border_style(Style::default().fg(self.active_color));
}
terminal
.draw(|f| {
f.render_widget(
Paragraph::new(self.tinput.value()).block(block),
self.input_chunk,
);
self.layout.render(f, &self.data.lock().unwrap());
if self.mode == Mode::Input || self.mode == Mode::Edit {
let width = self.input_chunk.width.max(3) - 3;
let scroll = self.tinput.visual_scroll(width as usize);
f.set_cursor_position(Position {
x: self.input_chunk.x
+ (self.tinput.visual_cursor().max(scroll) - scroll) as u16
+ 1,
y: self.input_chunk.y + 1,
});
}
self.popup.render_popup(f);
})
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(())
}
fn process_event(&mut self) -> Result<bool> {
self.handle_event_window(read()?);
Ok(self.quit)
}
fn handle_event_window(&mut self, e: Event) {
match (&e, &self.mode) {
(Event::Resize(width, height), _) => {
log::debug!("Resize event: width {width}, height {height}");
self.update_chunk(Rect::new(0, 0, *width, *height));
}
(
Event::Mouse(MouseEvent {
kind: event::MouseEventKind::Up(event::MouseButton::Left),
column,
row,
modifiers: _,
}),
_,
) => {
log::debug!("Mouse event: column {column}, row {row}");
self.layout.click(*column, *row, &self.data.lock().unwrap());
}
(Event::Paste(s), Mode::Normal) => {
if self.config.paste_behavior == PasteBehavior::Insert {
log::debug!("Pasted: {s}");
if let Err(e) = self.data.lock().unwrap().new_task(s) {
log::error!("Error while pasting new task: {e}");
self.popup.add_message(format!("Failed paste task: {e}"));
}
}
}
(Event::Paste(s), _) => self.tinput = Input::new(self.tinput.value().to_owned() + s),
(Event::Key(event), Mode::Input) => match event.code {
KeyCode::Enter => {
if let Err(e) = self.data.lock().unwrap().new_task(self.tinput.value()) {
log::error!("Error while adding new task: {e}");
self.popup.add_message(format!("Failed add task: {e}"));
}
self.tinput.reset();
self.mode = Mode::Normal;
self.layout.focus(&self.data.lock().unwrap());
}
KeyCode::Esc => {
self.mode = Mode::Normal;
self.layout.focus(&self.data.lock().unwrap());
}
KeyCode::Tab => {
if let Some(input) =
autocomplete(&self.data.lock().unwrap(), self.tinput.value())
{
self.tinput = input.into();
}
}
_ => {
self.tinput.handle_event(&e);
}
},
(Event::Key(event), Mode::Edit) => match event.code {
KeyCode::Enter => {
if let Err(e) = self.data.lock().unwrap().update_active(self.tinput.value()) {
log::error!("Error while updating existing task: {e}");
self.popup.add_message(format!("Failed update task: {e}"));
}
self.tinput.reset();
self.mode = Mode::Normal;
self.layout.focus(&self.data.lock().unwrap());
}
KeyCode::Esc => {
self.tinput.reset();
self.mode = Mode::Normal;
self.layout.focus(&self.data.lock().unwrap());
}
KeyCode::Tab => {
if let Some(input) =
autocomplete(&self.data.lock().unwrap(), self.tinput.value())
{
self.tinput = input.into();
}
}
_ => {
self.tinput.handle_event(&e);
}
},
(Event::Key(event), Mode::Search) => match event.code {
KeyCode::Enter => {
self.mode = Mode::Normal;
self.layout.focus(&self.data.lock().unwrap());
self.tinput.reset();
}
KeyCode::Esc => {
self.tinput.reset();
self.mode = Mode::Normal;
self.layout.clean_search();
self.layout.focus(&self.data.lock().unwrap());
}
_ => {
self.tinput.handle_event(&e);
self.layout.search(self.tinput.to_string())
}
},
(Event::Key(event), Mode::Normal) => {
log::debug!("Handle event: {:?}", event);
if self.popup.is_help_visible() && event.code == KeyCode::Esc {
self.popup.hide_help();
} else if !self.handle(event) && !self.popup.is_help_visible() {
self.layout
.handle_key(event, &mut self.data.lock().unwrap());
}
}
_ => {}
}
}
fn handle(&mut self, event: &event::KeyEvent) -> bool {
use UIEvent::*;
match self.config.window_keybinds.get_event(event) {
Quit => {
if let Some(path) = &self.config.save_state_path {
if let Err(e) =
UIState::new(&self.layout, &self.data.lock().unwrap()).save(path)
{
log::error!("Error while saving UI state: {}", e);
}
}
self.quit = true;
}
InsertMode => {
self.mode = Mode::Input;
self.layout.unfocus();
}
MoveRight => {
self.layout.right(&self.data.lock().unwrap());
}
MoveLeft => {
self.layout.left(&self.data.lock().unwrap());
}
MoveUp => {
self.layout.up(&self.data.lock().unwrap());
}
MoveDown => {
self.layout.down(&self.data.lock().unwrap());
}
Save => {
if let Err(e) = self.tx.send(FileWorkerCommands::ForceSave) {
log::error!("Error while send signal to save todo list: {e}");
self.popup
.add_message(format!("Cannot save todo list: {e}"));
}
}
Load => {
if let Err(e) = self.tx.send(FileWorkerCommands::Load) {
log::error!("Error while send signal to load todo list: {e}");
self.popup
.add_message(format!("Cannot load todo list: {e}"));
}
}
EditMode => {
if let Some(active) = self.data.lock().unwrap().get_active() {
self.tinput = active.to_string().into();
self.mode = Mode::Edit;
self.layout.unfocus();
}
}
SearchMode => {
self.tinput.reset();
self.mode = Mode::Search;
self.layout.unfocus();
}
ShowHelp => {
if self.popup.is_help_visible() {
self.popup.hide_help();
} else {
self.popup.show_help(self.help_text.clone());
}
}
_ => {
return false;
}
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{config::Conf, layout::widget::WidgetType};
use crossterm::event::KeyEvent;
use std::{env, str::FromStr};
use test_log::test;
macro_rules! handle_event {
($ui:expr, $code:expr) => {
let key_shortcut = KeyShortcut::from_str($code)?;
let event = Event::Key(KeyEvent::new(key_shortcut.key, key_shortcut.modifiers));
$ui.handle_event_window(event);
};
}
fn default_ui() -> Result<UI> {
let config = Config::from_reader(
format!(
r#"
todo_path = "{}test_behaviour_todo.txt"
save_state_path = "/this/path/does/not/exists"
save_policy = "ManualOnly"
[list_keybind]
E = "EditMode"
Enter = "Select"
I = "InsertMode"
u = "Load"
S = "Save"
j = "ListDown"
q = "Quit"
"#,
env::var("TODO_TUI_TEST_DIR")?
)
.as_bytes(),
)?;
UI::build(&config)
}
#[test]
fn test_moves() -> Result<()> {
let mut ui = default_ui()?;
ui.update_chunk(Rect::new(0, 0, 20, 20));
handle_event!(ui, "S+j");
assert_eq!(ui.layout.get_active_widget(), WidgetType::List);
handle_event!(ui, "S+l");
assert_eq!(ui.layout.get_active_widget(), WidgetType::Done);
handle_event!(ui, "S+k");
assert_eq!(ui.layout.get_active_widget(), WidgetType::Done);
handle_event!(ui, "S+l");
assert_eq!(ui.layout.get_active_widget(), WidgetType::Done);
handle_event!(ui, "S+j");
assert_eq!(ui.layout.get_active_widget(), WidgetType::Context);
handle_event!(ui, "S+h");
assert_eq!(ui.layout.get_active_widget(), WidgetType::List);
Ok(())
}
#[test]
fn test_behaviour() -> Result<()> {
let mut ui = default_ui()?;
ui.update_chunk(Rect::new(0, 0, 20, 20));
let event = Event::Resize(50, 50);
ui.handle_event_window(event);
handle_event!(ui, "j");
assert_eq!(ui.layout.get_active_widget(), WidgetType::List);
handle_event!(ui, "Enter");
assert!(ui.data.lock().unwrap().get_active().is_some());
handle_event!(ui, "S+i");
assert_eq!(ui.mode, Mode::Input);
handle_event!(ui, "Esc");
assert_eq!(ui.mode, Mode::Normal);
handle_event!(ui, "S+e");
assert_eq!(ui.mode, Mode::Edit);
handle_event!(ui, "Esc");
assert_eq!(ui.mode, Mode::Normal);
handle_event!(ui, "/");
assert_eq!(ui.mode, Mode::Search);
handle_event!(ui, "Esc");
assert_eq!(ui.mode, Mode::Normal);
handle_event!(ui, "/");
assert_eq!(ui.mode, Mode::Search);
handle_event!(ui, "a");
assert_eq!(ui.mode, Mode::Search);
handle_event!(ui, "Enter");
assert_eq!(ui.mode, Mode::Normal);
handle_event!(ui, "u");
handle_event!(ui, "S+i");
assert_eq!(ui.mode, Mode::Input);
handle_event!(ui, "a");
assert_eq!(ui.tinput.to_string(), "a");
assert_eq!(ui.mode, Mode::Input);
handle_event!(ui, "b");
assert_eq!(ui.tinput.to_string(), "ab");
assert_eq!(ui.mode, Mode::Input);
handle_event!(ui, "c");
assert_eq!(ui.tinput.to_string(), "abc");
assert_eq!(ui.mode, Mode::Input);
handle_event!(ui, "Tab");
assert_eq!(ui.tinput.to_string(), "abc");
assert_eq!(ui.mode, Mode::Input);
handle_event!(ui, "Enter");
assert_eq!(ui.tinput.to_string(), "");
assert_eq!(ui.mode, Mode::Normal);
handle_event!(ui, "S+e");
assert_eq!(ui.mode, Mode::Edit);
handle_event!(ui, " ");
assert_eq!(ui.tinput.to_string(), "Second task ");
assert_eq!(ui.mode, Mode::Edit);
handle_event!(ui, "plus");
assert_eq!(ui.tinput.to_string(), "Second task +");
assert_eq!(ui.mode, Mode::Edit);
handle_event!(ui, "a");
assert_eq!(ui.tinput.to_string(), "Second task +a");
assert_eq!(ui.mode, Mode::Edit);
handle_event!(ui, "Tab");
assert_eq!(ui.tinput.to_string(), "Second task +abcdef ");
assert_eq!(ui.mode, Mode::Edit);
handle_event!(ui, "Backspace");
handle_event!(ui, "Backspace");
handle_event!(ui, "Backspace");
handle_event!(ui, "Backspace");
handle_event!(ui, "Backspace");
handle_event!(ui, "Backspace");
handle_event!(ui, "Backspace");
handle_event!(ui, "Backspace");
handle_event!(ui, "Backspace");
handle_event!(ui, "Enter");
assert_eq!(ui.tinput.to_string(), "");
assert_eq!(ui.mode, Mode::Normal);
handle_event!(ui, "S+g");
handle_event!(ui, "x");
handle_event!(ui, "x");
handle_event!(ui, "x");
assert!(!ui.quit);
handle_event!(ui, "q");
assert!(ui.quit);
ui.quit = false;
Ok(())
}
#[test]
fn search_contexts() -> Result<()> {
let mut ui = default_ui()?;
ui.update_chunk(Rect::new(0, 0, 20, 20));
handle_event!(ui, "S+l");
assert_eq!(ui.layout.get_active_widget(), WidgetType::Done);
handle_event!(ui, "S+j");
assert_eq!(ui.layout.get_active_widget(), WidgetType::Context);
handle_event!(ui, "/");
assert_eq!(ui.mode, Mode::Search);
handle_event!(ui, "a");
assert_eq!(ui.mode, Mode::Search);
handle_event!(ui, "b");
assert_eq!(ui.mode, Mode::Search);
handle_event!(ui, "Enter");
assert_eq!(ui.mode, Mode::Normal);
handle_event!(ui, "h");
assert_eq!(ui.mode, Mode::Normal);
Ok(())
}
}