use std::io::{Stdout, Write, stdout};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use anyhow::Context;
use clap::{Parser, ValueEnum};
use crossterm::{
cursor, execute, queue,
style::{Attribute, Color, Print, ResetColor, SetAttribute, SetForegroundColor},
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
};
use rubecula::{
ActiveLyrics, FetchRequest, LookupKey, LrclibClient, LyricsOutcome, LyricsStore, LyricsView,
NowPlayingSource, PlayState, PlaybackClock, PlaybackSnapshot, PlaybackView, RenderChangeKey,
RenderModel, default_lyrics_data_dir, legacy_lyrics_data_paths,
};
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Parser)]
#[command(
version,
about = "Show the live lyric UI for the current song.",
long_about = "Show the live lyric UI for the current song.\n\nRun `rubin` or `rubecula` with no options for the default terminal UI. Flags are available for alternate sources, local LRC files, and one-shot rendering."
)]
struct Args {
#[arg(long)]
lrc: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = SourceChoice::Music)]
source: SourceChoice,
#[arg(long, value_enum, default_value_t = Mode::Live)]
mode: Mode,
#[arg(long, default_value = "mediaremote-adapter")]
mediaremote_command: PathBuf,
#[arg(long, default_value_t = 500)]
poll_ms: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, ValueEnum)]
enum Mode {
Live,
Once,
}
#[derive(Clone, Debug, PartialEq, Eq, ValueEnum)]
enum SourceChoice {
Music,
Mediaremote,
Auto,
}
#[derive(Debug)]
struct FetchResult {
request: FetchRequest,
outcome: LyricsOutcome,
}
pub async fn run_from_args() -> anyhow::Result<()> {
let args = Args::parse();
run(args).await
}
async fn run(args: Args) -> anyhow::Result<()> {
let source = build_source(&args);
let local_lrc = load_local_lrc(args.lrc.as_ref())?;
let client = LrclibClient::new()?;
let mut lyrics_data = LyricsStore::new(default_lyrics_data_dir(), legacy_lyrics_data_paths());
match args.mode {
Mode::Live => run_live(source, local_lrc, client, lyrics_data, args.poll_ms).await,
Mode::Once => run_once(source, local_lrc, client, &mut lyrics_data).await,
}
}
fn build_source(args: &Args) -> NowPlayingSource {
match args.source {
SourceChoice::Music => NowPlayingSource::music(),
SourceChoice::Mediaremote => {
NowPlayingSource::mediaremote_command(args.mediaremote_command.clone())
}
SourceChoice::Auto => NowPlayingSource::auto(args.mediaremote_command.clone()),
}
}
fn load_local_lrc(path: Option<&PathBuf>) -> anyhow::Result<Option<rubecula::lrc::LrcDocument>> {
match path {
Some(path) => {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
Ok(Some(
rubecula::lrc::parse_lrc(&contents).context("failed to parse --lrc file")?,
))
}
None => Ok(None),
}
}
async fn run_once(
source: NowPlayingSource,
local_lrc: Option<rubecula::lrc::LrcDocument>,
client: LrclibClient,
lyrics_data: &mut LyricsStore,
) -> anyhow::Result<()> {
let snapshot = match source.snapshot().await {
Ok(snapshot) => snapshot,
Err(error) => PlaybackSnapshot::source_error(error.to_string()),
};
let active_lyrics =
lyrics_for_snapshot(&snapshot, local_lrc.as_ref(), &client, lyrics_data).await;
let model = RenderModel::from_state(Some(&snapshot), &active_lyrics);
let mut renderer = TerminalRenderer::new(RenderMode::Once)?;
renderer.render(&model)?;
renderer.finish()
}
async fn lyrics_for_snapshot(
snapshot: &PlaybackSnapshot,
local_lrc: Option<&rubecula::lrc::LrcDocument>,
client: &LrclibClient,
lyrics_data: &mut LyricsStore,
) -> ActiveLyrics {
let Some(track) = snapshot.track.as_ref() else {
return ActiveLyrics::Empty;
};
let Some(key) = LookupKey::from_track(track) else {
return ActiveLyrics::Empty;
};
if let Some(document) = local_lrc {
return ActiveLyrics::Ready(LyricsOutcome::Synced(document.clone()));
}
if let Some(outcome) = lyrics_data.get(track, &key) {
return ActiveLyrics::Ready(outcome.clone());
}
let request = FetchRequest::new(key, track.source_track_id.clone());
let outcome = client.fetch(request.key()).await;
lyrics_data.insert(&request, &outcome);
ActiveLyrics::Ready(outcome)
}
async fn run_live(
source: NowPlayingSource,
local_lrc: Option<rubecula::lrc::LrcDocument>,
client: LrclibClient,
mut lyrics_data: LyricsStore,
poll_ms: u64,
) -> anyhow::Result<()> {
let mut renderer = TerminalRenderer::new(RenderMode::Live)?;
let mut source_interval = tokio::time::interval(Duration::from_millis(poll_ms.max(100)));
source_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut render_interval = tokio::time::interval(Duration::from_millis(250));
render_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let (fetch_tx, mut fetch_rx) = mpsc::channel::<FetchResult>(4);
let mut current_request: Option<FetchRequest> = None;
let mut fetching_request: Option<FetchRequest> = None;
let mut fetch_handle: Option<JoinHandle<()>> = None;
let mut active_lyrics = ActiveLyrics::Empty;
let mut playback_clock = PlaybackClock::default();
let exit_result = loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => {
abort_fetch(&mut fetch_handle);
break Ok(());
},
Some(result) = fetch_rx.recv() => {
if fetching_request.as_ref() == Some(&result.request) {
fetching_request = None;
fetch_handle = None;
}
lyrics_data.insert(&result.request, &result.outcome);
if fetch_matches_current(current_request.as_ref(), &result.request) {
active_lyrics = ActiveLyrics::Ready(result.outcome);
if let Err(error) = render_clock_snapshot(&mut renderer, &playback_clock, &active_lyrics) {
break Err(error);
}
}
}
_ = render_interval.tick() => {
if let Err(error) = render_clock_snapshot(&mut renderer, &playback_clock, &active_lyrics) {
break Err(error);
}
}
_ = source_interval.tick() => {
let snapshot = tokio::select! {
_ = tokio::signal::ctrl_c() => {
abort_fetch(&mut fetch_handle);
break Ok(());
}
result = source.snapshot() => match result {
Ok(snapshot) => snapshot,
Err(error) => PlaybackSnapshot::source_error(error.to_string()),
}
};
let measured_at = Instant::now();
let next_request = snapshot.track.as_ref().and_then(|track| {
LookupKey::from_track(track)
.map(|key| FetchRequest::new(key, track.source_track_id.clone()))
});
if next_request != current_request {
current_request = next_request.clone();
fetching_request = None;
abort_fetch(&mut fetch_handle);
active_lyrics = ActiveLyrics::Empty;
if let Some(request) = next_request {
if let Some(document) = &local_lrc {
active_lyrics = ActiveLyrics::Ready(LyricsOutcome::Synced(document.clone()));
} else if let Some(outcome) = snapshot
.track
.as_ref()
.and_then(|track| lyrics_data.get(track, request.key()))
{
active_lyrics = ActiveLyrics::Ready(outcome.clone());
} else {
active_lyrics = ActiveLyrics::Loading;
fetching_request = Some(request.clone());
let tx = fetch_tx.clone();
let client = client.clone();
fetch_handle = Some(tokio::spawn(async move {
let outcome = client.fetch(request.key()).await;
let _ = tx.send(FetchResult { request, outcome }).await;
}));
}
}
}
playback_clock.update(snapshot, measured_at);
if let Err(error) = render_clock_snapshot(&mut renderer, &playback_clock, &active_lyrics) {
break Err(error);
}
}
}
};
let finish_result = renderer.finish();
exit_result.and(finish_result)
}
fn render_clock_snapshot(
renderer: &mut TerminalRenderer,
playback_clock: &PlaybackClock,
active_lyrics: &ActiveLyrics,
) -> anyhow::Result<()> {
let snapshot = playback_clock.snapshot_at(Instant::now());
render_snapshot(renderer, snapshot.as_ref(), active_lyrics)
}
fn render_snapshot(
renderer: &mut TerminalRenderer,
snapshot: Option<&PlaybackSnapshot>,
active_lyrics: &ActiveLyrics,
) -> anyhow::Result<()> {
let model = RenderModel::from_state(snapshot, active_lyrics);
renderer.render_if_changed(&model)
}
fn abort_fetch(fetch_handle: &mut Option<JoinHandle<()>>) {
if let Some(handle) = fetch_handle.take() {
handle.abort();
}
}
fn fetch_matches_current(current_request: Option<&FetchRequest>, fetched: &FetchRequest) -> bool {
current_request == Some(fetched)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RenderMode {
Live,
Once,
}
const MIN_CONTENT_WIDTH: u16 = 30;
const MAX_CONTENT_WIDTH: u16 = 72;
const PROGRESS_BAR_CELLS: u16 = 32;
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const COLOR_ACCENT: Color = Color::Cyan;
const COLOR_TITLE: Color = Color::White;
const COLOR_DIM: Color = Color::DarkGrey;
const COLOR_PROGRESS_FILL: Color = Color::Cyan;
const COLOR_PROGRESS_THUMB: Color = Color::White;
const COLOR_PROGRESS_TRACK: Color = Color::DarkGrey;
const COLOR_CURRENT_LYRIC: Color = Color::Cyan;
const COLOR_INFO: Color = Color::DarkGrey;
const COLOR_ERROR: Color = Color::Red;
const COLOR_WARN: Color = Color::Yellow;
struct TerminalRenderer {
stdout: Stdout,
mode: RenderMode,
last_key: Option<DisplayKey>,
tick: u64,
finished: bool,
frame_buffer: Option<Vec<u8>>,
}
#[derive(Clone, PartialEq, Eq)]
struct DisplayKey {
base: RenderChangeKey,
size: (u16, u16),
spinner_frame: Option<usize>,
}
impl TerminalRenderer {
fn new(mode: RenderMode) -> anyhow::Result<Self> {
let mut stdout = stdout();
if mode == RenderMode::Live {
execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
}
Ok(Self {
stdout,
mode,
last_key: None,
tick: 0,
finished: false,
frame_buffer: None,
})
}
fn render_if_changed(&mut self, model: &RenderModel) -> anyhow::Result<()> {
self.tick = self.tick.wrapping_add(1);
let size = terminal::size().unwrap_or((80, 24));
let spinner_frame = match model {
RenderModel::Playback(view) if matches!(view.lyrics, LyricsView::Loading) => {
Some(self.tick as usize % SPINNER_FRAMES.len())
}
_ => None,
};
let key = DisplayKey {
base: model.change_key(),
size,
spinner_frame,
};
if self.last_key.as_ref() == Some(&key) {
return Ok(());
}
self.render(model)?;
self.last_key = Some(key);
Ok(())
}
fn render(&mut self, model: &RenderModel) -> anyhow::Result<()> {
match self.mode {
RenderMode::Live => self.render_live(model),
RenderMode::Once => self.print_once(model),
}
}
fn render_live(&mut self, model: &RenderModel) -> anyhow::Result<()> {
let (width, height) = terminal::size().unwrap_or((80, 24));
let mut frame = self
.frame_buffer
.take()
.unwrap_or_else(|| Vec::with_capacity(8 * 1024));
frame.clear();
queue!(frame, cursor::MoveTo(0, 0), Clear(ClearType::All))?;
match model {
RenderModel::Message(message) => {
draw_message_screen(&mut frame, message, width, height)?;
}
RenderModel::Playback(view) => {
draw_playback_screen(&mut frame, view, width, height, self.tick)?;
}
}
self.stdout.write_all(&frame)?;
self.stdout.flush()?;
self.frame_buffer = Some(frame);
Ok(())
}
fn print_once(&mut self, model: &RenderModel) -> anyhow::Result<()> {
match model {
RenderModel::Message(message) => {
queue!(self.stdout, Print(message), Print("\n"))?;
}
RenderModel::Playback(view) => {
print_once_playback(&mut self.stdout, view)?;
}
}
self.stdout.flush()?;
Ok(())
}
fn finish(&mut self) -> anyhow::Result<()> {
if self.finished {
return Ok(());
}
self.finished = true;
if self.mode == RenderMode::Live {
execute!(self.stdout, cursor::Show, LeaveAlternateScreen)?;
}
Ok(())
}
}
impl Drop for TerminalRenderer {
fn drop(&mut self) {
let _ = self.finish();
}
}
fn content_width(term_width: u16) -> u16 {
term_width.clamp(MIN_CONTENT_WIDTH, MAX_CONTENT_WIDTH)
}
fn draw_message_screen(
out: &mut impl Write,
message: &str,
width: u16,
height: u16,
) -> anyhow::Result<()> {
let row = height / 2;
write_centered_line(
out,
row,
width,
&[Segment::new(message).fg(COLOR_DIM).attr(Attribute::Italic)],
)?;
Ok(())
}
fn draw_playback_screen(
out: &mut impl Write,
view: &PlaybackView,
width: u16,
height: u16,
tick: u64,
) -> anyhow::Result<()> {
let inner = content_width(width);
let layout = Layout::compute(height);
let title = truncate_to_width(&view.title, inner.saturating_sub(4) as usize);
write_centered_line(
out,
layout.title_row,
width,
&[
Segment::new("♪ ").fg(COLOR_ACCENT).attr(Attribute::Bold),
Segment::new(&title).fg(COLOR_TITLE).attr(Attribute::Bold),
],
)?;
let mut subtitle: Vec<Segment> = vec![Segment::new(view.artist.clone()).fg(COLOR_DIM)];
if let Some(album) = view.album.as_deref() {
subtitle.push(Segment::new(" · ").fg(COLOR_DIM));
subtitle.push(Segment::new(album.to_string()).fg(COLOR_DIM));
}
let trimmed_subtitle = trim_segments_to_width(subtitle, inner as usize);
write_centered_line(out, layout.subtitle_row, width, &trimmed_subtitle)?;
let bar = build_progress_segments(view.elapsed_ms, view.duration_ms);
let time_label = format!(
"{} / {}",
format_time(view.elapsed_ms),
format_time(view.duration_ms),
);
let mut progress: Vec<Segment> = Vec::with_capacity(8);
progress.push(
Segment::new(format!("{} ", state_icon(&view.state)))
.fg(state_color(&view.state))
.attr(Attribute::Bold),
);
progress.extend(bar);
progress.push(Segment::new(" ").fg(COLOR_DIM));
progress.push(Segment::new(time_label).fg(COLOR_INFO));
write_centered_line(out, layout.progress_row, width, &progress)?;
draw_lyric_block(out, view, width, inner, &layout, tick)?;
if height >= 2 {
let footer = format!("via {}", view.source);
let col = width.saturating_sub(footer.width() as u16 + 2);
queue!(
out,
cursor::MoveTo(col, height.saturating_sub(1)),
SetForegroundColor(COLOR_INFO),
SetAttribute(Attribute::Dim),
Print(footer),
SetAttribute(Attribute::Reset),
ResetColor
)?;
}
Ok(())
}
fn draw_lyric_block(
out: &mut impl Write,
view: &PlaybackView,
width: u16,
inner: u16,
layout: &Layout,
tick: u64,
) -> anyhow::Result<()> {
match &view.lyrics {
LyricsView::Loading => {
let frame = SPINNER_FRAMES[tick as usize % SPINNER_FRAMES.len()];
write_centered_line(
out,
layout.lyric_block_center(),
width,
&[
Segment::new(format!("{frame} ")).fg(COLOR_ACCENT),
Segment::new("Loading synced lyrics").fg(COLOR_DIM),
],
)?;
}
LyricsView::PlainOnly => {
write_centered_line(
out,
layout.lyric_block_center(),
width,
&[Segment::new("Lyrics found, but not synced.").fg(COLOR_WARN)],
)?;
}
LyricsView::Missing => {
write_centered_line(
out,
layout.lyric_block_center(),
width,
&[Segment::new("No synced lyrics found.").fg(COLOR_DIM)],
)?;
}
LyricsView::Error(error) => {
let truncated = truncate_to_width(error, inner.saturating_sub(2) as usize);
write_centered_line(
out,
layout.lyric_block_center(),
width,
&[
Segment::new("Lyrics lookup failed: ").fg(COLOR_ERROR),
Segment::new(truncated).fg(COLOR_DIM),
],
)?;
}
LyricsView::Synced(window) => {
draw_dynamic_synced_lyrics(
out,
&window.lines,
window.current_index,
width,
inner,
layout,
)?;
}
}
Ok(())
}
fn draw_dynamic_synced_lyrics(
out: &mut impl Write,
lines: &[String],
current_index: Option<usize>,
width: u16,
inner: u16,
layout: &Layout,
) -> anyhow::Result<()> {
let capacity = layout.lyric_capacity() as usize;
if capacity == 0 {
return Ok(());
}
if lines.is_empty() {
write_centered_line(
out,
layout.lyric_block_center(),
width,
&[Segment::new("· · ·").fg(COLOR_DIM)],
)?;
return Ok(());
}
let slice = select_window(lines.len(), current_index, capacity);
let visible = &lines[slice.start..slice.end];
for (i, text) in visible.iter().enumerate() {
let global_index = slice.start + i;
let row = layout
.lyric_block_start
.saturating_add(slice.top_offset as u16)
.saturating_add(i as u16);
if row > layout.lyric_block_end {
break;
}
let distance = current_index
.map(|c| (global_index as isize) - (c as isize))
.unwrap_or(isize::MAX);
let (color, attr) = lyric_style_for_distance(distance);
let max_width = inner.saturating_sub(4) as usize;
let trimmed = truncate_to_width(text, max_width);
let mut segment = Segment::new(trimmed).fg(color);
if let Some(attr) = attr {
segment = segment.attr(attr);
}
write_centered_line(out, row, width, &[segment])?;
}
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct WindowSlice {
start: usize,
end: usize,
top_offset: usize,
}
fn select_window(total: usize, current: Option<usize>, capacity: usize) -> WindowSlice {
if total == 0 || capacity == 0 {
return WindowSlice {
start: 0,
end: 0,
top_offset: 0,
};
}
if total <= capacity {
let top_offset = (capacity - total) / 2;
return WindowSlice {
start: 0,
end: total,
top_offset,
};
}
let window = capacity;
let Some(center) = current else {
return WindowSlice {
start: 0,
end: window,
top_offset: 0,
};
};
let half = window / 2;
let start = center.saturating_sub(half).min(total - window);
WindowSlice {
start,
end: start + window,
top_offset: 0,
}
}
fn lyric_style_for_distance(distance: isize) -> (Color, Option<Attribute>) {
let d = distance.unsigned_abs();
match d {
0 => (COLOR_CURRENT_LYRIC, Some(Attribute::Bold)),
1 => (Color::White, None),
2..=3 => (Color::Grey, None),
4..=6 => (Color::DarkGrey, None),
_ => (Color::DarkGrey, Some(Attribute::Dim)),
}
}
fn print_once_playback(out: &mut impl Write, view: &PlaybackView) -> anyhow::Result<()> {
queue!(
out,
SetAttribute(Attribute::Bold),
Print(format!("♪ {}", view.title)),
SetAttribute(Attribute::Reset),
Print("\n"),
)?;
let mut subtitle = view.artist.clone();
if let Some(album) = view.album.as_deref() {
subtitle.push_str(" · ");
subtitle.push_str(album);
}
queue!(out, Print(subtitle), Print("\n"))?;
queue!(
out,
Print(format!(
"{} {} / {} ({})\n",
state_icon(&view.state),
format_time(view.elapsed_ms),
format_time(view.duration_ms),
view.source,
))
)?;
queue!(out, Print("\n"))?;
match &view.lyrics {
LyricsView::Loading => {
queue!(out, Print("Loading synced lyrics...\n"))?;
}
LyricsView::PlainOnly => {
queue!(out, Print("Lyrics found, but not synced.\n"))?;
}
LyricsView::Missing => {
queue!(out, Print("No synced lyrics found.\n"))?;
}
LyricsView::Error(error) => {
queue!(out, Print(format!("Lyrics lookup failed: {error}\n")))?;
}
LyricsView::Synced(window) => {
let total = window.lines.len();
match window.current_index {
Some(current) if current < total => {
if let Some(previous) = current.checked_sub(1).and_then(|i| window.lines.get(i))
{
queue!(out, Print(format!(" {previous}\n")))?;
}
queue!(
out,
SetAttribute(Attribute::Bold),
Print(format!("> {}\n", window.lines[current])),
SetAttribute(Attribute::Reset),
)?;
if let Some(next) = window.lines.get(current + 1) {
queue!(out, Print(format!(" {next}\n")))?;
}
}
_ => {
queue!(
out,
SetAttribute(Attribute::Bold),
Print("> ...\n"),
SetAttribute(Attribute::Reset),
)?;
if let Some(first) = window.lines.first() {
queue!(out, Print(format!(" {first}\n")))?;
}
}
}
}
}
Ok(())
}
#[derive(Clone, Copy)]
struct Layout {
title_row: u16,
subtitle_row: u16,
progress_row: u16,
lyric_block_start: u16,
lyric_block_end: u16,
}
impl Layout {
fn compute(height: u16) -> Self {
let height = height.max(8);
let title_row = 1u16;
let subtitle_row = 2u16;
let progress_row = 4u16;
let lyric_block_start = 6u16;
let lyric_block_end = height.saturating_sub(3).max(lyric_block_start);
Self {
title_row,
subtitle_row,
progress_row,
lyric_block_start,
lyric_block_end,
}
}
fn lyric_capacity(&self) -> u16 {
self.lyric_block_end
.saturating_sub(self.lyric_block_start)
.saturating_add(1)
}
fn lyric_block_center(&self) -> u16 {
self.lyric_block_start
.saturating_add(self.lyric_capacity() / 2)
}
}
fn build_progress_segments(elapsed_ms: Option<u64>, duration_ms: Option<u64>) -> Vec<Segment> {
let total_cells = PROGRESS_BAR_CELLS as usize;
let ratio = match (elapsed_ms, duration_ms) {
(Some(elapsed), Some(duration)) if duration > 0 => {
(elapsed.min(duration) as f64) / (duration as f64)
}
_ => 0.0,
};
let filled = ((ratio * total_cells as f64).round() as usize).min(total_cells);
let mut segments = Vec::with_capacity(3);
if filled > 0 {
let head = "━".repeat(filled.saturating_sub(1));
segments.push(Segment::new(head).fg(COLOR_PROGRESS_FILL));
segments.push(
Segment::new("╸")
.fg(COLOR_PROGRESS_THUMB)
.attr(Attribute::Bold),
);
}
let track = total_cells.saturating_sub(filled);
if track > 0 {
segments.push(Segment::new("─".repeat(track)).fg(COLOR_PROGRESS_TRACK));
}
segments
}
#[derive(Clone)]
struct Segment {
text: String,
fg: Option<Color>,
attr: Option<Attribute>,
}
impl Segment {
fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
fg: None,
attr: None,
}
}
fn fg(mut self, color: Color) -> Self {
self.fg = Some(color);
self
}
fn attr(mut self, attr: Attribute) -> Self {
self.attr = Some(attr);
self
}
fn width(&self) -> usize {
self.text.width()
}
}
fn write_centered_line(
out: &mut impl Write,
row: u16,
width: u16,
segments: &[Segment],
) -> anyhow::Result<()> {
let total: usize = segments.iter().map(Segment::width).sum();
let col = ((width as usize).saturating_sub(total) / 2) as u16;
queue!(out, cursor::MoveTo(col, row))?;
for segment in segments {
if let Some(color) = segment.fg {
queue!(out, SetForegroundColor(color))?;
}
if let Some(attr) = segment.attr {
queue!(out, SetAttribute(attr))?;
}
queue!(out, Print(&segment.text))?;
if segment.attr.is_some() {
queue!(out, SetAttribute(Attribute::Reset))?;
}
if segment.fg.is_some() {
queue!(out, ResetColor)?;
}
}
Ok(())
}
fn trim_segments_to_width(segments: Vec<Segment>, max: usize) -> Vec<Segment> {
let total: usize = segments.iter().map(Segment::width).sum();
if total <= max {
return segments;
}
let mut remaining = max.saturating_sub(1); let mut trimmed = Vec::with_capacity(segments.len() + 1);
for segment in segments {
if remaining == 0 {
break;
}
let w = segment.width();
if w <= remaining {
remaining -= w;
trimmed.push(segment);
} else {
let head = truncate_to_width(&segment.text, remaining);
remaining = 0;
trimmed.push(Segment {
text: head,
..segment
});
}
}
trimmed.push(Segment::new("…").fg(COLOR_DIM));
trimmed
}
fn truncate_to_width(text: &str, max: usize) -> String {
if text.width() <= max {
return text.to_string();
}
if max <= 1 {
return "…".to_string();
}
let mut accumulated = 0usize;
let mut out = String::new();
for ch in text.chars() {
let w = ch.to_string().width();
if accumulated + w + 1 > max {
break;
}
accumulated += w;
out.push(ch);
}
out.push('…');
out
}
fn state_icon(state: &PlayState) -> &'static str {
match state {
PlayState::Playing => "▶",
PlayState::Paused => "❚❚",
PlayState::Stopped => "■",
PlayState::Unknown => "·",
}
}
fn state_color(state: &PlayState) -> Color {
match state {
PlayState::Playing => COLOR_ACCENT,
PlayState::Paused => COLOR_WARN,
PlayState::Stopped => COLOR_DIM,
PlayState::Unknown => COLOR_DIM,
}
}
fn format_time(value_ms: Option<u64>) -> String {
let Some(value_ms) = value_ms else {
return "--:--".to_string();
};
let total_seconds = value_ms / 1_000;
let minutes = total_seconds / 60;
let seconds = total_seconds % 60;
format!("{minutes:02}:{seconds:02}")
}
#[cfg(test)]
mod tests {
use super::*;
fn request_for(title: &str, id: &str) -> FetchRequest {
let track = rubecula::TrackDescriptor {
title: title.to_string(),
artist: "artist".to_string(),
album: None,
duration_ms: Some(180_000),
source_track_id: Some(id.to_string()),
};
let key = LookupKey::from_track(&track).unwrap();
FetchRequest::new(key, track.source_track_id)
}
#[test]
fn no_args_open_the_live_music_ui() {
for command in ["rubin", "rubecula"] {
let args = Args::parse_from([command]);
assert_eq!(args.mode, Mode::Live);
assert_eq!(args.source, SourceChoice::Music);
assert_eq!(args.poll_ms, 500);
assert_eq!(
args.mediaremote_command,
PathBuf::from("mediaremote-adapter")
);
assert!(args.lrc.is_none());
}
}
#[test]
fn fetch_result_only_applies_to_current_track_key() {
let first_request = request_for("song a", "music:first");
let second_request = request_for("song b", "music:second");
assert!(fetch_matches_current(Some(&first_request), &first_request));
assert!(!fetch_matches_current(
Some(&second_request),
&first_request
));
assert!(!fetch_matches_current(None, &first_request));
}
#[test]
fn fetch_result_rejects_same_key_from_different_source_track() {
let old_request = request_for("song", "music:old");
let new_request = request_for("song", "music:new");
assert!(!fetch_matches_current(Some(&new_request), &old_request));
}
#[test]
fn progress_bar_fills_in_proportion_to_elapsed() {
let segments = build_progress_segments(Some(0), Some(100_000));
let visible: String = segments.iter().map(|s| s.text.clone()).collect();
assert_eq!(visible.width(), PROGRESS_BAR_CELLS as usize);
assert!(visible.contains('─'));
let segments = build_progress_segments(Some(100_000), Some(100_000));
let visible: String = segments.iter().map(|s| s.text.clone()).collect();
assert_eq!(visible.width(), PROGRESS_BAR_CELLS as usize);
assert!(visible.contains('━') || visible.contains('╸'));
let segments = build_progress_segments(Some(50_000), Some(100_000));
let visible: String = segments.iter().map(|s| s.text.clone()).collect();
assert_eq!(visible.width(), PROGRESS_BAR_CELLS as usize);
assert!(visible.contains('━'));
assert!(visible.contains('─'));
assert!(visible.contains('╸'));
}
#[test]
fn progress_bar_handles_missing_duration() {
let segments = build_progress_segments(None, None);
let visible: String = segments.iter().map(|s| s.text.clone()).collect();
assert_eq!(visible.width(), PROGRESS_BAR_CELLS as usize);
}
#[test]
fn truncate_to_width_appends_ellipsis_when_overflow() {
assert_eq!(truncate_to_width("short", 10), "short");
let truncated = truncate_to_width("a much longer line of lyrics", 8);
assert!(truncated.ends_with('…'));
assert!(truncated.width() <= 8);
}
#[test]
fn state_icon_distinguishes_play_states() {
assert_eq!(state_icon(&PlayState::Playing), "▶");
assert_ne!(
state_icon(&PlayState::Paused),
state_icon(&PlayState::Playing)
);
assert_ne!(
state_icon(&PlayState::Stopped),
state_icon(&PlayState::Playing)
);
}
#[test]
fn format_time_handles_missing_value() {
assert_eq!(format_time(None), "--:--");
assert_eq!(format_time(Some(0)), "00:00");
assert_eq!(format_time(Some(75_500)), "01:15");
}
#[test]
fn select_window_centers_on_current() {
let slice = select_window(20, Some(10), 5);
assert_eq!(
slice,
WindowSlice {
start: 8,
end: 13,
top_offset: 0,
}
);
}
#[test]
fn select_window_clamps_near_start() {
let slice = select_window(20, Some(0), 7);
assert_eq!(
slice,
WindowSlice {
start: 0,
end: 7,
top_offset: 0,
}
);
}
#[test]
fn select_window_clamps_near_end() {
let slice = select_window(20, Some(19), 7);
assert_eq!(
slice,
WindowSlice {
start: 13,
end: 20,
top_offset: 0,
}
);
assert!((slice.start..slice.end).contains(&19));
}
#[test]
fn select_window_centers_short_song_vertically() {
let slice = select_window(4, Some(2), 10);
assert_eq!(
slice,
WindowSlice {
start: 0,
end: 4,
top_offset: 3,
}
);
}
#[test]
fn select_window_handles_no_current_before_first_line() {
let slice = select_window(20, None, 5);
assert_eq!(
slice,
WindowSlice {
start: 0,
end: 5,
top_offset: 0,
}
);
}
#[test]
fn select_window_handles_empty_lines_or_zero_capacity() {
assert_eq!(
select_window(0, None, 5),
WindowSlice {
start: 0,
end: 0,
top_offset: 0,
}
);
assert_eq!(
select_window(10, Some(3), 0),
WindowSlice {
start: 0,
end: 0,
top_offset: 0,
}
);
}
#[test]
fn lyric_style_emphasizes_current_and_fades_with_distance() {
let (current_color, current_attr) = lyric_style_for_distance(0);
assert_eq!(current_attr, Some(Attribute::Bold));
assert_eq!(current_color, COLOR_CURRENT_LYRIC);
let (near_color, near_attr) = lyric_style_for_distance(2);
assert_eq!(near_color, Color::Grey);
assert!(near_attr.is_none());
let (far_color, _) = lyric_style_for_distance(10);
assert_eq!(far_color, Color::DarkGrey);
}
#[test]
fn layout_carves_lyric_block_between_header_and_footer() {
let layout = Layout::compute(30);
assert!(layout.title_row < layout.progress_row);
assert!(layout.progress_row < layout.lyric_block_start);
assert!(layout.lyric_block_end <= 30 - 2);
assert!(layout.lyric_capacity() >= 15);
}
#[test]
fn layout_collapses_gracefully_on_tiny_terminals() {
let layout = Layout::compute(4);
assert!(layout.lyric_block_end >= layout.lyric_block_start);
assert!(layout.lyric_capacity() >= 1);
}
}