From 0caa9551a904a1ba675fbde70435de6fb0a176d6 Mon Sep 17 00:00:00 2001 From: Michael Gattozzi Date: Wed, 27 Nov 2019 17:57:29 -0500 Subject: Add a tui for ticket This commit sets up a basic tui for the current functionality. It's traversable by keyboard and by mouse and shows the ticket state via tab, info in a row, and the description in it's own box when selected. This is necessary for a good user experience for in repo tools. Files are fine, but interactivity is better. --- ticket/Cargo.toml | 3 + ticket/src/actions.rs | 36 ++++++ ticket/src/main.rs | 42 ++++--- ticket/src/tui.rs | 336 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 401 insertions(+), 16 deletions(-) create mode 100644 ticket/src/actions.rs create mode 100644 ticket/src/tui.rs (limited to 'ticket') diff --git a/ticket/Cargo.toml b/ticket/Cargo.toml index 1c5c9eb..6c752b5 100644 --- a/ticket/Cargo.toml +++ b/ticket/Cargo.toml @@ -15,5 +15,8 @@ serde = { version = "1.0", features = ["derive"] } shared = { path = "../shared" } structopt = { version = "0.3", features = ["paw"] } toml = "0.5" +uuid = { version = "0.8", features = ["serde", "v1"] } log = "0.4" pretty_env_logger = "0.3" +tui = "0.7" +termion = "1.5" diff --git a/ticket/src/actions.rs b/ticket/src/actions.rs new file mode 100644 index 0000000..381c9f7 --- /dev/null +++ b/ticket/src/actions.rs @@ -0,0 +1,36 @@ +use crate::Ticket; +use anyhow::Result; +use log::*; +use shared::find_root; +use std::{ + fs, + path::PathBuf, +}; + +pub fn get_open_tickets() -> Result> { + get_tickets(ticket_root()?.join("open")) +} + +pub fn get_closed_tickets() -> Result> { + get_tickets(ticket_root()?.join("closed")) +} + +fn get_tickets(path: PathBuf) -> Result> { + let mut out = Vec::new(); + debug!("Looking for ticket."); + for entry in fs::read_dir(&path)? { + let entry = entry?; + let path = entry.path(); + trace!("Looking at entry {}.", path.display()); + if path.is_file() { + trace!("Entry is a file."); + out.push(toml::from_slice::(&fs::read(&path)?)?); + } + } + out.sort_by(|a, b| a.number.cmp(&b.number)); + Ok(out) +} + +pub fn ticket_root() -> Result { + Ok(find_root()?.join(".dev-suite").join("ticket")) +} diff --git a/ticket/src/main.rs b/ticket/src/main.rs index 7e05adf..4d5457d 100644 --- a/ticket/src/main.rs +++ b/ticket/src/main.rs @@ -1,3 +1,7 @@ +mod actions; +mod tui; + +use actions::*; use anyhow::{ bail, Result, @@ -12,17 +16,21 @@ use serde::{ Deserialize, Serialize, }; -use shared::find_root; use std::{ env, fs, - path::PathBuf, process, process::Command, }; #[derive(structopt::StructOpt)] -enum Args { +struct Args { + #[structopt(subcommand)] + cmd: Option, +} + +#[derive(structopt::StructOpt)] +enum Cmd { /// Initialize the repo to use ticket Init, New, @@ -40,19 +48,25 @@ fn main(args: Args) { env::set_var("RUST_LOG", "info"); }); pretty_env_logger::init(); - if let Err(e) = match args { - Args::Init => init(), - Args::New => new(), - Args::Show { id } => show(id), - Args::Close { id } => close(id), - } { + + if let Some(cmd) = args.cmd { + if let Err(e) = match cmd { + Cmd::Init => init(), + Cmd::New => new(), + Cmd::Show { id } => show(id), + Cmd::Close { id } => close(id), + } { + error!("{}", e); + std::process::exit(1); + } + } else if let Err(e) = tui::run() { error!("{}", e); std::process::exit(1); } } fn init() -> Result<()> { - let root = find_root()?.join(".dev-suite").join("ticket"); + let root = ticket_root()?; debug!("Creating ticket directory at {}.", root.display()); debug!("Creating open directory."); fs::create_dir_all(&root.join("open"))?; @@ -141,10 +155,6 @@ fn new() -> Result<()> { Ok(()) } -fn ticket_root() -> Result { - Ok(find_root()?.join(".dev-suite").join("ticket")) -} - fn show(id: usize) -> Result<()> { debug!("Getting ticket root."); let ticket_root = ticket_root()?; @@ -236,7 +246,7 @@ fn close(id: usize) -> Result<()> { } #[derive(Serialize, Deserialize)] -struct Ticket { +pub struct Ticket { title: String, status: Status, number: usize, @@ -245,7 +255,7 @@ struct Ticket { } #[derive(Serialize, Deserialize)] -enum Status { +pub enum Status { Open, Closed, } diff --git a/ticket/src/tui.rs b/ticket/src/tui.rs new file mode 100644 index 0000000..90dbeb6 --- /dev/null +++ b/ticket/src/tui.rs @@ -0,0 +1,336 @@ +use crate::{ + actions::{ + get_closed_tickets, + get_open_tickets, + }, + Status, + Ticket, +}; +use anyhow::Result; +use std::{ + collections::BTreeMap, + io, + sync::mpsc, + thread, + time::Duration, +}; +use termion::{ + event::Key, + input::{ + MouseTerminal, + TermRead, + }, + raw::IntoRawMode, + screen::AlternateScreen, +}; +use tui::{ + backend::TermionBackend, + layout::{ + Alignment, + Constraint, + Direction, + Layout, + }, + style::{ + Color, + Modifier, + Style, + }, + widgets::{ + Block, + Borders, + Paragraph, + Row, + Table, + Tabs, + Text, + Widget, + }, + Terminal, +}; + +pub struct TabsState<'a> { + pub titles: Vec<&'a str>, + pub index: usize, +} + +impl<'a> TabsState<'a> { + pub fn new(titles: Vec<&'a str>) -> TabsState { + TabsState { titles, index: 0 } + } + + pub fn next(&mut self) { + self.index = (self.index + 1) % self.titles.len(); + } + + pub fn previous(&mut self) { + if self.index > 0 { + self.index -= 1; + } else { + self.index = self.titles.len() - 1; + } + } +} +pub enum Event { + Input(I), + Tick, +} + +pub struct TicketState { + pub tickets: BTreeMap>, + pub index: usize, + pub status: Status, +} + +impl TicketState { + pub fn new(tickets: BTreeMap>) -> Self { + Self { + tickets, + index: 0, + status: Status::Open, + } + } + + fn len(&self) -> usize { + match self.status { + Status::Open => self.tickets.get("Open").unwrap().len(), + Status::Closed => self.tickets.get("Closed").unwrap().len(), + } + } + + pub fn next(&mut self) { + self.index = (self.index + 1) % self.len(); + } + + pub fn previous(&mut self) { + if self.index > 0 { + self.index -= 1; + } else { + self.index = self.len() - 1; + } + } +} +/// A small event handler that wrap termion input and tick events. Each event +/// type is handled in its own thread and returned to a common `Receiver` +#[allow(dead_code)] +pub struct Events { + rx: mpsc::Receiver>, + input_handle: thread::JoinHandle<()>, + tick_handle: thread::JoinHandle<()>, +} + +struct App<'a> { + tabs: TabsState<'a>, + tickets: TicketState, +} +#[derive(Debug, Clone, Copy)] +pub struct Config { + pub exit_key: Key, + pub tick_rate: Duration, +} + +impl Default for Config { + fn default() -> Config { + Config { + exit_key: Key::Char('q'), + tick_rate: Duration::from_millis(250), + } + } +} + +impl Events { + pub fn new() -> Events { + Events::with_config(Config::default()) + } + + pub fn with_config(config: Config) -> Events { + let (tx, rx) = mpsc::channel(); + let input_handle = { + let tx = tx.clone(); + thread::spawn(move || { + let stdin = io::stdin(); + for evt in stdin.keys() { + if let Ok(key) = evt { + if tx.send(Event::Input(key)).is_err() { + return; + } + if key == config.exit_key { + return; + } + } + } + }) + }; + let tick_handle = { + let tx = tx.clone(); + thread::spawn(move || { + let tx = tx.clone(); + loop { + tx.send(Event::Tick).unwrap(); + thread::sleep(config.tick_rate); + } + }) + }; + Events { + rx, + input_handle, + tick_handle, + } + } + + pub fn next(&self) -> Result, mpsc::RecvError> { + self.rx.recv() + } +} +pub fn run() -> Result<()> { + // Terminal initialization + let stdout = io::stdout().into_raw_mode()?; + let stdout = MouseTerminal::from(stdout); + let stdout = AlternateScreen::from(stdout); + let backend = TermionBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.hide_cursor()?; + + let events = Events::new(); + + // App + let mut app = App { + tabs: TabsState::new(vec!["Open", "Closed"]), + tickets: { + let mut map = BTreeMap::new(); + map.insert("Open".into(), get_open_tickets()?); + map.insert("Closed".into(), get_closed_tickets()?); + TicketState::new(map) + }, + }; + + // Main loop + loop { + terminal.draw(|mut f| { + let size = f.size(); + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0)].as_ref()) + .split(size); + let horizontal = Layout::default() + .direction(Direction::Horizontal) + .vertical_margin(3) + .constraints( + [Constraint::Percentage(50), Constraint::Percentage(50)].as_ref(), + ) + .split(size); + + Tabs::default() + .block(Block::default().borders(Borders::ALL).title("Status")) + .titles(&app.tabs.titles) + .select(app.tabs.index) + .style(Style::default().fg(Color::Cyan)) + .highlight_style(Style::default().fg(Color::Yellow)) + .render(&mut f, vertical[0]); + + match app.tabs.index { + 0 => { + app.table("Open").render(&mut f, horizontal[0]); + + Paragraph::new(app.description("Open").iter()) + .block(Block::default().title("Description").borders(Borders::ALL)) + .alignment(Alignment::Left) + .wrap(true) + .render(&mut f, horizontal[1]); + } + 1 => { + app.table("Closed").render(&mut f, horizontal[0]); + + Paragraph::new(app.description("Closed").iter()) + .block(Block::default().title("Description").borders(Borders::ALL)) + .alignment(Alignment::Left) + .wrap(true) + .render(&mut f, horizontal[1]); + } + _ => {} + } + })?; + + match events.next()? { + Event::Input(input) => match input { + Key::Char('q') => { + break; + } + Key::Right => { + if app.tabs.index == 0 { + app.tickets.status = Status::Closed; + app.tickets.index = 0; + } + app.tabs.next(); + } + Key::Left => { + if app.tabs.index != 0 { + app.tickets.status = Status::Open; + app.tickets.index = 0; + } + app.tabs.previous(); + } + Key::Up => app.tickets.previous(), + Key::Down => app.tickets.next(), + _ => {} + }, + Event::Tick => continue, + } + } + Ok(()) +} + +impl<'a> App<'a> { + fn table(&self, tab: &'a str) -> impl Widget + '_ { + Table::new( + ["Id", "Title", "Assignee"].iter(), + self + .tickets + .tickets + .get(tab) + .unwrap() + .iter() + .enumerate() + .map(move |(idx, i)| { + let data = vec![ + i.number.to_string(), + i.title.to_string(), + i.assignee + .as_ref() + .cloned() + .unwrap_or_else(|| "None".into()), + ] + .into_iter(); + let normal_style = Style::default().fg(Color::Yellow); + let selected_style = + Style::default().fg(Color::White).modifier(Modifier::BOLD); + if idx == self.tickets.index { + Row::StyledData(data, selected_style) + } else { + Row::StyledData(data, normal_style) + } + }), + ) + .block(Block::default().title(tab).borders(Borders::ALL)) + .header_style(Style::default().fg(Color::Yellow)) + .widths(&[ + Constraint::Percentage(10), + Constraint::Percentage(70), + Constraint::Percentage(20), + ]) + .style(Style::default().fg(Color::White)) + .column_spacing(1) + } + + fn description(&self, tab: &'a str) -> Vec { + let mut description = vec![]; + for (idx, i) in self.tickets.tickets.get(tab).unwrap().iter().enumerate() { + if idx == self.tickets.index { + description = vec![Text::raw(i.description.to_owned())]; + break; + } + } + + description + } +} -- cgit v1.2.3