use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use std::borrow::Cow;
use gtm_core::state::PlaybackStatus;
use crate::app::App;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FooterModule {
Playback,
Title,
Volume,
Repeat,
Shuffle,
Progress,
Queue,
KeyAction,
Backend,
System,
Device,
EqPreset,
SleepTimer,
}
impl FooterModule {
#[allow(dead_code)]
pub fn as_str(self) -> &'static str {
match self {
FooterModule::Playback => "Playback",
FooterModule::Title => "Title",
FooterModule::Volume => "Volume",
FooterModule::Repeat => "Repeat",
FooterModule::Shuffle => "Shuffle",
FooterModule::Progress => "Progress",
FooterModule::Queue => "Queue",
FooterModule::KeyAction => "KeyAction",
FooterModule::Backend => "Backend",
FooterModule::System => "System",
FooterModule::Device => "Device",
FooterModule::EqPreset => "EqPreset",
FooterModule::SleepTimer => "SleepTimer",
}
}
pub fn from_str_lossy(s: &str) -> Option<Self> {
Some(match s.trim() {
"Playback" => FooterModule::Playback,
"Title" => FooterModule::Title,
"Volume" => FooterModule::Volume,
"Repeat" => FooterModule::Repeat,
"Shuffle" => FooterModule::Shuffle,
"Progress" => FooterModule::Progress,
"Queue" => FooterModule::Queue,
"KeyAction" => FooterModule::KeyAction,
"Backend" => FooterModule::Backend,
"System" => FooterModule::System,
"Device" => FooterModule::Device,
"EqPreset" => FooterModule::EqPreset,
"SleepTimer" => FooterModule::SleepTimer,
_ => return None,
})
}
}
#[derive(Debug, Clone)]
pub struct FooterPreset {
pub name: Cow<'static, str>,
pub left: Vec<FooterModule>,
pub middle: Vec<FooterModule>,
pub right: Vec<FooterModule>,
}
pub fn presets() -> Vec<FooterPreset> {
vec![
FooterPreset {
name: Cow::Borrowed("Default"),
left: vec![
FooterModule::Playback,
FooterModule::Queue,
FooterModule::Repeat,
FooterModule::Shuffle,
FooterModule::Volume,
FooterModule::EqPreset,
],
middle: vec![FooterModule::KeyAction, FooterModule::SleepTimer],
right: vec![],
},
FooterPreset {
name: Cow::Borrowed("Minimal"),
left: vec![FooterModule::Playback, FooterModule::EqPreset],
middle: vec![FooterModule::KeyAction, FooterModule::SleepTimer],
right: vec![],
},
FooterPreset {
name: Cow::Borrowed("Full"),
left: vec![
FooterModule::Playback,
FooterModule::Title,
FooterModule::Volume,
FooterModule::Repeat,
FooterModule::Shuffle,
FooterModule::EqPreset,
FooterModule::Progress,
],
middle: vec![FooterModule::KeyAction, FooterModule::SleepTimer],
right: vec![],
},
]
}
pub fn user_presets_path() -> std::path::PathBuf {
let config = std::env::var("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
std::path::PathBuf::from(home).join(".config")
});
config.join("gtm").join("footer.toml")
}
#[derive(serde::Deserialize, Default)]
struct UserPresetsFile {
#[serde(default)]
preset: Vec<UserPreset>,
}
#[derive(serde::Deserialize, Default)]
struct UserPreset {
name: String,
#[serde(default)]
left: Vec<String>,
#[serde(default)]
middle: Vec<String>,
#[serde(default)]
right: Vec<String>,
}
fn parse_module_list(names: &[String]) -> Vec<FooterModule> {
names
.iter()
.filter_map(|s| FooterModule::from_str_lossy(s))
.collect()
}
pub fn load_user_presets() -> Vec<FooterPreset> {
let Ok(text) = std::fs::read_to_string(user_presets_path()) else {
return Vec::new();
};
let Ok(parsed) = toml::from_str::<UserPresetsFile>(&text) else {
return Vec::new();
};
parsed
.preset
.into_iter()
.map(|p| FooterPreset {
name: Cow::Owned(p.name),
left: parse_module_list(&p.left),
middle: parse_module_list(&p.middle),
right: parse_module_list(&p.right),
})
.collect()
}
pub fn merged_presets() -> Vec<FooterPreset> {
let mut v = presets();
for up in load_user_presets() {
if let Some(existing) = v.iter_mut().find(|p| p.name == up.name) {
*existing = up;
} else {
v.push(up);
}
}
v
}
#[derive(Clone)]
pub struct FooterGroup {
pub line: Line<'static>,
pub bg: Color,
pub width: u16,
}
#[derive(Clone)]
pub struct FooterRenderOutput {
pub groups: Vec<FooterGroup>,
pub right_bg: Color,
}
#[derive(Default)]
pub struct FooterCache {
pub last: Option<FooterRenderOutput>,
pub suppress_refresh: bool,
}
pub fn render(app: &App) -> Option<FooterRenderOutput> {
let preset = app
.footer_presets
.get(app.footer_preset)
.or_else(|| app.footer_presets.first())?;
let is_playing = app.state.status == PlaybackStatus::Playing;
let status_bg = if is_playing {
app.theme.accent
} else {
app.theme.fg_dim
};
let slot_bg = app.theme.fg_dim;
let slots: [(&[FooterModule], Color); 3] = [
(&preset.left, status_bg),
(&preset.middle, slot_bg),
(&preset.right, slot_bg),
];
let mut out_groups: Vec<FooterGroup> = Vec::new();
for (modules, bg) in &slots {
let parts = render_modules(modules, app);
if parts.is_empty() {
continue;
}
let mut spans: Vec<Span<'static>> = Vec::with_capacity(parts.len() * 2 + 2);
spans.push(Span::raw(" "));
let mut width: u32 = 1;
for (j, (text, color)) in parts.iter().enumerate() {
if j > 0 {
spans.push(Span::raw(" "));
width += 1;
}
let fg = crate::ui::readable_fg(*color, *bg);
let span = Span::styled(text.clone(), Style::default().fg(fg));
width += span.width() as u32;
spans.push(span);
}
spans.push(Span::raw(" "));
width += 1;
out_groups.push(FooterGroup {
line: Line::from(spans),
bg: *bg,
width: width as u16,
});
}
if out_groups.is_empty() {
return None;
}
Some(FooterRenderOutput {
groups: out_groups,
right_bg: app.theme.border,
})
}
pub fn draw(f: &mut Frame, area: Rect, out: &FooterRenderOutput) {
let total: u16 = out.groups.iter().map(|g| g.width).sum();
if total == 0 {
return;
}
let mut constraints: Vec<Constraint> = out
.groups
.iter()
.map(|g| Constraint::Length(g.width))
.collect();
constraints.push(Constraint::Min(0));
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints(constraints)
.split(area);
for (i, group) in out.groups.iter().enumerate() {
if chunks[i].x >= area.x + area.width || chunks[i].width == 0 {
break;
}
f.render_widget(
Paragraph::new(group.line.clone()).style(Style::default().bg(group.bg)),
chunks[i],
);
}
if let Some(trail) = chunks.last() {
if trail.x < area.x + area.width && trail.width > 0 {
f.render_widget(
Paragraph::new("").style(Style::default().bg(out.right_bg)),
*trail,
);
}
}
}
fn module_color(m: FooterModule, theme: &crate::theme::AppTheme) -> Color {
match m {
FooterModule::Playback => theme.accent,
FooterModule::Title => theme.success,
FooterModule::Volume => theme.warning,
FooterModule::Repeat => theme.accent,
FooterModule::Shuffle => theme.warning,
FooterModule::Progress => theme.accent,
FooterModule::Queue => theme.fg_dim,
FooterModule::KeyAction => theme.warning,
FooterModule::Backend => theme.fg_dim,
FooterModule::System => theme.fg_dim,
FooterModule::Device => theme.fg_dim,
FooterModule::EqPreset => theme.warning,
FooterModule::SleepTimer => theme.accent,
}
}
fn render_modules(modules: &[FooterModule], app: &App) -> Vec<(String, Color)> {
let mut parts = Vec::new();
for m in modules {
if let Some(text) = module_text(*m, app) {
if !text.is_empty() {
parts.push((text, module_color(*m, &app.theme)));
}
}
}
parts
}
fn module_text(m: FooterModule, app: &App) -> Option<String> {
match m {
FooterModule::Playback => Some(render_playback(app)),
FooterModule::Title => render_title(app),
FooterModule::Volume => Some(render_volume(app)),
FooterModule::Repeat => render_repeat(app),
FooterModule::Shuffle => render_shuffle(app),
FooterModule::Progress => render_progress(app),
FooterModule::Queue => render_queue(app),
FooterModule::KeyAction => render_keyaction(app),
FooterModule::Backend => Some(render_backend()),
FooterModule::System => Some(render_system()),
FooterModule::Device => render_device(app),
FooterModule::EqPreset => render_eq_preset(app),
FooterModule::SleepTimer => render_sleep_timer(app),
}
}
fn render_playback(app: &App) -> String {
match app.state.status {
PlaybackStatus::Playing => "\u{25b6}".into(),
PlaybackStatus::Paused => "\u{23f8}".into(),
PlaybackStatus::Stopped => "\u{25a0}".into(),
}
}
fn render_title(app: &App) -> Option<String> {
let raw = app
.state
.current_track
.as_ref()
.map_or_else(String::new, |t| {
if t.artist.is_empty() {
t.title.clone()
} else {
format!("{} \u{2013} {}", t.artist, t.title)
}
});
if raw.is_empty() {
return None;
}
const MAX: usize = 30;
let char_count = raw.chars().count();
if char_count > MAX {
let chars: Vec<char> = raw.chars().collect();
let offset = app.footer_title_scroll % char_count;
let s: String = chars.iter().cycle().skip(offset).take(MAX).collect();
Some(format!("{} \u{2026}", s))
} else {
Some(raw)
}
}
fn render_volume(app: &App) -> String {
if app.state.mute {
"MUTE".into()
} else {
format!("{:>3}%", app.state.volume)
}
}
fn render_repeat(app: &App) -> Option<String> {
match app.state.repeat {
gtm_core::state::RepeatMode::Off => None,
gtm_core::state::RepeatMode::One => Some("1".into()),
gtm_core::state::RepeatMode::All => Some("A".into()),
}
}
fn render_shuffle(app: &App) -> Option<String> {
if app.state.shuffle {
Some("S".into())
} else {
None
}
}
fn render_eq_preset(app: &App) -> Option<String> {
if app.state.eq_enabled {
Some(format!("EQ:{}", app.state.eq_preset.label()))
} else {
None
}
}
fn render_sleep_timer(app: &App) -> Option<String> {
if let Some(secs) = app.state.sleep_timer {
let m = secs / 60;
let s = secs % 60;
Some(format!("zzz {}:{:02}", m, s))
} else {
None
}
}
fn render_progress(app: &App) -> Option<String> {
let track = app.state.current_track.as_ref()?;
let pos = app.display_position as u64;
let dur = if app.state.duration > 0.0 {
app.state.duration as u64
} else {
track.duration as u64
};
if dur == 0 {
return None;
}
let ratio = (pos as f64 / dur as f64).clamp(0.0, 1.0);
let time_str = format!("{} / {}", format_duration(pos), format_duration(dur));
let bar_w = 14usize;
let progress = crate::ui::render_progress_variant(ratio, bar_w, app);
Some(format!("{} {}", progress, time_str))
}
fn format_duration(secs: u64) -> String {
let h = secs / 3600;
let m = (secs % 3600) / 60;
let s = secs % 60;
if h > 0 {
format!("{}:{:02}:{:02}", h, m, s)
} else {
format!("{}:{:02}", m, s)
}
}
fn render_queue(app: &App) -> Option<String> {
let len = app.queue_cache.len();
if len == 0 {
return None;
}
let cursor = app.queue_cursor;
let next = if cursor + 1 < len {
format!(
" \u{2192} {}",
app.queue_cache[cursor + 1]
.title
.chars()
.take(20)
.collect::<String>()
)
} else {
String::new()
};
Some(format!("[{}/{}]{}", cursor + 1, len, next))
}
fn render_keyaction(app: &App) -> Option<String> {
if let Some((ref action, expires)) = app.last_action_name {
if std::time::Instant::now() < expires {
return Some(format!("[{}]", action));
}
}
None
}
fn render_backend() -> String {
let rust_ver = option_env!("VERGEN_RUSTC_SEMVER").unwrap_or("?");
let crate_ver = option_env!("CARGO_PKG_VERSION").unwrap_or("?");
format!("rust {} \u{2022} v{}", rust_ver, crate_ver)
}
fn render_system() -> String {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
let cpus = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1);
let mem = read_process_memory_kb();
if let Some(kb) = mem {
if kb > 1024 * 1024 {
format!(
"{} {} \u{2022} {}GB {}CPU",
os,
arch,
kb / (1024 * 1024),
cpus
)
} else {
format!("{} {} \u{2022} {}MB {}CPU", os, arch, kb / 1024, cpus)
}
} else {
format!("{} {} \u{2022} {}CPU", os, arch, cpus)
}
}
fn read_process_memory_kb() -> Option<u64> {
let status = std::fs::read_to_string("/proc/self/status").ok()?;
for line in status.lines() {
if let Some(rest) = line.strip_prefix("VmRSS:") {
let kb: u64 = rest.split_whitespace().next()?.parse().ok()?;
return Some(kb);
}
}
None
}
fn render_device(app: &App) -> Option<String> {
let track_count = app.tracks_cache.len();
if track_count == 0 {
return None;
}
let total_dur: u64 = app.tracks_cache.iter().map(|t| t.duration as u64).sum();
let hours = total_dur / 3600;
let mins = (total_dur % 3600) / 60;
Some(format!(
"{} tracks \u{2022} {}h{}m",
track_count, hours, mins
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn module_round_trip() {
for m in [
FooterModule::Playback,
FooterModule::Title,
FooterModule::Volume,
FooterModule::Repeat,
FooterModule::Shuffle,
FooterModule::Progress,
FooterModule::Queue,
FooterModule::KeyAction,
FooterModule::Backend,
FooterModule::System,
FooterModule::Device,
FooterModule::EqPreset,
FooterModule::SleepTimer,
] {
let s = m.as_str();
assert_eq!(FooterModule::from_str_lossy(s), Some(m));
}
assert!(FooterModule::from_str_lossy("NoSuchModule").is_none());
}
#[test]
fn presets_have_unique_names() {
let all = presets();
let names: Vec<&str> = all.iter().map(|p| p.name.as_ref()).collect();
let mut sorted = names.clone();
sorted.sort();
sorted.dedup();
assert_eq!(sorted.len(), names.len());
}
#[test]
fn parse_module_list_drops_unknowns() {
let names = vec!["Playback".into(), "Bogus".into(), "Volume".into()];
let parsed = parse_module_list(&names);
assert_eq!(parsed, vec![FooterModule::Playback, FooterModule::Volume]);
}
#[test]
fn user_presets_round_trip() {
let toml_text = r#"
[[preset]]
name = "Custom"
left = ["Playback", "Queue"]
middle = ["KeyAction"]
right = ["Volume"]
"#;
let parsed: UserPresetsFile = toml::from_str(toml_text).unwrap();
assert_eq!(parsed.preset.len(), 1);
let preset = &parsed.preset[0];
assert_eq!(preset.name, "Custom");
assert_eq!(preset.left, vec!["Playback", "Queue"]);
let built = parse_module_list(&preset.left);
assert_eq!(built, vec![FooterModule::Playback, FooterModule::Queue]);
}
#[test]
fn render_title_handles_multibyte_without_panic() {
let raw = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; let chars: Vec<char> = raw.chars().collect();
let n = chars.len();
let max = 10;
let mut produced = Vec::new();
for offset in 0..n {
let s: String = chars.iter().cycle().skip(offset).take(max).collect();
produced.push(s);
}
assert_eq!(produced.len(), n);
for s in &produced {
assert_eq!(s.chars().count(), max);
}
}
}