diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d27bef7..21c35e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Added +- **Global customization**: Nearly every part of the UI is now configurable via `config.yml` and the Settings screen. Pick the screen that opens at launch (`startup_route`: home, recently played, podcasts, discover, artists, or saved albums — fetched on startup so it arrives populated) and a default sort order per screen (`default_sort_playlist_tracks` / `_saved_albums` / `_saved_artists` / `_recently_played`, as `field` or `field:desc`). Rearrange the layout with `sidebar_position: left|right|hidden` (hidden auto-reveals while the sidebar has focus) and `playbar_position: bottom|top`, and tune the responsive breakpoints (`small_terminal_width`/`_height`). New icon fields (progress-gauge fill, sort arrows, episode-played marker, active-source dot, list cursor) join the existing ones in a new **Icons** Settings tab, with one-column width validation where table alignment depends on it. The playbar buttons can be relabeled (`behavior.playbar_control_labels`, hitboxes resize automatically), the playbar status line and window title are format templates (`format:` section with `{state}`, `{device}`, `{volume}`, … placeholders), and every table's columns can be reordered, removed, renamed, and resized (`tables:` section). Timing constants are exposed too: status-message duration (`status_message_ttl_percent`), playback poll interval, table scroll padding, and like-animation length. Invalid values never brick the app — structural config errors log a warning and fall back to the built-in default. Documented in `docs/configuration.md` with a full commented example at `examples/config.example.yml`. - **Native cross-source play queue**: Press `z` on any track to add it to a native queue that plays across sources (Spotify, Local Files, Subsonic, and YouTube in one FIFO; internet radio is excluded since a live stream is not a finite track). Queued tracks play before the underlying playlist/album context, then that context resumes where it left off. Open the queue with `Shift+Q`: remove the selected item with `x` (rebindable via `remove_from_queue`, default `x`), reorder it with `J`/`K`, or press `Enter` to skip ahead and play it now. The Queue screen also previews what resumes from the underlying context once the queue drains. The queue survives restarts (persisted in `last_session.yml`). Spotify tracks queue through native (librespot) streaming; when you are controlling an external Spotify Connect device instead, `z` keeps the previous Web-API "add to Spotify's queue" behavior ([#206](https://github.com/LargeModGames/spotatui/issues/206)). - **Terminal (ANSI) theme preset**: A new theme preset built entirely from the terminal's own ANSI palette colors instead of hardcoded RGB, so terminal-theming tools like pywal recolor spotatui live, without a restart ([#336](https://github.com/LargeModGames/spotatui/issues/336)). - **Playlist Folders First setting**: A new `behavior.group_folders_first` toggle (Settings → "Playlist Folders First") lists your Spotify playlist folders at the top of the Playlists tab, above playlists, keeping each group's existing order. Off by default. Handy because folders keep their creation date and otherwise sink to the bottom of the recency-sorted list. diff --git a/README.md b/README.md index a9154586..93eca72e 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,9 @@ See the [Native Streaming Wiki](https://github.com/LargeModGames/spotatui/wiki/N The config file is at `${HOME}/.config/spotatui/config.yml`. You can also configure spotatui in-app by pressing `Alt-,` to open Settings. +Nearly everything is customizable: keybindings, themes, icons, playbar button labels, status-line and window-title format templates, table columns (reorder/rename/resize), default sorting per screen, startup screen, and layout (sidebar/playbar position). Invalid values fall back to defaults with a logged warning — a config typo never blocks startup. + +- Customization guide: [`docs/configuration.md`](docs/configuration.md), with a commented [`examples/config.example.yml`](examples/config.example.yml) - Full config reference: [Configuration Wiki](https://github.com/LargeModGames/spotatui/wiki/Configuration) - Built-in themes (Spotify, Dracula, Nord, …): [Themes Wiki](https://github.com/LargeModGames/spotatui/wiki/Themes) diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 00000000..12965be3 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,222 @@ +# Configuration + +spotatui reads `config.yml` from the app config directory: + +- Linux / macOS: `~/.config/spotatui/config.yml` +- Windows: `C:\Users\\.config\spotatui\config.yml` + +All fields are optional; omitted values use the built-in defaults. A complete, commented example lives in [`examples/config.example.yml`](../examples/config.example.yml). + +Simple values (numbers, toggles, icons, positions) can also be changed live in the in-app **Settings** screen (see the hint in the top-right of the UI). Structured config — `format:` templates, `tables:` columns, and `playbar_control_labels` — is file-only. Edit the file while the app is closed: saving from the Settings screen rewrites the `behavior`, `theme`, and `keybindings` sections, but your `format:`, `tables:`, and `plugin_commands:` sections survive in-app saves untouched. + +## Safe by default + +A typo in `config.yml` never prevents the app from starting. Structural mistakes — an unknown sort field, a bad template placeholder, an invalid column id, an icon that is too wide — are logged as warnings and the affected value falls back to its built-in default. Warnings go to the log file whose path is printed at startup (`/tmp/spotatui_logs/spotatuilog`). + +Only two kinds of errors are fatal: YAML syntax errors (the file cannot be parsed at all) and out-of-range numeric values that predate this policy (`volume_increment` outside 0–100, a zero tick rate, an unparseable `auto_update_delay`). + +## Behavior + +`behavior` controls interaction, timing, icons, and layout. The most commonly customized fields: + +```yaml +behavior: + # Timing + seek_milliseconds: 5000 # seek step for < / > + tick_rate_milliseconds: 250 # UI event-loop cadence + playback_poll_seconds: 5 # how often playback state is polled (min 1; + # near track end the app polls faster regardless) + status_message_ttl_percent: 100 # scales how long status messages stay visible + # (10..=1000; 200 = twice as long) + like_animation_frames: 10 # length of the heart burst when liking a track (min 1) + + # Volume + volume_increment: 1 # step for + / - (0..=100, fatal if outside) + volume_percent: 70 # startup volume + + # Scrolling + table_scroll_padding: 5 # rows kept visible below the selection before + # the table scrolls; clamped to half the table + # height so huge values cannot break scrolling +``` + +## Startup route + +`startup_route` picks which screen opens at launch. Its data is fetched automatically, so the screen arrives populated. Only context-free screens are valid (nothing that needs an album id, artist id, or search query): + +| Value | Screen | Alias | +|---|---|---| +| `home` (default) | Home | | +| `recently_played` | Recently Played | `recent` | +| `podcasts` | Podcasts | | +| `discover` | Discover | | +| `artists` | Followed Artists | `library` | +| `album_list` | Saved Albums | `albums` | + +Unknown values fall back to `home` with a warning. + +## Default sorting + +Each sortable screen can start pre-sorted. The value is `""` for ascending or `":desc"` for descending: + +```yaml +behavior: + default_sort_playlist_tracks: artist + default_sort_saved_albums: date_added:desc + default_sort_saved_artists: name + default_sort_recently_played: name:desc +``` + +Valid fields per screen: + +| Setting | Valid fields | +|---|---| +| `default_sort_playlist_tracks` | `default`, `name`, `date_added`, `artist`, `album`, `duration` | +| `default_sort_saved_albums` | `default`, `name`, `date_added`, `artist` | +| `default_sort_saved_artists` | `default`, `name` | +| `default_sort_recently_played` | `default`, `name`, `artist`, `album` | + +`default` keeps the order the API returns (playlist order, date saved, play order). A field that is not valid for that screen falls back to `default` with a warning. + +## Layout + +```yaml +behavior: + sidebar_position: left # left | right | hidden + playbar_position: bottom # bottom | top + sidebar_width_percent: 20 # 0 hides the sidebar entirely + library_height_percent: 30 + playbar_height_rows: 6 # 0 hides the playbar + small_terminal_width: 150 + small_terminal_height: 45 +``` + +- `sidebar_position: hidden` gives the content the full width, but the sidebar auto-reveals while the Library or Playlists panel has keyboard focus or is hovered, so it never becomes unreachable. +- `small_terminal_width` / `small_terminal_height` are the responsive-layout breakpoints. At or above `small_terminal_width` columns the app uses the wide layout (search box inside the sidebar); below it the search box gets its own full-width top row. `enforce_wide_search_bar: true` forces the full-width search row regardless of width. +- Unknown position strings fall back to the default with a warning. +- Mouse hit-testing follows every arrangement automatically. + +## Icons + +All icons are under `behavior` and can also be edited in the Settings screen. + +```yaml +behavior: + # Free-form (any width) + liked_icon: "♥" + shuffle_icon: "🔀" + repeat_track_icon: "🔂" + repeat_context_icon: "🔁" + paused_icon: "⏸" + active_source_icon: "●" # marks the active source in the device picker + list_highlight_icon: "▶" # cursor prefix in sidebar/menu lists + + # Fixed-cell: must be exactly ONE terminal column wide + playing_icon: "▶" # prefixes the playing row in track tables + gauge_filled_icon: "⣿" # progress/volume gauge fill + gauge_unfilled_icon: "⣉" # progress/volume gauge background + episode_played_icon: "✔" # played marker in the episodes table + sort_ascending_icon: "↑" # sort direction indicator in table headers + sort_descending_icon: "↓" +``` + +The fixed-cell icons sit in columns whose alignment math assumes one cell; a wider glyph is rejected at load and the default is used, with a warning. + +**Ambiguous-width caveat:** some glyphs (e.g. `↑ ↓ ● ▶`) have Unicode "East Asian Ambiguous" width. Terminals configured to render ambiguous-width characters as wide (common with CJK locales) draw them 2 cells wide, shifting column alignment even though the config validates. If that happens, set your terminal's ambiguous-width option to "narrow" or pick unambiguous glyphs. + +## Playbar control labels + +The clickable playbar buttons can be relabeled (config-only). Mouse hitboxes resize to fit the labels automatically. + +```yaml +behavior: + playbar_control_labels: + prev: "⏮" + play_pause: "[PLAY/PAUSE]" + next: "⏭" + shuffle: "shfl" + repeat: "rpt" + like: "♥+" + vol_down: "vol-" + vol_up: "vol+" +``` + +All eight keys are optional; omit a key (or set it to an empty string) to keep that button's built-in label. Unknown keys are skipped with a warning. + +## Format templates + +`format` controls the playbar status line and the terminal window title. Templates use `{key}` placeholders; write a literal brace as `{{` or `}}`. Newlines and tabs are stripped from rendered values. An unknown key or unbalanced brace falls back to the default template with a warning listing the valid keys. + +```yaml +format: + playbar_status: "{state} ({device} | Shuffle: {shuffle} | Repeat: {repeat} | Volume: {volume}%){party}" + playbar_status_source: "{state} ({source}{queue} | Volume: {volume}%)" + window_title: "{title}{artist}" +``` + +The defaults above reproduce the built-in output exactly. + +### `playbar_status` (Spotify playback) and `playbar_status_source` (local/Subsonic/Radio/YouTube playback) + +Both templates accept the same keys; keys that don't apply to the current mode render empty: + +| Key | Renders as | Notes | +|---|---|---| +| `{state}` | `Playing` / `Paused` | padded to 7 characters, matching the default layout | +| `{device}` | Spotify device name | Spotify playback only | +| `{source}` | active source label (e.g. `Local`, `Subsonic`) | source playback only | +| `{queue}` | ` \| 3/12` queue position, or empty | source playback only | +| `{shuffle}` | `On` / `Off` | padded to 3 characters | +| `{repeat}` | `Off` / `Track` / `All` | padded to 5 characters | +| `{volume}` | volume percentage number | append your own `%` | +| `{party}` | ` \| Party: 3 listeners` / ` \| Party: following `, or empty | pre-composed with its own separator | + +### `window_title` + +Applied only when `set_window_title: true`. Valid keys: `{title}` and `{artist}`. `{artist}` comes pre-composed as ` — ` and renders empty when the artist is unknown, so the default `"{title}{artist}"` produces `Song — Artist` or just `Song`. + +## Tables + +`tables` reorders, removes, renames, and resizes the columns of every track/album/podcast table. Omit a table (or the whole section) to keep its built-in columns. + +```yaml +tables: + songs: + - { id: liked } + - { id: title, width_percent: 40 } + - { id: artist, header: "Band", width_percent: 30 } + - { id: album } + - { id: length } +``` + +Each column entry supports: + +| Field | Meaning | +|---|---| +| `id` | required — which column (see valid ids below) | +| `header` | optional display-name override | +| `width_percent` | optional width as a percentage of the table (0–100, exclusive of 0) | +| `width` | optional absolute width in cells (non-zero) | + +Rules, all enforced at load (violations degrade that table to its defaults with a warning): + +- ids must be valid for the table, with no duplicates and at least one column +- a column may set `width_percent` **or** `width`, not both; neither means the built-in default width +- no zero widths, and a table's `width_percent` values may not sum past 100 (trailing columns would be clipped) + +Valid ids and default order per table: + +| Table | Screen | Default columns | All valid ids | +|---|---|---|---| +| `songs` | playlists, liked songs, search results | `liked, title, artist, album, length` | + `index` | +| `album_tracks` | an album's track list | `liked, index, title, artist, length` | + `album` | +| `recently_played` | Recently Played | `liked, title, artist, length` | + `index`, `album` | +| `albums` | Saved Albums | `title, artist, date` | + `liked` | +| `podcasts` | Podcasts | `title, publisher` | — | +| `episodes` | a podcast's episodes | `played, date, title, duration` | — | + +The ▶ now-playing marker attaches to the `title` column, or to the first column if you remove `title`. Sort keyboard shortcuts are unaffected by column layout. + +## Keybindings, theme, and plugins + +The `keybindings:` section rebinds ~40 named actions (`back: q`, `next_track: n`, modifier syntax like `ctrl-s` / `alt-,`), and `theme:` sets a preset plus 18 individual color slots (`"R, G, B"` or named colors) — both are easiest to edit from the in-app Settings screen, which writes them back to this file. `plugin_commands:` maps extra keys to Lua plugin commands. See [`examples/config.example.yml`](../examples/config.example.yml) and the [scripting docs](scripting.md). diff --git a/examples/config.example.yml b/examples/config.example.yml new file mode 100644 index 00000000..d18e4e9c --- /dev/null +++ b/examples/config.example.yml @@ -0,0 +1,114 @@ +# Example spotatui config.yml. Every field is optional — omit anything to keep +# the built-in default. A typo in any of these fields logs a warning and falls +# back to the default; it never prevents the app from starting. +# Full reference: docs/configuration.md + +behavior: + # --- Timing / behavior constants --- + status_message_ttl_percent: 100 # 10..=1000; 200 = status messages last twice as long + playback_poll_seconds: 5 # min 1; playback-state poll interval + table_scroll_padding: 5 # rows kept visible below the selection while scrolling + like_animation_frames: 10 # min 1; length of the heart burst when liking a track + + # --- Startup --- + # home | recently_played | podcasts | discover | artists | album_list + startup_route: home + + # --- Default sorting ("" or ":desc") --- + # playlist_tracks: default | name | date_added | artist | album | duration + # saved_albums: default | name | date_added | artist + # saved_artists: default | name + # recently_played: default | name | artist | album + default_sort_playlist_tracks: default + default_sort_saved_albums: default + default_sort_saved_artists: default + default_sort_recently_played: default + + # --- Layout --- + sidebar_position: left # left | right | hidden (auto-reveals when focused) + playbar_position: bottom # bottom | top + small_terminal_width: 150 # wide-layout breakpoint (columns) + small_terminal_height: 45 # margin breakpoint (rows) + + # --- Icons --- + # Fixed-cell icons (playing/gauge/episode/sort) must be exactly one terminal + # column wide; invalid glyphs fall back to the defaults with a warning. + # Note: some glyphs below (e.g. ↑ ↓ ● ▶) are Unicode "East Asian Ambiguous" + # width — terminals configured to render ambiguous-width characters as wide + # (common with CJK locales) draw them 2 cells wide, which shifts column + # alignment. If that happens, set your terminal's ambiguous width to + # "narrow" or pick unambiguous glyphs. + liked_icon: "♥" + shuffle_icon: "🔀" + playing_icon: "▶" + paused_icon: "⏸" + gauge_filled_icon: "⣿" + gauge_unfilled_icon: "⣉" + active_source_icon: "●" + episode_played_icon: "✔" + sort_ascending_icon: "↑" + sort_descending_icon: "↓" + list_highlight_icon: "▶" + + # --- Playbar button labels (config-only) --- + # All keys optional; omit (or set "") to keep a button's built-in label. + # Mouse hitboxes resize to fit automatically. + # playbar_control_labels: + # prev: "⏮" + # play_pause: "[PLAY/PAUSE]" + # next: "⏭" + # shuffle: "shfl" + # repeat: "rpt" + # like: "♥+" + # vol_down: "vol-" + # vol_up: "vol+" + +# --- Status line & window title templates (config-only) --- +# {key} placeholders, {{ / }} for literal braces. Unknown keys fall back to +# the default template with a warning listing the valid keys. +# playbar keys: state, device, source, queue, shuffle, repeat, volume, party +# window_title keys: title, artist ({artist} includes its own " — " separator +# and renders empty when unknown) +format: + playbar_status: "{state} ({device} | Shuffle: {shuffle} | Repeat: {repeat} | Volume: {volume}%){party}" + playbar_status_source: "{state} ({source}{queue} | Volume: {volume}%)" + window_title: "{title}{artist}" + +# --- Table columns (config-only) --- +# Reorder, remove, rename (header:) and resize (width_percent: or width:, not +# both, no zeros, percents may not sum past 100). Omit a table for defaults. +# Valid ids: +# songs / album_tracks / recently_played: liked, index, title, artist, album, length +# albums: title, artist, date, liked +# podcasts: title, publisher +# episodes: played, date, title, duration +tables: + songs: + - { id: liked } + - { id: title, width_percent: 40 } + - { id: artist, header: "Band", width_percent: 30 } + - { id: album } + - { id: length } + album_tracks: + - { id: liked } + - { id: index } + - { id: title } + - { id: artist } + - { id: length } + albums: + - { id: title } + - { id: artist } + - { id: date } + podcasts: + - { id: title } + - { id: publisher } + episodes: + - { id: played } + - { id: date } + - { id: title } + - { id: duration } + recently_played: + - { id: liked } + - { id: title } + - { id: artist } + - { id: length } diff --git a/src/core/app.rs b/src/core/app.rs index 3d354de6..a1b3388c 100644 --- a/src/core/app.rs +++ b/src/core/app.rs @@ -29,6 +29,7 @@ use std::{ collections::HashSet, time::{Duration, Instant, SystemTime}, }; +use unicode_width::UnicodeWidthStr; use arboard::Clipboard; #[cfg(feature = "streaming")] @@ -327,6 +328,47 @@ pub enum RouteId { LocalBrowser, } +impl RouteId { + /// Routes that can be shown at startup with no extra context (no album/artist + /// id, search query, etc.). These are the only routes `startup_route` may + /// select. + pub const STARTUP_OPTIONS: &'static [RouteId] = &[ + RouteId::Home, + RouteId::RecentlyPlayed, + RouteId::Podcasts, + RouteId::Discover, + RouteId::Artists, + RouteId::AlbumList, + ]; + + /// Parse a `startup_route` config token. Unknown / non-context-free strings + /// return `None` (the caller logs a warning and falls back to Home). + pub fn from_config_str(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "home" => Some(RouteId::Home), + "recently_played" | "recent" => Some(RouteId::RecentlyPlayed), + "podcasts" => Some(RouteId::Podcasts), + "discover" => Some(RouteId::Discover), + "artists" | "library" => Some(RouteId::Artists), + "album_list" | "albums" => Some(RouteId::AlbumList), + _ => None, + } + } + + /// The config-file token for this route (inverse of `from_config_str`). + pub fn to_config_str(&self) -> &'static str { + match self { + RouteId::Home => "home", + RouteId::RecentlyPlayed => "recently_played", + RouteId::Podcasts => "podcasts", + RouteId::Discover => "discover", + RouteId::Artists => "artists", + RouteId::AlbumList => "album_list", + _ => "home", + } + } +} + // ── Friends feature ─────────────────────────────────────────────────────────── #[derive(Clone, Debug)] @@ -720,6 +762,7 @@ pub enum CreatePlaylistFocus { pub enum SettingsCategory { #[default] Behavior, + Icons, Keybindings, Theme, } @@ -728,6 +771,7 @@ impl SettingsCategory { pub fn all() -> &'static [SettingsCategory] { &[ SettingsCategory::Behavior, + SettingsCategory::Icons, SettingsCategory::Keybindings, SettingsCategory::Theme, ] @@ -736,6 +780,7 @@ impl SettingsCategory { pub fn name(&self) -> &'static str { match self { SettingsCategory::Behavior => "Behavior", + SettingsCategory::Icons => "Icons", SettingsCategory::Keybindings => "Keybindings", SettingsCategory::Theme => "Theme", } @@ -744,16 +789,18 @@ impl SettingsCategory { pub fn index(&self) -> usize { match self { SettingsCategory::Behavior => 0, - SettingsCategory::Keybindings => 1, - SettingsCategory::Theme => 2, + SettingsCategory::Icons => 1, + SettingsCategory::Keybindings => 2, + SettingsCategory::Theme => 3, } } pub fn from_index(index: usize) -> Self { match index { 0 => SettingsCategory::Behavior, - 1 => SettingsCategory::Keybindings, - 2 => SettingsCategory::Theme, + 1 => SettingsCategory::Icons, + 2 => SettingsCategory::Keybindings, + 3 => SettingsCategory::Theme, _ => SettingsCategory::Behavior, } } @@ -772,6 +819,49 @@ pub enum SettingValue { Cycle(String, &'static [&'static str]), } +const STARTUP_ROUTE_SETTING_OPTIONS: &[&str] = &[ + "home", + "recently_played", + "podcasts", + "discover", + "artists", + "album_list", +]; +const PLAYLIST_TRACK_SORT_SETTING_OPTIONS: &[&str] = &[ + "default", + "name", + "name:desc", + "date_added", + "date_added:desc", + "artist", + "artist:desc", + "album", + "album:desc", + "duration", + "duration:desc", +]; +const SAVED_ALBUM_SORT_SETTING_OPTIONS: &[&str] = &[ + "default", + "name", + "name:desc", + "date_added", + "date_added:desc", + "artist", + "artist:desc", +]; +const SAVED_ARTIST_SORT_SETTING_OPTIONS: &[&str] = &["default", "name", "name:desc"]; +const RECENTLY_PLAYED_SORT_SETTING_OPTIONS: &[&str] = &[ + "default", + "name", + "name:desc", + "artist", + "artist:desc", + "album", + "album:desc", +]; +const SIDEBAR_POSITION_SETTING_OPTIONS: &[&str] = &["left", "right", "hidden"]; +const PLAYBAR_POSITION_SETTING_OPTIONS: &[&str] = &["bottom", "top"]; + impl SettingValue { #[allow(dead_code)] pub fn display(&self) -> String { @@ -1034,6 +1124,7 @@ pub struct App { pub playlist_sort: SortState, pub album_sort: SortState, pub artist_sort: SortState, + pub recently_played_sort: SortState, /// Animation frame counter for the "Liked" heart flash effect (0-10) pub liked_song_animation_frame: Option, /// Global animation tick counter, incremented every tick. @@ -1332,6 +1423,7 @@ impl Default for App { playlist_sort: SortState::new(), album_sort: SortState::new(), artist_sort: SortState::new(), + recently_played_sort: SortState::new(), liked_song_animation_frame: None, animation_tick: 0, last_party_sync_at: Instant::now(), @@ -1419,6 +1511,50 @@ impl App { // Read the persisted active source before moving user_config into the struct, // so the restored value overrides the Source::default() set by App::default(). let active_source = user_config.behavior.active_source; + // Resolve configurable per-context default sort states. Config validation + // already rejected invalid specs at load time, so parse failure here is a + // defensive fallback to the built-in default sort. + let parse_sort = |spec: &str, ctx: SortContext| -> SortState { + SortState::parse(spec, ctx).unwrap_or_default() + }; + let playlist_sort = parse_sort( + &user_config.behavior.default_sort_playlist_tracks, + SortContext::PlaylistTracks, + ); + let album_sort = parse_sort( + &user_config.behavior.default_sort_saved_albums, + SortContext::SavedAlbums, + ); + let artist_sort = parse_sort( + &user_config.behavior.default_sort_saved_artists, + SortContext::SavedArtists, + ); + let recently_played_sort = parse_sort( + &user_config.behavior.default_sort_recently_played, + SortContext::RecentlyPlayed, + ); + // Resolve the configurable startup route. Unknown / non-context-free values + // degrade to Home + warn (precedent: StartupBehavior::from_name). + let startup_route_id = match RouteId::from_config_str(&user_config.behavior.startup_route) { + Some(id) => id, + None => { + log::warn!( + "[config] startup_route '{}' is not a valid context-free route (valid: {}); using Home", + user_config.behavior.startup_route, + RouteId::STARTUP_OPTIONS + .iter() + .map(|r| r.to_config_str()) + .collect::>() + .join(", ") + ); + RouteId::Home + } + }; + let startup_route = Route { + id: startup_route_id, + active_block: ActiveBlock::Empty, + hovered_block: ActiveBlock::Library, + }; App { io_tx: Some(io_tx), user_config, @@ -1427,10 +1563,43 @@ impl App { spotify_connected: spotify_token_expiry.is_some(), spotify_token_expiry, active_source, + navigation_stack: vec![startup_route], + playlist_sort, + album_sort, + artist_sort, + recently_played_sort, ..App::default() } } + /// Sort the recently-played track list in place per `recently_played_sort`. + /// `Default` keeps the API's play order (a re-fetch restores it). + pub fn sort_recently_played_items(&mut self) { + let sort_state = self.recently_played_sort; + if sort_state.field == SortField::Default { + return; + } + if let Some(page) = self.recently_played.result.as_mut() { + page.items.sort_by(|a, b| { + let order = match sort_state.field { + SortField::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + SortField::Artist => { + let artist_a = a.artists.first().map(|s| s.to_lowercase()); + let artist_b = b.artists.first().map(|s| s.to_lowercase()); + artist_a.cmp(&artist_b) + } + SortField::Album => a.album.to_lowercase().cmp(&b.album.to_lowercase()), + _ => std::cmp::Ordering::Equal, + }; + if sort_state.order == SortOrder::Descending { + order.reverse() + } else { + order + } + }); + } + } + // Send a network event to the network thread /// Clone the IoEvent sender so a spawned task (e.g. the in-TUI Spotify login /// callback server) can dispatch events back into the pump without holding the @@ -1774,7 +1943,8 @@ impl App { } } self.status_message = Some(message.into()); - self.status_message_expires_at = Some(Instant::now() + Duration::from_secs(ttl_secs)); + let ttl = self.scaled_status_ttl(ttl_secs); + self.status_message_expires_at = Some(Instant::now() + Duration::from_secs(ttl)); self.status_message_is_error = false; } @@ -1789,10 +1959,20 @@ impl App { #[cfg_attr(not(feature = "scripting"), allow(dead_code))] pub fn set_error_status_message(&mut self, message: impl Into, ttl_secs: u64) { self.status_message = Some(message.into()); - self.status_message_expires_at = Some(Instant::now() + Duration::from_secs(ttl_secs)); + let ttl = self.scaled_status_ttl(ttl_secs); + self.status_message_expires_at = Some(Instant::now() + Duration::from_secs(ttl)); self.status_message_is_error = true; } + /// Scale a status-message TTL by `status_message_ttl_percent` (default 100 + /// == 1.0×). Applied here at the single sink so the ~66 call sites keep + /// their relative per-severity TTLs. + fn scaled_status_ttl(&self, ttl_secs: u64) -> u64 { + let pct = self.user_config.behavior.status_message_ttl_percent as u64; + // round to nearest, never zero. + ((ttl_secs * pct + 50) / 100).max(1) + } + #[cfg(feature = "streaming")] pub fn request_native_streaming_recovery_if_disconnected( &mut self, @@ -2017,10 +2197,12 @@ impl App { } // Poll interval depends on playback mode: - // - Native streaming: 5 seconds (real-time events provide updates between polls) - // - External players (spotifyd, etc.): 1 second (no events, need faster polling for smooth playbar) - let poll_interval_ms = if self.is_streaming_active { - 5_000 + // - Native streaming: configurable (default 5s; real-time events provide + // updates between polls). + // - External players (spotifyd, etc.): 1 second (no events, need faster + // polling for smooth playbar) — stays hardcoded, not a preference. + let poll_interval_ms: u128 = if self.is_streaming_active { + self.user_config.behavior.playback_poll_seconds as u128 * 1000 } else { 1_000 }; @@ -4559,6 +4741,30 @@ impl App { self.user_config.behavior.animation_tick_rate_milliseconds as i64, ), }, + SettingItem { + id: "behavior.status_message_ttl_percent".to_string(), + name: "Status TTL Percent".to_string(), + description: "Scale status message duration from 10% to 1000%".to_string(), + value: SettingValue::Number(self.user_config.behavior.status_message_ttl_percent as i64), + }, + SettingItem { + id: "behavior.playback_poll_seconds".to_string(), + name: "Playback Poll Seconds".to_string(), + description: "Seconds between regular playback refreshes".to_string(), + value: SettingValue::Number(self.user_config.behavior.playback_poll_seconds as i64), + }, + SettingItem { + id: "behavior.table_scroll_padding".to_string(), + name: "Table Scroll Padding".to_string(), + description: "Rows reserved while scrolling tables".to_string(), + value: SettingValue::Number(self.user_config.behavior.table_scroll_padding as i64), + }, + SettingItem { + id: "behavior.like_animation_frames".to_string(), + name: "Like Animation Frames".to_string(), + description: "Frames used by the playbar like animation".to_string(), + value: SettingValue::Number(self.user_config.behavior.like_animation_frames as i64), + }, SettingItem { id: "behavior.enable_text_emphasis".to_string(), name: "Text Emphasis".to_string(), @@ -4635,6 +4841,81 @@ impl App { crate::core::user_config::StartupBehavior::options(), ), }, + SettingItem { + id: "behavior.startup_route".to_string(), + name: "Startup Route".to_string(), + description: "Screen shown when spotatui starts".to_string(), + value: SettingValue::Cycle( + self.user_config.behavior.startup_route.clone(), + STARTUP_ROUTE_SETTING_OPTIONS, + ), + }, + SettingItem { + id: "behavior.default_sort_playlist_tracks".to_string(), + name: "Playlist Track Sort".to_string(), + description: "Default sort for playlist track tables".to_string(), + value: SettingValue::Cycle( + self.user_config.behavior.default_sort_playlist_tracks.clone(), + PLAYLIST_TRACK_SORT_SETTING_OPTIONS, + ), + }, + SettingItem { + id: "behavior.default_sort_saved_albums".to_string(), + name: "Saved Album Sort".to_string(), + description: "Default sort for saved albums".to_string(), + value: SettingValue::Cycle( + self.user_config.behavior.default_sort_saved_albums.clone(), + SAVED_ALBUM_SORT_SETTING_OPTIONS, + ), + }, + SettingItem { + id: "behavior.default_sort_saved_artists".to_string(), + name: "Saved Artist Sort".to_string(), + description: "Default sort for saved artists".to_string(), + value: SettingValue::Cycle( + self.user_config.behavior.default_sort_saved_artists.clone(), + SAVED_ARTIST_SORT_SETTING_OPTIONS, + ), + }, + SettingItem { + id: "behavior.default_sort_recently_played".to_string(), + name: "Recently Played Sort".to_string(), + description: "Default sort for recently played tracks".to_string(), + value: SettingValue::Cycle( + self.user_config.behavior.default_sort_recently_played.clone(), + RECENTLY_PLAYED_SORT_SETTING_OPTIONS, + ), + }, + SettingItem { + id: "behavior.sidebar_position".to_string(), + name: "Sidebar Position".to_string(), + description: "Place the sidebar left, right, or hide it".to_string(), + value: SettingValue::Cycle( + self.user_config.behavior.sidebar_position.clone(), + SIDEBAR_POSITION_SETTING_OPTIONS, + ), + }, + SettingItem { + id: "behavior.playbar_position".to_string(), + name: "Playbar Position".to_string(), + description: "Place the playbar at the bottom or top".to_string(), + value: SettingValue::Cycle( + self.user_config.behavior.playbar_position.clone(), + PLAYBAR_POSITION_SETTING_OPTIONS, + ), + }, + SettingItem { + id: "behavior.small_terminal_width".to_string(), + name: "Small Terminal Width".to_string(), + description: "Width below which compact layout is used".to_string(), + value: SettingValue::Number(self.user_config.behavior.small_terminal_width as i64), + }, + SettingItem { + id: "behavior.small_terminal_height".to_string(), + name: "Small Terminal Height".to_string(), + description: "Height below which compact margins are used".to_string(), + value: SettingValue::Number(self.user_config.behavior.small_terminal_height as i64), + }, SettingItem { id: "behavior.enable_announcements".to_string(), name: "Remote Announcements".to_string(), @@ -4681,6 +4962,31 @@ impl App { .unwrap_or_default(), ), }, + #[cfg(feature = "cover-art")] + SettingItem { + id: "behavior.draw_cover_art".to_string(), + name: "Draw Cover Art".to_string(), + description: "Enable rendering song/episode cover art".to_string(), + value: SettingValue::Bool(self.user_config.behavior.draw_cover_art), + }, + #[cfg(feature = "cover-art")] + SettingItem { + id: "behavior.draw_cover_art_forced".to_string(), + name: "Force Draw Cover Art".to_string(), + description: "Force rendering of cover art despite terminal support".to_string(), + value: SettingValue::Bool(self.user_config.behavior.draw_cover_art_forced), + }, + #[cfg(feature = "cover-art")] + SettingItem { + id: "behavior.playbar_cover_art_size_percent".to_string(), + name: "Cover Art Size".to_string(), + description: "Playbar cover art size as a percentage (25-200)".to_string(), + value: SettingValue::Number( + self.user_config.behavior.playbar_cover_art_size_percent as i64, + ), + }, + ], + SettingsCategory::Icons => vec![ SettingItem { id: "behavior.liked_icon".to_string(), name: "Liked Icon".to_string(), @@ -4705,28 +5011,47 @@ impl App { description: "Icon for paused state".to_string(), value: SettingValue::String(self.user_config.behavior.paused_icon.clone()), }, - #[cfg(feature = "cover-art")] SettingItem { - id: "behavior.draw_cover_art".to_string(), - name: "Draw Cover Art".to_string(), - description: "Enable rendering song/episode cover art".to_string(), - value: SettingValue::Bool(self.user_config.behavior.draw_cover_art), + id: "behavior.gauge_filled_icon".to_string(), + name: "Gauge Filled Icon".to_string(), + description: "Single-cell icon for filled gauge segments".to_string(), + value: SettingValue::String(self.user_config.behavior.gauge_filled_icon.clone()), }, - #[cfg(feature = "cover-art")] SettingItem { - id: "behavior.draw_cover_art_forced".to_string(), - name: "Force Draw Cover Art".to_string(), - description: "Force rendering of cover art despite terminal support".to_string(), - value: SettingValue::Bool(self.user_config.behavior.draw_cover_art_forced), + id: "behavior.gauge_unfilled_icon".to_string(), + name: "Gauge Empty Icon".to_string(), + description: "Single-cell icon for empty gauge segments".to_string(), + value: SettingValue::String(self.user_config.behavior.gauge_unfilled_icon.clone()), }, - #[cfg(feature = "cover-art")] SettingItem { - id: "behavior.playbar_cover_art_size_percent".to_string(), - name: "Cover Art Size".to_string(), - description: "Playbar cover art size as a percentage (25-200)".to_string(), - value: SettingValue::Number( - self.user_config.behavior.playbar_cover_art_size_percent as i64, - ), + id: "behavior.active_source_icon".to_string(), + name: "Active Source Icon".to_string(), + description: "Icon for the active playback source".to_string(), + value: SettingValue::String(self.user_config.behavior.active_source_icon.clone()), + }, + SettingItem { + id: "behavior.episode_played_icon".to_string(), + name: "Episode Played Icon".to_string(), + description: "Single-cell icon for fully played episodes".to_string(), + value: SettingValue::String(self.user_config.behavior.episode_played_icon.clone()), + }, + SettingItem { + id: "behavior.sort_ascending_icon".to_string(), + name: "Sort Ascending Icon".to_string(), + description: "Single-cell icon for ascending sort".to_string(), + value: SettingValue::String(self.user_config.behavior.sort_ascending_icon.clone()), + }, + SettingItem { + id: "behavior.sort_descending_icon".to_string(), + name: "Sort Descending Icon".to_string(), + description: "Single-cell icon for descending sort".to_string(), + value: SettingValue::String(self.user_config.behavior.sort_descending_icon.clone()), + }, + SettingItem { + id: "behavior.list_highlight_icon".to_string(), + name: "List Highlight Icon".to_string(), + description: "Icon shown next to highlighted list rows".to_string(), + value: SettingValue::String(self.user_config.behavior.list_highlight_icon.clone()), }, ], SettingsCategory::Keybindings => vec![ @@ -5066,6 +5391,7 @@ impl App { pub fn apply_settings_changes(&mut self) { use crate::core::user_config::{parse_theme_item, ThemePreset}; + let mut settings_error: Option = None; for setting in &self.settings_items { match setting.id.as_str() { // Behavior settings @@ -5090,6 +5416,26 @@ impl App { normalize_tick_rate_milliseconds(*v); } } + "behavior.status_message_ttl_percent" => { + if let SettingValue::Number(v) = &setting.value { + self.user_config.behavior.status_message_ttl_percent = (*v).clamp(10, 1000) as u16; + } + } + "behavior.playback_poll_seconds" => { + if let SettingValue::Number(v) = &setting.value { + self.user_config.behavior.playback_poll_seconds = (*v).max(1) as u64; + } + } + "behavior.table_scroll_padding" => { + if let SettingValue::Number(v) = &setting.value { + self.user_config.behavior.table_scroll_padding = (*v).max(0) as u16; + } + } + "behavior.like_animation_frames" => { + if let SettingValue::Number(v) = &setting.value { + self.user_config.behavior.like_animation_frames = (*v).max(1) as u8; + } + } "behavior.enable_text_emphasis" => { if let SettingValue::Bool(v) = &setting.value { self.user_config.behavior.enable_text_emphasis = *v; @@ -5136,6 +5482,51 @@ impl App { crate::core::user_config::StartupBehavior::from_name(v); } } + "behavior.startup_route" => { + if let SettingValue::Cycle(v, _) = &setting.value { + self.user_config.behavior.startup_route = v.clone(); + } + } + "behavior.default_sort_playlist_tracks" => { + if let SettingValue::Cycle(v, _) = &setting.value { + self.user_config.behavior.default_sort_playlist_tracks = v.clone(); + } + } + "behavior.default_sort_saved_albums" => { + if let SettingValue::Cycle(v, _) = &setting.value { + self.user_config.behavior.default_sort_saved_albums = v.clone(); + } + } + "behavior.default_sort_saved_artists" => { + if let SettingValue::Cycle(v, _) = &setting.value { + self.user_config.behavior.default_sort_saved_artists = v.clone(); + } + } + "behavior.default_sort_recently_played" => { + if let SettingValue::Cycle(v, _) = &setting.value { + self.user_config.behavior.default_sort_recently_played = v.clone(); + } + } + "behavior.sidebar_position" => { + if let SettingValue::Cycle(v, _) = &setting.value { + self.user_config.behavior.sidebar_position = v.clone(); + } + } + "behavior.playbar_position" => { + if let SettingValue::Cycle(v, _) = &setting.value { + self.user_config.behavior.playbar_position = v.clone(); + } + } + "behavior.small_terminal_width" => { + if let SettingValue::Number(v) = &setting.value { + self.user_config.behavior.small_terminal_width = (*v).max(1) as u16; + } + } + "behavior.small_terminal_height" => { + if let SettingValue::Number(v) = &setting.value { + self.user_config.behavior.small_terminal_height = (*v).max(1) as u16; + } + } "behavior.keepawake_enabled" => { if let SettingValue::Bool(v) = &setting.value { self.user_config.behavior.keepawake_enabled = *v; @@ -5203,6 +5594,49 @@ impl App { self.user_config.behavior.paused_icon = v.clone(); } } + "behavior.gauge_filled_icon" + | "behavior.gauge_unfilled_icon" + | "behavior.episode_played_icon" + | "behavior.sort_ascending_icon" + | "behavior.sort_descending_icon" => { + if let SettingValue::String(v) = &setting.value { + if UnicodeWidthStr::width(v.as_str()) == 1 { + match setting.id.as_str() { + "behavior.gauge_filled_icon" => { + self.user_config.behavior.gauge_filled_icon = v.clone() + } + "behavior.gauge_unfilled_icon" => { + self.user_config.behavior.gauge_unfilled_icon = v.clone() + } + "behavior.episode_played_icon" => { + self.user_config.behavior.episode_played_icon = v.clone() + } + "behavior.sort_ascending_icon" => { + self.user_config.behavior.sort_ascending_icon = v.clone() + } + "behavior.sort_descending_icon" => { + self.user_config.behavior.sort_descending_icon = v.clone() + } + _ => {} + } + } else { + settings_error = Some(format!( + "{} must be exactly one terminal cell wide", + setting.name + )); + } + } + } + "behavior.active_source_icon" => { + if let SettingValue::String(v) = &setting.value { + self.user_config.behavior.active_source_icon = v.clone(); + } + } + "behavior.list_highlight_icon" => { + if let SettingValue::String(v) = &setting.value { + self.user_config.behavior.list_highlight_icon = v.clone(); + } + } #[cfg(feature = "cover-art")] "behavior.draw_cover_art" => { if let SettingValue::Bool(v) = setting.value { @@ -5621,6 +6055,9 @@ impl App { _ => {} } } + if let Some(message) = settings_error { + self.set_status_message(message, 4); + } } /// Updates the colour RGB entries when switching through the presets in themes @@ -5727,6 +6164,41 @@ mod tests { assert!(rx.try_recv().is_err()); } + #[test] + fn default_sort_recently_played_seeds_state_and_sorts_items() { + let (tx, _rx) = channel(); + let mut config = UserConfig::new(); + config.behavior.default_sort_recently_played = "name:desc".to_string(); + let mut app = App::new(tx, config, Some(SystemTime::now())); + assert_eq!(app.recently_played_sort.field, SortField::Name); + assert_eq!(app.recently_played_sort.order, SortOrder::Descending); + + app.recently_played.result = Some(crate::core::pagination::CursorPaged { + items: vec![ + queue_track(None, "Alpha"), + queue_track(None, "Charlie"), + queue_track(None, "Bravo"), + ], + limit: 3, + next: None, + cursor_after: None, + total: None, + }); + + app.sort_recently_played_items(); + + let names: Vec<_> = app + .recently_played + .result + .as_ref() + .unwrap() + .items + .iter() + .map(|t| t.name.as_str()) + .collect(); + assert_eq!(names, vec!["Charlie", "Bravo", "Alpha"]); + } + #[test] fn add_track_to_native_queue_rejects_missing_uri() { let (tx, rx) = channel(); diff --git a/src/core/format.rs b/src/core/format.rs new file mode 100644 index 00000000..3482e99b --- /dev/null +++ b/src/core/format.rs @@ -0,0 +1,192 @@ +//! Format-string templates for customizable playbar / window-title text. +//! +//! A [`Template`] is a sequence of literal segments and `{placeholder}` segments. +//! The syntax deliberately mirrors `format!` placeholders loosely: +//! - `{key}` is substituted with the named value at render time. +//! - `{{` and `}}` produce a literal `{` and `}`. +//! +//! Width specifiers are **not** supported in v1; callers that need a padded +//! field (e.g. the 7-char play state) pre-pad the value before substituting. + +use anyhow::{anyhow, Result}; +use std::collections::HashMap; + +/// One piece of a parsed [`Template`]. +#[derive(Clone, Debug, PartialEq)] +enum Segment { + Literal(String), + Placeholder(usize), +} + +/// A parsed, reusable format template. +#[derive(Clone, Debug, PartialEq)] +pub struct Template { + segments: Vec, +} + +impl Template { + /// Parse `input`, allowing only the keys listed in `allowed`. + /// + /// Errors (unknown key, unbalanced brace) are hard errors listing the valid + /// keys, consistent with the rest of the config validation policy. + pub fn parse(input: &str, allowed: &[&str]) -> Result { + let key_index: HashMap<&str, usize> = + allowed.iter().enumerate().map(|(i, k)| (*k, i)).collect(); + + let bytes = input.as_bytes(); + let mut segments: Vec = Vec::new(); + let mut buf = String::new(); + let mut i = 0; + let list = || allowed.to_vec().join(", "); + + while i < bytes.len() { + let b = bytes[i]; + if b == b'{' { + // escaped {{ + if i + 1 < bytes.len() && bytes[i + 1] == b'{' { + buf.push('{'); + i += 2; + continue; + } + // placeholder — find closing } + let start = i + 1; + let mut j = start; + while j < bytes.len() && bytes[j] != b'}' { + j += 1; + } + if j >= bytes.len() { + return Err(anyhow!( + "format template has unbalanced '{{' (missing '}}'): valid keys are {}", + list() + )); + } + let key = &input[start..j]; + let key_trimmed = key.trim(); + let idx = key_index.get(key_trimmed).copied().ok_or_else(|| { + anyhow!( + "format template references unknown key '{{{}}}' (allowed: {})", + key_trimmed, + list() + ) + })?; + if !buf.is_empty() { + segments.push(Segment::Literal(std::mem::take(&mut buf))); + } + segments.push(Segment::Placeholder(idx)); + i = j + 1; + } else if b == b'}' { + // escaped }} + if i + 1 < bytes.len() && bytes[i + 1] == b'}' { + buf.push('}'); + i += 2; + continue; + } + return Err(anyhow!( + "format template has unbalanced '}}' (no matching '{{'): valid keys are {}", + list() + )); + } else { + // safe because we only index ASCII braces above; push the char. + // Using byte slicing from the original str keeps multi-byte UTF-8 intact. + let ch_start = i; + // advance by one UTF-8 char + let next = i + utf8_len(b); + buf.push_str(&input[ch_start..next]); + i = next; + } + } + if !buf.is_empty() { + segments.push(Segment::Literal(buf)); + } + Ok(Self { segments }) + } + + /// Render the template. `values` is indexed by the placeholder index assigned + /// during [`parse`](Self::parse) (position in `allowed`). + /// + /// Newlines / tabs in the rendered output are stripped — ratatui treats text + /// as text and would clip across rows, so this is the only real injection + /// surface. + pub fn render(&self, values: &[&str]) -> String { + let mut out = String::new(); + for seg in &self.segments { + match seg { + Segment::Literal(s) => out.push_str(s), + Segment::Placeholder(idx) => { + if let Some(v) = values.get(*idx) { + out.push_str(v); + } + } + } + } + out.retain(|c| c != '\n' && c != '\r' && c != '\t'); + out + } +} + +/// Number of bytes consumed by the UTF-8 codepoint whose first byte is `b`. +fn utf8_len(b: u8) -> usize { + if b < 0x80 { + 1 + } else if b >> 5 == 0b110 { + 2 + } else if b >> 4 == 0b1110 { + 3 + } else { + 4 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEYS: &[&str] = &["state", "device", "volume"]; + + #[test] + fn parse_and_render_simple() { + let t = Template::parse("{state} on {device} ({volume}%)", KEYS).unwrap(); + assert_eq!( + t.render(&["Playing", "Living Room", "42"]), + "Playing on Living Room (42%)" + ); + } + + #[test] + fn escapes_braces() { + let t = Template::parse("{{state}} = {state}", KEYS).unwrap(); + assert_eq!(t.render(&["Playing", "", ""]), "{state} = Playing"); + } + + #[test] + fn unknown_key_errors() { + let err = Template::parse("{bogus}", KEYS).unwrap_err(); + assert!(err.to_string().contains("bogus")); + assert!(err.to_string().contains("state")); + } + + #[test] + fn unbalanced_brace_errors() { + assert!(Template::parse("{state", KEYS).is_err()); + assert!(Template::parse("state}", KEYS).is_err()); + } + + #[test] + fn strips_newlines() { + let t = Template::parse("{state}", KEYS).unwrap(); + // a placeholder value containing a newline is sanitized + assert_eq!(t.render(&["Play\ning"]), "Playing"); + } + + #[test] + fn empty_allowed_key_errors_cleanly() { + let err = Template::parse("{x}", &[]).unwrap_err(); + assert!(err.to_string().contains("unknown key")); + } + + #[test] + fn literal_only() { + let t = Template::parse("just text", KEYS).unwrap(); + assert_eq!(t.render(&[]), "just text"); + } +} diff --git a/src/core/layout.rs b/src/core/layout.rs index 61353d53..9f1bc3f9 100644 --- a/src/core/layout.rs +++ b/src/core/layout.rs @@ -1,6 +1,136 @@ +use crate::core::app::{ActiveBlock, App}; use crate::core::user_config::BehaviorConfig; use ratatui::layout::{Constraint, Layout, Rect}; +pub const COMPACT_TOP_ROW_THRESHOLD: u16 = 60; +const COMPACT_HELP_WIDTH: u16 = 6; +const COMPACT_SETTINGS_WIDTH: u16 = 10; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MainLayoutAreas { + pub input: Option, + pub help: Option, + pub settings: Option, + pub playbar: Rect, + /// The full sidebar (user block) area, including the input row in wide layout. + pub sidebar: Rect, + pub library: Rect, + pub playlists: Rect, + pub content: Rect, +} + +pub fn small_terminal_width(behavior: &BehaviorConfig) -> u16 { + behavior.small_terminal_width.max(1) +} + +pub fn small_terminal_height(behavior: &BehaviorConfig) -> u16 { + behavior.small_terminal_height.max(1) +} + +pub fn main_layout_margin(app: &App) -> u16 { + if app.size.height > small_terminal_height(&app.user_config.behavior) { + 1 + } else { + 0 + } +} + +pub fn is_wide_layout(app: &App) -> bool { + app.size.width >= small_terminal_width(&app.user_config.behavior) + && !app.user_config.behavior.enforce_wide_search_bar +} + +pub fn compute_main_layout(app: &App) -> Option { + if app.size.width == 0 || app.size.height == 0 { + return None; + } + + let root = Rect::new(0, 0, app.size.width, app.size.height); + let margin = main_layout_margin(app); + let wide_layout = is_wide_layout(app); + let behavior = &app.user_config.behavior; + + let (input_area, routes_area, playbar_area) = if wide_layout { + if behavior.playbar_position == "top" { + let [playbar_area, routes_area] = root.layout( + &Layout::vertical([playbar_constraint(behavior), Constraint::Min(1)]).margin(margin), + ); + (None, routes_area, playbar_area) + } else { + let [routes_area, playbar_area] = root.layout( + &Layout::vertical([Constraint::Min(1), playbar_constraint(behavior)]).margin(margin), + ); + (None, routes_area, playbar_area) + } + } else if behavior.playbar_position == "top" { + let [playbar_area, input_area, routes_area] = root.layout( + &Layout::vertical([ + playbar_constraint(behavior), + Constraint::Length(3), + Constraint::Min(1), + ]) + .margin(margin), + ); + (Some(input_area), routes_area, playbar_area) + } else { + let [input_area, routes_area, playbar_area] = root.layout( + &Layout::vertical([ + Constraint::Length(3), + Constraint::Min(1), + playbar_constraint(behavior), + ]) + .margin(margin), + ); + (Some(input_area), routes_area, playbar_area) + }; + + let (user_area, content_area) = split_routes_area(app, routes_area); + let library = library_constraints(behavior); + let (input, help, settings, library_area, playlist_area) = if wide_layout { + let [input_area, library_area, playlist_area] = user_area.layout(&Layout::vertical([ + Constraint::Length(3), + library[0], + library[1], + ])); + let [input_text_area, help_area, settings_area] = + split_input_help_and_settings(app, input_area); + ( + Some(input_text_area), + Some(help_area), + Some(settings_area), + library_area, + playlist_area, + ) + } else { + let [library_area, playlist_area] = + user_area.layout(&Layout::vertical([library[0], library[1]])); + if let Some(input_area) = input_area { + let [input_text_area, help_area, settings_area] = + split_input_help_and_settings(app, input_area); + ( + Some(input_text_area), + Some(help_area), + Some(settings_area), + library_area, + playlist_area, + ) + } else { + (None, None, None, library_area, playlist_area) + } + }; + + Some(MainLayoutAreas { + input, + help, + settings, + playbar: playbar_area, + sidebar: user_area, + library: library_area, + playlists: playlist_area, + content: content_area, + }) +} + /// Returns horizontal constraints for the [sidebar, content] split based on config. /// When sidebar_width_percent is 0, the sidebar is hidden (zero length). /// When sidebar_width_percent is 100, content is hidden. @@ -13,6 +143,69 @@ pub fn sidebar_constraints(behavior: &BehaviorConfig) -> [Constraint; 2] { ] } +fn split_routes_area(app: &App, routes_area: Rect) -> (Rect, Rect) { + let behavior = &app.user_config.behavior; + let sidebar = if sidebar_is_hidden(app) { + [Constraint::Length(0), Constraint::Min(1)] + } else { + sidebar_constraints(behavior) + }; + + match behavior.sidebar_position.as_str() { + "right" => { + let [content_area, user_area] = + routes_area.layout(&Layout::horizontal([sidebar[1], sidebar[0]])); + (user_area, content_area) + } + _ => { + let [user_area, content_area] = + routes_area.layout(&Layout::horizontal([sidebar[0], sidebar[1]])); + (user_area, content_area) + } + } +} + +fn sidebar_is_hidden(app: &App) -> bool { + if app.user_config.behavior.sidebar_position != "hidden" { + return false; + } + + let route = app.get_current_route(); + !matches!( + route.active_block, + ActiveBlock::Library | ActiveBlock::MyPlaylists + ) && !matches!( + route.hovered_block, + ActiveBlock::Library | ActiveBlock::MyPlaylists + ) +} + +pub fn split_input_help_and_settings(app: &App, input_row_area: Rect) -> [Rect; 3] { + let compact_top_row = input_row_area.width < COMPACT_TOP_ROW_THRESHOLD; + + let constraints = if compact_top_row { + [ + Constraint::Min(1), + Constraint::Length(COMPACT_HELP_WIDTH), + Constraint::Length(COMPACT_SETTINGS_WIDTH), + ] + } else if is_wide_layout(app) { + [ + Constraint::Percentage(65), + Constraint::Percentage(18), + Constraint::Percentage(17), + ] + } else { + [ + Constraint::Percentage(80), + Constraint::Percentage(8), + Constraint::Percentage(12), + ] + }; + + input_row_area.layout(&Layout::horizontal(constraints)) +} + /// Returns the playbar height constraint based on config. /// When playbar_height_rows is 0, the playbar is hidden. pub fn playbar_constraint(behavior: &BehaviorConfig) -> Constraint { diff --git a/src/core/mod.rs b/src/core/mod.rs index 3399044c..1bbf21e1 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -2,6 +2,7 @@ pub mod app; pub mod auth; pub mod config; pub mod first_run; +pub mod format; pub mod layout; pub mod pagination; pub mod persisted_playback; diff --git a/src/core/sort.rs b/src/core/sort.rs index 69ed2184..78166cdf 100644 --- a/src/core/sort.rs +++ b/src/core/sort.rs @@ -46,6 +46,32 @@ impl SortField { SortField::Album => Some('l'), } } + + /// Lowercase config-file token, e.g. `"name"`, `"date_added"`. + pub fn to_config_str(self) -> &'static str { + match self { + SortField::Default => "default", + SortField::Name => "name", + SortField::DateAdded => "date_added", + SortField::Artist => "artist", + SortField::Duration => "duration", + SortField::Album => "album", + } + } + + /// Parse a config-file token back to a `SortField`. Returns `None` for + /// unknown strings (callers surface the context's valid fields in the error). + pub fn from_config_str(s: &str) -> Option { + match s.trim() { + "default" => Some(SortField::Default), + "name" => Some(SortField::Name), + "date_added" => Some(SortField::DateAdded), + "artist" => Some(SortField::Artist), + "duration" => Some(SortField::Duration), + "album" => Some(SortField::Album), + _ => None, + } + } } /// Sort order direction @@ -66,12 +92,39 @@ impl SortOrder { } /// Get the sort indicator arrow + #[allow(dead_code)] pub fn indicator(&self) -> &'static str { match self { SortOrder::Ascending => "↑", SortOrder::Descending => "↓", } } + + /// Get the sort indicator using caller-supplied icons (from config). + pub fn indicator_icon<'a>(&self, ascending: &'a str, descending: &'a str) -> &'a str { + match self { + SortOrder::Ascending => ascending, + SortOrder::Descending => descending, + } + } + + /// Config-file token for the direction suffix (`:desc`). + #[allow(dead_code)] + pub fn to_config_suffix(self) -> &'static str { + match self { + SortOrder::Ascending => "", + SortOrder::Descending => ":desc", + } + } + + /// Parse a `:desc` direction suffix. Only `desc` flips to descending; + /// everything else (including `asc` or empty) is ascending. + pub fn parse_suffix(s: &str) -> Self { + match s.trim() { + "desc" => SortOrder::Descending, + _ => SortOrder::Ascending, + } + } } /// Context that supports sorting @@ -145,6 +198,54 @@ impl SortState { self.field = SortField::Default; self.order = SortOrder::Ascending; } + + /// Render as a config token: `"field"` or `"field:desc"`. + #[allow(dead_code)] + pub fn to_config_str(self) -> String { + format!( + "{}{}", + self.field.to_config_str(), + self.order.to_config_suffix() + ) + } + + /// Parse a `""` / `":desc"` spec, validating that the field is + /// available in `ctx`. Hard error (with the context's valid fields) if the + /// field is unknown or unavailable in this context. + pub fn parse(spec: &str, ctx: SortContext) -> Result { + let (field_str, order_str) = match spec.split_once(':') { + Some((f, o)) => (f, o), + None => (spec, ""), + }; + let field = SortField::from_config_str(field_str).ok_or_else(|| { + format!( + "unknown sort field '{}' (valid for this context: {})", + field_str.trim(), + ctx + .available_fields() + .iter() + .map(|f| f.to_config_str()) + .collect::>() + .join(", ") + ) + })?; + if !ctx.available_fields().contains(&field) { + return Err(format!( + "sort field '{}' is not available in this context (valid: {})", + field.to_config_str(), + ctx + .available_fields() + .iter() + .map(|f| f.to_config_str()) + .collect::>() + .join(", ") + )); + } + Ok(Self { + field, + order: SortOrder::parse_suffix(order_str), + }) + } } pub struct Sorter { @@ -226,6 +327,31 @@ mod tests { assert_eq!(SortOrder::Descending.toggle(), SortOrder::Ascending); } + #[test] + fn sort_order_indicators_and_config_suffixes_match_direction() { + assert_eq!(SortOrder::Ascending.indicator(), "↑"); + assert_eq!(SortOrder::Descending.indicator(), "↓"); + assert_eq!(SortOrder::Ascending.to_config_suffix(), ""); + assert_eq!(SortOrder::Descending.to_config_suffix(), ":desc"); + } + + #[test] + fn sort_state_config_round_trips() { + let parsed = SortState::parse("artist:desc", SortContext::PlaylistTracks).unwrap(); + assert_eq!( + parsed, + SortState { + field: SortField::Artist, + order: SortOrder::Descending + } + ); + assert_eq!(parsed.to_config_str(), "artist:desc"); + assert_eq!( + SortState::parse("artist", SortContext::SavedArtists).unwrap_err(), + "sort field 'artist' is not available in this context (valid: default, name)" + ); + } + #[test] fn test_context_available_fields() { let fields = SortContext::PlaylistTracks.available_fields(); diff --git a/src/core/user_config.rs b/src/core/user_config.rs index eaa9401b..030599e7 100644 --- a/src/core/user_config.rs +++ b/src/core/user_config.rs @@ -1,3 +1,4 @@ +use crate::core::format::Template; use crate::core::source::Source; use crate::tui::event::Key; use anyhow::{anyhow, Result}; @@ -819,6 +820,30 @@ pub struct BehaviorConfigString { pub subsonic_password: Option, pub radio_stations: Option>, pub ytdlp_path: Option, + // --- Phase 2: icons / glyphs / labels (defaults = today's glyphs) --- + pub gauge_filled_icon: Option, + pub gauge_unfilled_icon: Option, + pub active_source_icon: Option, + pub episode_played_icon: Option, + pub sort_ascending_icon: Option, + pub sort_descending_icon: Option, + pub list_highlight_icon: Option, + pub playbar_control_labels: Option>, + // --- Phase 3: behavior constants / startup / sort --- + pub status_message_ttl_percent: Option, + pub playback_poll_seconds: Option, + pub table_scroll_padding: Option, + pub like_animation_frames: Option, + pub startup_route: Option, + pub default_sort_playlist_tracks: Option, + pub default_sort_saved_albums: Option, + pub default_sort_saved_artists: Option, + pub default_sort_recently_played: Option, + // --- Phase 6: layout arrangement --- + pub sidebar_position: Option, + pub playbar_position: Option, + pub small_terminal_width: Option, + pub small_terminal_height: Option, } #[derive(Clone)] @@ -890,6 +915,107 @@ pub struct BehaviorConfig { /// Path to the `yt-dlp` binary used by the YouTube source. `None` resolves /// plain `yt-dlp` through `$PATH`. pub ytdlp_path: Option, + // --- Phase 2: icons / glyphs / labels --- + pub gauge_filled_icon: String, + pub gauge_unfilled_icon: String, + pub active_source_icon: String, + pub episode_played_icon: String, + pub sort_ascending_icon: String, + pub sort_descending_icon: String, + pub list_highlight_icon: String, + /// Optional override of playbar control button labels, keyed by + /// `prev`/`play_pause`/`next`/`shuffle`/`repeat`/`like`/`vol_down`/`vol_up`. + pub playbar_control_labels: HashMap, + // --- Phase 3: behavior constants / startup / sort --- + pub status_message_ttl_percent: u16, + pub playback_poll_seconds: u64, + pub table_scroll_padding: u16, + pub like_animation_frames: u8, + pub startup_route: String, + pub default_sort_playlist_tracks: String, + pub default_sort_saved_albums: String, + pub default_sort_saved_artists: String, + pub default_sort_recently_played: String, + // --- Phase 6: layout arrangement --- + pub sidebar_position: String, + pub playbar_position: String, + pub small_terminal_width: u16, + pub small_terminal_height: u16, +} + +impl BehaviorConfig { + /// Return the emphasis modifier to apply to emphasized text, gated on + /// `enable_text_emphasis`. Callers pass the modifier they *want* + /// (e.g. `Modifier::BOLD`, `Modifier::BOLD | Modifier::ITALIC`) and get + /// `Modifier::empty()` when emphasis is disabled — so a single call site + /// replaces the previous unconditional `Modifier::BOLD`. + pub fn emphasis(&self, m: ratatui::style::Modifier) -> ratatui::style::Modifier { + if self.enable_text_emphasis { + m + } else { + ratatui::style::Modifier::empty() + } + } +} + +// ===== Phase 4: format templates ===== + +/// Placeholder keys available to every format template, in index order. +pub const FORMAT_KEYS: &[&str] = &[ + "state", "device", "source", "queue", "shuffle", "repeat", "volume", "party", +]; + +/// On-disk format config: all templates optional, defaulting to today's output. +#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FormatConfigString { + pub playbar_status: Option, + pub playbar_status_source: Option, + pub window_title: Option, +} + +/// Parsed format templates. Defaults reproduce today's `format!` output +/// byte-for-byte. +#[derive(Clone, Debug, PartialEq)] +pub struct FormatConfig { + /// Spotify playbar title: `"{state} ({device} | Shuffle: {shuffle} | Repeat: {repeat} | Volume: {volume}%){party}"` + pub playbar_status: Template, + /// Local-source playbar title: `"{state} ({source}{queue} | Volume: {volume}%)"` + pub playbar_status_source: Template, + /// Window title: `"{state}: {artist} - {title}"` + pub window_title: Template, +} + +impl FormatConfig { + /// Today's hardcoded Spotify playbar format string. + pub const DEFAULT_PLAYBAR_STATUS: &'static str = + "{state} ({device} | Shuffle: {shuffle} | Repeat: {repeat} | Volume: {volume}%){party}"; + /// Today's hardcoded local-source playbar format string. + pub const DEFAULT_PLAYBAR_STATUS_SOURCE: &'static str = + "{state} ({source}{queue} | Volume: {volume}%)"; + /// Today's hardcoded window-title format string: `"{title} — {artist}"`. + /// (The artist segment is composed by the call site and omitted when empty.) + pub const DEFAULT_WINDOW_TITLE: &'static str = "{title}{artist}"; + + /// The keys valid for window-title templates (a subset: artist/title are + /// resolved at the call site, not via FORMAT_KEYS). + pub const WINDOW_TITLE_KEYS: &'static [&'static str] = &["title", "artist"]; + + pub fn default_templates() -> Self { + Self { + playbar_status: Template::parse(Self::DEFAULT_PLAYBAR_STATUS, FORMAT_KEYS) + .expect("default playbar_status template must parse"), + playbar_status_source: Template::parse(Self::DEFAULT_PLAYBAR_STATUS_SOURCE, FORMAT_KEYS) + .expect("default playbar_status_source template must parse"), + window_title: Template::parse(Self::DEFAULT_WINDOW_TITLE, Self::WINDOW_TITLE_KEYS) + .expect("default window_title template must parse"), + } + } +} + +impl Default for FormatConfig { + fn default() -> Self { + Self::default_templates() + } } #[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -898,6 +1024,49 @@ pub struct UserConfigString { behavior: Option, theme: Option, plugin_commands: Option>, + format: Option, + tables: Option, +} + +// ===== Phase 5: table columns ===== + +/// A single on-disk column spec. `header` overrides the default display text. +/// Exactly one of `width_percent` / `width` may be set; both set (or neither +/// for a column that expects a fixed default) — specifying both is a hard +/// error. When neither is set, the column's built-in default width applies. +#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ColumnSpec { + /// Defaulted so an entry missing `id` fails that table's resolution (a + /// recoverable, warn-level error) instead of failing the whole YAML parse. + #[serde(default)] + pub id: String, + pub header: Option, + pub width_percent: Option, + pub width: Option, +} + +#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TablesConfigString { + pub songs: Option>, + pub album_tracks: Option>, + pub albums: Option>, + pub podcasts: Option>, + pub episodes: Option>, + pub recently_played: Option>, +} + +/// Validated (but not yet render-bound) per-table column lists. Defaults are +/// represented as empty `Vec`s; the rendering layer substitutes the built-in +/// default columns when a table is empty. This keeps `core` free of `tui` +/// dependencies — the column registry lives in `tui::ui::columns`. +#[derive(Clone, Debug, PartialEq, Default)] +pub struct TablesConfig { + pub songs: Vec, + pub album_tracks: Vec, + pub albums: Vec, + pub podcasts: Vec, + pub episodes: Vec, + pub recently_played: Vec, } #[derive(Clone)] @@ -910,6 +1079,10 @@ pub struct UserConfig { pub path_to_config: Option, /// Keybindings for plugin commands: key -> command name. pub plugin_command_keys: HashMap, + /// Parsed format templates (Phase 4). + pub format: FormatConfig, + /// Resolved per-table column layouts (Phase 5). + pub tables: TablesConfig, } impl UserConfig { @@ -1031,8 +1204,36 @@ impl UserConfig { subsonic_password: None, radio_stations: Vec::new(), ytdlp_path: None, + // --- Phase 2: icons / glyphs / labels (defaults = today's glyphs) --- + gauge_filled_icon: "⣿".to_string(), + gauge_unfilled_icon: "⣉".to_string(), + active_source_icon: "●".to_string(), + episode_played_icon: "✔".to_string(), + sort_ascending_icon: "↑".to_string(), + sort_descending_icon: "↓".to_string(), + list_highlight_icon: "▶".to_string(), + playbar_control_labels: HashMap::new(), + // --- Phase 3: behavior constants / startup / sort --- + status_message_ttl_percent: 100, + playback_poll_seconds: 5, + table_scroll_padding: 5, + like_animation_frames: 10, + startup_route: "home".to_string(), + default_sort_playlist_tracks: "default".to_string(), + default_sort_saved_albums: "default".to_string(), + default_sort_saved_artists: "default".to_string(), + default_sort_recently_played: "default".to_string(), + // --- Phase 6: layout arrangement --- + sidebar_position: "left".to_string(), + playbar_position: "bottom".to_string(), + small_terminal_width: 150, + small_terminal_height: 45, }, path_to_config: None, + // Phase 4 / 5: parsed templates + resolved columns default to today's + // built-in output (empty TablesConfig == built-in default columns). + format: FormatConfig::default_templates(), + tables: TablesConfig::default(), } } @@ -1262,10 +1463,6 @@ impl UserConfig { self.behavior.paused_icon = paused_icon; } - if let Some(playing_icon) = behavior_config.playing_icon { - self.behavior.playing_icon = playing_icon; - } - if let Some(shuffle_icon) = behavior_config.shuffle_icon { self.behavior.shuffle_icon = shuffle_icon; } @@ -1444,6 +1641,213 @@ impl UserConfig { if let Some(ytdlp_path) = trim_to_none(behavior_config.ytdlp_path) { self.behavior.ytdlp_path = Some(ytdlp_path); } + + // ===== Phase 2: icons / glyphs / labels ===== + // Width-restricted glyphs (column math depends on them) are validated to + // exactly one terminal column; free-form labels are accepted as-is. + // A bad glyph degrades to the built-in default with a warning rather than + // failing config load (the app must stay launchable on a typo). + let load_width1_icon = |dest: &mut String, value: Option, field: &str| { + if let Some(icon) = value { + let icon = icon.trim().to_string(); + if icon.is_empty() { + log::warn!("[config] {field} must not be empty; using default"); + return; + } + let width: usize = unicode_width::UnicodeWidthStr::width(icon.as_str()); + if width != 1 { + log::warn!( + "[config] {field} must be exactly one terminal column wide (got {width} columns): {icon}; using default" + ); + return; + } + *dest = icon; + } + }; + load_width1_icon( + &mut self.behavior.gauge_filled_icon, + behavior_config.gauge_filled_icon, + "gauge_filled_icon", + ); + load_width1_icon( + &mut self.behavior.gauge_unfilled_icon, + behavior_config.gauge_unfilled_icon, + "gauge_unfilled_icon", + ); + // playing_icon prefixes the title cell of the playing row (padded to two + // columns in padded_playing_icon), so it must be exactly one column wide. + load_width1_icon( + &mut self.behavior.playing_icon, + behavior_config.playing_icon, + "playing_icon", + ); + // active_source_icon, list_highlight_icon render in free space, not a + // fixed-width column → accept any non-empty glyph. + if let Some(icon) = behavior_config.active_source_icon { + let icon = icon.trim().to_string(); + if !icon.is_empty() { + self.behavior.active_source_icon = icon; + } + } + if let Some(icon) = behavior_config.list_highlight_icon { + let icon = icon.trim().to_string(); + if !icon.is_empty() { + self.behavior.list_highlight_icon = icon; + } + } + // episode_played_icon renders in a width-2 "played" column (tables.rs), + // so it must be exactly one column wide (the leading space is added at + // the call site). + load_width1_icon( + &mut self.behavior.episode_played_icon, + behavior_config.episode_played_icon, + "episode_played_icon", + ); + // sort direction icons render in a width-1 column. + load_width1_icon( + &mut self.behavior.sort_ascending_icon, + behavior_config.sort_ascending_icon, + "sort_ascending_icon", + ); + load_width1_icon( + &mut self.behavior.sort_descending_icon, + behavior_config.sort_descending_icon, + "sort_descending_icon", + ); + // playbar control labels: free-form strings keyed by control id. Keep only + // the known keys so typos don't silently no-op; empty values are dropped + // (falling back to the built-in label). + if let Some(labels) = behavior_config.playbar_control_labels { + let allowed = [ + "prev", + "play_pause", + "next", + "shuffle", + "repeat", + "like", + "vol_down", + "vol_up", + ]; + let mut kept = HashMap::new(); + for (key, val) in labels { + let key = key.trim().to_string(); + let val = val.trim().to_string(); + if !allowed.contains(&key.as_str()) { + log::warn!( + "[config] playbar_control_labels: skipping unknown key '{key}' (allowed: {})", + allowed.join(", ") + ); + continue; + } + if val.is_empty() { + // empty == reset to default; drop the override + continue; + } + kept.insert(key, val); + } + self.behavior.playbar_control_labels = kept; + } + + // ===== Phase 3: behavior constants / startup / sort ===== + if let Some(pct) = behavior_config.status_message_ttl_percent { + self.behavior.status_message_ttl_percent = pct.clamp(10, 1000); + } + if let Some(secs) = behavior_config.playback_poll_seconds { + if secs < 1 { + return Err(anyhow!( + "playback_poll_seconds must be at least 1, is {secs}" + )); + } + self.behavior.playback_poll_seconds = secs; + } + if let Some(padding) = behavior_config.table_scroll_padding { + self.behavior.table_scroll_padding = padding; + } + if let Some(frames) = behavior_config.like_animation_frames { + if frames < 1 { + return Err(anyhow!( + "like_animation_frames must be at least 1, is {frames}" + )); + } + self.behavior.like_animation_frames = frames; + } + if let Some(route) = behavior_config.startup_route { + let route = route.trim().to_string(); + if !route.is_empty() { + // Validation of the route id happens in App::apply_startup_route(); + // store the raw string here so an unknown value degrades to Home + warn + // rather than failing config load. + self.behavior.startup_route = route; + } + } + // Per-context default sort: "" or ":desc". Validate against + // the context's available fields; a typo degrades to the default order + // with a warning rather than failing config load. + let load_sort_default = |dest: &mut String, + value: Option, + ctx: crate::core::sort::SortContext, + field: &str| { + if let Some(spec) = value { + let spec = spec.trim().to_string(); + if spec.is_empty() { + return; + } + match crate::core::sort::SortState::parse(&spec, ctx) { + Ok(_) => *dest = spec, + Err(e) => log::warn!("[config] {field}: {e}; using default sort"), + } + } + }; + load_sort_default( + &mut self.behavior.default_sort_playlist_tracks, + behavior_config.default_sort_playlist_tracks, + crate::core::sort::SortContext::PlaylistTracks, + "default_sort_playlist_tracks", + ); + load_sort_default( + &mut self.behavior.default_sort_saved_albums, + behavior_config.default_sort_saved_albums, + crate::core::sort::SortContext::SavedAlbums, + "default_sort_saved_albums", + ); + load_sort_default( + &mut self.behavior.default_sort_saved_artists, + behavior_config.default_sort_saved_artists, + crate::core::sort::SortContext::SavedArtists, + "default_sort_saved_artists", + ); + load_sort_default( + &mut self.behavior.default_sort_recently_played, + behavior_config.default_sort_recently_played, + crate::core::sort::SortContext::RecentlyPlayed, + "default_sort_recently_played", + ); + + // ===== Phase 6: layout arrangement ===== + if let Some(pos) = behavior_config.sidebar_position { + let pos = pos.trim().to_string(); + match pos.as_str() { + "left" | "right" | "hidden" => self.behavior.sidebar_position = pos, + _ => log::warn!( + "[config] sidebar_position '{pos}' is invalid (expected left|right|hidden); using left" + ), + } + } + if let Some(pos) = behavior_config.playbar_position { + let pos = pos.trim().to_string(); + match pos.as_str() { + "bottom" | "top" => self.behavior.playbar_position = pos, + _ => log::warn!( + "[config] playbar_position '{pos}' is invalid (expected bottom|top); using bottom" + ), + } + } + if let Some(w) = behavior_config.small_terminal_width { + self.behavior.small_terminal_width = w.max(1); + } + if let Some(h) = behavior_config.small_terminal_height { + self.behavior.small_terminal_height = h.max(1); + } Ok(()) } @@ -1550,6 +1954,12 @@ impl UserConfig { if let Some(plugin_commands) = config_yml.plugin_commands { self.load_plugin_commands(plugin_commands); } + if let Some(format) = config_yml.format { + self.load_formatconfig(format); + } + if let Some(tables) = config_yml.tables { + self.load_tablesconfig(tables); + } Ok(()) } else { @@ -1557,6 +1967,55 @@ impl UserConfig { } } + /// Validate and apply format templates (Phase 4). Each template is parsed + /// against `FORMAT_KEYS` (or the window-title subset); a parse error + /// degrades that template to the built-in default with a warning listing + /// the valid keys, so a typo never blocks app launch. + pub fn load_formatconfig(&mut self, format: FormatConfigString) { + if let Some(s) = format.playbar_status { + match Template::parse(s.trim(), FORMAT_KEYS) { + Ok(t) => self.format.playbar_status = t, + Err(e) => log::warn!("[config] format.playbar_status: {e}; using default"), + } + } + if let Some(s) = format.playbar_status_source { + match Template::parse(s.trim(), FORMAT_KEYS) { + Ok(t) => self.format.playbar_status_source = t, + Err(e) => log::warn!("[config] format.playbar_status_source: {e}; using default"), + } + } + if let Some(s) = format.window_title { + match Template::parse(s.trim(), FormatConfig::WINDOW_TITLE_KEYS) { + Ok(t) => self.format.window_title = t, + Err(e) => log::warn!("[config] format.window_title: {e}; using default"), + } + } + } + + /// Validate and apply table column specs (Phase 5). Unknown / duplicate + /// ids, empty lists, or both-widths-set degrade that table to its built-in + /// default columns with a warning listing valid ids, so a typo never + /// blocks app launch. + pub fn load_tablesconfig(&mut self, tables: TablesConfigString) { + // Each table is validated against its registry of valid column ids (kept + // in the rendering layer). Empty specs are dropped (== built-in defaults). + let load = |table: &'static str, specs: Option>| -> Vec { + match resolve_table_specs(table, specs) { + Ok(specs) => specs, + Err(e) => { + log::warn!("[config] {e}; using default columns"); + Vec::new() + } + } + }; + self.tables.songs = load("songs", tables.songs); + self.tables.album_tracks = load("album_tracks", tables.album_tracks); + self.tables.albums = load("albums", tables.albums); + self.tables.podcasts = load("podcasts", tables.podcasts); + self.tables.episodes = load("episodes", tables.episodes); + self.tables.recently_played = load("recently_played", tables.recently_played); + } + /// Save the current configuration to the config file pub fn save_config(&self) -> Result<()> { let paths = match &self.path_to_config { @@ -1620,6 +2079,32 @@ impl UserConfig { playbar_cover_art_size_percent: Some(self.behavior.playbar_cover_art_size_percent), keepawake_enabled: Some(self.behavior.keepawake_enabled), enable_media_keys: Some(self.behavior.enable_media_keys), + // --- Phase 2/3/6 new fields (persist whatever the user set) --- + gauge_filled_icon: Some(self.behavior.gauge_filled_icon.clone()), + gauge_unfilled_icon: Some(self.behavior.gauge_unfilled_icon.clone()), + active_source_icon: Some(self.behavior.active_source_icon.clone()), + episode_played_icon: Some(self.behavior.episode_played_icon.clone()), + sort_ascending_icon: Some(self.behavior.sort_ascending_icon.clone()), + sort_descending_icon: Some(self.behavior.sort_descending_icon.clone()), + list_highlight_icon: Some(self.behavior.list_highlight_icon.clone()), + playbar_control_labels: if self.behavior.playbar_control_labels.is_empty() { + None + } else { + Some(self.behavior.playbar_control_labels.clone()) + }, + status_message_ttl_percent: Some(self.behavior.status_message_ttl_percent), + playback_poll_seconds: Some(self.behavior.playback_poll_seconds), + table_scroll_padding: Some(self.behavior.table_scroll_padding), + like_animation_frames: Some(self.behavior.like_animation_frames), + startup_route: Some(self.behavior.startup_route.clone()), + default_sort_playlist_tracks: Some(self.behavior.default_sort_playlist_tracks.clone()), + default_sort_saved_albums: Some(self.behavior.default_sort_saved_albums.clone()), + default_sort_saved_artists: Some(self.behavior.default_sort_saved_artists.clone()), + default_sort_recently_played: Some(self.behavior.default_sort_recently_played.clone()), + sidebar_position: Some(self.behavior.sidebar_position.clone()), + playbar_position: Some(self.behavior.playbar_position.clone()), + small_terminal_width: Some(self.behavior.small_terminal_width), + small_terminal_height: Some(self.behavior.small_terminal_height), }; // Helper to convert Key to config string @@ -1741,6 +2226,8 @@ impl UserConfig { behavior: Some(build_behavior()), theme: Some(build_theme()), plugin_commands: None, + format: None, + tables: None, } } } else { @@ -1749,6 +2236,8 @@ impl UserConfig { behavior: Some(build_behavior()), theme: Some(build_theme()), plugin_commands: None, + format: None, + tables: None, } }; @@ -1767,7 +2256,14 @@ impl UserConfig { } pub fn padded_liked_icon(&self) -> String { - format!("{} ", &self.behavior.liked_icon) + format!("{} ", self.behavior.liked_icon) + } + + /// The configured `playing_icon` followed by a single trailing space, for + /// prepending to the title cell of the currently-playing row. Width-2 + /// (the icon is validated to one column at load time). + pub fn padded_playing_icon(&self) -> String { + format!("{} ", self.behavior.playing_icon) } pub fn add_radio_station( @@ -1857,6 +2353,99 @@ impl UserConfig { } } +/// Canonical valid column ids per table. This is the single source of truth +/// shared by config validation (here) and the rendering registry +/// (`tui::ui::columns`). Adding a column id means adding it here *and* to the +/// registry; the round-trip test guards the two staying in sync. +pub fn valid_column_ids(table: &str) -> &'static [&'static str] { + match table { + "songs" | "album_tracks" | "recently_played" => { + &["liked", "index", "title", "artist", "album", "length"] + } + "albums" => &["title", "artist", "date", "liked"], + "podcasts" => &["title", "publisher"], + "episodes" => &["played", "date", "title", "duration"], + _ => &[], + } +} + +/// Validate a single table's column specs: unknown id, duplicate id, empty +/// list, or both widths set are hard errors. An empty/absent list yields an +/// empty `Vec` (== built-in default columns at render time). +fn resolve_table_specs( + table: &'static str, + specs: Option>, +) -> Result> { + let Some(specs) = specs else { + return Ok(Vec::new()); + }; + let valid = valid_column_ids(table); + let mut seen: Vec = Vec::new(); + let mut out = Vec::with_capacity(specs.len()); + for spec in specs { + if spec.id.trim().is_empty() { + return Err(anyhow!( + "tables.{table}: column with empty id (valid ids: {})", + valid.join(", ") + )); + } + let id = spec.id.trim().to_string(); + if !valid.contains(&id.as_str()) { + return Err(anyhow!( + "tables.{table}: unknown column id '{id}' (valid: {})", + valid.join(", ") + )); + } + if seen.iter().any(|s| s == &id) { + return Err(anyhow!("tables.{table}: duplicate column id '{id}'")); + } + if spec.width_percent.is_some() && spec.width.is_some() { + return Err(anyhow!( + "tables.{table}: column '{id}' sets both width_percent and width — pick one" + )); + } + if let Some(pct) = spec.width_percent { + if !(0.0..=100.0).contains(&pct) { + return Err(anyhow!( + "tables.{table}: column '{id}' width_percent {pct} out of range 0..=100" + )); + } + if pct == 0.0 { + return Err(anyhow!( + "tables.{table}: column '{id}' has width_percent 0 (it would be invisible) — remove the column instead" + )); + } + } + if spec.width == Some(0) { + return Err(anyhow!( + "tables.{table}: column '{id}' has width 0 (it would be invisible) — remove the column instead" + )); + } + seen.push(id.clone()); + out.push(ColumnSpec { + id, + header: spec + .header + .map(|h| h.trim().to_string()) + .filter(|h| !h.is_empty()), + width_percent: spec.width_percent, + width: spec.width, + }); + } + if out.is_empty() { + return Err(anyhow!( + "tables.{table}: column list must not be empty (omit the key to use defaults)" + )); + } + let percent_sum: f32 = out.iter().filter_map(|c| c.width_percent).sum(); + if percent_sum > 100.0 { + return Err(anyhow!( + "tables.{table}: width_percent values sum to {percent_sum} (must be <= 100) — trailing columns would be clipped" + )); + } + Ok(out) +} + pub fn parse_theme_item(theme_item: &str) -> Result { let color = match theme_item { "Reset" => Color::Reset, @@ -2432,4 +3021,181 @@ radio_stations: Source::Local ); } + + #[test] + fn example_config_loads_without_falling_back() { + use super::{UserConfig, UserConfigString}; + + // The shipped example must always be valid: it deserializes, and every + // section applies as written instead of degrading to defaults. + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/config.example.yml"); + let raw = std::fs::read_to_string(path).expect("example config must exist"); + let yml: UserConfigString = + serde_yaml::from_str(&raw).expect("example config must deserialize"); + + let mut config = UserConfig::new(); + if let Some(behavior) = yml.behavior { + config + .load_behaviorconfig(behavior) + .expect("example behavior section must load"); + } + if let Some(format) = yml.format { + config.load_formatconfig(format); + } + if let Some(tables) = yml.tables { + config.load_tablesconfig(tables); + } + + // Spot-check that the documented values were applied, not defaulted away. + assert_eq!(config.behavior.startup_route, "home"); + assert_eq!(config.behavior.playing_icon, "▶"); + // Every documented table resolves to its example columns (an empty Vec + // would mean that table's spec was rejected and fell back to defaults). + assert_eq!(config.tables.songs.len(), 5); + assert_eq!(config.tables.album_tracks.len(), 5); + assert_eq!(config.tables.albums.len(), 3); + assert_eq!(config.tables.podcasts.len(), 2); + assert_eq!(config.tables.episodes.len(), 4); + assert_eq!(config.tables.recently_played.len(), 4); + assert_eq!( + config.tables.songs[2].header.as_deref(), + Some("Band"), + "header override from the example must survive resolution" + ); + } + + #[test] + fn structural_behavior_errors_degrade_to_defaults_instead_of_failing_load() { + use super::{BehaviorConfigString, UserConfig}; + + // A two-column playing icon, an empty gauge icon, an invalid sort field, + // and an unknown playbar label key must all warn-and-fallback: the app + // must stay launchable on a config typo. + let yaml = r#" +playing_icon: "WW" +gauge_filled_icon: "" +default_sort_saved_albums: "dtae_added" +playbar_control_labels: + bogus_key: "x" + play_pause: "PLAY" +"#; + let behavior: BehaviorConfigString = serde_yaml::from_str(yaml).unwrap(); + let mut config = UserConfig::new(); + let defaults = UserConfig::new(); + + config.load_behaviorconfig(behavior).unwrap(); + + assert_eq!(config.behavior.playing_icon, defaults.behavior.playing_icon); + assert_eq!( + config.behavior.gauge_filled_icon, + defaults.behavior.gauge_filled_icon + ); + assert_eq!( + config.behavior.default_sort_saved_albums, + defaults.behavior.default_sort_saved_albums + ); + // The unknown key is skipped, the valid one is kept. + assert_eq!( + config.behavior.playbar_control_labels.get("play_pause"), + Some(&"PLAY".to_string()) + ); + assert!(!config + .behavior + .playbar_control_labels + .contains_key("bogus_key")); + } + + #[test] + fn playing_icon_is_width_validated_like_other_fixed_cell_icons() { + use super::{BehaviorConfigString, UserConfig}; + + let behavior: BehaviorConfigString = serde_yaml::from_str("playing_icon: \"»\"").unwrap(); + let mut config = UserConfig::new(); + config.load_behaviorconfig(behavior).unwrap(); + assert_eq!(config.behavior.playing_icon, "»"); + } + + #[test] + fn invalid_format_template_falls_back_to_default() { + use super::{FormatConfig, FormatConfigString, UserConfig}; + + let mut config = UserConfig::new(); + config.load_formatconfig(FormatConfigString { + window_title: Some("{bogus}".to_string()), + playbar_status: Some("{unbalanced".to_string()), + ..Default::default() + }); + + let defaults = FormatConfig::default(); + assert_eq!(config.format.window_title, defaults.window_title); + assert_eq!(config.format.playbar_status, defaults.playbar_status); + } + + #[test] + fn invalid_table_columns_fall_back_to_default_columns() { + use super::{ColumnSpec, TablesConfigString, UserConfig}; + + let mut config = UserConfig::new(); + config.load_tablesconfig(TablesConfigString { + songs: Some(vec![ColumnSpec { + id: "bogus".to_string(), + ..Default::default() + }]), + albums: Some(vec![ColumnSpec { + id: "title".to_string(), + ..Default::default() + }]), + ..Default::default() + }); + + // The bad table degrades to defaults (empty == built-in columns); the + // valid table is kept. + assert!(config.tables.songs.is_empty()); + assert_eq!(config.tables.albums.len(), 1); + } + + #[test] + fn column_spec_missing_id_is_recoverable_not_a_parse_error() { + use super::{TablesConfigString, UserConfig}; + + // Missing `id` must not fail YAML deserialization of the whole config; + // it degrades that table to defaults during resolution. + let tables: TablesConfigString = serde_yaml::from_str("songs:\n - { width_percent: 40 }\n") + .expect("missing id must not fail deserialization"); + let mut config = UserConfig::new(); + config.load_tablesconfig(tables); + assert!(config.tables.songs.is_empty()); + } + + #[test] + fn table_specs_reject_zero_and_oversubscribed_widths() { + use super::{resolve_table_specs, ColumnSpec}; + + let col = |id: &str, pct: Option, width: Option| ColumnSpec { + id: id.to_string(), + header: None, + width_percent: pct, + width, + }; + + assert!(resolve_table_specs("songs", Some(vec![col("title", Some(0.0), None)])).is_err()); + assert!(resolve_table_specs("songs", Some(vec![col("title", None, Some(0))])).is_err()); + assert!(resolve_table_specs( + "songs", + Some(vec![ + col("title", Some(70.0), None), + col("artist", Some(70.0), None) + ]) + ) + .is_err()); + // A valid subset still resolves. + assert!(resolve_table_specs( + "songs", + Some(vec![ + col("title", Some(60.0), None), + col("artist", Some(40.0), None) + ]) + ) + .is_ok()); + } } diff --git a/src/infra/network/mod.rs b/src/infra/network/mod.rs index c5159d63..f8705bdf 100644 --- a/src/infra/network/mod.rs +++ b/src/infra/network/mod.rs @@ -754,8 +754,11 @@ impl Network { async fn show_status_message(&self, message: String, ttl_secs: u64) { let mut app = self.app.lock().await; + // Scale by status_message_ttl_percent, matching App::set_status_message. + let pct = app.user_config.behavior.status_message_ttl_percent as u64; + let ttl = ((ttl_secs * pct + 50) / 100).max(1); app.status_message = Some(message); - app.status_message_expires_at = Some(Instant::now() + Duration::from_secs(ttl_secs)); + app.status_message_expires_at = Some(Instant::now() + Duration::from_secs(ttl)); } async fn refresh_authentication(&mut self) { diff --git a/src/infra/network/user.rs b/src/infra/network/user.rs index 9687d3a5..641e3e4c 100644 --- a/src/infra/network/user.rs +++ b/src/infra/network/user.rs @@ -254,6 +254,7 @@ impl UserNetwork for Network { app.dispatch(IoEvent::CurrentUserSavedTracksContains(track_check)); } app.recently_played.result = Some(domain_page); + app.sort_recently_played_items(); app.push_navigation_stack(RouteId::RecentlyPlayed, ActiveBlock::RecentlyPlayed); } Err(e) => { diff --git a/src/tui/handlers/mouse.rs b/src/tui/handlers/mouse.rs index b5e30057..a8061294 100644 --- a/src/tui/handlers/mouse.rs +++ b/src/tui/handlers/mouse.rs @@ -3,20 +3,15 @@ use crate::core::app::{ ActiveBlock, App, RouteId, SettingValue, SettingsCategory, LIBRARY_OPTIONS, }; use crate::core::layout::{ - fullscreen_view_layout, library_constraints, miniplayer_playbar_area, playbar_constraint, - sidebar_constraints, + compute_main_layout, fullscreen_view_layout, miniplayer_playbar_area, MainLayoutAreas, }; use crate::tui::event::Key; use crate::tui::ui::player::{playbar_control_at, playbar_progress_line}; use crate::tui::ui::tables::table_scroll_offset; -use crate::tui::ui::util::{get_main_layout_margin, SMALL_TERMINAL_WIDTH}; use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::{Constraint, Layout, Rect}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; -const COMPACT_TOP_ROW_THRESHOLD: u16 = 60; -const COMPACT_HELP_WIDTH: u16 = 8; -const COMPACT_SETTINGS_WIDTH: u16 = 12; const SETTINGS_UNSAVED_PROMPT_WIDTH: u16 = 58; const SETTINGS_UNSAVED_PROMPT_HEIGHT: u16 = 9; @@ -771,16 +766,6 @@ fn settings_item_index_from_click( (row_index < item_count).then_some(row_index) } -struct MainLayoutAreas { - input: Option, - help: Option, - settings: Option, - playbar: Rect, - library: Rect, - playlists: Rect, - content: Rect, -} - struct SettingsLayoutAreas { tabs: Rect, list: Rect, @@ -874,115 +859,7 @@ fn miniplayer_view_playbar_area(app: &App) -> Option { } fn main_layout_areas(app: &App) -> Option { - if app.size.width == 0 || app.size.height == 0 { - return None; - } - - let root = Rect::new(0, 0, app.size.width, app.size.height); - let margin = get_main_layout_margin(app); - let wide_layout = - app.size.width >= SMALL_TERMINAL_WIDTH && !app.user_config.behavior.enforce_wide_search_bar; - - let behavior = &app.user_config.behavior; - let playbar = playbar_constraint(behavior); - let sidebar = sidebar_constraints(behavior); - let library = library_constraints(behavior); - - let (routes_area, playbar_area) = if wide_layout { - let [routes_area, playbar_area] = - root.layout(&Layout::vertical([Constraint::Min(1), playbar]).margin(margin)); - (routes_area, playbar_area) - } else { - let [input_area, routes_area, playbar_area] = root.layout( - &Layout::vertical([Constraint::Length(3), Constraint::Min(1), playbar]).margin(margin), - ); - let [input_text_area, help_area, settings_area] = - split_input_help_and_settings(app, input_area); - - let [library_area, playlist_area] = - user_area_for_routes(routes_area).layout(&Layout::vertical([library[0], library[1]])); - let [_user_area, content_area] = - routes_area.layout(&Layout::horizontal([sidebar[0], sidebar[1]])); - - return Some(MainLayoutAreas { - input: Some(input_text_area), - help: Some(help_area), - settings: Some(settings_area), - playbar: playbar_area, - library: library_area, - playlists: playlist_area, - content: content_area, - }); - }; - - let [user_area, content_area] = routes_area.layout(&Layout::horizontal([sidebar[0], sidebar[1]])); - - if wide_layout { - let [input_area, library_area, playlist_area] = user_area.layout(&Layout::vertical([ - Constraint::Length(3), - library[0], - library[1], - ])); - let [input_text_area, help_area, settings_area] = - split_input_help_and_settings(app, input_area); - Some(MainLayoutAreas { - input: Some(input_text_area), - help: Some(help_area), - settings: Some(settings_area), - playbar: playbar_area, - library: library_area, - playlists: playlist_area, - content: content_area, - }) - } else { - let [library_area, playlist_area] = - user_area.layout(&Layout::vertical([library[0], library[1]])); - Some(MainLayoutAreas { - input: None, - help: None, - settings: None, - playbar: playbar_area, - library: library_area, - playlists: playlist_area, - content: content_area, - }) - } -} - -fn split_input_help_and_settings(app: &App, input_row_area: Rect) -> [Rect; 3] { - let compact_top_row = input_row_area.width < COMPACT_TOP_ROW_THRESHOLD; - - let constraints = if compact_top_row { - [ - Constraint::Min(1), - Constraint::Length(COMPACT_HELP_WIDTH), - Constraint::Length(COMPACT_SETTINGS_WIDTH), - ] - } else if app.size.width >= SMALL_TERMINAL_WIDTH - && !app.user_config.behavior.enforce_wide_search_bar - { - [ - Constraint::Percentage(65), - Constraint::Percentage(18), - Constraint::Percentage(17), - ] - } else { - [ - Constraint::Percentage(80), - Constraint::Percentage(10), - Constraint::Percentage(10), - ] - }; - - input_row_area.layout(&Layout::horizontal(constraints)) -} - -fn user_area_for_routes(routes_area: Rect) -> Rect { - let [user_area, _content_area] = routes_area.layout(&Layout::horizontal([ - Constraint::Percentage(20), - Constraint::Percentage(80), - ])); - user_area + compute_main_layout(app) } fn rect_contains(rect: Rect, x: u16, y: u16) -> bool { @@ -1410,13 +1287,17 @@ mod tests { .unwrap(); let buffer = terminal.backend().buffer(); - // The LineGauge fills with "⣿" and "⣉"; neither appears in the time label, so the - // first such cell on the progress row is the true gauge start column. + // The LineGauge fills with the configured filled/unfilled symbols; neither + // appears in the time label, so the first such cell on the progress row is + // the true gauge start column. Parameterized on the config fields so an + // overridden gauge glyph can't silently break the alignment guard. + let filled = app.user_config.behavior.gauge_filled_icon.as_str(); + let unfilled = app.user_config.behavior.gauge_unfilled_icon.as_str(); let rendered_start = (areas.playbar.x..areas.playbar.x + areas.playbar.width) .find(|&x| { matches!( buffer.cell((x, line.row)).map(|c| c.symbol()), - Some("⣿") | Some("⣉") + Some(s) if s == filled || s == unfilled ) }) .expect("expected rendered gauge cells on the progress row"); @@ -1623,10 +1504,18 @@ mod tests { let divider = 1u16; let behavior_tab_width = left_padding + SettingsCategory::Behavior.name().len() as u16 + right_padding; + let icons_tab_width = + left_padding + SettingsCategory::Icons.name().len() as u16 + right_padding; let keybindings_tab_width = left_padding + SettingsCategory::Keybindings.name().len() as u16 + right_padding; - let theme_title_start = - inner_left + behavior_tab_width + divider + keybindings_tab_width + divider + left_padding; + let theme_title_start = inner_left + + behavior_tab_width + + divider + + icons_tab_width + + divider + + keybindings_tab_width + + divider + + left_padding; let theme_tab_x = theme_title_start + 1; let tab_y = areas.tabs.y + 1; diff --git a/src/tui/handlers/sort_menu.rs b/src/tui/handlers/sort_menu.rs index 02e6a032..db0437b3 100644 --- a/src/tui/handlers/sort_menu.rs +++ b/src/tui/handlers/sort_menu.rs @@ -78,7 +78,7 @@ pub fn open_sort_menu(app: &mut App, context: SortContext) { SortContext::PlaylistTracks => app.playlist_sort.field, SortContext::SavedAlbums => app.album_sort.field, SortContext::SavedArtists => app.artist_sort.field, - SortContext::RecentlyPlayed => SortField::Default, // No persistent sort for this + SortContext::RecentlyPlayed => app.recently_played_sort.field, }; let available = context.available_fields(); @@ -116,7 +116,7 @@ fn apply_sort(app: &mut App, field: SortField) { } SortContext::SavedAlbums => sort_saved_albums(app), SortContext::SavedArtists => sort_saved_artists(app), - SortContext::RecentlyPlayed => { /* no persistent sort */ } + SortContext::RecentlyPlayed => app.sort_recently_played_items(), } } } @@ -126,7 +126,7 @@ fn get_sort_state_mut(app: &mut App, ctx: SortContext) -> &mut crate::core::sort SortContext::PlaylistTracks => &mut app.playlist_sort, SortContext::SavedAlbums => &mut app.album_sort, SortContext::SavedArtists => &mut app.artist_sort, - SortContext::RecentlyPlayed => &mut app.playlist_sort, // fallback + SortContext::RecentlyPlayed => &mut app.recently_played_sort, } } diff --git a/src/tui/runner.rs b/src/tui/runner.rs index 9f12acd5..46d5a204 100644 --- a/src/tui/runner.rs +++ b/src/tui/runner.rs @@ -218,12 +218,19 @@ fn playback_window_title(app: &App) -> String { }; let title = sanitize_window_title_component(&snapshot.metadata.title); - let artist = sanitize_window_title_component(&snapshot.primary_artist()); - if artist.trim().is_empty() { - title + let artist_raw = sanitize_window_title_component(&snapshot.primary_artist()); + // Compose the artist segment with the em-dash separator, matching today's + // `"{title} — {artist}"` output; omitted when there's no artist. + let artist = if artist_raw.trim().is_empty() { + String::new() } else { - format!("{} — {}", title, artist) - } + format!(" — {}", artist_raw) + }; + app + .user_config + .format + .window_title + .render(&[&title, &artist]) } fn sanitize_window_title_component(value: &str) -> String { @@ -683,7 +690,9 @@ pub async fn start_ui( terminal.hide_cursor()?; } - let cursor_offset = if app.size.height > ui::util::SMALL_TERMINAL_HEIGHT { + let cursor_offset = if app.size.height + > crate::core::layout::small_terminal_height(&app.user_config.behavior) + { 2 } else { 1 @@ -1192,6 +1201,17 @@ pub async fn start_ui( app.dispatch(IoEvent::GetCurrentPlayback); app.dispatch(IoEvent::GetPlaylists); app.dispatch(IoEvent::GetUser); + // startup_route seeds the nav stack directly (App::new), bypassing the + // handlers that normally fetch a screen's data on navigation — kick + // off that fetch here or the screen renders empty until re-entered. + // (Home needs nothing extra; Discover fetches from within its menu.) + match app.get_current_route().id { + RouteId::RecentlyPlayed => app.dispatch(IoEvent::GetRecentlyPlayed), + RouteId::AlbumList => app.dispatch(IoEvent::GetCurrentUserSavedAlbums(None)), + RouteId::Artists => app.dispatch(IoEvent::GetFollowedArtists(None)), + RouteId::Podcasts => app.dispatch(IoEvent::GetCurrentUserSavedShows(None)), + _ => {} + } } // A persisted non-Spotify active source needs its sidebar data loaded // too (all of these are inert no-ops when the feature is off). diff --git a/src/tui/ui/artist.rs b/src/tui/ui/artist.rs index 9808e4c9..3881c6ab 100644 --- a/src/tui/ui/artist.rs +++ b/src/tui/ui/artist.rs @@ -42,7 +42,7 @@ pub fn draw_artist_albums(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { f, app, tracks_area, - &format!("{} - Top Tracks", &artist.artist_name), + &format!("{} - Top Tracks", artist.artist_name), &top_tracks, get_artist_highlight_state(app, ArtistBlock::TopTracks), Some(artist.selected_top_track_index), diff --git a/src/tui/ui/audio_analysis.rs b/src/tui/ui/audio_analysis.rs index f2e7dd2a..75bc7f2e 100644 --- a/src/tui/ui/audio_analysis.rs +++ b/src/tui/ui/audio_analysis.rs @@ -1,5 +1,5 @@ -use super::util; use crate::core::app::App; +use crate::core::layout::main_layout_margin; use crate::core::user_config::{normalize_tick_rate_milliseconds, VisualizerStyle}; use ratatui::{ buffer::Buffer, @@ -14,7 +14,7 @@ use tui_bar_graph::{BarGraph, BarStyle, ColorMode}; use tui_equalizer::{Band, Equalizer}; pub fn draw(f: &mut Frame<'_>, app: &App) { - let margin = util::get_main_layout_margin(app); + let margin = main_layout_margin(app); let [info_area, visualizer_area] = f .area() diff --git a/src/tui/ui/columns.rs b/src/tui/ui/columns.rs new file mode 100644 index 00000000..114bbdb4 --- /dev/null +++ b/src/tui/ui/columns.rs @@ -0,0 +1,283 @@ +use crate::core::user_config::ColumnSpec; + +use super::tables::ColumnId; +use super::util::get_percentage_width; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum TableColumnSet { + Songs, + AlbumTracks, + Albums, + Podcasts, + Episodes, + RecentlyPlayed, +} + +#[derive(Clone, Copy, PartialEq)] +enum WidthSpec { + Fixed(u16), + Percent(f32), + PercentMinus(f32, u16), +} + +#[derive(Clone, Copy)] +struct ColumnDef { + id: &'static str, + header: &'static str, + width: WidthSpec, + column_id: ColumnId, +} + +#[derive(Clone)] +pub struct ResolvedColumn { + pub id: String, + pub header: String, + pub width: u16, + pub column_id: ColumnId, +} + +pub fn resolve_columns( + table: TableColumnSet, + layout_width: u16, + configured: &[ColumnSpec], +) -> Vec { + let specs = if configured.is_empty() { + default_specs(table) + } else { + configured.to_vec() + }; + + specs + .iter() + .filter_map(|spec| { + let def = column_def(table, &spec.id)?; + Some(ResolvedColumn { + id: spec.id.clone(), + header: spec + .header + .clone() + .unwrap_or_else(|| def.header.to_string()), + width: spec.width.unwrap_or_else(|| { + spec.width_percent.map_or_else( + || resolve_width(layout_width, def.width), + |pct| get_percentage_width(layout_width, pct / 100.0), + ) + }), + column_id: def.column_id, + }) + }) + .collect() +} + +fn default_specs(table: TableColumnSet) -> Vec { + default_column_ids(table) + .iter() + .map(|id| ColumnSpec { + id: (*id).to_string(), + ..Default::default() + }) + .collect() +} + +fn default_column_ids(table: TableColumnSet) -> &'static [&'static str] { + match table { + TableColumnSet::Songs => &["liked", "title", "artist", "album", "length"], + TableColumnSet::AlbumTracks => &["liked", "index", "title", "artist", "length"], + TableColumnSet::Albums => &["title", "artist", "date"], + TableColumnSet::Podcasts => &["title", "publisher"], + TableColumnSet::Episodes => &["played", "date", "title", "duration"], + TableColumnSet::RecentlyPlayed => &["liked", "title", "artist", "length"], + } +} + +fn resolve_width(layout_width: u16, spec: WidthSpec) -> u16 { + match spec { + WidthSpec::Fixed(width) => width, + WidthSpec::Percent(percent) => get_percentage_width(layout_width, percent), + WidthSpec::PercentMinus(percent, minus) => { + get_percentage_width(layout_width, percent).saturating_sub(minus) + } + } +} + +fn column_def(table: TableColumnSet, id: &str) -> Option { + column_defs(table).iter().find(|def| def.id == id).copied() +} + +fn column_defs(table: TableColumnSet) -> &'static [ColumnDef] { + match table { + TableColumnSet::Songs => &SONG_COLUMNS, + TableColumnSet::AlbumTracks => &ALBUM_TRACK_COLUMNS, + TableColumnSet::Albums => &ALBUM_COLUMNS, + TableColumnSet::Podcasts => &PODCAST_COLUMNS, + TableColumnSet::Episodes => &EPISODE_COLUMNS, + TableColumnSet::RecentlyPlayed => &RECENTLY_PLAYED_COLUMNS, + } +} + +const SONG_COLUMNS: [ColumnDef; 6] = [ + liked_column(), + index_column(), + title_column(WidthSpec::Percent(0.3), "Title"), + artist_column(WidthSpec::Percent(0.3)), + album_column(WidthSpec::Percent(0.3)), + length_column(WidthSpec::Percent(0.1), "Length"), +]; + +const ALBUM_TRACK_COLUMNS: [ColumnDef; 6] = [ + liked_column(), + index_column(), + title_column(WidthSpec::PercentMinus(2.0 / 5.0, 5), "Title"), + artist_column(WidthSpec::Percent(2.0 / 5.0)), + album_column(WidthSpec::Percent(0.3)), + length_column(WidthSpec::Percent(1.0 / 5.0), "Length"), +]; + +const ALBUM_COLUMNS: [ColumnDef; 4] = [ + title_column(WidthSpec::Percent(2.0 / 5.0), "Name"), + artist_column(WidthSpec::Percent(2.0 / 5.0)), + ColumnDef { + id: "date", + header: "Release Date", + width: WidthSpec::Percent(1.0 / 5.0), + column_id: ColumnId::None, + }, + liked_column(), +]; + +const PODCAST_COLUMNS: [ColumnDef; 2] = [ + title_column(WidthSpec::Percent(2.0 / 5.0), "Name"), + ColumnDef { + id: "publisher", + header: "Publisher(s)", + width: WidthSpec::Percent(2.0 / 5.0), + column_id: ColumnId::None, + }, +]; + +const EPISODE_COLUMNS: [ColumnDef; 4] = [ + ColumnDef { + id: "played", + header: "", + width: WidthSpec::Fixed(2), + column_id: ColumnId::None, + }, + ColumnDef { + id: "date", + header: "Date", + width: WidthSpec::PercentMinus(0.5 / 5.0, 2), + column_id: ColumnId::None, + }, + title_column(WidthSpec::Percent(3.5 / 5.0), "Name"), + ColumnDef { + id: "duration", + header: "Duration", + width: WidthSpec::Percent(1.0 / 5.0), + column_id: ColumnId::None, + }, +]; + +const RECENTLY_PLAYED_COLUMNS: [ColumnDef; 6] = [ + liked_column(), + index_column(), + title_column(WidthSpec::PercentMinus(2.0 / 5.0, 2), "Title"), + artist_column(WidthSpec::Percent(2.0 / 5.0)), + album_column(WidthSpec::Percent(0.3)), + length_column(WidthSpec::Percent(1.0 / 5.0), "Length"), +]; + +const fn liked_column() -> ColumnDef { + ColumnDef { + id: "liked", + header: "", + width: WidthSpec::Fixed(2), + column_id: ColumnId::Liked, + } +} + +const fn index_column() -> ColumnDef { + ColumnDef { + id: "index", + header: "#", + width: WidthSpec::Fixed(3), + column_id: ColumnId::None, + } +} + +const fn title_column(width: WidthSpec, header: &'static str) -> ColumnDef { + ColumnDef { + id: "title", + header, + width, + column_id: ColumnId::Title, + } +} + +const fn artist_column(width: WidthSpec) -> ColumnDef { + ColumnDef { + id: "artist", + header: "Artist", + width, + column_id: ColumnId::None, + } +} + +const fn album_column(width: WidthSpec) -> ColumnDef { + ColumnDef { + id: "album", + header: "Album", + width, + column_id: ColumnId::None, + } +} + +const fn length_column(width: WidthSpec, header: &'static str) -> ColumnDef { + ColumnDef { + id: "length", + header, + width, + column_id: ColumnId::None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_are_rendered_when_config_is_empty() { + let columns = resolve_columns(TableColumnSet::Songs, 100, &[]); + assert_eq!( + columns.iter().map(|c| c.id.as_str()).collect::>(), + ["liked", "title", "artist", "album", "length"] + ); + } + + #[test] + fn configured_columns_can_reorder_and_override_headers() { + let columns = resolve_columns( + TableColumnSet::Songs, + 100, + &[ + ColumnSpec { + id: "artist".to_string(), + header: Some("Band".to_string()), + width_percent: Some(30.0), + width: None, + }, + ColumnSpec { + id: "title".to_string(), + header: None, + width_percent: None, + width: Some(40), + }, + ], + ); + assert_eq!(columns[0].id, "artist"); + assert_eq!(columns[0].header, "Band"); + assert_eq!(columns[0].width, 29); + assert_eq!(columns[1].id, "title"); + assert_eq!(columns[1].width, 40); + assert_eq!(columns[1].column_id, ColumnId::Title); + } +} diff --git a/src/tui/ui/create_playlist.rs b/src/tui/ui/create_playlist.rs index fc967aaa..369705fc 100644 --- a/src/tui/ui/create_playlist.rs +++ b/src/tui/ui/create_playlist.rs @@ -33,7 +33,7 @@ fn draw_name_stage(f: &mut Frame<'_>, app: &App, area: Rect) { "Create Playlist (Esc to cancel)", Style::default() .fg(theme.header) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), )) .borders(Borders::ALL) .style(theme.base_style()) @@ -83,7 +83,7 @@ fn draw_add_tracks_stage(f: &mut Frame<'_>, app: &App, area: Rect) { title, Style::default() .fg(theme.header) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), )) .borders(Borders::ALL) .style(theme.base_style()) @@ -161,7 +161,7 @@ fn draw_add_tracks_stage(f: &mut Frame<'_>, app: &App, area: Rect) { .highlight_style( Style::default() .fg(theme.selected) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), ) .style(theme.base_style()); f.render_stateful_widget(results_list, panels[0], &mut results_state); @@ -206,7 +206,7 @@ fn draw_add_tracks_stage(f: &mut Frame<'_>, app: &App, area: Rect) { .highlight_style( Style::default() .fg(theme.selected) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), ) .style(theme.base_style()); f.render_stateful_widget(added_list, panels[1], &mut added_state); diff --git a/src/tui/ui/discover.rs b/src/tui/ui/discover.rs index d46d3647..29462720 100644 --- a/src/tui/ui/discover.rs +++ b/src/tui/ui/discover.rs @@ -106,7 +106,10 @@ pub fn draw_discover(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { )) .border_style(get_color(highlight_state, app.user_config.theme)), ) - .highlight_style(get_color(highlight_state, app.user_config.theme).add_modifier(Modifier::BOLD)) + .highlight_style( + get_color(highlight_state, app.user_config.theme) + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), + ) .highlight_symbol(Line::from("▶ ").style(get_color(highlight_state, app.user_config.theme))); f.render_stateful_widget(list, list_area, &mut state); diff --git a/src/tui/ui/friends.rs b/src/tui/ui/friends.rs index 40ea80a8..faff834b 100644 --- a/src/tui/ui/friends.rs +++ b/src/tui/ui/friends.rs @@ -22,7 +22,8 @@ pub fn draw_friends(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { .border_style(get_color(highlight_state, app.user_config.theme)) .title(Span::styled( " Friends ", - get_color(highlight_state, app.user_config.theme).add_modifier(Modifier::BOLD), + get_color(highlight_state, app.user_config.theme) + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), )); let inner = outer_block.inner(layout_chunk); @@ -63,7 +64,7 @@ fn draw_no_token_prompt(f: &mut Frame<'_>, app: &App, area: Rect) { "Account Required", Style::default() .fg(theme.banner) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), )), Line::default(), Line::from(Span::styled( @@ -79,7 +80,9 @@ fn draw_no_token_prompt(f: &mut Frame<'_>, app: &App, area: Rect) { Span::styled(" Sign up at: ", Style::default().fg(theme.inactive)), Span::styled( "https://spotatui.com", - Style::default().fg(theme.hint).add_modifier(Modifier::BOLD), + Style::default() + .fg(theme.hint) + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), ), ]), Line::default(), @@ -116,11 +119,13 @@ fn draw_friend_code_card(f: &mut Frame<'_>, app: &App, area: Rect) { " YOUR CODE ", Style::default() .fg(theme.inactive) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), ), Span::styled( code_text, - Style::default().fg(theme.hint).add_modifier(Modifier::BOLD), + Style::default() + .fg(theme.hint) + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), ), Span::raw(" "), Span::styled(hint, Style::default().fg(theme.inactive)), @@ -186,14 +191,14 @@ fn draw_filter_tabs(f: &mut Frame<'_>, app: &App, area: Rect) { let all_style = if app.friend_filter == FriendFilter::All { Style::default() .fg(theme.selected) - .add_modifier(Modifier::BOLD) + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)) } else { Style::default().fg(theme.inactive) }; let online_style = if app.friend_filter == FriendFilter::Online { Style::default() .fg(theme.selected) - .add_modifier(Modifier::BOLD) + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)) } else { Style::default().fg(theme.inactive) }; @@ -241,7 +246,7 @@ fn draw_friend_list(f: &mut Frame<'_>, app: &App, area: Rect, friends: &[&Friend let name_style = if is_selected { Style::default() .fg(theme.selected) - .add_modifier(Modifier::BOLD) + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)) } else { Style::default().fg(theme.text) }; @@ -287,7 +292,7 @@ fn draw_friend_list(f: &mut Frame<'_>, app: &App, area: Rect, friends: &[&Friend .highlight_style( Style::default() .fg(theme.selected) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), ) .highlight_symbol("▶ "); @@ -347,7 +352,7 @@ fn draw_add_friend_dialog(f: &mut Frame<'_>, app: &App, parent: Rect) { " Add Friend ", Style::default() .fg(theme.active) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), )); let inner = block.inner(dialog_area); @@ -362,16 +367,22 @@ fn draw_add_friend_dialog(f: &mut Frame<'_>, app: &App, parent: Rect) { // Tab row let code_style = if app.friend_add_mode == FriendAddMode::Code { - Style::default() - .fg(theme.selected) - .add_modifier(Modifier::BOLD | Modifier::UNDERLINED) + Style::default().fg(theme.selected).add_modifier( + app + .user_config + .behavior + .emphasis(Modifier::BOLD | Modifier::UNDERLINED), + ) } else { Style::default().fg(theme.inactive) }; let search_style = if app.friend_add_mode == FriendAddMode::Search { - Style::default() - .fg(theme.selected) - .add_modifier(Modifier::BOLD | Modifier::UNDERLINED) + Style::default().fg(theme.selected).add_modifier( + app + .user_config + .behavior + .emphasis(Modifier::BOLD | Modifier::UNDERLINED), + ) } else { Style::default().fg(theme.inactive) }; @@ -411,7 +422,9 @@ fn draw_add_by_code(f: &mut Frame<'_>, app: &App, area: Rect) { let style = if input.is_empty() { Style::default().fg(theme.inactive) } else { - Style::default().fg(theme.hint).add_modifier(Modifier::BOLD) + Style::default() + .fg(theme.hint) + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)) }; let [_, input_row, hint_row, _] = area.layout(&Layout::vertical([ @@ -492,7 +505,7 @@ fn draw_add_by_search(f: &mut Frame<'_>, app: &App, area: Rect) { let style = if is_sel { Style::default() .fg(theme.selected) - .add_modifier(Modifier::BOLD) + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)) } else { Style::default().fg(theme.text) }; @@ -509,7 +522,7 @@ fn draw_add_by_search(f: &mut Frame<'_>, app: &App, area: Rect) { let list = List::new(items).highlight_style( Style::default() .fg(theme.selected) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), ); f.render_stateful_widget(list, results_area, &mut state); } diff --git a/src/tui/ui/library.rs b/src/tui/ui/library.rs index 85b1ab8e..347066c1 100644 --- a/src/tui/ui/library.rs +++ b/src/tui/ui/library.rs @@ -1,15 +1,12 @@ use crate::core::app::{ActiveBlock, App, LIBRARY_OPTIONS}; -use crate::core::layout::library_constraints; +use crate::core::layout::{is_wide_layout, library_constraints, split_input_help_and_settings}; use crate::core::source::Source; use ratatui::{ layout::{Constraint, Layout, Rect}, Frame, }; -use super::{ - search::draw_input_and_help_box, - util::{draw_selectable_list, SMALL_TERMINAL_WIDTH}, -}; +use super::{search::draw_input_and_help_box, util::draw_selectable_list}; pub fn draw_library_block(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { let current_route = app.get_current_route(); @@ -189,12 +186,14 @@ pub fn draw_user_block(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { || app.active_source == Source::Radio || app.active_source == Source::YouTube { - if app.size.width >= SMALL_TERMINAL_WIDTH && !app.user_config.behavior.enforce_wide_search_bar { + if is_wide_layout(app) { let [input_area, playlist_area] = layout_chunk.layout(&Layout::vertical([ Constraint::Length(3), Constraint::Min(0), ])); - draw_input_and_help_box(f, app, input_area); + let [input_text_area, help_area, settings_area] = + split_input_help_and_settings(app, input_area); + draw_input_and_help_box(f, app, input_text_area, help_area, settings_area); draw_playlist_block(f, app, playlist_area); } else { draw_playlist_block(f, app, layout_chunk); @@ -203,7 +202,7 @@ pub fn draw_user_block(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { } // Check for width to make a responsive layout - if app.size.width >= SMALL_TERMINAL_WIDTH && !app.user_config.behavior.enforce_wide_search_bar { + if is_wide_layout(app) { let lib_constraints = library_constraints(&app.user_config.behavior); let [input_area, library_area, playlist_area] = layout_chunk.layout(&Layout::vertical([ Constraint::Length(3), @@ -212,7 +211,9 @@ pub fn draw_user_block(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { ])); // Search input and help - draw_input_and_help_box(f, app, input_area); + let [input_text_area, help_area, settings_area] = + split_input_help_and_settings(app, input_area); + draw_input_and_help_box(f, app, input_text_area, help_area, settings_area); draw_library_block(f, app, library_area); draw_playlist_block(f, app, playlist_area); } else { diff --git a/src/tui/ui/mod.rs b/src/tui/ui/mod.rs index 9bbc4c33..4d7417fd 100644 --- a/src/tui/ui/mod.rs +++ b/src/tui/ui/mod.rs @@ -1,5 +1,6 @@ pub mod artist; pub mod audio_analysis; +pub mod columns; pub mod create_playlist; pub mod discover; pub mod friends; @@ -14,11 +15,8 @@ pub mod tables; pub mod util; use crate::core::app::{App, RouteId}; -use crate::core::layout::{playbar_constraint, sidebar_constraints}; -use ratatui::{ - layout::{Constraint, Layout, Rect}, - Frame, -}; +use crate::core::layout::{compute_main_layout, is_wide_layout}; +use ratatui::{layout::Rect, Frame}; pub use self::artist::draw_artist_albums; pub use self::create_playlist::draw_create_playlist_form; @@ -39,43 +37,20 @@ pub use self::tables::{ draw_album_list, draw_album_table, draw_artist_table, draw_local_browser, draw_podcast_table, draw_recently_played_table, draw_recommendations_table, draw_show_episodes, draw_song_table, }; -use self::util::{get_main_layout_margin, SMALL_TERMINAL_WIDTH}; - pub fn draw_main_layout(f: &mut Frame<'_>, app: &App) { - let margin = get_main_layout_margin(app); - // Responsive layout: new one kicks in at width 150 or higher - if app.size.width >= SMALL_TERMINAL_WIDTH && !app.user_config.behavior.enforce_wide_search_bar { - let [routes_area, playbar_area] = f.area().layout( - &Layout::vertical([ - Constraint::Min(1), - playbar_constraint(&app.user_config.behavior), - ]) - .margin(margin), - ); - - // Nested main block with potential routes - draw_routes(f, app, routes_area); - - // Currently playing - draw_playbar(f, app, playbar_area); - } else { - let [input_area, routes_area, playbar_area] = f.area().layout( - &Layout::vertical([ - Constraint::Length(3), - Constraint::Min(1), - playbar_constraint(&app.user_config.behavior), - ]) - .margin(margin), - ); - - // Search input and help - draw_input_and_help_box(f, app, input_area); - - // Nested main block with potential routes - draw_routes(f, app, routes_area); - - // Currently playing - draw_playbar(f, app, playbar_area); + if let Some(areas) = compute_main_layout(app) { + // In wide layout the input row lives inside the sidebar and is drawn by + // draw_user_block (it varies per source); the top row is only drawn here. + if !is_wide_layout(app) { + if let (Some(input_area), Some(help_area), Some(settings_area)) = + (areas.input, areas.help, areas.settings) + { + draw_input_and_help_box(f, app, input_area, help_area, settings_area); + } + } + draw_user_block(f, app, areas.sidebar); + draw_route_content(f, app, areas.content); + draw_playbar(f, app, areas.playbar); } // Possibly draw confirm dialog @@ -85,13 +60,7 @@ pub fn draw_main_layout(f: &mut Frame<'_>, app: &App) { draw_sort_menu(f, app); } -pub fn draw_routes(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - let [user_area, content_area] = layout_chunk.layout(&Layout::horizontal(sidebar_constraints( - &app.user_config.behavior, - ))); - - draw_user_block(f, app, user_area); - +fn draw_route_content(f: &mut Frame<'_>, app: &App, content_area: Rect) { let current_route = app.get_current_route(); match current_route.id { @@ -153,3 +122,71 @@ pub fn draw_routes(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { RouteId::CreatePlaylist => {} // This is drawn as an overlay via draw_create_playlist_form. }; } + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::app::ActiveBlock; + use ratatui::{backend::TestBackend, buffer::Buffer, layout::Size, Terminal}; + + fn render(width: u16, height: u16) -> (App, Buffer) { + let mut app = App::default(); + app.size = Size { width, height }; + app.push_navigation_stack(RouteId::Home, ActiveBlock::Home); + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal.draw(|f| draw_main_layout(f, &app)).unwrap(); + let buffer = terminal.backend().buffer().clone(); + (app, buffer) + } + + fn row_text(buffer: &Buffer, y: u16) -> String { + (0..buffer.area().width) + .filter_map(|x| buffer.cell((x, y)).map(|c| c.symbol().to_string())) + .collect() + } + + // The drawn input/help/settings boxes and sidebar panels must land exactly + // where `compute_main_layout` puts the mouse hitboxes, and the search box + // must be drawn once (a second copy used to overlay the Library panel in + // wide layout, shifting it down three rows). + #[test] + fn wide_layout_draws_input_row_and_library_at_hitbox_positions() { + let (app, buffer) = render(160, 50); + let areas = compute_main_layout(&app).expect("layout areas"); + let input = areas.input.expect("input area"); + + let input_row = row_text(&buffer, input.y); + assert!(input_row.contains("Search"), "input row: {input_row}"); + assert!(input_row.contains("Help"), "input row: {input_row}"); + assert!(input_row.contains("Settings"), "input row: {input_row}"); + + let library_row = row_text(&buffer, areas.library.y); + assert!( + library_row.contains("Library"), + "library panel not at hitbox row: {library_row}" + ); + let playlists_row = row_text(&buffer, areas.playlists.y); + assert!( + playlists_row.contains("Playlists"), + "playlists panel not at hitbox row: {playlists_row}" + ); + } + + #[test] + fn narrow_layout_draws_input_row_and_library_at_hitbox_positions() { + let (app, buffer) = render(100, 40); + let areas = compute_main_layout(&app).expect("layout areas"); + let input = areas.input.expect("input area"); + + let input_row = row_text(&buffer, input.y); + assert!(input_row.contains("Search"), "input row: {input_row}"); + assert!(input_row.contains("Help"), "input row: {input_row}"); + assert!(input_row.contains("Settings"), "input row: {input_row}"); + + let library_row = row_text(&buffer, areas.library.y); + assert!( + library_row.contains("Library"), + "library panel not at hitbox row: {library_row}" + ); + } +} diff --git a/src/tui/ui/player.rs b/src/tui/ui/player.rs index c19866da..078f5a85 100644 --- a/src/tui/ui/player.rs +++ b/src/tui/ui/player.rs @@ -5,7 +5,7 @@ use crate::core::{ }; use ratatui::{ layout::{Alignment, Constraint, Layout, Position, Rect}, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span, Text}, widgets::{ canvas::Canvas, Block, BorderType, Borders, LineGauge, List, ListItem, ListState, Paragraph, @@ -52,7 +52,8 @@ pub(crate) enum PlaybarControl { } impl PlaybarControl { - const fn button_label(self) -> &'static str { + /// The built-in (default) label for this control. + const fn default_label(self) -> &'static str { match self { Self::Prev => "[Prev]", Self::PlayPause => "[Play/Pause]", @@ -64,6 +65,33 @@ impl PlaybarControl { Self::VolumeUp => "[Vol+]", } } + + /// The `playbar_control_labels` map key for this control. + const fn config_key(self) -> &'static str { + match self { + Self::Prev => "prev", + Self::PlayPause => "play_pause", + Self::Next => "next", + Self::Shuffle => "shuffle", + Self::Repeat => "repeat", + Self::Like => "like", + Self::VolumeDown => "vol_down", + Self::VolumeUp => "vol_up", + } + } + + /// The label for this control, honoring a `playbar_control_labels` override + /// from config (falling back to the built-in default). Mouse hit-testing + /// uses the same resolver, so hitboxes always match what's rendered. + fn label( + self, + behavior: &crate::core::user_config::BehaviorConfig, + ) -> std::borrow::Cow<'static, str> { + match behavior.playbar_control_labels.get(self.config_key()) { + Some(override_label) => std::borrow::Cow::Owned(override_label.clone()), + None => std::borrow::Cow::Borrowed(self.default_label()), + } + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -134,8 +162,12 @@ fn playbar_layout_areas(app: &App, layout_chunk: Rect) -> PlaybarLayoutAreas { ); if let Some(rendered_size) = app.cover_art.size_for(cover_layout.image_area) { let cover_layout = cover_layout.with_rendered_size(rendered_size); - let (artist_area, controls_area, progress_area) = - split_cover_playbar_rows(other, cover_layout.text_area, cover_layout.image_area); + let (artist_area, controls_area, progress_area) = split_cover_playbar_rows( + other, + cover_layout.text_area, + cover_layout.image_area, + &app.user_config.behavior, + ); return PlaybarLayoutAreas { artist_area, @@ -248,7 +280,12 @@ fn bottom_aligned_rect_within(bounds: Rect, size: Rect) -> Rect { } #[cfg(feature = "cover-art")] -fn split_cover_playbar_rows(inner: Rect, text_area: Rect, image_area: Rect) -> (Rect, Rect, Rect) { +fn split_cover_playbar_rows( + inner: Rect, + text_area: Rect, + image_area: Rect, + behavior: &crate::core::user_config::BehaviorConfig, +) -> (Rect, Rect, Rect) { if inner.width == 0 || inner.height == 0 || text_area.width == 0 || text_area.height == 0 { let empty = Rect::new(text_area.x, text_area.y, text_area.width, 0); return (empty, empty, empty); @@ -269,7 +306,8 @@ fn split_cover_playbar_rows(inner: Rect, text_area: Rect, image_area: Rect) -> ( ) }; - let controls_area = cover_playbar_controls_area(inner, text_area, image_area, progress_area); + let controls_area = + cover_playbar_controls_area(inner, text_area, image_area, progress_area, behavior); let artist_area = cover_playbar_artist_area(text_area, image_area, controls_area, progress_area); (artist_area, controls_area, progress_area) @@ -281,8 +319,9 @@ fn cover_playbar_controls_area( text_area: Rect, image_area: Rect, progress_area: Rect, + behavior: &crate::core::user_config::BehaviorConfig, ) -> Rect { - let required_width = playbar_controls_required_width(); + let required_width = playbar_controls_required_width(behavior); let controls_y = progress_area.y.saturating_sub(1); let image_bottom = image_area.y.saturating_add(image_area.height); @@ -317,12 +356,15 @@ fn cover_playbar_artist_area( Rect::new(text_area.x, y, text_area.width, height) } -fn playbar_control_hitboxes_in_area(controls_area: Rect) -> Vec { +fn playbar_control_hitboxes_in_area( + controls_area: Rect, + behavior: &crate::core::user_config::BehaviorConfig, +) -> Vec { if controls_area.width == 0 || controls_area.height == 0 { return Vec::new(); } - let required_width = playbar_controls_required_width(); + let required_width = playbar_controls_required_width(behavior); let start_x = if controls_area.width > required_width { controls_area .x @@ -337,7 +379,8 @@ fn playbar_control_hitboxes_in_area(controls_area: Rect) -> Vec right { break; } @@ -356,14 +399,14 @@ fn playbar_control_hitboxes_in_area(controls_area: Rect) -> Vec u16 { +fn playbar_controls_required_width(behavior: &crate::core::user_config::BehaviorConfig) -> u16 { PLAYBAR_CONTROLS .iter() .enumerate() .fold(0u16, |width, (idx, control)| { - width - .saturating_add(u16::from(idx > 0)) - .saturating_add(control.button_label().len() as u16) + width.saturating_add(u16::from(idx > 0)).saturating_add( + unicode_width::UnicodeWidthStr::width(control.label(behavior).as_ref()) as u16, + ) }) } @@ -376,10 +419,11 @@ pub(crate) fn playbar_control_hitboxes( } let controls_area = playbar_layout_areas(app, playbar_area).controls_area; - let mut hitboxes: Vec<(PlaybarControl, Rect)> = playbar_control_hitboxes_in_area(controls_area) - .into_iter() - .map(|hitbox| (hitbox.control, hitbox.rect)) - .collect(); + let mut hitboxes: Vec<(PlaybarControl, Rect)> = + playbar_control_hitboxes_in_area(controls_area, &app.user_config.behavior) + .into_iter() + .map(|hitbox| (hitbox.control, hitbox.rect)) + .collect(); // A non-Spotify source only has a verified-correct route for Play/Pause // (`App::toggle_playback` branches on the owning source before falling @@ -545,8 +589,11 @@ pub(crate) fn playbar_progress_line(app: &App, playbar_area: Rect) -> Option, app: &App, controls_area: Rect) { let controls_style = Style::default().fg(app.user_config.theme.playbar_text); - for hitbox in playbar_control_hitboxes_in_area(controls_area) { - let control = Paragraph::new(Span::styled(hitbox.control.button_label(), controls_style)); + for hitbox in playbar_control_hitboxes_in_area(controls_area, &app.user_config.behavior) { + let control = Paragraph::new(Span::styled( + hitbox.control.label(&app.user_config.behavior).into_owned(), + controls_style, + )); f.render_widget(control, hitbox.rect); } } @@ -606,7 +653,7 @@ fn draw_cover_art_content(f: &mut Frame<'_>, app: &App, area: Rect) { CoverArtStatus::Loaded | CoverArtStatus::NotStarted => "No cover art available", }; let p = Paragraph::new(message) - .style(Style::default().fg(Color::Rgb(100, 100, 100))) + .style(Style::default().fg(app.user_config.theme.inactive)) .alignment(Alignment::Center); let vertical_center = area.y + area.height / 2; @@ -655,7 +702,7 @@ fn draw_cover_art_content(f: &mut Frame<'_>, app: &App, area: Rect) { .style( Style::default() .fg(app.user_config.theme.selected) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), ) .alignment(Alignment::Center); f.render_widget( @@ -710,7 +757,7 @@ fn draw_lyrics(f: &mut Frame<'_>, app: &App, area: Rect) { let block = Block::default() .borders(Borders::ALL) .title(" Lyrics ") - .style(Style::default().fg(Color::Rgb(100, 100, 100))); // RGB for cross-terminal compat + .style(Style::default().fg(app.user_config.theme.inactive)); f.render_widget(block.clone(), area); let inner_area = block.inner(area); @@ -725,7 +772,7 @@ fn draw_lyrics(f: &mut Frame<'_>, app: &App, area: Rect) { if !text.is_empty() { let p = Paragraph::new(text) - .style(Style::default().fg(Color::Rgb(100, 100, 100))) // RGB for cross-terminal compat + .style(Style::default().fg(app.user_config.theme.inactive)) .alignment(Alignment::Center); // Center vertically in inner area @@ -781,9 +828,9 @@ fn draw_lyrics(f: &mut Frame<'_>, app: &App, area: Rect) { let style = if is_active { Style::default() .fg(app.user_config.theme.highlighted_lyrics) // Use theme color for highlighted lyrics - .add_modifier(Modifier::BOLD) + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)) } else { - Style::default().fg(Color::Rgb(100, 100, 100)) // Dim gray for inactive lines + Style::default().fg(app.user_config.theme.inactive) // Dim gray for inactive lines }; let p = Paragraph::new(text.clone()) @@ -965,10 +1012,23 @@ fn render_local_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect, view: Some((current, total)) => format!(" | {current}/{total}"), None => String::new(), }; - let title = format!( - "{:-7} ({}{} | Volume: {:-2}%)", - play_title, view.source_label, queue_label, view.volume_percent - ); + // Build the title from the configurable playbar_status_source template. + // Values are pre-padded to match the original `{:-N}` format widths so the + // default template reproduces today's output byte-for-byte. + let title = app.user_config.format.playbar_status_source.render(&[ + // state — left-aligned to 7 cols (matches `{:-7}`) + &format!("{:<7}", play_title), + // device / source / queue + "", + view.source_label, + &queue_label, + // shuffle / repeat / volume / party — unused in the source template + "", + "", + // volume — left-aligned to 2 (matches `{:-2}`) + &format!("{:<2}", view.volume_percent), + "", + ]); let mut title_spans = vec![Span::styled( title, get_color(highlight_state, app.user_config.theme), @@ -1001,7 +1061,7 @@ fn render_local_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect, view: view.name.clone(), Style::default() .fg(app.user_config.theme.selected) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), )), ); f.render_widget(artist, playbar_areas.artist_area); @@ -1042,8 +1102,8 @@ fn render_local_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect, view: .add_modifier(modifier), ) .ratio(perc as f64 / 100.0) - .filled_symbol("⣿") - .unfilled_symbol("⣉") + .filled_symbol(app.user_config.behavior.gauge_filled_icon.as_str()) + .unfilled_symbol(app.user_config.behavior.gauge_unfilled_icon.as_str()) .label(Span::styled( &song_progress_label, Style::default().fg(app.user_config.theme.playbar_progress_text), @@ -1172,26 +1232,38 @@ pub fn draw_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { RepeatState::Context => "All", }; - let mut title = format!( - "{:-7} ({} | Shuffle: {:-3} | Repeat: {:-5} | Volume: {:-2}%)", - play_title, - current_playback_context.device.name, - shuffle_text, - repeat_text, - app.desired_volume() - ); - - if let Some(session) = &app.party_session { - let party_label = match session.role { + // Build the title from the configurable playbar_status template. + // Values are pre-padded to match the original `{:-N}` format widths so + // the default template reproduces today's output byte-for-byte. + let party_segment = if let Some(session) = &app.party_session { + match session.role { crate::infra::network::sync::PartyRole::Host => { - format!("Party: {} listeners", session.guests.len()) + format!(" | Party: {} listeners", session.guests.len()) } crate::infra::network::sync::PartyRole::Guest => { - format!("Party: following {}", session.host_name) + format!(" | Party: following {}", session.host_name) } - }; - title = format!("{} | {}", title, party_label); - } + } + } else { + String::new() + }; + let title = app.user_config.format.playbar_status.render(&[ + // state — left-aligned to 7 cols (matches `{:-7}`) + &format!("{:<7}", play_title), + // device + ¤t_playback_context.device.name, + // source / queue — unused in the Spotify template + "", + "", + // shuffle — left-aligned to 3 (matches `{:-3}`) + &format!("{:<3}", shuffle_text), + // repeat — left-aligned to 5 (matches `{:-5}`) + &format!("{:<5}", repeat_text), + // volume — left-aligned to 2 (matches `{:-2}`) + &format!("{:<2}", app.desired_volume()), + // party — pre-composed optional segment + &party_segment, + ]); let current_route = app.get_current_route(); let highlight_state = ( @@ -1274,7 +1346,7 @@ pub fn draw_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { }; let track_name = if app.liked_song_ids_set.contains(&item_id) { - format!("{}{}", &app.user_config.padded_liked_icon(), display_name) + format!("{}{}", app.user_config.padded_liked_icon(), display_name) } else { display_name }; @@ -1291,7 +1363,7 @@ pub fn draw_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { track_name, Style::default() .fg(app.user_config.theme.selected) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), )), ); f.render_widget(artist, artist_area); @@ -1323,8 +1395,8 @@ pub fn draw_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { .add_modifier(modifier), ) .ratio(perc as f64 / 100.0) - .filled_symbol("⣿") - .unfilled_symbol("⣉") + .filled_symbol(app.user_config.behavior.gauge_filled_icon.as_str()) + .unfilled_symbol(app.user_config.behavior.gauge_unfilled_icon.as_str()) .label(Span::styled( &song_progress_label, Style::default().fg(app.user_config.theme.playbar_progress_text), @@ -1333,28 +1405,34 @@ pub fn draw_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { // Draw "Like" animation (heart burst) if active if let Some(frame) = app.liked_song_animation_frame { - let progress = (10 - frame) as f64; + let total_frames = app.user_config.behavior.like_animation_frames.max(1); + let progress = (total_frames.saturating_sub(frame)) as f64; let y_base = 20.0 + progress * 5.0; // Rise up + let heart = app.user_config.behavior.liked_icon.clone(); + let heart_color = app.user_config.theme.selected; let canvas = Canvas::default() .block(Block::default()) // No border, transparent .x_bounds([0.0, 100.0]) .y_bounds([0.0, 100.0]) - .paint(|ctx| { - let color = app.user_config.theme.selected; + .paint(move |ctx| { // Center heart - ctx.print(50.0, y_base, Span::styled("♥", Style::default().fg(color))); + ctx.print( + 50.0, + y_base, + Span::styled(heart.clone(), Style::default().fg(heart_color)), + ); // Left particle (lagging slightly) ctx.print( 48.0, y_base - 3.0, - Span::styled("♥", Style::default().fg(color)), + Span::styled(heart.clone(), Style::default().fg(heart_color)), ); // Right particle (lagging slightly) ctx.print( 52.0, y_base - 3.0, - Span::styled("♥", Style::default().fg(color)), + Span::styled(heart.clone(), Style::default().fg(heart_color)), ); }); @@ -1485,7 +1563,7 @@ pub fn draw_device_list(f: &mut Frame<'_>, app: &App) { "Welcome to spotatui!", Style::default() .fg(app.user_config.theme.active) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), )), ); f.render_widget(instructions, instructions_area); @@ -1501,12 +1579,16 @@ pub fn draw_device_list(f: &mut Frame<'_>, app: &App) { .iter() .map(|s| { let is_active = *s == app.active_source; - let marker = if is_active { "●" } else { " " }; + let marker = if is_active { + app.user_config.behavior.active_source_icon.as_str() + } else { + " " + }; let suffix = if is_active { " (active)" } else { "" }; let style = if is_active { Style::default() .fg(app.user_config.theme.active) - .add_modifier(Modifier::BOLD) + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)) } else { app.user_config.theme.base_style() }; @@ -1535,7 +1617,7 @@ pub fn draw_device_list(f: &mut Frame<'_>, app: &App) { Style::default() .fg(app.user_config.theme.active) .bg(app.user_config.theme.inactive) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), ) .highlight_symbol(Line::from("▶ ").style(Style::default().fg(app.user_config.theme.active))); f.render_stateful_widget(source_list, source_area, &mut source_state); @@ -1591,7 +1673,7 @@ pub fn draw_device_list(f: &mut Frame<'_>, app: &App) { Style::default() .fg(app.user_config.theme.active) .bg(app.user_config.theme.inactive) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), ) .highlight_symbol(Line::from("▶ ").style(Style::default().fg(app.user_config.theme.active))); f.render_stateful_widget(device_list, devices_area, &mut state); @@ -1639,13 +1721,15 @@ mod tests { #[test] fn control_hitboxes_handle_zero_sized_area() { - assert!(playbar_control_hitboxes_in_area(Rect::new(0, 0, 0, 0)).is_empty()); - assert!(playbar_control_hitboxes_in_area(Rect::new(0, 0, 10, 0)).is_empty()); + let behavior = crate::core::user_config::UserConfig::new().behavior; + assert!(playbar_control_hitboxes_in_area(Rect::new(0, 0, 0, 0), &behavior).is_empty()); + assert!(playbar_control_hitboxes_in_area(Rect::new(0, 0, 10, 0), &behavior).is_empty()); } #[test] fn control_hitboxes_truncate_for_tiny_widths() { - let hitboxes = playbar_control_hitboxes_in_area(Rect::new(5, 10, 8, 1)); + let behavior = crate::core::user_config::UserConfig::new().behavior; + let hitboxes = playbar_control_hitboxes_in_area(Rect::new(5, 10, 8, 1), &behavior); assert_eq!(hitboxes.len(), 1); assert_eq!(hitboxes[0].control, PlaybarControl::Prev); assert_eq!(hitboxes[0].rect, Rect::new(5, 10, 6, 1)); @@ -1653,7 +1737,8 @@ mod tests { #[test] fn control_hitboxes_include_all_controls_when_wide_enough() { - let hitboxes = playbar_control_hitboxes_in_area(Rect::new(0, 0, 200, 1)); + let behavior = crate::core::user_config::UserConfig::new().behavior; + let hitboxes = playbar_control_hitboxes_in_area(Rect::new(0, 0, 200, 1), &behavior); assert_eq!(hitboxes.len(), PLAYBAR_CONTROLS.len()); assert_eq!(hitboxes[0].control, PlaybarControl::Prev); assert_eq!( @@ -1712,11 +1797,12 @@ mod tests { #[cfg(feature = "cover-art")] #[test] fn cover_playbar_progress_reclaims_the_row_below_the_cover() { + let behavior = crate::core::user_config::UserConfig::new().behavior; let inner = Rect::new(1, 3, 49, 4); let text_area = Rect::new(10, 3, 40, 4); let image_area = Rect::new(1, 3, 6, 3); let (artist_area, controls_area, progress_area) = - split_cover_playbar_rows(inner, text_area, image_area); + split_cover_playbar_rows(inner, text_area, image_area, &behavior); assert_eq!(artist_area, Rect::new(10, 3, 40, 2)); assert_eq!(controls_area, Rect::new(10, 3, 40, 0)); @@ -1726,11 +1812,12 @@ mod tests { #[cfg(feature = "cover-art")] #[test] fn cover_playbar_progress_stays_beside_a_full_height_cover() { + let behavior = crate::core::user_config::UserConfig::new().behavior; let inner = Rect::new(1, 3, 49, 4); let text_area = Rect::new(10, 3, 40, 4); let image_area = Rect::new(1, 3, 8, 4); let (artist_area, controls_area, progress_area) = - split_cover_playbar_rows(inner, text_area, image_area); + split_cover_playbar_rows(inner, text_area, image_area, &behavior); assert_eq!(artist_area, Rect::new(10, 3, 40, 2)); assert_eq!(controls_area, Rect::new(10, 3, 40, 0)); @@ -1740,11 +1827,12 @@ mod tests { #[cfg(feature = "cover-art")] #[test] fn cover_playbar_rows_reserve_full_width_controls_below_the_cover() { + let behavior = crate::core::user_config::UserConfig::new().behavior; let inner = Rect::new(1, 3, 109, 6); let text_area = Rect::new(14, 3, 96, 6); let image_area = Rect::new(1, 3, 10, 4); let (artist_area, controls_area, progress_area) = - split_cover_playbar_rows(inner, text_area, image_area); + split_cover_playbar_rows(inner, text_area, image_area, &behavior); assert_eq!(artist_area, Rect::new(14, 3, 96, 2)); assert_eq!(controls_area, Rect::new(1, 7, 109, 1)); diff --git a/src/tui/ui/popups.rs b/src/tui/ui/popups.rs index 49929beb..533a59f1 100644 --- a/src/tui/ui/popups.rs +++ b/src/tui/ui/popups.rs @@ -226,7 +226,10 @@ pub fn draw_queue(f: &mut Frame<'_>, app: &App) { .unwrap_or_else(|| "—".to_string()); items.push( ListItem::new(Line::from(vec![ - Span::styled("Now playing: ", style.add_modifier(Modifier::BOLD)), + Span::styled( + "Now playing: ", + style.add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), + ), Span::raw(now_text), ])) .style(style), @@ -303,7 +306,7 @@ pub fn draw_queue(f: &mut Frame<'_>, app: &App) { Style::default() .fg(app.user_config.theme.active) .bg(app.user_config.theme.inactive) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), ) .highlight_symbol(Line::from("▶ ").style(Style::default().fg(app.user_config.theme.active))); f.render_stateful_widget(list, area, &mut state); @@ -383,7 +386,7 @@ pub fn draw_dialog(f: &mut Frame<'_>, app: &App) { Line::from(Span::raw("Are you sure you want to delete the playlist: ")), Line::from(Span::styled( playlist.as_str(), - Style::default().add_modifier(Modifier::BOLD), + Style::default().add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), )), Line::from(Span::raw("?")), ]; @@ -396,11 +399,11 @@ pub fn draw_dialog(f: &mut Frame<'_>, app: &App) { Line::from(Span::raw("Remove this track from playlist?")), Line::from(Span::styled( format!("Track: {}", pending_remove.track_name), - Style::default().add_modifier(Modifier::BOLD), + Style::default().add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), )), Line::from(Span::styled( format!("Playlist: {}", pending_remove.playlist_name), - Style::default().add_modifier(Modifier::BOLD), + Style::default().add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), )), ]; draw_confirmation_dialog(f, app, "Remove Track", text, 60); @@ -413,7 +416,7 @@ pub fn draw_dialog(f: &mut Frame<'_>, app: &App) { Line::from(Span::raw("Use fallback shortcut for Open Settings?")), Line::from(Span::styled( format!("Save as: {}", persist.open_settings_key), - Style::default().add_modifier(Modifier::BOLD), + Style::default().add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), )), ]; draw_confirmation_dialog(f, app, "Save Shortcut Fallback", text, 66); @@ -448,7 +451,7 @@ fn draw_confirmation_dialog( title, Style::default() .fg(app.user_config.theme.header) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), )) .borders(Borders::ALL) .style(app.user_config.theme.base_style()) @@ -501,7 +504,7 @@ fn draw_add_track_to_playlist_picker_dialog(f: &mut Frame<'_>, app: &App) { "Add Track To Playlist", Style::default() .fg(app.user_config.theme.header) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), )) .borders(Borders::ALL) .style(app.user_config.theme.base_style()) @@ -609,7 +612,7 @@ pub fn draw_announcement_prompt(f: &mut Frame<'_>, app: &App) { let mut text = vec![ Line::from(Span::styled( format!("{} {}", level_label, announcement.title), - Style::default().add_modifier(Modifier::BOLD), + Style::default().add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), )), Line::from(""), ]; @@ -622,7 +625,7 @@ pub fn draw_announcement_prompt(f: &mut Frame<'_>, app: &App) { text.push(Line::from("")); text.push(Line::from(Span::styled( format!("More: {}", url), - Style::default().add_modifier(Modifier::ITALIC), + Style::default().add_modifier(app.user_config.behavior.emphasis(Modifier::ITALIC)), ))); } @@ -659,7 +662,7 @@ pub fn draw_exit_prompt(f: &mut Frame<'_>, app: &App) { let text = vec![ Line::from(Span::styled( "Exit spotatui?", - Style::default().add_modifier(Modifier::BOLD), + Style::default().add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), )), Line::from(""), Line::from("Press Y for Yes or N for No"), @@ -699,7 +702,7 @@ pub fn draw_sort_menu(f: &mut Frame<'_>, app: &App) { crate::core::sort::SortContext::PlaylistTracks => &app.playlist_sort, crate::core::sort::SortContext::SavedAlbums => &app.album_sort, crate::core::sort::SortContext::SavedArtists => &app.artist_sort, - crate::core::sort::SortContext::RecentlyPlayed => &app.playlist_sort, + crate::core::sort::SortContext::RecentlyPlayed => &app.recently_played_sort, }; let width = std::cmp::min(f.area().width.saturating_sub(4), 35); @@ -720,7 +723,13 @@ pub fn draw_sort_menu(f: &mut Frame<'_>, app: &App) { .map(|c| format!(" ({})", c)) .unwrap_or_default(); let indicator = if *field == current_sort.field { - format!(" {}", current_sort.order.indicator()) + format!( + " {}", + current_sort.order.indicator_icon( + &app.user_config.behavior.sort_ascending_icon, + &app.user_config.behavior.sort_descending_icon, + ) + ) } else { String::new() }; @@ -729,7 +738,7 @@ pub fn draw_sort_menu(f: &mut Frame<'_>, app: &App) { let style = if i == app.sort_menu_selected { Style::default() .fg(app.user_config.theme.active) - .add_modifier(Modifier::BOLD) + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)) } else if *field == current_sort.field { Style::default().fg(app.user_config.theme.hovered) } else { @@ -757,13 +766,13 @@ pub fn draw_sort_menu(f: &mut Frame<'_>, app: &App) { title, Style::default() .fg(app.user_config.theme.active) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), )), ) .highlight_style( Style::default() .fg(app.user_config.theme.active) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), ) .highlight_symbol(Line::from("▶ ").style(Style::default().fg(app.user_config.theme.active))); @@ -789,7 +798,7 @@ pub fn draw_party(f: &mut Frame<'_>, app: &App) { let style = app.user_config.theme.base_style(); let active_style = Style::default() .fg(app.user_config.theme.active) - .add_modifier(Modifier::BOLD); + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)); let hint_style = Style::default().fg(app.user_config.theme.hint); let mut lines: Vec = Vec::new(); @@ -1017,7 +1026,7 @@ pub fn draw_plugin_popup(f: &mut Frame<'_>, app: &App) { popup.title.clone(), Style::default() .fg(app.user_config.theme.header) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), )); let paragraph = Paragraph::new(ratatui_lines) diff --git a/src/tui/ui/search.rs b/src/tui/ui/search.rs index bed95bd4..f2fda48b 100644 --- a/src/tui/ui/search.rs +++ b/src/tui/ui/search.rs @@ -1,4 +1,5 @@ use crate::core::app::{ActiveBlock, App, InputContext, SearchResultBlock}; +use crate::core::layout::COMPACT_TOP_ROW_THRESHOLD; use ratatui::{ layout::{Constraint, Layout, Rect}, style::Style, @@ -12,41 +13,23 @@ use rspotify::prelude::Id; use super::util::{ draw_selectable_list, get_color, get_search_results_highlight_state, join_artist_names, - SMALL_TERMINAL_WIDTH, }; -const COMPACT_TOP_ROW_THRESHOLD: u16 = 60; -const COMPACT_HELP_WIDTH: u16 = 6; -const COMPACT_SETTINGS_WIDTH: u16 = 10; - -pub fn draw_input_and_help_box(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - let compact_top_row = layout_chunk.width < COMPACT_TOP_ROW_THRESHOLD; - - // Check for the width and change the constraints accordingly - let constraints = if compact_top_row { - [ - Constraint::Min(1), - Constraint::Length(COMPACT_HELP_WIDTH), - Constraint::Length(COMPACT_SETTINGS_WIDTH), - ] - } else if app.size.width >= SMALL_TERMINAL_WIDTH - && !app.user_config.behavior.enforce_wide_search_bar - { - [ - Constraint::Percentage(65), - Constraint::Percentage(18), - Constraint::Percentage(17), - ] - } else { - [ - Constraint::Percentage(80), - Constraint::Percentage(8), - Constraint::Percentage(12), - ] - }; - - let [input_area, help_area, settings_area] = - layout_chunk.layout(&Layout::horizontal(constraints)); +/// Draws the search input, Help button and Settings button into pre-split +/// areas (see `core::layout::split_input_help_and_settings`). Splitting is +/// owned by `compute_main_layout` so mouse hit-testing always matches. +pub fn draw_input_and_help_box( + f: &mut Frame<'_>, + app: &App, + input_area: Rect, + help_area: Rect, + settings_area: Rect, +) { + let row_width = input_area + .width + .saturating_add(help_area.width) + .saturating_add(settings_area.width); + let compact_top_row = row_width < COMPACT_TOP_ROW_THRESHOLD; let current_route = app.get_current_route(); diff --git a/src/tui/ui/settings.rs b/src/tui/ui/settings.rs index e245734b..1f7b101d 100644 --- a/src/tui/ui/settings.rs +++ b/src/tui/ui/settings.rs @@ -47,7 +47,7 @@ fn draw_category_tabs(f: &mut Frame<'_>, app: &App, area: Rect) { .highlight_style( Style::default() .fg(app.user_config.theme.selected) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), ) .style(app.user_config.theme.base_style()); @@ -111,7 +111,7 @@ fn draw_settings_list(f: &mut Frame<'_>, app: &App, area: Rect) { let name_style = if is_selected { Style::default() .fg(app.user_config.theme.selected) - .add_modifier(Modifier::BOLD) + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)) } else { Style::default().fg(app.user_config.theme.text) }; @@ -119,7 +119,7 @@ fn draw_settings_list(f: &mut Frame<'_>, app: &App, area: Rect) { let value_style = if is_editing { Style::default() .fg(app.user_config.theme.hint) - .add_modifier(Modifier::BOLD) + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)) } else if is_selected { Style::default().fg(app.user_config.theme.selected) } else { @@ -152,13 +152,13 @@ fn draw_settings_list(f: &mut Frame<'_>, app: &App, area: Rect) { .highlight_style( Style::default() .fg(app.user_config.theme.selected) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), ) .highlight_symbol( Line::from("▶ ").style( Style::default() .fg(app.user_config.theme.selected) - .add_modifier(Modifier::BOLD), + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), ), ); diff --git a/src/tui/ui/tables.rs b/src/tui/ui/tables.rs index 06e95d02..1e22d683 100644 --- a/src/tui/ui/tables.rs +++ b/src/tui/ui/tables.rs @@ -1,6 +1,7 @@ use crate::core::app::{ ActiveBlock, AlbumTableContext, App, EpisodeTableContext, RecommendationsContext, }; +use crate::core::plugin_api::{EpisodeInfo, SavedAlbumInfo, ShowInfo, TrackInfo}; use ratatui::{ layout::{Constraint, Rect}, style::{Modifier, Style}, @@ -11,6 +12,7 @@ use ratatui::{ use rspotify::model::PlayableItem; use rspotify::prelude::Id; +use super::columns::{resolve_columns, ResolvedColumn, TableColumnSet}; use super::util::{get_color, get_percentage_width, join_artist_names, millis_to_minutes}; #[derive(Clone, Copy, PartialEq, Eq)] @@ -24,7 +26,7 @@ pub enum TableId { PodcastEpisodes, } -#[derive(Default, PartialEq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum ColumnId { #[default] None, @@ -32,24 +34,25 @@ pub enum ColumnId { Liked, } -pub struct TableHeader<'a> { +pub struct TableHeader { pub id: TableId, - pub items: Vec>, + pub items: Vec, } -impl TableHeader<'_> { +impl TableHeader { pub fn get_index(&self, id: ColumnId) -> Option { self.items.iter().position(|item| item.id == id) } } #[derive(Default)] -pub struct TableHeaderItem<'a> { +pub struct TableHeaderItem { pub id: ColumnId, - pub text: &'a str, + pub text: String, pub width: u16, } +#[derive(Clone)] pub struct TableItem { pub id: String, pub format: Vec, @@ -61,6 +64,124 @@ struct AlbumUi { title: String, } +fn table_columns(app: &App, table: TableColumnSet, layout_width: u16) -> Vec { + let configured = match table { + TableColumnSet::Songs => &app.user_config.tables.songs, + TableColumnSet::AlbumTracks => &app.user_config.tables.album_tracks, + TableColumnSet::Albums => &app.user_config.tables.albums, + TableColumnSet::Podcasts => &app.user_config.tables.podcasts, + TableColumnSet::Episodes => &app.user_config.tables.episodes, + TableColumnSet::RecentlyPlayed => &app.user_config.tables.recently_played, + }; + resolve_columns(table, layout_width, configured) +} + +fn table_header(id: TableId, columns: &[ResolvedColumn]) -> TableHeader { + TableHeader { + id, + items: columns + .iter() + .map(|column| TableHeaderItem { + id: column.column_id, + text: column.header.clone(), + width: column.width, + }) + .collect(), + } +} + +fn track_table_item(track: &TrackInfo, columns: &[ResolvedColumn]) -> TableItem { + TableItem { + id: track.id.clone().unwrap_or_default(), + format: columns + .iter() + .map(|column| match column.id.as_str() { + "liked" => String::new(), + "index" => track.track_number.to_string(), + "title" => track.name.clone(), + "artist" => track.artists.join(", "), + "album" => track.album.clone(), + "length" => millis_to_minutes(track.duration_ms as u128), + _ => String::new(), + }) + .collect(), + } +} + +fn album_table_item(album: &SavedAlbumInfo, columns: &[ResolvedColumn], app: &App) -> TableItem { + let has_liked_column = columns.iter().any(|column| column.id == "liked"); + TableItem { + id: album.album.id.clone().unwrap_or_default(), + format: columns + .iter() + .map(|column| match column.id.as_str() { + "liked" => app.user_config.padded_liked_icon(), + "title" => { + if has_liked_column { + album.album.name.clone() + } else { + format!( + "{}{}", + app.user_config.padded_liked_icon(), + album.album.name + ) + } + } + "artist" => join_artist_names(&album.album.artists), + "date" => album.album.release_date.clone().unwrap_or_default(), + _ => String::new(), + }) + .collect(), + } +} + +fn podcast_table_item(show: &ShowInfo, columns: &[ResolvedColumn]) -> TableItem { + TableItem { + id: show.id.clone().unwrap_or_default(), + format: columns + .iter() + .map(|column| match column.id.as_str() { + "title" => show.name.clone(), + "publisher" => show.publisher.clone(), + _ => String::new(), + }) + .collect(), + } +} + +fn episode_table_item(episode: &EpisodeInfo, columns: &[ResolvedColumn], app: &App) -> TableItem { + let duration_ms = episode.duration_ms as u128; + let (played_str, time_str) = match &episode.resume_point { + Some(resume_point) => ( + if resume_point.fully_played { + format!(" {}", app.user_config.behavior.episode_played_icon) + } else { + String::new() + }, + format!( + "{} / {}", + millis_to_minutes(resume_point.resume_position_ms as u128), + millis_to_minutes(duration_ms) + ), + ), + None => (String::new(), millis_to_minutes(duration_ms)), + }; + + TableItem { + id: episode.id.clone().unwrap_or_default(), + format: columns + .iter() + .map(|column| match column.id.as_str() { + "played" => played_str.clone(), + "date" => episode.release_date.clone(), + "title" => episode.name.clone(), + "duration" => time_str.clone(), + _ => String::new(), + }) + .collect(), + } +} + /// Render the Local Files folder browser: a selectable list of folders (one per /// subdirectory of the configured music directory), each with its track count. pub fn draw_local_browser(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { @@ -103,7 +224,7 @@ pub fn draw_artist_table(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { let header = TableHeader { id: TableId::Artist, items: vec![TableHeaderItem { - text: "Artist", + text: "Artist".to_string(), width: get_percentage_width(layout_chunk.width, 1.0), ..Default::default() }], @@ -148,21 +269,8 @@ pub fn draw_artist_table(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { } pub fn draw_podcast_table(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - let header = TableHeader { - id: TableId::Podcast, - items: vec![ - TableHeaderItem { - text: "Name", - width: get_percentage_width(layout_chunk.width, 2.0 / 5.0), - ..Default::default() - }, - TableHeaderItem { - text: "Publisher(s)", - width: get_percentage_width(layout_chunk.width, 2.0 / 5.0), - ..Default::default() - }, - ], - }; + let columns = table_columns(app, TableColumnSet::Podcasts, layout_chunk.width); + let header = table_header(TableId::Podcast, &columns); let current_route = app.get_current_route(); @@ -175,10 +283,7 @@ pub fn draw_podcast_table(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { let items = saved_shows .items .iter() - .map(|show| TableItem { - id: show.id.clone().unwrap_or_default(), - format: vec![show.name.to_owned(), show.publisher.to_owned()], - }) + .map(|show| podcast_table_item(show, &columns)) .collect::>(); draw_table( @@ -194,36 +299,8 @@ pub fn draw_podcast_table(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { } pub fn draw_album_table(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - let header = TableHeader { - id: TableId::Album, - items: vec![ - TableHeaderItem { - id: ColumnId::Liked, - text: "", - width: 2, - }, - TableHeaderItem { - text: "#", - width: 3, - ..Default::default() - }, - TableHeaderItem { - id: ColumnId::Title, - text: "Title", - width: get_percentage_width(layout_chunk.width, 2.0 / 5.0) - 5, - }, - TableHeaderItem { - text: "Artist", - width: get_percentage_width(layout_chunk.width, 2.0 / 5.0), - ..Default::default() - }, - TableHeaderItem { - text: "Length", - width: get_percentage_width(layout_chunk.width, 1.0 / 5.0), - ..Default::default() - }, - ], - }; + let columns = table_columns(app, TableColumnSet::AlbumTracks, layout_chunk.width); + let header = table_header(TableId::Album, &columns); let current_route = app.get_current_route(); let highlight_state = ( @@ -241,16 +318,7 @@ pub fn draw_album_table(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { .tracks .items .iter() - .map(|item| TableItem { - id: item.id.clone().unwrap_or_default(), - format: vec![ - "".to_string(), - item.track_number.to_string(), - item.name.to_owned(), - item.artists.join(", "), - millis_to_minutes(item.duration_ms as u128), - ], - }) + .map(|item| track_table_item(item, &columns)) .collect::>(), title: format!( "{} by {}", @@ -266,16 +334,7 @@ pub fn draw_album_table(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { .album .tracks .iter() - .map(|item| TableItem { - id: item.id.clone().unwrap_or_default(), - format: vec![ - "".to_string(), - item.track_number.to_string(), - item.name.to_owned(), - item.artists.join(", "), - millis_to_minutes(item.duration_ms as u128), - ], - }) + .map(|item| track_table_item(item, &columns)) .collect::>(), title: format!( "{} by {}", @@ -302,36 +361,8 @@ pub fn draw_album_table(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { } pub fn draw_recommendations_table(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - let header = TableHeader { - id: TableId::Song, - items: vec![ - TableHeaderItem { - id: ColumnId::Liked, - text: "", - width: 2, - }, - TableHeaderItem { - id: ColumnId::Title, - text: "Title", - width: get_percentage_width(layout_chunk.width, 0.3), - }, - TableHeaderItem { - text: "Artist", - width: get_percentage_width(layout_chunk.width, 0.3), - ..Default::default() - }, - TableHeaderItem { - text: "Album", - width: get_percentage_width(layout_chunk.width, 0.3), - ..Default::default() - }, - TableHeaderItem { - text: "Length", - width: get_percentage_width(layout_chunk.width, 0.1), - ..Default::default() - }, - ], - }; + let columns = table_columns(app, TableColumnSet::Songs, layout_chunk.width); + let header = table_header(TableId::Song, &columns); let current_route = app.get_current_route(); let highlight_state = ( @@ -343,26 +374,17 @@ pub fn draw_recommendations_table(f: &mut Frame<'_>, app: &App, layout_chunk: Re .track_table .tracks .iter() - .map(|item| TableItem { - id: item.id.clone().unwrap_or_default(), - format: vec![ - "".to_string(), - item.name.to_owned(), - item.artists.join(", "), - item.album.clone(), - millis_to_minutes(item.duration_ms as u128), - ], - }) + .map(|item| track_table_item(item, &columns)) .collect::>(); // match RecommendedContext let recommendations_ui = match &app.recommendations_context { Some(RecommendationsContext::Song) => format!( "Recommendations based on Song \'{}\'", - &app.recommendations_seed + app.recommendations_seed ), Some(RecommendationsContext::Artist) => format!( "Recommendations based on Artist \'{}\'", - &app.recommendations_seed + app.recommendations_seed ), None => "Recommendations".to_string(), }; @@ -378,36 +400,8 @@ pub fn draw_recommendations_table(f: &mut Frame<'_>, app: &App, layout_chunk: Re } pub fn draw_song_table(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - let header = TableHeader { - id: TableId::Song, - items: vec![ - TableHeaderItem { - id: ColumnId::Liked, - text: "", - width: 2, - }, - TableHeaderItem { - id: ColumnId::Title, - text: "Title", - width: get_percentage_width(layout_chunk.width, 0.3), - }, - TableHeaderItem { - text: "Artist", - width: get_percentage_width(layout_chunk.width, 0.3), - ..Default::default() - }, - TableHeaderItem { - text: "Album", - width: get_percentage_width(layout_chunk.width, 0.3), - ..Default::default() - }, - TableHeaderItem { - text: "Length", - width: get_percentage_width(layout_chunk.width, 0.1), - ..Default::default() - }, - ], - }; + let columns = table_columns(app, TableColumnSet::Songs, layout_chunk.width); + let header = table_header(TableId::Song, &columns); let current_route = app.get_current_route(); let highlight_state = ( @@ -419,16 +413,7 @@ pub fn draw_song_table(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { .track_table .tracks .iter() - .map(|item| TableItem { - id: item.id.clone().unwrap_or_default(), - format: vec![ - "".to_string(), - item.name.to_owned(), - item.artists.join(", "), - item.album.clone(), - millis_to_minutes(item.duration_ms as u128), - ], - }) + .map(|item| track_table_item(item, &columns)) .collect::>(); let title = if app.is_playlist_track_table_context() { @@ -457,26 +442,8 @@ pub fn draw_song_table(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { } pub fn draw_album_list(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - let header = TableHeader { - id: TableId::AlbumList, - items: vec![ - TableHeaderItem { - text: "Name", - width: get_percentage_width(layout_chunk.width, 2.0 / 5.0), - ..Default::default() - }, - TableHeaderItem { - text: "Artists", - width: get_percentage_width(layout_chunk.width, 2.0 / 5.0), - ..Default::default() - }, - TableHeaderItem { - text: "Release Date", - width: get_percentage_width(layout_chunk.width, 1.0 / 5.0), - ..Default::default() - }, - ], - }; + let columns = table_columns(app, TableColumnSet::Albums, layout_chunk.width); + let header = table_header(TableId::AlbumList, &columns); let current_route = app.get_current_route(); @@ -491,18 +458,7 @@ pub fn draw_album_list(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { let items = saved_albums .items .iter() - .map(|saved_album| TableItem { - id: saved_album.album.id.clone().unwrap_or_default(), - format: vec![ - format!( - "{}{}", - app.user_config.padded_liked_icon(), - &saved_album.album.name - ), - join_artist_names(&saved_album.album.artists), - saved_album.album.release_date.clone().unwrap_or_default(), - ], - }) + .map(|saved_album| album_table_item(saved_album, &columns, app)) .collect::>(); draw_table( @@ -518,32 +474,8 @@ pub fn draw_album_list(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { } pub fn draw_show_episodes(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - let header = TableHeader { - id: TableId::PodcastEpisodes, - items: vec![ - TableHeaderItem { - // Column to mark an episode as fully played - text: "", - width: 2, - ..Default::default() - }, - TableHeaderItem { - text: "Date", - width: get_percentage_width(layout_chunk.width, 0.5 / 5.0) - 2, - ..Default::default() - }, - TableHeaderItem { - text: "Name", - width: get_percentage_width(layout_chunk.width, 3.5 / 5.0), - id: ColumnId::Title, - }, - TableHeaderItem { - text: "Duration", - width: get_percentage_width(layout_chunk.width, 1.0 / 5.0), - ..Default::default() - }, - ], - }; + let columns = table_columns(app, TableColumnSet::Episodes, layout_chunk.width); + let header = table_header(TableId::PodcastEpisodes, &columns); let current_route = app.get_current_route(); @@ -556,33 +488,7 @@ pub fn draw_show_episodes(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { let items = episodes .items .iter() - .map(|episode| { - let duration_ms = episode.duration_ms as u128; - let (played_str, time_str) = match &episode.resume_point { - Some(resume_point) => ( - if resume_point.fully_played { - " ✔".to_owned() - } else { - "".to_owned() - }, - format!( - "{} / {}", - millis_to_minutes(resume_point.resume_position_ms as u128), - millis_to_minutes(duration_ms) - ), - ), - None => ("".to_owned(), millis_to_minutes(duration_ms)), - }; - TableItem { - id: episode.id.clone().unwrap_or_default(), - format: vec![ - played_str, - episode.release_date.to_owned(), - episode.name.to_owned(), - time_str, - ], - } - }) + .map(|episode| episode_table_item(episode, &columns, app)) .collect::>(); let title = match &app.episode_table_context { @@ -619,32 +525,8 @@ pub fn draw_show_episodes(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { } pub fn draw_recently_played_table(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - let header = TableHeader { - id: TableId::RecentlyPlayed, - items: vec![ - TableHeaderItem { - id: ColumnId::Liked, - text: "", - width: 2, - }, - TableHeaderItem { - id: ColumnId::Title, - text: "Title", - // We need to subtract the fixed value of the previous column - width: get_percentage_width(layout_chunk.width, 2.0 / 5.0) - 2, - }, - TableHeaderItem { - text: "Artist", - width: get_percentage_width(layout_chunk.width, 2.0 / 5.0), - ..Default::default() - }, - TableHeaderItem { - text: "Length", - width: get_percentage_width(layout_chunk.width, 1.0 / 5.0), - ..Default::default() - }, - ], - }; + let columns = table_columns(app, TableColumnSet::RecentlyPlayed, layout_chunk.width); + let header = table_header(TableId::RecentlyPlayed, &columns); if let Some(recently_played) = &app.recently_played.result { let current_route = app.get_current_route(); @@ -659,15 +541,7 @@ pub fn draw_recently_played_table(f: &mut Frame<'_>, app: &App, layout_chunk: Re let items = recently_played .items .iter() - .map(|item| TableItem { - id: item.id.clone().unwrap_or_default(), - format: vec![ - "".to_string(), - item.name.clone(), - item.artists.join(", "), - millis_to_minutes(item.duration_ms as u128), - ], - }) + .map(|item| track_table_item(item, &columns)) .collect::>(); draw_table( @@ -717,7 +591,14 @@ fn draw_table( // Make sure that the selected item is visible on the page. Need to add some rows of padding // to chunk height for header and header space to get a true table height - let padding = 5; + // Clamp the configured padding to half the table height so an oversized + // value can never zero out the visible-row count (which would pin the + // scroll offset to 0 and make off-screen rows unreachable). + let padding = app + .user_config + .behavior + .table_scroll_padding + .min(layout_chunk.height / 2); let visible_rows = layout_chunk .height .checked_sub(padding) @@ -733,17 +614,20 @@ fn draw_table( // if table displays songs match header.id { TableId::Song | TableId::RecentlyPlayed | TableId::Album => { - // First check if the song should be highlighted because it is currently playing - if let Some(title_idx) = header.get_index(ColumnId::Title) { - if let Some(track_playing_offset_index) = - track_playing_index.and_then(|idx| idx.checked_sub(offset)) - { - if i == track_playing_offset_index { - formatted_row[title_idx] = format!("▶ {}", &formatted_row[title_idx]); - style = Style::default() - .fg(app.user_config.theme.active) - .add_modifier(Modifier::BOLD); + // First check if the song should be highlighted because it is currently playing. + // The marker goes on the title cell, falling back to the first cell when the + // user's column config omits `title` so the playing row is never unmarked. + if let Some(track_playing_offset_index) = + track_playing_index.and_then(|idx| idx.checked_sub(offset)) + { + if i == track_playing_offset_index { + let title_idx = header.get_index(ColumnId::Title).unwrap_or(0); + if let Some(cell) = formatted_row.get_mut(title_idx) { + cell.insert_str(0, &app.user_config.padded_playing_icon()); } + style = Style::default() + .fg(app.user_config.theme.active) + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)); } } @@ -755,16 +639,17 @@ fn draw_table( } } TableId::PodcastEpisodes => { - if let Some(name_idx) = header.get_index(ColumnId::Title) { - if let Some(track_playing_offset_index) = - track_playing_index.and_then(|idx| idx.checked_sub(offset)) - { - if i == track_playing_offset_index { - formatted_row[name_idx] = format!("▶ {}", &formatted_row[name_idx]); - style = Style::default() - .fg(app.user_config.theme.active) - .add_modifier(Modifier::BOLD); + if let Some(track_playing_offset_index) = + track_playing_index.and_then(|idx| idx.checked_sub(offset)) + { + if i == track_playing_offset_index { + let name_idx = header.get_index(ColumnId::Title).unwrap_or(0); + if let Some(cell) = formatted_row.get_mut(name_idx) { + cell.insert_str(0, &app.user_config.padded_playing_icon()); } + style = Style::default() + .fg(app.user_config.theme.active) + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)); } } } @@ -788,7 +673,7 @@ fn draw_table( let table = Table::new(rows, &widths) .header( - Row::new(header.items.iter().map(|h| h.text)) + Row::new(header.items.iter().map(|h| h.text.as_str())) .style(Style::default().fg(app.user_config.theme.header)), ) .block( diff --git a/src/tui/ui/util.rs b/src/tui/ui/util.rs index d6db5e19..e318a7c4 100644 --- a/src/tui/ui/util.rs +++ b/src/tui/ui/util.rs @@ -10,9 +10,6 @@ use ratatui::{ use rspotify::model::artist::SimplifiedArtist; use std::time::Duration; -pub const SMALL_TERMINAL_WIDTH: u16 = 150; -pub const SMALL_TERMINAL_HEIGHT: u16 = 45; - pub fn get_search_results_highlight_state( app: &App, block_to_match: SearchResultBlock, @@ -80,7 +77,10 @@ pub fn draw_selectable_list( get_color(highlight_state, app.user_config.theme) .add_modifier(Modifier::BOLD | Modifier::REVERSED), ) - .highlight_symbol(Line::from("▶ ").style(get_color(highlight_state, app.user_config.theme))); + .highlight_symbol( + Line::from(format!("{} ", app.user_config.behavior.list_highlight_icon)) + .style(get_color(highlight_state, app.user_config.theme)), + ); f.render_stateful_widget(list, layout_chunk, &mut state); } @@ -159,14 +159,6 @@ pub fn get_track_progress_percentage(song_progress_ms: u128, track_duration: Dur } // Make better use of space on small terminals -pub fn get_main_layout_margin(app: &App) -> u16 { - if app.size.height > SMALL_TERMINAL_HEIGHT { - 1 - } else { - 0 - } -} - #[cfg(test)] mod tests { use super::*;