aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorkrolxon <krolyxon@tutanota.com>2024-04-23 16:10:03 +0530
committerkrolxon <krolyxon@tutanota.com>2024-04-23 16:10:03 +0530
commita0582ead78fda02e4137a82e100963e88362f252 (patch)
tree557c85daaa8b015b177de952af9cd1d786d52fa1 /src
parenta0a313996428b598e83016c97adeacd08ad42628 (diff)
get basic tui working with Ratatui
Diffstat (limited to 'src')
-rwxr-xr-xsrc/app.rs61
-rwxr-xr-xsrc/cli.rs44
-rwxr-xr-xsrc/connection.rs46
-rwxr-xr-xsrc/error.rs6
-rwxr-xr-xsrc/event.rs79
-rwxr-xr-xsrc/handler.rs47
-rwxr-xr-xsrc/lib.rs23
-rwxr-xr-xsrc/list.rs24
-rwxr-xr-xsrc/main.rs71
-rwxr-xr-xsrc/tui.rs66
-rwxr-xr-xsrc/ui.rs37
11 files changed, 458 insertions, 46 deletions
diff --git a/src/app.rs b/src/app.rs
new file mode 100755
index 0000000..d57c0b6
--- /dev/null
+++ b/src/app.rs
@@ -0,0 +1,61 @@
+use crate::connection::Connection;
+use crate::list::ContentList;
+use std::collections::VecDeque;
+
+// Application result type
+pub type AppResult<T> = std::result::Result<T, Box<dyn std::error::Error>>;
+
+/// Application
+#[derive(Debug)]
+pub struct App {
+ /// check if app is running
+ pub running: bool,
+ pub conn: Connection,
+ pub play_deque: VecDeque<String>,
+ pub list: ContentList,
+}
+
+impl App {
+ pub fn new(addrs: &str) -> Self {
+ let mut conn = Connection::new(addrs).unwrap();
+ let mut vec: VecDeque<String> = VecDeque::new();
+ Self::get_queue(&mut conn, &mut vec);
+ Self {
+ running: true,
+ conn,
+ play_deque: vec,
+ list: ContentList::new(),
+ }
+ }
+
+ pub fn tick(&self) {}
+
+ pub fn quit(&mut self) {
+ self.running = false;
+ }
+
+ pub fn get_queue(conn: &mut Connection, vec: &mut VecDeque<String>) {
+ conn.conn.queue().unwrap().into_iter().for_each(|x| {
+ if let Some(title) = x.title {
+ if let Some(artist) = x.artist {
+ vec.push_back(format!("{} - {}", artist, title));
+ } else {
+ vec.push_back(title)
+ }
+ } else {
+ vec.push_back(x.file)
+ }
+ });
+ }
+
+ pub fn update_queue(&mut self) {
+ self.play_deque.clear();
+ Self::get_queue(&mut self.conn, &mut self.play_deque);
+ }
+}
+
+fn to_vecdeque(filenames: &Vec<String>) -> VecDeque<String> {
+ let mut v: VecDeque<String> = VecDeque::new();
+ v = filenames.iter().map(|x| x.to_string()).collect();
+ v
+}
diff --git a/src/cli.rs b/src/cli.rs
index f1d648c..131f746 100755
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -1,28 +1,12 @@
use clap::{Parser, Subcommand};
+
#[derive(Parser, Debug)]
-#[command(version, about)]
-#[clap(author = "krolyxon")]
+#[clap(version, about, author = "krolyxon")]
/// MPD client made with Rust
pub struct Args {
- /// pause
- #[clap(short, long, default_value = "false")]
- pub pause: bool,
-
- /// toggle pause
- #[arg(short, long, default_value = "false")]
- pub toggle_pause: bool,
-
- /// show current status
- #[arg(short, long, default_value = "false")]
- pub show_status: bool,
-
- /// use fzf selector for selecting songs
- #[arg(short, long, default_value = "false")]
- pub fzf_select: bool,
-
- /// use dmenu selector for selecting songss
- #[arg(short, long, default_value = "false")]
- pub dmenu_select: bool,
+ /// No TUI
+ #[arg(short= 'n', default_value="false")]
+ pub no_tui: bool,
#[command(subcommand)]
pub command: Command,
@@ -30,14 +14,30 @@ pub struct Args {
#[derive(Debug, Subcommand)]
pub enum Command {
- #[command(arg_required_else_help = true)]
+ #[command(arg_required_else_help = true, long_flag = "volume" , short_flag = 'v')]
+ /// Set Volume
Volume {
vol: String,
},
+ /// Use dmenu for selection
+ #[command(long_flag = "dmenu" , short_flag = 'd')]
Dmenu,
+
+ /// Use Fzf for selection
+ #[command(long_flag = "fzf" , short_flag = 'f')]
Fzf,
+
+ /// Check Status
+ #[command(long_flag = "status" , short_flag = 's')]
Status,
+
+ /// Pause playback
+ #[command(long_flag = "pause" , short_flag = 'p')]
Pause,
+
+ /// Toggle Playback
+ #[command(long_flag = "toggle" , short_flag = 't')]
Toggle,
+
}
diff --git a/src/connection.rs b/src/connection.rs
index 81fb176..f2499e6 100755
--- a/src/connection.rs
+++ b/src/connection.rs
@@ -1,15 +1,20 @@
use mpd::song::Song;
use mpd::{Client, State};
use simple_dmenu::dmenu;
+use std::process::Command;
+pub type Result<T> = core::result::Result<T, Error>;
+pub type Error = Box<dyn std::error::Error>;
+
+#[derive(Debug)]
pub struct Connection {
pub conn: Client,
pub songs_filenames: Vec<String>,
}
impl Connection {
- pub fn new(addrs: &str) -> Result<Self, mpd::error::Error> {
- let mut conn = Client::connect(addrs)?;
+ pub fn new(addrs: &str) -> Result<Self> {
+ let mut conn = Client::connect(addrs).unwrap();
let songs_filenames: Vec<String> = conn
.listall()
.unwrap()
@@ -23,23 +28,29 @@ impl Connection {
})
}
- pub fn play_fzf(&mut self) {
+ pub fn play_fzf(&mut self) -> Result<()> {
+ is_installed("fzf").map_err(|ex| ex)?;
+
let ss = &self.songs_filenames;
let fzf_choice = rust_fzf::select(ss.clone(), Vec::new()).unwrap();
let index = get_choice_index(&self.songs_filenames, fzf_choice.get(0).unwrap());
let song = self.get_song_with_only_filename(ss.get(index).unwrap());
- self.push(&song);
+ self.push(&song)?;
+
+ Ok(())
}
- pub fn play_dmenu(&mut self) {
+ pub fn play_dmenu(&mut self) -> Result<()> {
+ is_installed("dmenu").map_err(|ex| ex)?;
let ss: Vec<&str> = self.songs_filenames.iter().map(|x| x.as_str()).collect();
let op = dmenu!(iter &ss; args "-l", "30");
let index = get_choice_index(&self.songs_filenames, &op);
let song = self.get_song_with_only_filename(ss.get(index).unwrap());
- self.push(&song);
+ self.push(&song)?;
+ Ok(())
}
- fn push(&mut self, song: &Song) {
+ pub fn push(&mut self, song: &Song) -> Result<()> {
if self.conn.queue().unwrap().is_empty() {
self.conn.push(song).unwrap();
self.conn.play().unwrap();
@@ -50,9 +61,11 @@ impl Connection {
}
self.conn.next().unwrap();
}
+
+ Ok(())
}
- fn get_song_with_only_filename(&self, filename: &str) -> Song {
+ pub fn get_song_with_only_filename(&self, filename: &str) -> Song {
Song {
file: filename.to_string(),
artist: None,
@@ -66,6 +79,10 @@ impl Connection {
}
}
+ pub fn get_current_song(&mut self) -> Option<String> {
+ self.conn.currentsong().unwrap().unwrap_or_default().title
+
+ }
pub fn status(&mut self) {
let current_song = self.conn.currentsong();
let status = self.conn.status().unwrap();
@@ -112,3 +129,16 @@ fn get_choice_index(ss: &Vec<String>, selection: &str) -> usize {
choice
}
+
+fn is_installed(ss: &str) -> Result<()> {
+ let output = Command::new("which")
+ .arg(ss)
+ .output()
+ .expect("Failed to execute command");
+ if output.status.success() {
+ Ok(())
+ } else {
+ let err = format!("{} not installed", ss);
+ Err(err.into())
+ }
+}
diff --git a/src/error.rs b/src/error.rs
new file mode 100755
index 0000000..1e5b119
--- /dev/null
+++ b/src/error.rs
@@ -0,0 +1,6 @@
+#[derive(Debug)]
+pub enum Error {
+ DmenuNotInstalled,
+ FzfNotInstalled,
+}
+
diff --git a/src/event.rs b/src/event.rs
new file mode 100755
index 0000000..439c31b
--- /dev/null
+++ b/src/event.rs
@@ -0,0 +1,79 @@
+use crate::app::AppResult;
+use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEvent};
+use std::sync::mpsc;
+use std::thread;
+use std::time::{Duration, Instant};
+
+/// Terminal events.
+#[derive(Clone, Copy, Debug)]
+pub enum Event {
+ /// Terminal tick.
+ Tick,
+ /// Key press.
+ Key(KeyEvent),
+ /// Mouse click/scroll.
+ Mouse(MouseEvent),
+ /// Terminal resize.
+ Resize(u16, u16),
+}
+
+/// Terminal event handler.
+#[allow(dead_code)]
+#[derive(Debug)]
+pub struct EventHandler {
+ /// Event sender channel.
+ sender: mpsc::Sender<Event>,
+ /// Event receiver channel.
+ receiver: mpsc::Receiver<Event>,
+ /// Event handler thread.
+ handler: thread::JoinHandle<()>,
+}
+
+impl EventHandler {
+ /// Constructs a new instance of [`EventHandler`].
+ pub fn new(tick_rate: u64) -> Self {
+ let tick_rate = Duration::from_millis(tick_rate);
+ let (sender, receiver) = mpsc::channel();
+ let handler = {
+ let sender = sender.clone();
+ thread::spawn(move || {
+ let mut last_tick = Instant::now();
+ loop {
+ let timeout = tick_rate
+ .checked_sub(last_tick.elapsed())
+ .unwrap_or(tick_rate);
+
+ if event::poll(timeout).expect("failed to poll new events") {
+ match event::read().expect("unable to read event") {
+ CrosstermEvent::Key(e) => sender.send(Event::Key(e)),
+ CrosstermEvent::Mouse(e) => sender.send(Event::Mouse(e)),
+ CrosstermEvent::Resize(w, h) => sender.send(Event::Resize(w, h)),
+ CrosstermEvent::FocusGained => Ok(()),
+ CrosstermEvent::FocusLost => Ok(()),
+ CrosstermEvent::Paste(_) => unimplemented!(),
+ }
+ .expect("failed to send terminal event")
+ }
+
+ if last_tick.elapsed() >= tick_rate {
+ sender.send(Event::Tick).expect("failed to send tick event");
+ last_tick = Instant::now();
+ }
+ }
+ })
+ };
+ Self {
+ sender,
+ receiver,
+ handler,
+ }
+ }
+
+ /// Receive the next event from the handler thread.
+ ///
+ /// This function will always block the current thread if
+ /// there is no data available and it's possible for more data to be sent.
+ pub fn next(&self) -> AppResult<Event> {
+ Ok(self.receiver.recv()?)
+ }
+}
diff --git a/src/handler.rs b/src/handler.rs
new file mode 100755
index 0000000..32b4b6f
--- /dev/null
+++ b/src/handler.rs
@@ -0,0 +1,47 @@
+use crate::app::{App, AppResult};
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+pub fn handle_key_events(key_event: KeyEvent, app: &mut App) -> AppResult<()> {
+ match key_event.code {
+ KeyCode::Char('q') | KeyCode::Esc => app.quit(),
+ KeyCode::Char('c') | KeyCode::Char('C') => {
+ if key_event.modifiers == KeyModifiers::CONTROL {
+ app.quit();
+ }
+ }
+
+ KeyCode::Char('j') => {
+ app.list.next();
+ }
+
+ KeyCode::Char('k') => {
+ app.list.prev();
+ }
+
+ KeyCode::Enter | KeyCode::Char('l') => {
+ let song = app.conn.get_song_with_only_filename(app.conn.songs_filenames.get(app.list.index).unwrap());
+ app.conn.push(&song).unwrap();
+ app.update_queue();
+ }
+
+ // Playback controls
+ // Toggle Pause
+ KeyCode::Char('p') => {
+ app.conn.toggle_pause();
+ }
+
+ // Pause
+ KeyCode::Char('s') => {
+ app.conn.pause();
+ }
+
+ // Clearn Queue
+ KeyCode::Char('x') => {
+ app.conn.conn.clear()?;
+ app.update_queue();
+ }
+ _ => {}
+ }
+
+ Ok(())
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100755
index 0000000..60df19e
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,23 @@
+/// Command line interface (deprecated)
+pub mod cli;
+
+/// Handle mpd connection
+pub mod connection;
+
+/// Widget renderer
+pub mod ui;
+
+/// Terminal user Interface
+pub mod tui;
+
+/// Content list
+pub mod list;
+
+/// Event Handler
+pub mod event;
+
+/// KeyEvent Handler
+pub mod handler;
+
+/// Application
+pub mod app;
diff --git a/src/list.rs b/src/list.rs
new file mode 100755
index 0000000..669d1af
--- /dev/null
+++ b/src/list.rs
@@ -0,0 +1,24 @@
+#[derive(Debug)]
+pub struct ContentList {
+ pub index: usize
+}
+
+impl ContentList {
+ pub fn new() -> Self {
+ ContentList {
+ index: 0
+ }
+ }
+
+ // Go to next item in list
+ pub fn next(&mut self) {
+ self.index += 1;
+ }
+
+ /// Go to previous item in list
+ pub fn prev(&mut self) {
+ if self.index != 0 {
+ self.index -= 1;
+ }
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 09c7363..3fbfc14 100755
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,24 +1,63 @@
-mod cli;
-mod connection;
+#![allow(unused_imports)]
use clap::Parser;
-use cli::Args;
-use cli::Command;
-use connection::Connection;
+use rmptui::app;
+use rmptui::app::App;
+use rmptui::app::AppResult;
+use rmptui::cli::Args;
+use rmptui::cli::Command;
+use rmptui::connection::Connection;
+use rmptui::event::Event;
+use rmptui::event::EventHandler;
+use rmptui::handler;
+use rmptui::tui;
+use std::io;
-fn main() -> Result<(), Box<dyn std::error::Error>> {
- let args = Args::parse();
- let mut conn = Connection::new("127.0.0.1:6600")?;
+use crossterm::event::{self, KeyCode, KeyEvent, KeyEventKind};
+use ratatui::{
+ prelude::*,
+ symbols::border,
+ widgets::{block::*, *},
+};
- match args.command {
- Command::Volume { vol } => {
- conn.set_volume(vol);
+pub type Result<T> = core::result::Result<T, Error>;
+pub type Error = Box<dyn std::error::Error>;
+
+fn main() -> AppResult<()> {
+ // let args = Args::parse();
+
+ // if args.no_tui {
+ // handle_tui()?;
+ // } else {
+ // match args.command {
+ // Command::Volume { vol } => {
+ // conn.set_volume(vol);
+ // }
+ // Command::Dmenu => conn.play_dmenu().unwrap(),
+ // Command::Fzf => conn.play_fzf().unwrap(),
+ // Command::Status => conn.status(),
+ // Command::Pause => conn.pause(),
+ // Command::Toggle => conn.toggle_pause(),
+ // };
+ // }
+
+ let backend = CrosstermBackend::new(io::stderr());
+ let terminal = Terminal::new(backend)?;
+ let mut app = App::new("127.0.0.1:6600");
+ let events = EventHandler::new(250);
+
+ let mut tui = tui::Tui::new(terminal, events);
+ tui.init()?;
+
+ while app.running {
+ tui.draw(&mut app)?;
+ match tui.events.next()? {
+ Event::Tick => app.tick(),
+ Event::Key(key_event) => handler::handle_key_events(key_event, &mut app)?,
+ Event::Mouse(_) => {}
+ Event::Resize(_, _) => {}
}
- Command::Dmenu => conn.play_dmenu(),
- Command::Fzf => conn.play_fzf(),
- Command::Status => conn.status(),
- Command::Pause => conn.pause(),
- Command::Toggle => conn.toggle_pause(),
}
+
Ok(())
}
diff --git a/src/tui.rs b/src/tui.rs
new file mode 100755
index 0000000..5c94b3b
--- /dev/null
+++ b/src/tui.rs
@@ -0,0 +1,66 @@
+use crate::connection::Connection;
+use crate::ui;
+use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
+use crossterm::{
+ terminal::{self, *},
+};
+use ratatui::prelude::*;
+use std::io::{self, stdout, Stdout};
+use std::panic;
+
+use crate::app::{App, AppResult};
+use crate::event::EventHandler;
+
+pub type CrosstermTerminal = ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stderr>>;
+
+#[derive(Debug)]
+pub struct Tui {
+ terminal: CrosstermTerminal,
+ pub events: EventHandler,
+}
+
+impl Tui {
+ pub fn new(terminal: CrosstermTerminal, events: EventHandler) -> Self {
+ Self { terminal, events }
+ }
+
+ pub fn init(&mut self) -> AppResult<()> {
+ terminal::enable_raw_mode()?;
+ crossterm::execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
+
+ let panic_hook = panic::take_hook();
+ panic::set_hook(Box::new(move |panic| {
+ Self::reset().expect("failed to reset the terminal");
+ panic_hook(panic);
+ }));
+ Ok(())
+ }
+
+ /// [`Draw`] the terminal interface by [`rendering`] the widgets.
+ ///
+ /// [`Draw`]: ratatui::Terminal::draw
+ /// [`rendering`]: crate::ui::render
+ pub fn draw(&mut self, app: &mut App) -> AppResult<()> {
+ self.terminal.draw(|frame| ui::render(app, frame))?;
+ Ok(())
+ }
+
+ /// Resets the terminal interface.
+ ///
+ /// This function is also used for the panic hook to revert
+ /// the terminal properties if unexpected errors occur.
+ fn reset() -> AppResult<()> {
+ terminal::disable_raw_mode()?;
+ crossterm::execute!(io::stderr(), LeaveAlternateScreen, DisableMouseCapture)?;
+ Ok(())
+ }
+
+ /// Exits the terminal interface.
+ ///
+ /// It disables the raw mode and reverts back the terminal properties.
+ pub fn exit(&mut self) -> AppResult<()> {
+ Self::reset()?;
+ self.terminal.show_cursor()?;
+ Ok(())
+ }
+}
diff --git a/src/ui.rs b/src/ui.rs
new file mode 100755
index 0000000..3f2eec8
--- /dev/null
+++ b/src/ui.rs
@@ -0,0 +1,37 @@
+use crate::{app::App, connection::Connection};
+use ratatui::{prelude::*, widgets::*};
+
+/// Renders the user interface widgets
+pub fn render(app: &mut App, frame: &mut Frame) {
+ // This is where you add new widgets.
+ // See the following resources:
+ // - https://docs.rs/ratatui/latest/ratatui/widgets/index.html
+ // - https://github.com/ratatui-org/ratatui/tree/master/examples
+
+ // List of songs
+ let mut state = ListState::default();
+ let size = Rect::new(100, 0, frame.size().width, frame.size().height - 3);
+ let list = List::new(app.conn.songs_filenames.clone())
+ .block(Block::default().title("Song List").borders(Borders::ALL))
+ .highlight_style(Style::new().add_modifier(Modifier::REVERSED))
+ .highlight_symbol(">>")
+ .repeat_highlight_symbol(true);
+
+ state.select(Some(app.list.index));
+ frame.render_stateful_widget(list, size, &mut state);
+
+ // Play Queue
+ let size = Rect::new(0, 0, 100, frame.size().height - 25);
+ let list = List::new(app.play_deque.clone())
+ .block(Block::default().title("Play Queue").borders(Borders::ALL))
+ .highlight_style(Style::new().add_modifier(Modifier::REVERSED))
+ .highlight_symbol(">>")
+ .repeat_highlight_symbol(true);
+ frame.render_widget(list, size);
+
+ // Status
+ let size = Rect::new(0, frame.size().height - 3, frame.size().width, 3);
+ let status = Paragraph::new(app.conn.conn.status().unwrap().volume.to_string())
+ .block(Block::default().title("Status").borders(Borders::ALL));
+ frame.render_widget(status, size);
+}