diff --git a/Cargo.toml b/Cargo.toml index d923309..6f2f574 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,14 @@ edition = "2021" license = "LGPL-3.0-or-later" authors = ["Paul-Christian Volkmer"] +[[example]] +name = "arsnova-client-tui" + [dependencies] +clap = { version = "4.4", features = ["std", "help", "usage", "derive", "error-context"], default-features = false } +ratatui = "0.25" +crossterm = "0.27" + futures-util = "0.3" reqwest = { version = "0.11", features = ["json", "rustls-tls"], default-features = false } serde = { version = "1.0", features = ["derive"] } diff --git a/README.md b/README.md index 078619b..bd6583c 100644 --- a/README.md +++ b/README.md @@ -49,4 +49,15 @@ Forward feedback to a channel: let (tx, rx) = tokio::sync::mpsc::channel::(10); client.on_feedback_changed( & cli.room, FeedbackHandler::Sender(tx.clone())).await; -``` \ No newline at end of file +``` + +## Example + +See [`examples/arsnova-client-tui.rs`](examples/arsnova-client-tui.rs) for a simple terminal-based feedback client +application. + +```bash +arsnova-client-tui 23269388 +``` + +![arsnova-client-tui](examples/arsnova-client.gif) \ No newline at end of file diff --git a/examples/arsnova-client-tui.rs b/examples/arsnova-client-tui.rs new file mode 100644 index 0000000..b49d73f --- /dev/null +++ b/examples/arsnova-client-tui.rs @@ -0,0 +1,212 @@ +/* + * This file is part of arsnova-client + * + * Copyright (C) 2023 Paul-Christian Volkmer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +use std::io::{stdout, Stdout}; + +use clap::Parser; +use crossterm::event::{KeyCode, KeyEventKind}; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use crossterm::{event, ExecutableCommand}; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Alignment, Constraint, Direction, Layout}; +use ratatui::style::Stylize; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Terminal; +use tokio::select; +use tokio::sync::mpsc::Receiver; + +use arsnova_client::{Client, Feedback, FeedbackHandler}; + +#[derive(Parser)] +#[command(author, version, about = "Terminal-based ARSnova live feedback client", long_about = None)] +#[command(arg_required_else_help(true))] +pub struct Cli { + #[arg(help = "Raum")] + room: String, + #[arg( + short = 'u', + long = "url", + help = "API-URL", + default_value = "https://ars.particify.de/api" + )] + url: String, +} + +#[tokio::main(worker_threads = 3)] +async fn main() -> Result<(), ()> { + let cli = Cli::parse(); + + let mut client = match Client::new(&cli.url) { + Ok(client) => client, + Err(_) => return Err(()), + }; + + if client.guest_login().await.is_err() { + return Err(()); + } + + stdout().execute(EnterAlternateScreen).map_err(|_| ())?; + enable_raw_mode().map_err(|_| ())?; + let mut terminal = Terminal::new(CrosstermBackend::new(stdout())).map_err(|_| ())?; + terminal.clear().map_err(|_| ())?; + + let (tx, rx) = tokio::sync::mpsc::channel::(10); + + let l1 = client.on_feedback_changed(&cli.room, FeedbackHandler::Sender(tx.clone())); + + let _ = tx + .clone() + .send(client.get_feedback(&cli.room).await.unwrap()) + .await; + + let room_info = client.get_room_info(&cli.room).await.map_err(|_| ())?; + let title = format!("Live Feedback: {} ({})", room_info.name, room_info.short_id); + + let l2 = create_ui(&mut terminal, &title, rx); + + let l3 = tokio::spawn(async { + loop { + if event::poll(std::time::Duration::from_millis(16)) + .map_err(|_| ()) + .is_ok() + { + if let event::Event::Key(key) = event::read().map_err(|_| ()).unwrap() { + if key.kind == KeyEventKind::Press && key.code == KeyCode::Esc { + break; + } + } + } + } + }); + + select! { + _ = l1 => {}, + _ = l2 => {}, + _ = l3 => {}, + } + + let _ = stdout().execute(LeaveAlternateScreen).map_err(|_| ()); + let _ = disable_raw_mode().map_err(|_| ()).map_err(|_| ()); + + Ok(()) +} + +async fn create_ui( + terminal: &mut Terminal>, + title: &str, + mut rx: Receiver, +) -> Result<(), ()> { + fn feedback_paragraph(feedback: &Feedback, idx: usize, width: usize) -> Paragraph<'static> { + let value = match idx { + 0 => feedback.very_good, + 1 => feedback.good, + 2 => feedback.bad, + 3 => feedback.very_bad, + _ => 0, + }; + + let icon = match idx { + 0 => "Super ", + 1 => "Gut ", + 2 => "Nicht so gut", + 3 => "Schlecht ", + _ => " ", + }; + + let width = width - 24; + + let l = ((value as f32 / feedback.count_votes() as f32) * width as f32) as usize; + + match idx { + 0..=3 => Paragraph::new(Line::from(vec![ + Span::raw(format!("{} : ", icon)), + Span::raw(format!("[{: >5}] ", value)).dim(), + Span::raw("■".to_string().repeat(l).to_string()) + .green() + .on_black(), + Span::raw(" ".to_string().repeat(width - l).to_string()).on_black(), + ])), + _ => Paragraph::default(), + } + } + + loop { + let feedback = match rx.recv().await { + Some(feedback) => feedback, + _ => continue, + }; + + let _ = terminal.draw(|frame| { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints(vec![ + Constraint::Max(1), + Constraint::Max(6), + Constraint::Min(1), + Constraint::Max(1), + ]) + .split(frame.size()); + + frame.render_widget( + Paragraph::new(title) + .white() + .on_blue() + .bold() + .alignment(Alignment::Center), + layout[0], + ); + + frame.render_widget( + Paragraph::new(format!("{} Antworten", feedback.count_votes())) + .white() + .bold() + .alignment(Alignment::Center), + layout[2], + ); + + frame.render_widget( + Paragraph::new("Beenden mit ") + .on_blue() + .alignment(Alignment::Left), + layout[3], + ); + + let feedback_layout = Layout::default() + .direction(Direction::Vertical) + .constraints(vec![ + Constraint::Max(1), + Constraint::Max(1), + Constraint::Max(1), + Constraint::Max(1), + ]) + .margin(1) + .split(layout[1]); + + [0usize, 1, 2, 3].iter().for_each(|&idx| { + frame.render_widget( + feedback_paragraph(&feedback, idx, feedback_layout[idx].width as usize), + feedback_layout[idx], + ) + }); + }); + } +} diff --git a/examples/arsnova-client.gif b/examples/arsnova-client.gif new file mode 100644 index 0000000..58171fe Binary files /dev/null and b/examples/arsnova-client.gif differ