aboutsummaryrefslogtreecommitdiff
path: root/ticket/src/tui.rs
diff options
context:
space:
mode:
Diffstat (limited to 'ticket/src/tui.rs')
-rw-r--r--ticket/src/tui.rs336
1 files changed, 336 insertions, 0 deletions
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
+ }
+}