diff --git a/src/app/actions.rs b/src/app/actions.rs index b743fd9..6aa60f6 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1,12 +1,8 @@ //! Application actions and message passing -use crate::subsonic::models::{Album, Artist, Child, Playlist}; - /// Actions that can be sent to the audio backend #[derive(Debug, Clone)] pub enum AudioAction { - /// Play a specific song by URL - Play { url: String, song: Child }, /// Pause playback Pause, /// Resume playback @@ -26,86 +22,3 @@ pub enum AudioAction { /// Set volume (0-100) SetVolume(i32), } - -/// Actions that can be sent to update the UI -#[derive(Debug, Clone)] -pub enum UiAction { - /// Update playback position - UpdatePosition { position: f64, duration: f64 }, - /// Update playback state - UpdatePlaybackState(PlaybackStateUpdate), - /// Update audio properties - UpdateAudioProperties { - sample_rate: Option, - bit_depth: Option, - format: Option, - }, - /// Track ended (EOF from MPV) - TrackEnded, - /// Show notification - Notify { message: String, is_error: bool }, - /// Artists loaded from server - ArtistsLoaded(Vec), - /// Albums loaded for an artist - AlbumsLoaded { - artist_id: String, - albums: Vec, - }, - /// Songs loaded for an album - SongsLoaded { album_id: String, songs: Vec }, - /// Playlists loaded from server - PlaylistsLoaded(Vec), - /// Playlist songs loaded - PlaylistSongsLoaded { - playlist_id: String, - songs: Vec, - }, - /// Server connection test result - ConnectionTestResult { success: bool, message: String }, - /// Force redraw - Redraw, -} - -/// Playback state update -#[derive(Debug, Clone, Copy)] -pub enum PlaybackStateUpdate { - Playing, - Paused, - Stopped, -} - -/// Actions for the Subsonic client -#[derive(Debug, Clone)] -pub enum SubsonicAction { - /// Fetch all artists - FetchArtists, - /// Fetch albums for an artist - FetchAlbums { artist_id: String }, - /// Fetch songs for an album - FetchAlbum { album_id: String }, - /// Fetch all playlists - FetchPlaylists, - /// Fetch songs in a playlist - FetchPlaylist { playlist_id: String }, - /// Test server connection - TestConnection, -} - -/// Queue manipulation actions -#[derive(Debug, Clone)] -pub enum QueueAction { - /// Append songs to queue - Append(Vec), - /// Insert songs after current position - InsertNext(Vec), - /// Clear the queue - Clear, - /// Remove song at index - Remove(usize), - /// Move song from one index to another - Move { from: usize, to: usize }, - /// Shuffle the queue (keeping current song in place) - Shuffle, - /// Play song at queue index - PlayIndex(usize), -} diff --git a/src/app/state.rs b/src/app/state.rs index 6f8de23..5733a60 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -32,16 +32,6 @@ impl Page { } } - pub fn from_index(index: usize) -> Self { - match index { - 0 => Page::Artists, - 1 => Page::Queue, - 2 => Page::Playlists, - 3 => Page::Server, - 4 => Page::Settings, - _ => Page::Artists, - } - } pub fn label(&self) -> &'static str { match self { @@ -205,6 +195,8 @@ pub struct SettingsState { pub theme_index: usize, /// Cava visualizer enabled pub cava_enabled: bool, + /// Cava visualizer height percentage (10-80, step 5) + pub cava_size: u8, } impl Default for SettingsState { @@ -214,6 +206,7 @@ impl Default for SettingsState { themes: vec![ThemeData::default_theme()], theme_index: 0, cava_enabled: false, + cava_size: 40, } } } @@ -269,10 +262,8 @@ pub struct Notification { #[derive(Debug, Clone, Default)] pub struct LayoutAreas { pub header: Rect, - pub cava: Option, pub content: Rect, pub now_playing: Rect, - pub footer: Rect, /// Left pane for dual-pane pages (Artists tree, Playlists list) pub content_left: Option, /// Right pane for dual-pane pages (Songs list) @@ -349,6 +340,7 @@ impl AppState { state.server_state.password = config.password.clone(); // Initialize cava from config state.settings_state.cava_enabled = config.cava; + state.settings_state.cava_size = config.cava_size.clamp(10, 80); state } diff --git a/src/audio/mod.rs b/src/audio/mod.rs index 230a8f0..ef8e16d 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -1,7 +1,4 @@ //! Audio playback module -#![allow(dead_code)] - pub mod mpv; pub mod pipewire; -pub mod queue; diff --git a/src/audio/mpv.rs b/src/audio/mpv.rs index 9bd7311..288900d 100644 --- a/src/audio/mpv.rs +++ b/src/audio/mpv.rs @@ -32,8 +32,9 @@ struct MpvResponse { error: String, } -/// MPV event +/// MPV event (used for deserialization and debug tracing) #[derive(Debug, Deserialize)] +#[allow(dead_code)] // Fields populated by deserialization, read via Debug struct MpvEvent { event: String, #[serde(default)] @@ -42,27 +43,6 @@ struct MpvEvent { data: Option, } -/// Events emitted by MPV -#[derive(Debug, Clone)] -pub enum MpvEvent2 { - /// Track reached end of file - EndFile, - /// Playback paused - Pause, - /// Playback resumed - Unpause, - /// Position changed (time in seconds) - TimePos(f64), - /// Audio properties changed - AudioProperties { - sample_rate: Option, - bit_depth: Option, - format: Option, - }, - /// MPV shut down - Shutdown, -} - /// MPV controller pub struct MpvController { /// Path to the IPC socket @@ -217,13 +197,6 @@ impl MpvController { Ok(()) } - /// Clear the playlist except current track - pub fn playlist_clear(&mut self) -> Result<(), AudioError> { - debug!("Clearing playlist"); - self.send_command(vec![json!("playlist-clear")])?; - Ok(()) - } - /// Remove a specific entry from the playlist by index pub fn playlist_remove(&mut self, index: usize) -> Result<(), AudioError> { debug!("Removing playlist entry {}", index); @@ -307,15 +280,6 @@ impl MpvController { Ok(data.and_then(|v| v.as_f64()).unwrap_or(0.0)) } - /// Get volume (0-100) - pub fn get_volume(&mut self) -> Result { - let data = self.send_command(vec![json!("get_property"), json!("volume")])?; - Ok(data - .and_then(|v| v.as_f64()) - .map(|v| v as i32) - .unwrap_or(100)) - } - /// Set volume (0-100) pub fn set_volume(&mut self, volume: i32) -> Result<(), AudioError> { debug!("Setting volume to {}", volume); @@ -378,24 +342,12 @@ impl MpvController { })) } - /// Get current filename/URL - pub fn get_path(&mut self) -> Result, AudioError> { - let data = self.send_command(vec![json!("get_property"), json!("path")])?; - Ok(data.and_then(|v| v.as_str().map(String::from))) - } - /// Check if anything is loaded pub fn is_idle(&mut self) -> Result { let data = self.send_command(vec![json!("get_property"), json!("idle-active")])?; Ok(data.and_then(|v| v.as_bool()).unwrap_or(true)) } - /// Check if current file has reached EOF - pub fn is_eof(&mut self) -> Result { - let data = self.send_command(vec![json!("get_property"), json!("eof-reached")])?; - Ok(data.and_then(|v| v.as_bool()).unwrap_or(false)) - } - /// Quit MPV pub fn quit(&mut self) -> Result<(), AudioError> { if self.socket.is_some() { @@ -414,11 +366,6 @@ impl MpvController { Ok(()) } - /// Observe a property for changes - pub fn observe_property(&mut self, id: u64, name: &str) -> Result<(), AudioError> { - self.send_command(vec![json!("observe_property"), json!(id), json!(name)])?; - Ok(()) - } } impl Drop for MpvController { diff --git a/src/audio/pipewire.rs b/src/audio/pipewire.rs index ea14273..d1bb863 100644 --- a/src/audio/pipewire.rs +++ b/src/audio/pipewire.rs @@ -5,9 +5,6 @@ use tracing::{debug, error, info}; use crate::error::AudioError; -/// Default audio device ID for PipeWire -const DEFAULT_DEVICE_ID: u32 = 0; - /// PipeWire sample rate controller pub struct PipeWireController { /// Original sample rate before ferrosonic started @@ -133,47 +130,6 @@ impl PipeWireController { Ok(()) } - /// Check if PipeWire is available - pub fn is_available() -> bool { - Command::new("pw-metadata") - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - } - - /// Get the effective sample rate (from pw-metadata or system default) - pub fn get_effective_rate() -> Option { - // Try to get from PipeWire - let output = Command::new("pw-metadata") - .arg("-n") - .arg("settings") - .output() - .ok()?; - - let stdout = String::from_utf8_lossy(&output.stdout); - - // Look for clock.rate or clock.force-rate - for line in stdout.lines() { - if (line.contains("clock.rate") || line.contains("clock.force-rate")) - && line.contains("value:") - { - if let Some(start) = line.find("value:'") { - let rest = &line[start + 7..]; - if let Some(end) = rest.find('\'') { - let rate_str = &rest[..end]; - if let Ok(rate) = rate_str.parse::() { - if rate > 0 { - return Some(rate); - } - } - } - } - } - } - - None - } } impl Default for PipeWireController { @@ -190,13 +146,3 @@ impl Drop for PipeWireController { } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_is_available() { - // This test just checks the function doesn't panic - let _ = PipeWireController::is_available(); - } -} diff --git a/src/audio/queue.rs b/src/audio/queue.rs deleted file mode 100644 index 37bc48a..0000000 --- a/src/audio/queue.rs +++ /dev/null @@ -1,321 +0,0 @@ -//! Play queue management - -use rand::seq::SliceRandom; -use tracing::debug; - -use crate::subsonic::models::Child; -use crate::subsonic::SubsonicClient; - -/// Play queue -#[derive(Debug, Clone, Default)] -pub struct PlayQueue { - /// Songs in the queue - songs: Vec, - /// Current position in the queue (None = stopped) - position: Option, -} - -impl PlayQueue { - /// Create a new empty queue - pub fn new() -> Self { - Self::default() - } - - /// Get the songs in the queue - pub fn songs(&self) -> &[Child] { - &self.songs - } - - /// Get the current position - pub fn position(&self) -> Option { - self.position - } - - /// Get the current song - pub fn current(&self) -> Option<&Child> { - self.position.and_then(|pos| self.songs.get(pos)) - } - - /// Get number of songs in queue - pub fn len(&self) -> usize { - self.songs.len() - } - - /// Check if queue is empty - pub fn is_empty(&self) -> bool { - self.songs.is_empty() - } - - /// Add songs to the end of the queue - pub fn append(&mut self, songs: impl IntoIterator) { - self.songs.extend(songs); - debug!("Queue now has {} songs", self.songs.len()); - } - - /// Insert songs after the current position - pub fn insert_next(&mut self, songs: impl IntoIterator) { - let insert_pos = self.position.map(|p| p + 1).unwrap_or(0); - let new_songs: Vec<_> = songs.into_iter().collect(); - let count = new_songs.len(); - - for (i, song) in new_songs.into_iter().enumerate() { - self.songs.insert(insert_pos + i, song); - } - - debug!("Inserted {} songs at position {}", count, insert_pos); - } - - /// Clear the queue - pub fn clear(&mut self) { - self.songs.clear(); - self.position = None; - debug!("Queue cleared"); - } - - /// Remove song at index - pub fn remove(&mut self, index: usize) -> Option { - if index >= self.songs.len() { - return None; - } - - let song = self.songs.remove(index); - - // Adjust position if needed - if let Some(pos) = self.position { - if index < pos { - self.position = Some(pos - 1); - } else if index == pos { - // Removed current song - if self.songs.is_empty() { - self.position = None; - } else if pos >= self.songs.len() { - self.position = Some(self.songs.len() - 1); - } - } - } - - debug!("Removed song at index {}", index); - Some(song) - } - - /// Move song from one position to another - pub fn move_song(&mut self, from: usize, to: usize) { - if from >= self.songs.len() || to >= self.songs.len() { - return; - } - - let song = self.songs.remove(from); - self.songs.insert(to, song); - - // Adjust position if needed - if let Some(pos) = self.position { - if from == pos { - self.position = Some(to); - } else if from < pos && to >= pos { - self.position = Some(pos - 1); - } else if from > pos && to <= pos { - self.position = Some(pos + 1); - } - } - - debug!("Moved song from {} to {}", from, to); - } - - /// Shuffle the queue, keeping current song in place - pub fn shuffle(&mut self) { - if self.songs.len() <= 1 { - return; - } - - let mut rng = rand::thread_rng(); - - if let Some(pos) = self.position { - // Keep current song, shuffle the rest - let current = self.songs.remove(pos); - - // Shuffle remaining songs - self.songs.shuffle(&mut rng); - - // Put current song at the front - self.songs.insert(0, current); - self.position = Some(0); - } else { - // No current song, shuffle everything - self.songs.shuffle(&mut rng); - } - - debug!("Queue shuffled"); - } - - /// Set current position - pub fn set_position(&mut self, position: Option) { - if let Some(pos) = position { - if pos < self.songs.len() { - self.position = Some(pos); - debug!("Position set to {}", pos); - } - } else { - self.position = None; - debug!("Position cleared"); - } - } - - /// Advance to next song - /// Returns true if there was a next song - pub fn next(&mut self) -> bool { - match self.position { - Some(pos) if pos + 1 < self.songs.len() => { - self.position = Some(pos + 1); - debug!("Advanced to position {}", pos + 1); - true - } - _ => { - self.position = None; - debug!("Reached end of queue"); - false - } - } - } - - /// Go to previous song - /// Returns true if there was a previous song - pub fn previous(&mut self) -> bool { - match self.position { - Some(pos) if pos > 0 => { - self.position = Some(pos - 1); - debug!("Went back to position {}", pos - 1); - true - } - _ => { - if !self.songs.is_empty() { - self.position = Some(0); - } - debug!("At start of queue"); - false - } - } - } - - /// Get stream URL for current song - pub fn current_stream_url(&self, client: &SubsonicClient) -> Option { - self.current() - .and_then(|song| client.get_stream_url(&song.id).ok()) - } - - /// Get stream URL for song at index - pub fn stream_url_at(&self, index: usize, client: &SubsonicClient) -> Option { - self.songs - .get(index) - .and_then(|song| client.get_stream_url(&song.id).ok()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_song(id: &str, title: &str) -> Child { - Child { - id: id.to_string(), - title: title.to_string(), - parent: None, - is_dir: false, - album: None, - artist: None, - track: None, - year: None, - genre: None, - cover_art: None, - size: None, - content_type: None, - suffix: None, - duration: None, - bit_rate: None, - path: None, - disc_number: None, - } - } - - #[test] - fn test_append_and_len() { - let mut queue = PlayQueue::new(); - assert!(queue.is_empty()); - - queue.append(vec![make_song("1", "Song 1"), make_song("2", "Song 2")]); - assert_eq!(queue.len(), 2); - } - - #[test] - fn test_position_and_navigation() { - let mut queue = PlayQueue::new(); - queue.append(vec![ - make_song("1", "Song 1"), - make_song("2", "Song 2"), - make_song("3", "Song 3"), - ]); - - assert!(queue.current().is_none()); - - queue.set_position(Some(0)); - assert_eq!(queue.current().unwrap().id, "1"); - - assert!(queue.next()); - assert_eq!(queue.current().unwrap().id, "2"); - - assert!(queue.next()); - assert_eq!(queue.current().unwrap().id, "3"); - - assert!(!queue.next()); - assert!(queue.current().is_none()); - } - - #[test] - fn test_remove() { - let mut queue = PlayQueue::new(); - queue.append(vec![ - make_song("1", "Song 1"), - make_song("2", "Song 2"), - make_song("3", "Song 3"), - ]); - queue.set_position(Some(1)); - - // Remove song before current - queue.remove(0); - assert_eq!(queue.position(), Some(0)); - assert_eq!(queue.current().unwrap().id, "2"); - - // Remove current song - queue.remove(0); - assert_eq!(queue.current().unwrap().id, "3"); - } - - #[test] - fn test_insert_next() { - let mut queue = PlayQueue::new(); - queue.append(vec![make_song("1", "Song 1"), make_song("3", "Song 3")]); - queue.set_position(Some(0)); - - queue.insert_next(vec![make_song("2", "Song 2")]); - - assert_eq!(queue.songs[0].id, "1"); - assert_eq!(queue.songs[1].id, "2"); - assert_eq!(queue.songs[2].id, "3"); - } - - #[test] - fn test_move_song() { - let mut queue = PlayQueue::new(); - queue.append(vec![ - make_song("1", "Song 1"), - make_song("2", "Song 2"), - make_song("3", "Song 3"), - ]); - queue.set_position(Some(0)); - - queue.move_song(0, 2); - assert_eq!(queue.songs[0].id, "2"); - assert_eq!(queue.songs[1].id, "3"); - assert_eq!(queue.songs[2].id, "1"); - assert_eq!(queue.position(), Some(2)); - } -} diff --git a/src/config/mod.rs b/src/config/mod.rs index 1d7c4f2..7280845 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -30,9 +30,17 @@ pub struct Config { /// Enable cava audio visualizer #[serde(rename = "Cava", default)] pub cava: bool, + + /// Cava visualizer height percentage (10-80, step 5) + #[serde(rename = "CavaSize", default = "Config::default_cava_size")] + pub cava_size: u8, } impl Config { + fn default_cava_size() -> u8 { + 40 + } + /// Create a new empty config pub fn new() -> Self { Self::default() diff --git a/src/mpris/mod.rs b/src/mpris/mod.rs index 6057400..b03cf5b 100644 --- a/src/mpris/mod.rs +++ b/src/mpris/mod.rs @@ -1,5 +1,3 @@ //! MPRIS2 D-Bus integration module -#![allow(dead_code)] - pub mod server; diff --git a/src/subsonic/client.rs b/src/subsonic/client.rs index 229c80b..ddd0235 100644 --- a/src/subsonic/client.rs +++ b/src/subsonic/client.rs @@ -297,77 +297,23 @@ impl SubsonicClient { Ok(url.to_string()) } - /// Parse song ID from a stream URL - /// - /// Useful for session restoration - pub fn parse_song_id_from_url(url: &str) -> Option { - let parsed = Url::parse(url).ok()?; - parsed - .query_pairs() - .find(|(k, _)| k == "id") - .map(|(_, v)| v.to_string()) - } - - /// Get cover art URL for a given cover art ID - pub fn get_cover_art_url(&self, cover_art_id: &str) -> Result { - let (salt, token) = generate_auth_params(&self.password); - let mut url = Url::parse(&format!("{}/rest/getCoverArt", self.base_url))?; - - url.query_pairs_mut() - .append_pair("id", cover_art_id) - .append_pair("u", &self.username) - .append_pair("t", &token) - .append_pair("s", &salt) - .append_pair("v", API_VERSION) - .append_pair("c", CLIENT_NAME); - - Ok(url.to_string()) - } - - /// Search for artists, albums, and songs - pub async fn search( - &self, - query: &str, - ) -> Result<(Vec, Vec, Vec), SubsonicError> { - let url = self.build_url(&format!("search3?query={}", urlencoding::encode(query)))?; - debug!("Searching: {}", query); - - let response = self.http.get(url).send().await?; - let text = response.text().await?; - - let parsed: SubsonicResponse = serde_json::from_str(&text) - .map_err(|e| SubsonicError::Parse(format!("Failed to parse search response: {}", e)))?; - - if parsed.subsonic_response.status != "ok" { - if let Some(error) = parsed.subsonic_response.error { - return Err(SubsonicError::Api { - code: error.code, - message: error.message, - }); - } - } - - let result = parsed - .subsonic_response - .data - .ok_or_else(|| SubsonicError::Parse("Empty search data".to_string()))? - .search_result3; - - debug!( - "Search found {} artists, {} albums, {} songs", - result.artist.len(), - result.album.len(), - result.song.len() - ); - - Ok((result.artist, result.album, result.song)) - } } #[cfg(test)] mod tests { use super::*; + impl SubsonicClient { + /// Parse song ID from a stream URL + fn parse_song_id_from_url(url: &str) -> Option { + let parsed = Url::parse(url).ok()?; + parsed + .query_pairs() + .find(|(k, _)| k == "id") + .map(|(_, v)| v.to_string()) + } + } + #[test] fn test_parse_song_id() { let url = "https://example.com/rest/stream?id=12345&u=user&t=token&s=salt&v=1.16.1&c=test"; diff --git a/src/subsonic/mod.rs b/src/subsonic/mod.rs index 2d3a441..9257fa3 100644 --- a/src/subsonic/mod.rs +++ b/src/subsonic/mod.rs @@ -1,7 +1,5 @@ //! Subsonic API client module -#![allow(dead_code)] - pub mod auth; pub mod client; pub mod models; diff --git a/src/subsonic/models.rs b/src/subsonic/models.rs index f2633e7..1c33c62 100644 --- a/src/subsonic/models.rs +++ b/src/subsonic/models.rs @@ -12,6 +12,7 @@ pub struct SubsonicResponse { #[derive(Debug, Deserialize)] pub struct SubsonicResponseInner { pub status: String, + #[allow(dead_code)] // Present in API response, needed for deserialization pub version: String, #[serde(default)] pub error: Option, @@ -40,6 +41,7 @@ pub struct ArtistsIndex { #[derive(Debug, Deserialize)] pub struct ArtistIndex { + #[allow(dead_code)] // Present in API response, needed for deserialization pub name: String, #[serde(default)] pub artist: Vec, @@ -217,19 +219,3 @@ pub struct PlaylistDetail { #[derive(Debug, Deserialize)] pub struct PingData {} -/// Search result -#[derive(Debug, Deserialize)] -pub struct SearchResult3Data { - #[serde(rename = "searchResult3")] - pub search_result3: SearchResult3, -} - -#[derive(Debug, Deserialize)] -pub struct SearchResult3 { - #[serde(default)] - pub artist: Vec, - #[serde(default)] - pub album: Vec, - #[serde(default)] - pub song: Vec, -} diff --git a/src/ui/layout.rs b/src/ui/layout.rs index 0b0e11e..b5afe47 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -28,7 +28,7 @@ pub fn draw(frame: &mut Frame, state: &mut AppState) { let (header_area, cava_area, content_area, now_playing_area, footer_area) = if cava_active { let chunks = Layout::vertical([ Constraint::Length(1), // Header - Constraint::Percentage(40), // Cava visualizer — top half-ish + Constraint::Percentage(state.settings_state.cava_size as u16), // Cava visualizer Constraint::Min(10), // Page content Constraint::Length(7), // Now playing Constraint::Length(1), // Footer @@ -62,10 +62,8 @@ pub fn draw(frame: &mut Frame, state: &mut AppState) { // Store layout areas for mouse hit-testing state.layout = LayoutAreas { header: header_area, - cava: cava_area, content: content_area, now_playing: now_playing_area, - footer: footer_area, content_left, content_right, }; diff --git a/src/ui/pages/settings.rs b/src/ui/pages/settings.rs index be26315..af1989c 100644 --- a/src/ui/pages/settings.rs +++ b/src/ui/pages/settings.rs @@ -34,6 +34,8 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { Constraint::Length(2), // Theme selector Constraint::Length(1), // Spacing Constraint::Length(2), // Cava toggle + Constraint::Length(1), // Spacing + Constraint::Length(2), // Cava size Constraint::Min(1), // Remaining space ]) .split(inner); @@ -66,11 +68,29 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { &colors, ); + // Cava size (field 2) + let cava_size_value = if !state.cava_available { + "N/A (cava not found)".to_string() + } else { + format!("{}%", settings.cava_size) + }; + + render_option( + frame, + chunks[5], + "Cava Size", + &cava_size_value, + settings.selected_field == 2, + &colors, + ); + // Help text at bottom let help_text = match settings.selected_field { 0 => "← → or Enter to change theme (auto-saves)", 1 if state.cava_available => "← → or Enter to toggle cava visualizer (auto-saves)", 1 => "cava is not installed on this system", + 2 if state.cava_available => "← → to adjust cava size (10%-80%, auto-saves)", + 2 => "cava is not installed on this system", _ => "", }; let help = Paragraph::new(help_text).style(Style::default().fg(colors.muted));