pub mod pactl;
use pactl::*;
use ratatui::{
DefaultTerminal, Frame,
buffer::Buffer,
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind},
layout::{Constraint, Layout, Rect},
style::{Style, Stylize},
text::{Line, Span},
widgets::{Block, BorderType, Row, StatefulWidget, Table, TableState, Widget},
};
use std::{
cell::RefCell,
future::Future,
time::{Duration, Instant},
};
use tokio::task::JoinHandle;
impl Kind {
fn index(self) -> usize {
self as usize
}
fn next(self) -> Self {
match self {
Kind::Stream => Kind::Sink,
Kind::Sink => Kind::Source,
Kind::Source => Kind::Stream,
}
}
fn prev(self) -> Self {
match self {
Kind::Stream => Kind::Source,
Kind::Sink => Kind::Stream,
Kind::Source => Kind::Sink,
}
}
}
#[derive(Default)]
struct Cursor {
number: usize,
id: Option<u32>,
}
impl Cursor {
fn sync(&mut self, entries: &[Entry]) {
let position = self
.id
.and_then(|id| entries.iter().position(|e| e.index == id));
self.number = position.unwrap_or_else(|| self.number.min(entries.len().saturating_sub(1)));
self.remember(entries);
}
fn remember(&mut self, entries: &[Entry]) {
self.id = entries.get(self.number).map(|e| e.index);
}
fn step(&mut self, entries: &[Entry], forward: bool) {
let len = entries.len();
if len == 0 {
return;
}
self.number = if forward {
(self.number + 1) % len
} else {
self.number.checked_sub(1).unwrap_or(len - 1)
};
self.remember(entries);
}
}
pub struct AudioTUI {
exit: bool,
init_handle: Option<JoinHandle<anyhow::Result<Pactl>>>,
refresh_handle: Option<JoinHandle<anyhow::Result<Snapshot>>>,
action_handles: Vec<JoinHandle<anyhow::Result<()>>>,
pactl: Option<Pactl>,
lists: [Vec<Entry>; 3],
cursors: [Cursor; 3],
focus: Kind,
refresh_started: Instant,
last_action: Instant,
error: Option<String>,
states: RefCell<[TableState; 3]>,
scroll_started: Instant,
}
impl AudioTUI {
pub fn new() -> Self {
let init_handle = tokio::spawn(async { Pactl::new().await });
Self {
exit: false,
init_handle: Some(init_handle),
refresh_handle: None,
action_handles: Vec::new(),
pactl: None,
lists: Default::default(),
cursors: Default::default(),
focus: Kind::Stream,
refresh_started: Instant::now() - Duration::from_millis(500),
last_action: Instant::now() - Duration::from_secs(1),
error: None,
states: Default::default(),
scroll_started: Instant::now(),
}
}
pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> {
while !self.exit {
self.poll_tasks().await?;
terminal.draw(|frame| self.draw(frame))?;
self.handle_events().await?;
}
Ok(())
}
async fn poll_tasks(&mut self) -> anyhow::Result<()> {
if self.init_handle.as_ref().is_some_and(|h| h.is_finished()) {
self.pactl = Some(self.init_handle.take().unwrap().await??);
}
let (done, pending): (Vec<_>, Vec<_>) = std::mem::take(&mut self.action_handles)
.into_iter()
.partition(|h| h.is_finished());
self.action_handles = pending;
for handle in done {
if let Err(error) = handle.await? {
self.error = Some(error.to_string());
}
}
if self
.refresh_handle
.as_ref()
.is_some_and(|h| h.is_finished())
{
match self.refresh_handle.take().unwrap().await? {
Ok(snapshot)
if self.refresh_started > self.last_action + Duration::from_millis(250) =>
{
self.apply(snapshot);
}
Ok(_) => {}
Err(error) => self.error = Some(error.to_string()),
}
}
if self.refresh_started.elapsed() >= Duration::from_millis(500)
&& self.refresh_handle.is_none()
{
if let Some(pactl) = self.pactl.clone() {
self.refresh_handle = Some(tokio::spawn(async move { pactl.snapshot().await }));
self.refresh_started = Instant::now();
}
}
Ok(())
}
fn apply(&mut self, snapshot: Snapshot) {
self.lists = [snapshot.streams, snapshot.sinks, snapshot.sources];
for (cursor, list) in self.cursors.iter_mut().zip(&self.lists) {
cursor.sync(list);
}
}
fn run_action(&mut self, task: impl Future<Output = anyhow::Result<()>> + Send + 'static) {
self.error = None;
self.last_action = Instant::now();
self.action_handles.push(tokio::spawn(task));
}
fn selected_entry_mut(&mut self) -> Option<&mut Entry> {
let i = self.focus.index();
self.lists[i].get_mut(self.cursors[i].number)
}
fn switch_focus(&mut self, kind: Kind) {
self.focus = kind;
self.scroll_started = Instant::now();
}
fn move_selection(&mut self, forward: bool) {
let i = self.focus.index();
self.cursors[i].step(&self.lists[i], forward);
self.scroll_started = Instant::now();
}
fn adjust_volume(&mut self, delta: i32) {
let Some(pactl) = self.pactl.clone() else {
return;
};
let kind = self.focus;
let Some(entry) = self.selected_entry_mut() else {
return;
};
let volume = (entry.volume as i32 + delta).clamp(0, 150) as u32;
if volume == entry.volume {
return;
}
entry.volume = volume;
let index = entry.index;
self.run_action(async move { pactl.set_volume(kind, index, volume).await });
}
fn toggle_mute(&mut self) {
let Some(pactl) = self.pactl.clone() else {
return;
};
let kind = self.focus;
let Some(entry) = self.selected_entry_mut() else {
return;
};
entry.mute = !entry.mute;
let (index, mute) = (entry.index, entry.mute);
self.run_action(async move { pactl.set_mute(kind, index, mute).await });
}
fn set_default(&mut self) {
let kind = self.focus;
if kind == Kind::Stream {
return;
}
let Some(pactl) = self.pactl.clone() else {
return;
};
let Some(entry) = self.selected_entry_mut() else {
return;
};
let (index, name) = (entry.index, entry.name.clone());
for entry in &mut self.lists[kind.index()] {
entry.default = entry.index == index;
}
self.run_action(async move { pactl.set_default(kind, &name).await });
}
fn draw(&self, frame: &mut Frame) {
frame.render_widget(self, frame.area());
}
async fn handle_events(&mut self) -> anyhow::Result<()> {
if event::poll(Duration::ZERO)? {
if let Event::Key(key_event) = event::read()? {
if key_event.kind == KeyEventKind::Press {
self.handle_key_event(key_event).await;
}
}
} else {
tokio::time::sleep(Duration::from_millis(50)).await;
}
Ok(())
}
pub async fn handle_key_event(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('q') => self.exit = true,
KeyCode::Tab => self.switch_focus(self.focus.next()),
KeyCode::BackTab => self.switch_focus(self.focus.prev()),
KeyCode::Up => self.move_selection(false),
KeyCode::Down => self.move_selection(true),
KeyCode::Left => self.adjust_volume(-5),
KeyCode::Right => self.adjust_volume(5),
KeyCode::Char('m') => self.toggle_mute(),
KeyCode::Char(' ') => self.set_default(),
_ => {}
}
}
fn pane_title(&self, kind: Kind) -> Span<'static> {
let title = match kind {
Kind::Stream => " Playing ",
Kind::Sink => " Output ",
Kind::Source => " Input ",
};
if self.focus == kind {
title.black().on_magenta()
} else {
title.white()
}
}
fn render_pane(&self, kind: Kind, area: Rect, buf: &mut Buffer) {
let i = kind.index();
let labels = match kind {
Kind::Stream => ["Application", "Volume", "Muted", "Media"],
_ => ["Device", "Volume", "Muted", "Default"],
};
let header = Row::new([
Line::from(labels[0].bold()).right_aligned(),
Line::from(labels[1].bold()).left_aligned(),
Line::from(labels[2].bold()).right_aligned(),
Line::from(labels[3].bold()).left_aligned(),
]);
let widths = [
Constraint::Fill(1),
Constraint::Length(13),
Constraint::Length(5),
Constraint::Fill(1),
];
let columns = Layout::horizontal(widths)
.spacing(1u16)
.split(Rect::new(0, 0, area.width, 1));
let text_widths = (columns[0].width as usize, columns[3].width as usize);
let step = (self.focus == kind).then(|| {
let elapsed = self.scroll_started.elapsed();
(elapsed
.saturating_sub(Duration::from_millis(1000))
.as_millis()
/ 150) as usize
});
let rows = self.lists[i].iter().enumerate().map(|(n, entry)| {
entry_row(kind, entry, n == self.cursors[i].number, text_widths, step)
});
let table = Table::new(rows, widths).header(header);
let mut states = self.states.borrow_mut();
states[i].select(if self.lists[i].is_empty() {
None
} else {
Some(self.cursors[i].number)
});
StatefulWidget::render(table, area, buf, &mut states[i]);
}
fn render_divider(&self, kind: Kind, area: Rect, y: u16, buf: &mut Buffer) {
let style = Style::new().magenta();
for x in area.left()..area.right() {
let symbol = if x == area.left() {
"╟"
} else if x == area.right() - 1 {
"╢"
} else {
"─"
};
buf[(x, y)].set_symbol(symbol).set_style(style);
}
buf.set_line(
area.left() + 1,
y,
&Line::from(self.pane_title(kind)),
area.width.saturating_sub(2),
);
}
}
fn fit(text: &str, width: usize, step: Option<usize>) -> String {
let length = text.chars().count();
if length <= width {
return text.to_string();
}
if width == 0 {
return String::new();
}
match step {
Some(step) => text
.chars()
.chain(" ".chars())
.chain(text.chars())
.skip(step % (length + 3))
.take(width)
.collect(),
None => text
.chars()
.take(width - 1)
.chain(std::iter::once('…'))
.collect(),
}
}
fn entry_row(
kind: Kind,
entry: &Entry,
selected: bool,
(label_width, detail_width): (usize, usize),
step: Option<usize>,
) -> Row<'static> {
let scroll = if selected { step } else { None };
let detail = match kind {
Kind::Stream => fit(&entry.detail, detail_width, scroll).yellow(),
_ if entry.default => "default".green(),
_ => Span::raw(""),
};
let filled = ((entry.volume.min(100) * 8 + 50) / 100) as usize;
let bar = format!(
"{}{} {:>3}%",
"█".repeat(filled),
"░".repeat(8 - filled),
entry.volume
);
let volume = if entry.volume > 100 {
bar.red()
} else {
bar.green()
};
let muted = if entry.mute {
"yes".red()
} else {
"no".green()
};
let row = Row::new([
Line::from(fit(&entry.label, label_width, scroll))
.right_aligned()
.magenta(),
Line::from(volume).left_aligned(),
Line::from(muted).right_aligned(),
Line::from(detail).left_aligned(),
])
.dim();
if selected {
row.style(Style::new().bold())
} else {
row
}
}
impl Widget for &AudioTUI {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
let keybinds = Line::from(vec![
" Volume ".white(),
"<←/→>".magenta().bold(),
" Mute ".white(),
"<m>".magenta().bold(),
" Default ".white(),
"<space>".magenta().bold(),
" Pane ".white(),
"<tab>".magenta().bold(),
" Quit ".white(),
"<q> ".magenta().bold(),
]);
let server_status = match &self.pactl {
Some(pactl) => Line::from(vec![
" Server: ".bold().magenta(),
pactl.server.bold().yellow(),
" ".into(),
]),
None => Line::from(" Server ".bold().magenta()),
};
let error_status = match &self.error {
Some(error) => {
Line::from(format!(" {} ", error.chars().take(40).collect::<String>()).red())
}
None => Line::from(""),
};
let block = Block::bordered()
.title(" Audio ".black().on_magenta().into_centered_line())
.title(Line::from(self.pane_title(Kind::Stream)).left_aligned())
.title_bottom(keybinds.centered())
.title_bottom(server_status.left_aligned())
.title_bottom(error_status.right_aligned())
.border_type(BorderType::Double)
.border_style(Style::new().magenta());
let inner = block.inner(area);
block.render(area, buf);
let [streams, first_divider, sinks, second_divider, sources] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Fill(1),
])
.areas(inner);
self.render_pane(Kind::Stream, streams, buf);
self.render_pane(Kind::Sink, sinks, buf);
self.render_pane(Kind::Source, sources, buf);
self.render_divider(Kind::Sink, area, first_divider.y, buf);
self.render_divider(Kind::Source, area, second_divider.y, buf);
}
}