Improved UI.

This commit is contained in:
Ultradesu
2026-07-23 17:35:11 +03:00
parent 5d54894437
commit af2542b668
8 changed files with 241 additions and 60 deletions
+3 -2
View File
@@ -93,8 +93,9 @@ Common key bindings:
| `x` | Like / unlike |
| `a` | Add track next |
| `Shift-A` | Add track to the end of the queue |
| `Shift-P` | Add track to a playlist |
| `Shift-D` | Open device picker |
| `Shift-P` | Add selected/current track(s) to a playlist |
| `i`, `Shift-I` | Track info / current track info |
| `Shift-D` | Delete selected item |
| `v` | Toggle tile/table view |
| `/` | Search |
| `:` | Open command line |
+4 -1
View File
@@ -34,6 +34,7 @@ pub enum Action {
ToggleLike,
ToggleTrackSelection,
OpenTrackInfo,
OpenCurrentTrackInfo,
QueueAddNext,
QueueAddLast,
/// Download the selected federated track(s) into the local library.
@@ -105,7 +106,8 @@ impl Action {
| Action::NewPlaylist
| Action::ToggleLike
| Action::ToggleTrackSelection
| Action::OpenTrackInfo => Category::Queue,
| Action::OpenTrackInfo
| Action::OpenCurrentTrackInfo => Category::Queue,
Action::MoveUp
| Action::MoveDown
| Action::MoveLeft
@@ -174,6 +176,7 @@ impl Action {
Action::ToggleLike => "Like / unlike".into(),
Action::ToggleTrackSelection => "Track line selection".into(),
Action::OpenTrackInfo => "Track info".into(),
Action::OpenCurrentTrackInfo => "Current track info".into(),
Action::QueueAddNext => "Queue: add next".into(),
Action::QueueAddLast => "Queue: add to end".into(),
Action::DownloadSelected => "Federation: download to library".into(),
+15 -6
View File
@@ -12,7 +12,8 @@ use crossterm::event::{KeyCode, KeyEvent};
use crate::app::Runtime;
use crate::app::event::AppEvent;
use crate::app::state::{
AppState, DeleteTarget, EditField, EditTarget, FedInputField, Popup, addable_playlists,
AppState, DeleteTarget, EditField, EditTarget, FedInputField, Loadable, Popup,
addable_playlists,
};
use crate::library::models::{ReleaseEdit, TrackEdit, TrackItem};
@@ -536,7 +537,9 @@ fn handle_picker(
cursor: usize,
key: KeyEvent,
) {
let waiting = matches!(&state.playlists.list, None | Some(Loadable::Loading));
let options = addable_playlists(state);
let new_index = options.len();
match key.code {
KeyCode::Esc => {}
KeyCode::Up | KeyCode::Char('k') => {
@@ -548,18 +551,23 @@ fn handle_picker(
KeyCode::Down | KeyCode::Char('j') => {
state.popup = Some(Popup::AddToPlaylist {
target,
cursor: (cursor + 1).min(options.len()),
cursor: (cursor + 1).min(new_index),
});
}
KeyCode::Enter => {
if cursor == 0 {
if waiting {
state.status_message = Some("loading playlists…".into());
state.popup = Some(Popup::AddToPlaylist { target, cursor });
} else if cursor < options.len() {
if let Some((id, title)) = options.get(cursor).cloned() {
spawn_add_target(runtime, id, title, target);
}
} else {
state.popup = Some(Popup::NewPlaylist {
for_target: Some(target),
input: crate::app::input::LineEdit::default(),
busy: false,
});
} else if let Some((id, title)) = options.get(cursor - 1).cloned() {
spawn_add_target(runtime, id, title, target);
}
}
_ => state.popup = Some(Popup::AddToPlaylist { target, cursor }),
@@ -586,7 +594,8 @@ fn handle_name_entry(
KeyCode::Esc => {
// Reached from the picker → step back to it; otherwise close.
if let Some(target) = for_target {
state.popup = Some(Popup::AddToPlaylist { target, cursor: 0 });
let cursor = addable_playlists(state).len();
state.popup = Some(Popup::AddToPlaylist { target, cursor });
}
}
KeyCode::Enter => {
+1 -1
View File
@@ -480,7 +480,7 @@ impl PlaylistAddTarget {
/// Modal dialog over the main screen.
#[derive(Debug)]
pub enum Popup {
/// Pick one of the playlists (row 0 = "create new"); the target is
/// Pick one of the playlists (last row = "create new"); the target is
/// added on Enter (federated tracks are downloaded first).
AddToPlaylist {
target: PlaylistAddTarget,
+142 -35
View File
@@ -283,27 +283,11 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
}
Action::OpenTrackInfo => {
let tracks = selected_tracks(state);
if tracks.is_empty() {
state.status_message = Some("no track selected".into());
None
} else {
let fed_tracks = tracks
.iter()
.filter(|track| track_info_needs_fed_metadata(track))
.filter_map(|track| track.fed.as_ref().map(|fed| (track.id, fed.clone())))
.collect::<Vec<_>>();
state.popup = Some(super::state::Popup::TrackInfo {
tracks,
cursor: 0,
scroll: 0,
});
if fed_tracks.is_empty() {
None
} else {
state.status_message = Some("federation: fetching track metadata…".to_string());
Some(Effect::FedFetchTrackInfo { tracks: fed_tracks })
}
}
open_track_info(state, tracks, "no track selected")
}
Action::OpenCurrentTrackInfo => {
let tracks = state.player.current.clone().into_iter().collect();
open_track_info(state, tracks, "nothing playing")
}
Action::RemoveFromQueue => remove_selected_from_queue(state),
Action::QueueAddNext => queue_add(state, true),
@@ -317,19 +301,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
None
}
Action::AddToPlaylist => {
let fed = selected_fed_tracks(state);
let target = if !fed.is_empty() {
Some(super::state::PlaylistAddTarget::Fed(fed))
} else {
let local = selected_tracks(state);
if !local.is_empty() {
Some(super::state::PlaylistAddTarget::Local(local))
} else {
selected_track(state)
.or_else(|| state.player.current.clone())
.map(|track| super::state::PlaylistAddTarget::Local(vec![track]))
}
};
let target = selected_playlist_target(state, true);
match target {
Some(target) => {
state.popup = Some(super::state::Popup::AddToPlaylist { target, cursor: 0 });
@@ -355,11 +327,13 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
}
}
Action::NewPlaylist => {
let for_target = selected_playlist_target(state, false);
state.popup = Some(super::state::Popup::NewPlaylist {
for_target: None,
for_target,
input: crate::app::input::LineEdit::default(),
busy: false,
});
state.track_selection.clear();
None
}
Action::ClearQueue => {
@@ -399,6 +373,54 @@ fn track_info_needs_fed_metadata(track: &TrackItem) -> bool {
|| track.file_path.is_empty())
}
fn open_track_info(
state: &mut AppState,
tracks: Vec<TrackItem>,
empty_message: &'static str,
) -> Option<Effect> {
if tracks.is_empty() {
state.status_message = Some(empty_message.into());
return None;
}
let fed_tracks = tracks
.iter()
.filter(|track| track_info_needs_fed_metadata(track))
.filter_map(|track| track.fed.as_ref().map(|fed| (track.id, fed.clone())))
.collect::<Vec<_>>();
state.popup = Some(super::state::Popup::TrackInfo {
tracks,
cursor: 0,
scroll: 0,
});
if fed_tracks.is_empty() {
None
} else {
state.status_message = Some("federation: fetching track metadata…".to_string());
Some(Effect::FedFetchTrackInfo { tracks: fed_tracks })
}
}
fn selected_playlist_target(
state: &AppState,
include_current: bool,
) -> Option<super::state::PlaylistAddTarget> {
let fed = selected_fed_tracks(state);
if !fed.is_empty() {
return Some(super::state::PlaylistAddTarget::Fed(fed));
}
let local = selected_tracks(state);
if !local.is_empty() {
return Some(super::state::PlaylistAddTarget::Local(local));
}
include_current
.then(|| state.player.current.clone())
.flatten()
.map(|track| super::state::PlaylistAddTarget::Local(vec![track]))
}
/// `e`: open the metadata edit form for whatever is under the cursor —
/// an artist tile, a release, a track or a playlist.
fn open_edit_popup(state: &mut AppState) {
@@ -2748,6 +2770,91 @@ mod tests {
assert!(!state.player.playing);
}
#[test]
fn current_track_info_uses_now_playing_track() {
let mut state = AppState {
active_tab: Tab::Queue,
..AppState::default()
};
state.player.queue = vec![test_track(1), test_track(2)];
state.queue_tab.cursor = 0;
state.player.current = Some(test_track(2));
assert_eq!(update(&mut state, Action::OpenCurrentTrackInfo), None);
match &state.popup {
Some(crate::app::state::Popup::TrackInfo { tracks, .. }) => {
assert_eq!(
tracks.iter().map(|track| track.id).collect::<Vec<_>>(),
vec![2]
);
}
other => panic!("expected track info popup, got {other:?}"),
}
}
#[test]
fn add_to_playlist_from_release_carries_selected_track() {
use crate::app::state::{PlaylistAddTarget, Popup};
use crate::library::models::ReleaseDetail;
let mut state = AppState::default();
state
.global
.stack
.push(GlobalView::Release { id: 1, cursor: 1 });
state.release_views.insert(
1,
Loadable::Ready(ReleaseDetail {
id: 1,
title: "r".into(),
release_type: "album".into(),
year: None,
cover_path: None,
artists: vec![],
tracks: vec![test_track(1), test_track(2)],
}),
);
assert_eq!(update(&mut state, Action::AddToPlaylist), None);
match &state.popup {
Some(Popup::AddToPlaylist {
target: PlaylistAddTarget::Local(tracks),
..
}) => {
assert_eq!(
tracks.iter().map(|track| track.id).collect::<Vec<_>>(),
vec![2]
);
}
other => panic!("expected add-to-playlist popup with selected track, got {other:?}"),
}
}
#[test]
fn add_to_playlist_from_non_track_view_uses_current_track() {
use crate::app::state::{PlaylistAddTarget, Popup};
let mut state = AppState {
active_tab: Tab::Logs,
..AppState::default()
};
state.player.current = Some(test_track(7));
assert_eq!(update(&mut state, Action::AddToPlaylist), None);
match &state.popup {
Some(Popup::AddToPlaylist {
target: PlaylistAddTarget::Local(tracks),
..
}) => {
assert_eq!(
tracks.iter().map(|track| track.id).collect::<Vec<_>>(),
vec![7]
);
}
other => panic!("expected add-to-playlist popup with current track, got {other:?}"),
}
}
#[test]
fn visual_selection_removes_queue_range() {
let mut state = AppState {
+4 -5
View File
@@ -86,11 +86,6 @@ command = "GoToRelease"
key_sequence = "shift-p"
command = "AddToPlaylist"
[[keymaps]]
key_sequence = "n"
command = "NewPlaylist"
context = "playlists"
[[keymaps]]
key_sequence = "j"
command = "MoveDown"
@@ -211,6 +206,10 @@ command = "ToggleTrackSelection"
key_sequence = "i"
command = "OpenTrackInfo"
[[keymaps]]
key_sequence = "shift-i"
command = "OpenCurrentTrackInfo"
[[keymaps]]
key_sequence = "e"
command = "EditSelected"
+43
View File
@@ -416,6 +416,49 @@ mod tests {
);
}
#[test]
fn default_playlist_context_keeps_next_track_on_n() {
let mut km = keymap_from(DEFAULT_KEYMAP);
assert_eq!(
km.resolve(key!(n), KeyContext::Playlists),
KeyResolution::Action(Action::NextTrack)
);
}
#[test]
fn default_shift_n_is_unbound() {
let mut km = keymap_from(DEFAULT_KEYMAP);
let shift_n = KeyCombination::new(KeyCode::Char('N'), KeyModifiers::SHIFT);
assert_eq!(
km.resolve(shift_n, KeyContext::Library),
KeyResolution::Unmatched
);
assert_eq!(
km.resolve(shift_n, KeyContext::Playlists),
KeyResolution::Unmatched
);
}
#[test]
fn default_add_to_playlist_key_is_global() {
let mut km = keymap_from(DEFAULT_KEYMAP);
let shift_p = KeyCombination::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
assert_eq!(
km.resolve(shift_p, KeyContext::Library),
KeyResolution::Action(Action::AddToPlaylist)
);
}
#[test]
fn default_current_track_info_key_resolves() {
let mut km = keymap_from(DEFAULT_KEYMAP);
let shift_i = KeyCombination::new(KeyCode::Char('I'), KeyModifiers::SHIFT);
assert_eq!(
km.resolve(shift_i, KeyContext::Library),
KeyResolution::Action(Action::OpenCurrentTrackInfo)
);
}
#[test]
fn user_binding_overrides_default() {
let mut bindings = parse_bindings(DEFAULT_KEYMAP).unwrap();
+29 -10
View File
@@ -441,8 +441,14 @@ fn file_size(value: Option<i64>) -> String {
fn draw_picker(frame: &mut Frame, state: &AppState, track_title: &str, cursor: usize) {
let options = addable_playlists(state);
let loading = !matches!(&state.playlists.list, Some(Loadable::Ready(_)));
let rows = options.len() + 1;
let loading = matches!(&state.playlists.list, None | Some(Loadable::Loading));
let rows = if loading {
1
} else if options.is_empty() {
2
} else {
options.len() + 1
};
let height = (rows as u16 + 4)
.min(frame.area().height.saturating_sub(2))
.max(6);
@@ -463,18 +469,30 @@ fn draw_picker(frame: &mut Frame, state: &AppState, track_title: &str, cursor: u
])
.areas(inner);
let mut lines: Vec<Line> = vec![Line::styled("+ New playlist…", theme::accent())];
let mut lines: Vec<Line> = Vec::new();
if loading {
lines.push(Line::styled("loading playlists…", theme::dim()));
} else if matches!(&state.playlists.list, Some(Loadable::Failed(_))) {
lines.push(Line::styled("playlist list unavailable", theme::dim()));
lines.push(Line::styled("+ New playlist…", theme::accent()));
} else if options.is_empty() {
lines.push(Line::styled("no playlists yet", theme::dim()));
lines.push(Line::styled("+ New playlist…", theme::accent()));
} else {
for (_, title) in &options {
lines.push(Line::raw(title.clone()));
}
lines.push(Line::styled("+ New playlist…", theme::accent()));
}
let selected_line = if loading {
0
} else if options.is_empty() {
lines.len().saturating_sub(1)
} else {
cursor.min(options.len())
};
let visible = usize::from(list_area.height.max(1));
let first = cursor
let first = selected_line
.saturating_sub(visible / 2)
.min(lines.len().saturating_sub(visible));
for (index, line) in lines.into_iter().enumerate().skip(first).take(visible) {
@@ -485,17 +503,18 @@ fn draw_picker(frame: &mut Frame, state: &AppState, track_title: &str, cursor: u
height: 1,
};
frame.render_widget(Paragraph::new(line), row);
if index == cursor {
if index == selected_line {
frame.buffer_mut().set_style(row, theme::tab_active());
}
}
let footer_text = if loading {
format!("{track_title} · loading playlists · esc close")
} else {
format!("{track_title} · enter add/create · esc close")
};
frame.render_widget(
Paragraph::new(Line::styled(
format!("{track_title} · enter add · esc close"),
theme::dim(),
))
.alignment(Alignment::Center),
Paragraph::new(Line::styled(footer_text, theme::dim())).alignment(Alignment::Center),
footer,
);
}