aboutsummaryrefslogtreecommitdiff
path: root/ticket
diff options
context:
space:
mode:
authorMichael Gattozzi <mgattozzi@gmail.com>2019-11-27 17:57:29 -0500
committerMichael Gattozzi <mgattozzi@gmail.com>2019-12-02 15:06:03 -0500
commit0caa9551a904a1ba675fbde70435de6fb0a176d6 (patch)
tree1b6224f031e13e20358f00081ef1ec91f8b118a9 /ticket
parentfc072f0656ceb99994f1217325aa11f932881d55 (diff)
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.
Diffstat (limited to 'ticket')
-rw-r--r--ticket/Cargo.toml3
-rw-r--r--ticket/src/actions.rs36
-rw-r--r--ticket/src/main.rs42
-rw-r--r--ticket/src/tui.rs336
4 files changed, 401 insertions, 16 deletions
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<Vec<Ticket>> {
+ get_tickets(ticket_root()?.join("open"))
+}
+
+pub fn get_closed_tickets() -> Result<Vec<Ticket>> {
+ get_tickets(ticket_root()?.join("closed"))
+}
+
+fn get_tickets(path: PathBuf) -> Result<Vec<Ticket>> {
+ 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::<Ticket>(&fs::read(&path)?)?);
+ }
+ }
+ out.sort_by(|a, b| a.number.cmp(&b.number));
+ Ok(out)
+}
+
+pub fn ticket_root() -> Result<PathBuf> {
+ 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<Cmd>,
+}
+
+#[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<PathBuf> {
- 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<I> {
+ Input(I),
+ Tick,
+}
+
+pub struct TicketState {
+ pub tickets: BTreeMap<String, Vec<Ticket>>,
+ pub index: usize,
+ pub status: Status,
+}
+
+impl TicketState {
+ pub fn new(tickets: BTreeMap<String, Vec<Ticket>>) -> 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<Event<Key>>,
+ 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<Event<Key>, 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<Text> {
+ 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
+ }
+}