use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Bar, BarChart, BarGroup, Block, Borders, Gauge, List, ListItem, Paragraph, Tabs}; use ratatui::Frame; use ratatui_image::StatefulImage; use crate::app::{protocol_to_str, App, Focus, LibraryMode, LibraryView, RightPanelView, Tab}; pub fn draw(frame: &mut Frame, app: &mut App) { if app.fullscreen { draw_fullscreen(frame, app); return; } let area = frame.area(); const MIN_WIDTH: u16 = 70; const MIN_HEIGHT: u16 = 18; if area.width < MIN_WIDTH || area.height < MIN_HEIGHT { draw_too_small(frame, area, MIN_WIDTH, MIN_HEIGHT); return; } let root = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), ]) .split(frame.area()); draw_tabs(frame, app, root[0]); let body = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) .split(root[1]); match app.tab { Tab::Library => draw_library(frame, app, body[0]), Tab::Playlists => draw_playlists(frame, app, body[0]), Tab::Queue => draw_queue(frame, app, body[0]), Tab::Radio => draw_radio(frame, app, body[0]), Tab::Stats => draw_stats(frame, app, body[0]), Tab::NewReleases => draw_new_releases(frame, app, body[0]), } draw_now_playing(frame, app, body[1]); draw_status(frame, app, root[2]); if app.focus == Focus::FirstRunPrompt { draw_first_run_prompt(frame); } } /// Небольшой прямоугольник по центру экрана заданного размера (в колонках/строках). fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { let width = width.min(area.width); let height = height.min(area.height); let x = area.x + (area.width.saturating_sub(width)) / 2; let y = area.y + (area.height.saturating_sub(height)) / 2; Rect::new(x, y, width, height) } fn draw_first_run_prompt(frame: &mut Frame) { let area = centered_rect(60, 9, frame.area()); frame.render_widget(ratatui::widgets::Clear, area); let block = Block::default() .borders(Borders::ALL) .title(" Welcome to nsm ") .style(Style::default().bg(Color::Black)); let inner = block.inner(area); frame.render_widget(block, area); let lines = vec![ Line::from(""), Line::from("Remember what you listen to?"), Line::from("This lets you see listening stats later (day/week/month)."), Line::from(""), Line::from(Span::styled( " Y — yes, track it N — no, don't track", Style::default().add_modifier(Modifier::BOLD), )), Line::from(""), Line::from("(You can change this anytime with 'h')"), ]; frame.render_widget( Paragraph::new(lines).alignment(Alignment::Center), inner, ); } fn draw_fullscreen(frame: &mut Frame, app: &mut App) { let root = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(0), Constraint::Length(1)]) .split(frame.area()); let title = match app.right_panel_view { RightPanelView::Cover => " Cover — fullscreen ", RightPanelView::Spectrum => " Spectrum — fullscreen ", }; let block = Block::default().borders(Borders::ALL).title(title); let inner = block.inner(root[0]); frame.render_widget(block, root[0]); match app.right_panel_view { RightPanelView::Cover => { if let Some(protocol) = app.cover_protocol.as_mut() { let image = StatefulImage::new(None); frame.render_stateful_widget(image, inner, protocol); } else { frame.render_widget( Paragraph::new("(no cover)").alignment(Alignment::Center), inner, ); } } RightPanelView::Spectrum => draw_spectrum_bars(frame, &app.spectrum, inner), } let now_playing_line = app .now_playing .as_ref() .map(|np| format!("{} — {}", np.artist, np.title)) .unwrap_or_else(|| "Nothing playing".to_string()); let hint = format!( "{now_playing_line} (v: switch, z/Esc: exit fullscreen, Space: play/pause, N/P: next/prev, ←/→: seek)" ); frame.render_widget(Paragraph::new(hint), root[1]); } fn draw_too_small(frame: &mut Frame, area: Rect, min_width: u16, min_height: u16) { let msg = format!( "Terminal too small ({}x{}).\nResize to at least {}x{}.", area.width, area.height, min_width, min_height ); frame.render_widget( Paragraph::new(msg).alignment(Alignment::Center), area, ); } fn draw_tabs(frame: &mut Frame, app: &App, area: Rect) { let titles = [ Tab::Library, Tab::Playlists, Tab::Queue, Tab::Radio, Tab::Stats, Tab::NewReleases, ] .iter() .map(|t| t.title()) .collect::>(); let selected = match app.tab { Tab::Library => 0, Tab::Playlists => 1, Tab::Queue => 2, Tab::Radio => 3, Tab::Stats => 4, Tab::NewReleases => 5, }; let tabs = Tabs::new(titles) .block(Block::default().borders(Borders::ALL).title(" nsm ")) .select(selected) .highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); frame.render_widget(tabs, area); } fn draw_library(frame: &mut Frame, app: &App, area: Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(3), Constraint::Min(0)]) .split(area); let search_style = if app.focus == Focus::Search { Style::default().fg(Color::Yellow) } else { Style::default() }; let search = Paragraph::new(format!("/{}", app.search_query)) .style(search_style) .block(Block::default().borders(Borders::ALL).title(" Search ")); frame.render_widget(search, chunks[0]); let items: Vec = match &app.library_view { LibraryView::Albums(rows) => { if let Some(album_idx) = app.expanded_album { if let Some(row) = rows.get(album_idx) { row.tracks .iter() .enumerate() .map(|(i, item)| { let name = item.title_override.clone().unwrap_or_else(|| { item.path .file_name() .and_then(|n| n.to_str()) .unwrap_or("?") .to_string() }); ListItem::new(format!(" {:>2}. {}", i + 1, name)) }) .collect() } else { Vec::new() } } else { rows.iter() .map(|r| { ListItem::new(format!( "{} — {} ({} tracks)", r.artist, r.album, r.tracks.len() )) }) .collect() } } LibraryView::Tracks(rows) => rows .iter() .map(|r| ListItem::new(format!("{} — {} [{}]", r.artist, r.title, r.album))) .collect(), LibraryView::Search(rows) => rows .iter() .map(|r| ListItem::new(format!("{} — {} [{}]", r.artist, r.title, r.album))) .collect(), }; let title = if app.expanded_album.is_some() { " Album (Enter: play from here, Esc: back) " } else if !app.search_query.is_empty() { " Search results (Enter: play, Esc: clear search & show all) " } else { match app.library_mode { LibraryMode::Albums => { " Albums (Enter: expand/play, /: search, a: add to playlist, t: all tracks, c: connect SMB, L: add folder, C: clear library) " } LibraryMode::Tracks => { " All Tracks (Enter: play all from here, /: search, a: add to playlist, t: by album) " } } }; let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(title)) .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)) .highlight_symbol("> "); let mut state = ratatui::widgets::ListState::default(); state.select(Some(app.library_selected)); frame.render_stateful_widget(list, chunks[1], &mut state); } fn draw_playlists(frame: &mut Frame, app: &App, area: Rect) { if let Some(pl) = &app.open_playlist { let items: Vec = pl .tracks .iter() .map(|p| { let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("?"); ListItem::new(name.to_string()) }) .collect(); let list = List::new(items) .block( Block::default().borders(Borders::ALL).title(format!( " {} (Enter: play, J/K: move, d: delete, Esc: back) ", pl.name )), ) .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)) .highlight_symbol("> "); let mut state = ratatui::widgets::ListState::default(); state.select(Some(app.playlist_track_selected)); frame.render_stateful_widget(list, area, &mut state); } else { let items: Vec = app .playlist_names .iter() .map(|n| ListItem::new(n.clone())) .collect(); let list = List::new(items) .block( Block::default() .borders(Borders::ALL) .title(" Playlists (Enter: open, n: new) "), ) .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)) .highlight_symbol("> "); let mut state = ratatui::widgets::ListState::default(); state.select(Some(app.playlists_selected)); frame.render_stateful_widget(list, area, &mut state); } } fn draw_queue(frame: &mut Frame, app: &App, area: Rect) { let items: Vec = app .current_queue .iter() .enumerate() .map(|(i, item)| { let name = item.title_override.clone().unwrap_or_else(|| { item.path .file_name() .and_then(|n| n.to_str()) .unwrap_or("?") .to_string() }); let marker = if i == app.queue_selected { "▶ " } else { " " }; ListItem::new(format!("{marker}{name}")) }) .collect(); let list = List::new(items).block(Block::default().borders(Borders::ALL).title(" Queue ")); frame.render_widget(list, area); } fn draw_new_releases(frame: &mut Frame, app: &App, area: Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(2), Constraint::Min(0)]) .split(area); let checked_line = match &app.new_releases_checked_at { Some(t) => format!("Last checked: {}", t.format("%Y-%m-%d %H:%M")), None => "Never checked yet".to_string(), }; let progress_line = if let Some((idx, total, artist)) = &app.new_releases_progress { format!("Checking… {idx}/{total} ({artist})") } else { format!("{checked_line} Period: {} (p: cycle) f: refresh all a: check one artist", app.new_releases_period.label()) }; frame.render_widget( Paragraph::new(vec![ Line::from(Span::styled( "New releases — for artists already in your library", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan), )), Line::from(progress_line), ]), chunks[0], ); let days = match app.new_releases_period { library::history::StatsPeriod::Day => 1, library::history::StatsPeriod::Week => 7, library::history::StatsPeriod::Month => 30, }; let filtered = library::new_releases::filter_within_days(&app.new_releases, days); let items: Vec = if filtered.is_empty() { vec![ListItem::new( "(nothing found for this period — press 'f' to check, or widen the period with 'p')", )] } else { filtered .iter() .map(|r| { ListItem::new(format!( "{} — {} [{}] ({})", r.artist, r.title, r.primary_type, r.release_date.format("%Y-%m-%d") )) }) .collect() }; let list = List::new(items).block( Block::default() .borders(Borders::ALL) .title(format!(" {} new releases ", filtered.len())), ); frame.render_widget(list, chunks[1]); } fn draw_stats(frame: &mut Frame, app: &App, area: Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(1), Constraint::Min(0)]) .split(area); let history_status = if app.settings.history_enabled { "ON" } else { "OFF" }; let reset_hint = if app.reset_armed { " Press 'x' AGAIN to confirm erase!" } else { " x: reset history" }; let header = format!( "History tracking: {history_status} (toggle: h) Period: {} (p: cycle){reset_hint}", app.stats_period.label() ); frame.render_widget( Paragraph::new(header).style(if app.reset_armed { Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) } else { Style::default() }), chunks[0], ); let entries = app.history_store.load_all().unwrap_or_default(); let total = library::history::total_count(&entries); let buckets = library::history::aggregate(&entries, app.stats_period); let recent = library::history::entries_within_period(&entries, app.stats_period); let body = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) .split(chunks[1]); let bars: Vec = buckets .iter() .map(|b| { Bar::default() .value(b.count) .label(Line::from(b.label.clone())) .text_value(b.count.to_string()) }) .collect(); let chart = BarChart::default() .block( Block::default() .borders(Borders::ALL) .title(format!(" Plays by {} — total {} ", app.stats_period.label().to_lowercase(), total)), ) .data(BarGroup::default().bars(&bars)) .bar_width(if app.stats_period == library::history::StatsPeriod::Day { 3 } else { 4 }) .bar_gap(1) .bar_style(Style::default().fg(Color::Green)) .value_style(Style::default().fg(Color::Black).bg(Color::Green)); frame.render_widget(chart, body[0]); let track_items: Vec = if recent.is_empty() { vec![ListItem::new("(nothing played in this period yet)")] } else { recent .iter() .take(200) .map(|e| { ListItem::new(format!( "{} {} — {}", e.played_at.format("%d.%m %H:%M"), e.artist, e.title )) }) .collect() }; let track_list = List::new(track_items).block( Block::default() .borders(Borders::ALL) .title(" What played (newest first) "), ); frame.render_widget(track_list, body[1]); } fn draw_radio(frame: &mut Frame, app: &App, area: Rect) { if app.station_search_in_progress { let msg = Paragraph::new("Searching online (doesn't block the UI — feel free to keep browsing other tabs)…") .alignment(Alignment::Center) .block(Block::default().borders(Borders::ALL).title(" Radio — searching… ")); frame.render_widget(msg, area); return; } if !app.online_results.is_empty() { let items: Vec = app .online_results .iter() .map(|s| ListItem::new(s.name.clone())) .collect(); let list = List::new(items) .block(Block::default().borders(Borders::ALL).title( " Search results (Enter: play, a: add to my stations, Esc: back) ", )) .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)) .highlight_symbol("> "); let mut state = ratatui::widgets::ListState::default(); state.select(Some(app.online_selected)); frame.render_stateful_widget(list, area, &mut state); return; } let items: Vec = app .stations .iter() .map(|s| { let playing = app.current_station.as_deref() == Some(s.url.as_str()); let marker = if playing { "▶ " } else { " " }; ListItem::new(format!("{marker}{}", s.name)) }) .collect(); let list = List::new(items) .block(Block::default().borders(Borders::ALL).title( " Radio (Enter: play, o: search online, u: add by URL, d: remove custom) ", )) .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)) .highlight_symbol("> "); let mut state = ratatui::widgets::ListState::default(); state.select(Some(app.radio_selected)); frame.render_stateful_widget(list, area, &mut state); } /// Компактный рендер спектра для маленькой панели (внутри блока Cover/Spectrum) — /// простые вертикальные бары символами блоков, без BarChart (там подписи не влезли бы /// в такую узкую область). fn draw_spectrum_bars(frame: &mut Frame, spectrum: &[f32], area: Rect) { if area.height == 0 || area.width == 0 || spectrum.is_empty() { return; } let n = spectrum.len(); let col_width = (area.width / n as u16).max(1); let mut lines: Vec = Vec::with_capacity(area.height as usize); for row in 0..area.height { let level_at_row = 1.0 - (row as f32 + 1.0) / area.height as f32; // 0 внизу, 1 наверху let mut spans = Vec::with_capacity(n); for &v in spectrum { let filled = v >= level_at_row; let ch = if filled { "█" } else { " " }; spans.push(Span::raw(ch.repeat(col_width as usize))); } lines.push(Line::from(spans)); } frame.render_widget( Paragraph::new(lines).style(Style::default().fg(Color::Green)), area, ); } fn draw_now_playing(frame: &mut Frame, app: &mut App, area: Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(12), // обложка + инфо Constraint::Length(1), // транспорт (speed/shuffle/repeat) Constraint::Percentage(50), // эквалайзер Constraint::Percentage(50), // танцующие Шумовые Десантники ]) .split(area); let top = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) .split(chunks[0]); let protocol_name = app .picker .as_ref() .map(|p| protocol_to_str(p.protocol_type)) .unwrap_or("none"); let cover_title = match app.right_panel_view { RightPanelView::Cover => format!(" Cover [{protocol_name}] (v/z: view, I: cycle protocol) "), RightPanelView::Spectrum => " Spectrum (v/z: view) ".to_string(), }; let cover_block = Block::default().borders(Borders::ALL).title(cover_title); let cover_inner = cover_block.inner(top[0]); frame.render_widget(cover_block, top[0]); match app.right_panel_view { RightPanelView::Cover => { if let Some(protocol) = app.cover_protocol.as_mut() { let image = StatefulImage::new(None); frame.render_stateful_widget(image, cover_inner, protocol); } else { let placeholder = Paragraph::new("(no cover)").alignment(Alignment::Center); frame.render_widget(placeholder, cover_inner); } } RightPanelView::Spectrum => draw_spectrum_bars(frame, &app.spectrum, cover_inner), } draw_track_info(frame, app, top[1]); draw_transport(frame, app, chunks[1]); draw_eq(frame, app, chunks[2]); draw_noise_marines(frame, app, chunks[3]); } fn draw_track_info(frame: &mut Frame, app: &App, area: Rect) { let block = Block::default().borders(Borders::ALL).title(" Now Playing "); let inner = block.inner(area); frame.render_widget(block, area); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0)]) .split(inner); if let Some(np) = &app.now_playing { frame.render_widget(Paragraph::new(Line::from(Span::styled( np.title.clone(), Style::default().add_modifier(Modifier::BOLD), ))), chunks[0]); frame.render_widget(Paragraph::new(np.artist.clone()), chunks[1]); frame.render_widget(Paragraph::new(np.album.clone()), chunks[2]); if let Some(song) = &np.radio_song { frame.render_widget( Paragraph::new(format!("♪ {song}")).style(Style::default().fg(Color::Cyan)), chunks[3], ); } let total = np.duration.unwrap_or_default().as_secs_f64().max(1.0); let pos = np.position.as_secs_f64().min(total); let ratio = (pos / total).clamp(0.0, 1.0); let label = if np.duration.is_some() { format!("{} / {}", fmt_time(np.position), fmt_time(np.duration.unwrap_or_default())) } else { format!("{} / live", fmt_time(np.position)) }; let gauge = Gauge::default() .gauge_style(Style::default().fg(Color::Green)) .ratio(if np.duration.is_some() { ratio } else { 0.0 }) .label(label); frame.render_widget(gauge, chunks[4]); } else { frame.render_widget(Paragraph::new("Nothing playing"), chunks[0]); } } fn draw_transport(frame: &mut Frame, app: &App, area: Rect) { let repeat_label = match app.repeat { player::RepeatMode::Off => "Off", player::RepeatMode::Track => "Track", player::RepeatMode::All => "All", }; let line = format!( "v/z:view {:.2}x Shuf:{} Rep:{} Mute:{} Fade:{} Norm:{} ←→:seek", app.speed.0, if app.shuffle { "On" } else { "Off" }, repeat_label, if app.muted { "On" } else { "Off" }, if app.fade_out_enabled { "On" } else { "Off" }, if app.loudness_norm_enabled { "On" } else { "Off" } ); frame.render_widget(Paragraph::new(line), area); } /// Пасхалка: Шумовые Десантники (Noise Marines) из Warhammer 40000 — культовая "музыкальная" /// фракция Хаоса, поклоняющаяся Слаанешу через звук и звуковое оружие (Doom Siren/Blastmaster). /// Поза синхронизирована с басом ТЕКУЩЕГО трека через уже существующий спектроанализатор — /// буквально пляшут под музыку, а не просто крутят произвольную анимацию. fn draw_noise_marines(frame: &mut Frame, app: &App, area: Rect) { let block = Block::default() .borders(Borders::ALL) .title(" Noise Marines "); let inner = block.inner(area); frame.render_widget(block, area); // Басовая энергия усредняется по нескольким нижним полосам спектра, а не берётся из // одной-единственной (полоса 0 при размере FFT-окна 1024 — это всего один узкий бин // ~43Гц, слишком шумно и не отражает реальный бас трека). "Удар баса" детектируется // относительно недавнего среднего (bass_ema), а не фиксированным порогом — так реакция // подстраивается под конкретный трек, а не пробивается либо всегда, либо никогда. let n = app.spectrum.len().min(7); let bass_now = if n > 0 { app.spectrum[..n].iter().sum::() / n as f32 } else { 0.0 }; let pose_a = crate::app::is_bass_hit(bass_now, app.bass_ema); // Тело и рог/сирена — в одной строке за раз, как в изначальной, подтверждённо рабочей // версии (была проверена вживую на реальном терминале). Отдельные "гитарные" аксессуары // с другими символами не прижились — рассинхронизировали ширину колонок в разных // шрифтах/терминалах, поэтому вернулись к простому и надёжному. const FRAME_A: [&str; 9] = [ " ▄██▄ ", " ◢█◉◉█◣ ", " █▀▀▀▀█ ", "▟████████▙ ╭─◯ ))) ", " █▓▓▓▓█ ╱ ", " █▓▓▓▓█ ╱ ", " ▐▓▓▓▓▌ ╱ ", " █ █ ", " ▟█▙▟█▙ ", ]; const FRAME_B: [&str; 9] = [ " ▄██▄ ", " ◢█◉◉█◣ ", " █▀▀▀▀█ ", "▟████████▙ ", " █▓▓▓▓█ ╲ ", " █▓▓▓▓█ ╲ ", " ▐▓▓▓▓▌╭─◯ ))) ", " ▟█▙ █ ", " █ ▟█▙ ", ]; let sprite = if pose_a { &FRAME_A } else { &FRAME_B }; const MARINE_WIDTH: usize = 24; let count = ((inner.width as usize) / MARINE_WIDTH).clamp(1, 4); let lines: Vec = sprite .iter() .map(|row| { let mut s = String::new(); for _ in 0..count { let visible_len = row.chars().count(); s.push_str(row); s.push_str(&" ".repeat(MARINE_WIDTH.saturating_sub(visible_len))); } Line::from(s) }) .collect(); frame.render_widget( Paragraph::new(lines) .style(Style::default().fg(Color::Magenta)) .alignment(Alignment::Center), inner, ); } fn draw_eq(frame: &mut Frame, app: &App, area: Rect) { let block = Block::default() .borders(Borders::ALL) .title(" Equalizer (e: focus, ←/→ band, ↑/↓ gain, 0: reset band) "); let inner = block.inner(area); frame.render_widget(block, area); let n = player::EQ_BANDS; let constraints: Vec = (0..n).map(|_| Constraint::Ratio(1, n as u32)).collect(); let cols = Layout::default() .direction(Direction::Horizontal) .constraints(constraints) .split(inner); for band in 0..n { let gain = app.eq_gains[band]; let selected = app.focus == Focus::Eq && app.eq_selected == band; draw_eq_band(frame, cols[band], band, gain, selected); } } fn draw_eq_band(frame: &mut Frame, area: Rect, band: usize, gain: f32, selected: bool) { // Вертикальная полоса: делим высоту на верх (положительный gain) и низ (отрицательный), // с нулевой линией посередине. let rows = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(1), Constraint::Length(1)]) .split(area); let bar_area = rows[0]; let half = bar_area.height as f32 / 2.0; let filled = ((gain.abs() / 12.0) * half).round() as u16; let style = if selected { Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) } else if gain > 0.0 { Style::default().fg(Color::Green) } else if gain < 0.0 { Style::default().fg(Color::Red) } else { Style::default().fg(Color::DarkGray) }; let mut lines: Vec = Vec::new(); let total_rows = bar_area.height; let mid = total_rows / 2; for row in 0..total_rows { let is_mid = row == mid; let filled_here = if gain >= 0.0 { row >= mid.saturating_sub(filled) && row < mid } else { row > mid && row <= mid + filled }; let ch = if is_mid { "─" } else if filled_here { "█" } else { " " }; lines.push(Line::from(Span::styled(ch, style))); } frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), bar_area); let freq_label = App::eq_band_freq_label(band); frame.render_widget( Paragraph::new(freq_label).alignment(Alignment::Center), rows[1], ); let gain_label = format!("{:+.0}", gain); frame.render_widget( Paragraph::new(gain_label) .alignment(Alignment::Center) .style(style), rows[2], ); } fn draw_status(frame: &mut Frame, app: &App, area: Rect) { let text = match app.focus { Focus::Search => format!("SEARCH: {} (Enter: confirm, Esc: cancel)", app.search_query), Focus::NewPlaylist => format!( "NEW PLAYLIST: {} (Enter: create, Esc: cancel)", app.new_playlist_name ), Focus::NewStationName => format!( "NEW STATION NAME: {} (Enter: next, Esc: cancel)", app.new_station_name ), Focus::NewStationUrl => format!( "NEW STATION URL: {} (Enter: save, Esc: cancel)", app.new_station_url ), Focus::StationSearch => format!( "SEARCH STATIONS ONLINE: {} (Enter: search, Esc: cancel)", app.station_query ), Focus::SmbServer => format!( "SMB — SERVER: {} (Enter: next, Esc: cancel)", app.smb_server ), Focus::SmbShare => format!( "SMB — SHARE NAME: {} (Enter: next, Esc: cancel)", app.smb_share ), Focus::SmbUsername => format!( "SMB — USERNAME (empty = anonymous/guest): {} (Enter: next)", app.smb_username ), Focus::SmbPassword => format!( "SMB — PASSWORD (optional): {} (Enter: connect, Esc: cancel)", "*".repeat(app.smb_password.chars().count()) ), Focus::LibraryPathInput => format!( "ADD FOLDER: {} (Enter: scan, Esc: cancel)", app.library_path_input ), Focus::NewReleaseArtistInput => format!( "CHECK ARTIST: {} (Enter: check, Esc: cancel)", app.new_release_artist_input ), _ => app.status_message.clone(), }; frame.render_widget(Paragraph::new(text), area); } fn fmt_time(d: std::time::Duration) -> String { let secs = d.as_secs(); format!("{:02}:{:02}", secs / 60, secs % 60) }