Reworked queue. Fixed global view. minor improvements
This commit is contained in:
@@ -300,6 +300,19 @@ impl ApiClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tell last.fm (via the server) what is playing right now. Called at
|
||||||
|
/// track start; the completed-play scrobble goes through /history.
|
||||||
|
pub async fn lastfm_now_playing(&self, track_id: i64) -> Result<(), ApiError> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Body {
|
||||||
|
track_id: i64,
|
||||||
|
}
|
||||||
|
let _: serde_json::Value = self
|
||||||
|
.post_json("/api/player/lastfm/now-playing", &Body { track_id })
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Report a finished/aborted listen to the play history.
|
/// Report a finished/aborted listen to the play history.
|
||||||
/// Body shape is the backend's HistoryEntry; `completed` marks a full
|
/// Body shape is the backend's HistoryEntry; `completed` marks a full
|
||||||
/// play (vs a manual skip).
|
/// play (vs a manual skip).
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ pub enum Action {
|
|||||||
ToggleLike,
|
ToggleLike,
|
||||||
QueueAddNext,
|
QueueAddNext,
|
||||||
QueueAddLast,
|
QueueAddLast,
|
||||||
|
ClearQueue,
|
||||||
ToggleHelp,
|
ToggleHelp,
|
||||||
ToggleViewMode,
|
ToggleViewMode,
|
||||||
OpenCommandLine,
|
OpenCommandLine,
|
||||||
@@ -65,6 +66,7 @@ impl Action {
|
|||||||
Action::ToggleLike => "Like / unlike".into(),
|
Action::ToggleLike => "Like / unlike".into(),
|
||||||
Action::QueueAddNext => "Queue: add next".into(),
|
Action::QueueAddNext => "Queue: add next".into(),
|
||||||
Action::QueueAddLast => "Queue: add to end".into(),
|
Action::QueueAddLast => "Queue: add to end".into(),
|
||||||
|
Action::ClearQueue => "Queue: clear".into(),
|
||||||
Action::ToggleHelp => "Show / hide keybindings".into(),
|
Action::ToggleHelp => "Show / hide keybindings".into(),
|
||||||
Action::ToggleViewMode => "Toggle tiles / table view".into(),
|
Action::ToggleViewMode => "Toggle tiles / table view".into(),
|
||||||
Action::OpenCommandLine => "Open command line (:/name searches)".into(),
|
Action::OpenCommandLine => "Open command line (:/name searches)".into(),
|
||||||
|
|||||||
+42
-6
@@ -117,9 +117,17 @@ pub async fn run(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ARTISTS_PAGE_SIZE: i64 = 48;
|
|
||||||
const ARTISTS_PREFETCH_MARGIN: usize = 24;
|
const ARTISTS_PREFETCH_MARGIN: usize = 24;
|
||||||
|
|
||||||
|
/// How many artist tiles one screen holds right now (grid geometry from the
|
||||||
|
/// live terminal size), so the initial load always fills the viewport.
|
||||||
|
fn artist_grid_capacity() -> usize {
|
||||||
|
let (width, height) = crossterm::terminal::size().unwrap_or((80, 24));
|
||||||
|
let columns = usize::from((width.saturating_sub(2) / state::TILE_WIDTH).max(1));
|
||||||
|
let rows = usize::from((height.saturating_sub(5) / state::TILE_HEIGHT).max(1));
|
||||||
|
columns * rows
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs after every event: kicks off whatever background work the current
|
/// Runs after every event: kicks off whatever background work the current
|
||||||
/// state needs — the first artists page, the next page when the selection
|
/// state needs — the first artists page, the next page when the selection
|
||||||
/// nears the end, and artwork for loaded artists.
|
/// nears the end, and artwork for loaded artists.
|
||||||
@@ -133,16 +141,26 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let global = &mut state.global;
|
let global = &mut state.global;
|
||||||
let initial = global.artists.is_empty();
|
// Keep at least a full screen plus a margin loaded, and stay ahead
|
||||||
let near_end =
|
// of the cursor: a big terminal fills itself on startup without any
|
||||||
!initial && global.selected + ARTISTS_PREFETCH_MARGIN >= global.artists.len();
|
// scrolling, page after page.
|
||||||
if global.has_more && !global.loading && global.error.is_none() && (initial || near_end) {
|
let needed = artist_grid_capacity()
|
||||||
|
.max(global.selected + ARTISTS_PREFETCH_MARGIN)
|
||||||
|
+ ARTISTS_PREFETCH_MARGIN;
|
||||||
|
if global.has_more
|
||||||
|
&& !global.loading
|
||||||
|
&& global.error.is_none()
|
||||||
|
&& global.artists.len() < needed
|
||||||
|
{
|
||||||
global.loading = true;
|
global.loading = true;
|
||||||
let page = global.next_page;
|
let page = global.next_page;
|
||||||
|
let limit = *global
|
||||||
|
.page_limit
|
||||||
|
.get_or_insert_with(|| (needed as i64).clamp(48, 200));
|
||||||
let api = Arc::clone(&api);
|
let api = Arc::clone(&api);
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let event = match api.artists(page, ARTISTS_PAGE_SIZE).await {
|
let event = match api.artists(page, limit).await {
|
||||||
Ok(page) => AppEvent::ArtistsLoaded(Ok(page)),
|
Ok(page) => AppEvent::ArtistsLoaded(Ok(page)),
|
||||||
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
|
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
|
||||||
Err(err) => AppEvent::ArtistsLoaded(Err(err.to_string())),
|
Err(err) => AppEvent::ArtistsLoaded(Err(err.to_string())),
|
||||||
@@ -423,6 +441,7 @@ fn play_current(state: &mut AppState, runtime: &Runtime) {
|
|||||||
state.player.track_started_at = Some(auth::now_epoch_seconds());
|
state.player.track_started_at = Some(auth::now_epoch_seconds());
|
||||||
state.player.prefetched_pos = None;
|
state.player.prefetched_pos = None;
|
||||||
state.status_message = Some(format!("▶ {} — {}", track.title, track.artist_line()));
|
state.status_message = Some(format!("▶ {} — {}", track.title, track.artist_line()));
|
||||||
|
report_now_playing(runtime, track.id);
|
||||||
|
|
||||||
let controller = runtime.player.clone();
|
let controller = runtime.player.clone();
|
||||||
let volume = player::amplitude(state.player.volume);
|
let volume = player::amplitude(state.player.volume);
|
||||||
@@ -518,6 +537,19 @@ fn push_state_now(state: &AppState, runtime: &mut Runtime) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Announce the just-started track as "now playing" on last.fm. Quiet on
|
||||||
|
/// failure — last.fm may simply not be connected for this account.
|
||||||
|
fn report_now_playing(runtime: &Runtime, track_id: i64) {
|
||||||
|
let Some(api) = runtime.api.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(err) = api.lastfm_now_playing(track_id).await {
|
||||||
|
tracing::debug!(%err, track_id, "lastfm now-playing failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Fire-and-forget history report; listens shorter than 5s are noise.
|
/// Fire-and-forget history report; listens shorter than 5s are noise.
|
||||||
fn report_history(
|
fn report_history(
|
||||||
runtime: &Runtime,
|
runtime: &Runtime,
|
||||||
@@ -654,6 +686,7 @@ fn reset_library_state(state: &mut AppState) {
|
|||||||
state.release_views.clear();
|
state.release_views.clear();
|
||||||
state.playlists = state::PlaylistsTab::default();
|
state.playlists = state::PlaylistsTab::default();
|
||||||
state.playlist_views.clear();
|
state.playlist_views.clear();
|
||||||
|
state.queue_tab = state::QueueTab::default();
|
||||||
state.likes.clear();
|
state.likes.clear();
|
||||||
state.likes_loaded = false;
|
state.likes_loaded = false;
|
||||||
state.search = state::SearchState::default();
|
state.search = state::SearchState::default();
|
||||||
@@ -776,6 +809,9 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
state.player.current = state.player.queue.get(state.player.queue_pos).cloned();
|
state.player.current = state.player.queue.get(state.player.queue_pos).cloned();
|
||||||
state.player.position_secs = 0.0;
|
state.player.position_secs = 0.0;
|
||||||
state.player.track_started_at = Some(auth::now_epoch_seconds());
|
state.player.track_started_at = Some(auth::now_epoch_seconds());
|
||||||
|
if let Some(track) = &state.player.current {
|
||||||
|
report_now_playing(runtime, track.id);
|
||||||
|
}
|
||||||
push_media_metadata(state, runtime);
|
push_media_metadata(state, runtime);
|
||||||
push_media_update(state, runtime, true);
|
push_media_update(state, runtime, true);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+17
-10
@@ -75,6 +75,9 @@ pub struct GlobalTab {
|
|||||||
pub selected: usize,
|
pub selected: usize,
|
||||||
pub view: ViewMode,
|
pub view: ViewMode,
|
||||||
pub stack: Vec<GlobalView>,
|
pub stack: Vec<GlobalView>,
|
||||||
|
/// Page size, fixed at the first request — the server's offset is
|
||||||
|
/// `(page-1) * limit`, so it must not change between pages.
|
||||||
|
pub page_limit: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for GlobalTab {
|
impl Default for GlobalTab {
|
||||||
@@ -89,6 +92,7 @@ impl Default for GlobalTab {
|
|||||||
selected: 0,
|
selected: 0,
|
||||||
view: ViewMode::default(),
|
view: ViewMode::default(),
|
||||||
stack: Vec::new(),
|
stack: Vec::new(),
|
||||||
|
page_limit: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,6 +171,13 @@ pub struct PlaylistsTab {
|
|||||||
pub opened: Option<OpenedPlaylist>,
|
pub opened: Option<OpenedPlaylist>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Queue tab's own cursor — independent from the playing position, so
|
||||||
|
/// the user can browse and pick tracks while something else plays.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct QueueTab {
|
||||||
|
pub cursor: usize,
|
||||||
|
}
|
||||||
|
|
||||||
/// Severity steps for the Logs tab filter, cycled with the view-toggle key.
|
/// Severity steps for the Logs tab filter, cycled with the view-toggle key.
|
||||||
pub const LOG_LEVELS: [tracing::Level; 5] = [
|
pub const LOG_LEVELS: [tracing::Level; 5] = [
|
||||||
tracing::Level::ERROR,
|
tracing::Level::ERROR,
|
||||||
@@ -300,25 +311,17 @@ pub enum Tab {
|
|||||||
Global,
|
Global,
|
||||||
Playlists,
|
Playlists,
|
||||||
Queue,
|
Queue,
|
||||||
Devices,
|
|
||||||
Logs,
|
Logs,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tab {
|
impl Tab {
|
||||||
pub const ALL: [Tab; 5] = [
|
pub const ALL: [Tab; 4] = [Tab::Global, Tab::Playlists, Tab::Queue, Tab::Logs];
|
||||||
Tab::Global,
|
|
||||||
Tab::Playlists,
|
|
||||||
Tab::Queue,
|
|
||||||
Tab::Devices,
|
|
||||||
Tab::Logs,
|
|
||||||
];
|
|
||||||
|
|
||||||
pub fn title(self) -> &'static str {
|
pub fn title(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Tab::Global => "Global",
|
Tab::Global => "Global",
|
||||||
Tab::Playlists => "Playlists",
|
Tab::Playlists => "Playlists",
|
||||||
Tab::Queue => "Queue",
|
Tab::Queue => "Queue",
|
||||||
Tab::Devices => "Devices",
|
|
||||||
Tab::Logs => "Logs",
|
Tab::Logs => "Logs",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,7 +347,6 @@ impl Tab {
|
|||||||
Tab::Global => KeyContext::Library,
|
Tab::Global => KeyContext::Library,
|
||||||
Tab::Playlists => KeyContext::Playlists,
|
Tab::Playlists => KeyContext::Playlists,
|
||||||
Tab::Queue => KeyContext::Queue,
|
Tab::Queue => KeyContext::Queue,
|
||||||
Tab::Devices => KeyContext::Devices,
|
|
||||||
Tab::Logs => KeyContext::Logs,
|
Tab::Logs => KeyContext::Logs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -393,6 +395,9 @@ pub struct PlayerBar {
|
|||||||
pub prefetched_pos: Option<usize>,
|
pub prefetched_pos: Option<usize>,
|
||||||
pub volume: u8,
|
pub volume: u8,
|
||||||
pub shuffle: bool,
|
pub shuffle: bool,
|
||||||
|
/// Track ids in pre-shuffle order; restores the queue when shuffle is
|
||||||
|
/// turned off.
|
||||||
|
pub original_order: Option<Vec<i64>>,
|
||||||
pub repeat: RepeatMode,
|
pub repeat: RepeatMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,6 +412,7 @@ impl Default for PlayerBar {
|
|||||||
position_secs: 0.0,
|
position_secs: 0.0,
|
||||||
track_started_at: None,
|
track_started_at: None,
|
||||||
prefetched_pos: None,
|
prefetched_pos: None,
|
||||||
|
original_order: None,
|
||||||
volume: 80,
|
volume: 80,
|
||||||
shuffle: false,
|
shuffle: false,
|
||||||
repeat: RepeatMode::Off,
|
repeat: RepeatMode::Off,
|
||||||
@@ -439,6 +445,7 @@ pub struct AppState {
|
|||||||
pub likes: std::collections::HashSet<i64>,
|
pub likes: std::collections::HashSet<i64>,
|
||||||
pub likes_loaded: bool,
|
pub likes_loaded: bool,
|
||||||
pub logs: LogsTab,
|
pub logs: LogsTab,
|
||||||
|
pub queue_tab: QueueTab,
|
||||||
pub cmdline: Cmdline,
|
pub cmdline: Cmdline,
|
||||||
pub search: SearchState,
|
pub search: SearchState,
|
||||||
/// Shared image cache keyed by `art::cache_key(url, w, h)`; reused by
|
/// Shared image cache keyed by `art::cache_key(url, w, h)`; reused by
|
||||||
|
|||||||
+231
-26
@@ -103,6 +103,14 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
|||||||
}
|
}
|
||||||
Action::ToggleShuffle => {
|
Action::ToggleShuffle => {
|
||||||
state.player.shuffle = !state.player.shuffle;
|
state.player.shuffle = !state.player.shuffle;
|
||||||
|
// Shuffle physically reorders the unplayed tail, so the Queue
|
||||||
|
// tab always shows the real upcoming order; turning it off
|
||||||
|
// restores the original ordering.
|
||||||
|
if state.player.shuffle {
|
||||||
|
shuffle_upcoming(&mut state.player);
|
||||||
|
} else {
|
||||||
|
restore_queue_order(&mut state.player);
|
||||||
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
Action::CycleRepeat => {
|
Action::CycleRepeat => {
|
||||||
@@ -179,6 +187,23 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
|||||||
}
|
}
|
||||||
Action::QueueAddNext => queue_add(state, true),
|
Action::QueueAddNext => queue_add(state, true),
|
||||||
Action::QueueAddLast => queue_add(state, false),
|
Action::QueueAddLast => queue_add(state, false),
|
||||||
|
Action::ClearQueue => {
|
||||||
|
let had_tracks = !state.player.queue.is_empty();
|
||||||
|
state.player.queue.clear();
|
||||||
|
state.player.queue_pos = 0;
|
||||||
|
state.player.current = None;
|
||||||
|
state.player.playing = false;
|
||||||
|
state.player.paused = false;
|
||||||
|
state.player.prefetched_pos = None;
|
||||||
|
state.player.original_order = None;
|
||||||
|
state.queue_tab.cursor = 0;
|
||||||
|
if had_tracks {
|
||||||
|
state.status_message = Some("queue cleared".into());
|
||||||
|
Some(Effect::StopPlayback)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
// Needs the Runtime, so it is intercepted in app::handle_main_key
|
// Needs the Runtime, so it is intercepted in app::handle_main_key
|
||||||
// before reaching update().
|
// before reaching update().
|
||||||
Action::Logout => None,
|
Action::Logout => None,
|
||||||
@@ -207,12 +232,8 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
|
|||||||
let opened = state.playlists.opened.as_ref()?;
|
let opened = state.playlists.opened.as_ref()?;
|
||||||
playlist_tracks(state, opened.id)?.get(opened.cursor).cloned()
|
playlist_tracks(state, opened.id)?.get(opened.cursor).cloned()
|
||||||
}
|
}
|
||||||
Tab::Queue => state
|
Tab::Queue => state.player.queue.get(state.queue_tab.cursor).cloned(),
|
||||||
.player
|
Tab::Logs => None,
|
||||||
.queue
|
|
||||||
.get(state.player.queue_pos)
|
|
||||||
.cloned(),
|
|
||||||
Tab::Devices | Tab::Logs => None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +316,8 @@ pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manual queue navigation (n / p).
|
/// Manual queue navigation (n / p); the tail is pre-shuffled when shuffle
|
||||||
|
/// is on, so stepping is always sequential.
|
||||||
fn queue_step(state: &mut AppState, direction: isize) -> Option<Effect> {
|
fn queue_step(state: &mut AppState, direction: isize) -> Option<Effect> {
|
||||||
let player = &mut state.player;
|
let player = &mut state.player;
|
||||||
if player.queue.is_empty() {
|
if player.queue.is_empty() {
|
||||||
@@ -303,10 +325,6 @@ fn queue_step(state: &mut AppState, direction: isize) -> Option<Effect> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let len = player.queue.len();
|
let len = player.queue.len();
|
||||||
if player.shuffle && direction > 0 {
|
|
||||||
player.queue_pos = pseudo_random(len);
|
|
||||||
return Some(Effect::PlayCurrent);
|
|
||||||
}
|
|
||||||
let next = player.queue_pos as isize + direction;
|
let next = player.queue_pos as isize + direction;
|
||||||
if next < 0 {
|
if next < 0 {
|
||||||
player.queue_pos = 0;
|
player.queue_pos = 0;
|
||||||
@@ -325,13 +343,13 @@ fn queue_step(state: &mut AppState, direction: isize) -> Option<Effect> {
|
|||||||
|
|
||||||
/// What plays after the current track, without mutating anything — used to
|
/// What plays after the current track, without mutating anything — used to
|
||||||
/// pick the gapless prefetch target. Mirrors `advance_after_finish`.
|
/// pick the gapless prefetch target. Mirrors `advance_after_finish`.
|
||||||
|
/// Shuffle needs no special case: the queue tail is already shuffled.
|
||||||
pub fn peek_next_pos(player: &super::state::PlayerBar) -> Option<usize> {
|
pub fn peek_next_pos(player: &super::state::PlayerBar) -> Option<usize> {
|
||||||
if player.queue.is_empty() {
|
if player.queue.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
match player.repeat {
|
match player.repeat {
|
||||||
super::state::RepeatMode::One => Some(player.queue_pos),
|
super::state::RepeatMode::One => Some(player.queue_pos),
|
||||||
_ if player.shuffle => Some(pseudo_random(player.queue.len())),
|
|
||||||
repeat => {
|
repeat => {
|
||||||
if player.queue_pos + 1 < player.queue.len() {
|
if player.queue_pos + 1 < player.queue.len() {
|
||||||
Some(player.queue_pos + 1)
|
Some(player.queue_pos + 1)
|
||||||
@@ -344,8 +362,8 @@ pub fn peek_next_pos(player: &super::state::PlayerBar) -> Option<usize> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The current track finished: pick what plays next according to
|
/// The current track finished: play the next queue position (the tail is
|
||||||
/// repeat/shuffle, or stop at the end of the queue.
|
/// pre-shuffled when shuffle is on), or stop at the end.
|
||||||
pub fn advance_after_finish(state: &mut AppState) -> Option<Effect> {
|
pub fn advance_after_finish(state: &mut AppState) -> Option<Effect> {
|
||||||
let player = &mut state.player;
|
let player = &mut state.player;
|
||||||
if player.queue.is_empty() {
|
if player.queue.is_empty() {
|
||||||
@@ -355,10 +373,6 @@ pub fn advance_after_finish(state: &mut AppState) -> Option<Effect> {
|
|||||||
}
|
}
|
||||||
match player.repeat {
|
match player.repeat {
|
||||||
super::state::RepeatMode::One => Some(Effect::PlayCurrent),
|
super::state::RepeatMode::One => Some(Effect::PlayCurrent),
|
||||||
_ if player.shuffle => {
|
|
||||||
player.queue_pos = pseudo_random(player.queue.len());
|
|
||||||
Some(Effect::PlayCurrent)
|
|
||||||
}
|
|
||||||
repeat => {
|
repeat => {
|
||||||
if player.queue_pos + 1 < player.queue.len() {
|
if player.queue_pos + 1 < player.queue.len() {
|
||||||
player.queue_pos += 1;
|
player.queue_pos += 1;
|
||||||
@@ -375,13 +389,55 @@ pub fn advance_after_finish(state: &mut AppState) -> Option<Effect> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shuffle pick without a rand dependency: clock-derived index.
|
/// First index of the not-yet-played queue tail: everything after the
|
||||||
fn pseudo_random(len: usize) -> usize {
|
/// current track, or from the current position when nothing is loaded.
|
||||||
let nanos = std::time::SystemTime::now()
|
fn upcoming_start(player: &super::state::PlayerBar) -> usize {
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
if player.current.is_some() {
|
||||||
.map(|d| d.subsec_nanos())
|
(player.queue_pos + 1).min(player.queue.len())
|
||||||
.unwrap_or(0);
|
} else {
|
||||||
nanos as usize % len.max(1)
|
player.queue_pos.min(player.queue.len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remember the original order and Fisher-Yates the unplayed tail.
|
||||||
|
pub fn shuffle_upcoming(player: &mut super::state::PlayerBar) {
|
||||||
|
if player.queue.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if player.original_order.is_none() {
|
||||||
|
player.original_order = Some(player.queue.iter().map(|t| t.id).collect());
|
||||||
|
}
|
||||||
|
shuffle_range(player, upcoming_start(player));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put the unplayed tail back into pre-shuffle order. Tracks queued while
|
||||||
|
/// shuffled (absent from the snapshot) keep their relative order at the end.
|
||||||
|
pub fn restore_queue_order(player: &mut super::state::PlayerBar) {
|
||||||
|
let Some(order) = player.original_order.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let start = upcoming_start(player);
|
||||||
|
if start >= player.queue.len() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let tail = player.queue.split_off(start);
|
||||||
|
let mut used = vec![false; order.len()];
|
||||||
|
let mut keyed: Vec<(usize, usize, crate::api::models::TrackItem)> = tail
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(position, track)| {
|
||||||
|
let key = order
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.position(|(slot, id)| !used[slot] && *id == track.id)
|
||||||
|
.inspect(|&slot| used[slot] = true)
|
||||||
|
.unwrap_or(usize::MAX);
|
||||||
|
(key, position, track)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
keyed.sort_by_key(|(key, position, _)| (*key, *position));
|
||||||
|
player.queue.extend(keyed.into_iter().map(|(_, _, track)| track));
|
||||||
|
player.prefetched_pos = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Columns of the Global tile grid. Derived from the terminal width the same
|
/// Columns of the Global tile grid. Derived from the terminal width the same
|
||||||
@@ -461,6 +517,15 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if state.active_tab == Tab::Queue {
|
||||||
|
let len = state.player.queue.len();
|
||||||
|
if len == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.queue_tab.cursor =
|
||||||
|
(state.queue_tab.cursor as isize + dy).clamp(0, len as isize - 1) as usize;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if state.active_tab != Tab::Global {
|
if state.active_tab != Tab::Global {
|
||||||
return not_yet(state, "Navigation in this view");
|
return not_yet(state, "Navigation in this view");
|
||||||
}
|
}
|
||||||
@@ -575,6 +640,9 @@ fn current_view_len(state: &AppState) -> usize {
|
|||||||
if state.active_tab == Tab::Playlists {
|
if state.active_tab == Tab::Playlists {
|
||||||
return playlists_view_len(state);
|
return playlists_view_len(state);
|
||||||
}
|
}
|
||||||
|
if state.active_tab == Tab::Queue {
|
||||||
|
return state.player.queue.len();
|
||||||
|
}
|
||||||
match state.global.stack.last() {
|
match state.global.stack.last() {
|
||||||
None => state.global.artists.len(),
|
None => state.global.artists.len(),
|
||||||
Some(GlobalView::Artist { id, .. }) => match state.artist_views.get(id) {
|
Some(GlobalView::Artist { id, .. }) => match state.artist_views.get(id) {
|
||||||
@@ -590,6 +658,13 @@ fn current_view_len(state: &AppState) -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn jump_selection(state: &mut AppState, first: bool) {
|
fn jump_selection(state: &mut AppState, first: bool) {
|
||||||
|
if state.active_tab == Tab::Queue {
|
||||||
|
let len = state.player.queue.len();
|
||||||
|
if len > 0 {
|
||||||
|
state.queue_tab.cursor = if first { 0 } else { len - 1 };
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if state.active_tab == Tab::Logs {
|
if state.active_tab == Tab::Logs {
|
||||||
if first {
|
if first {
|
||||||
state.logs.follow = false;
|
state.logs.follow = false;
|
||||||
@@ -631,6 +706,7 @@ fn select_playlist(state: &mut AppState) -> Option<Effect> {
|
|||||||
}
|
}
|
||||||
state.player.queue = tracks;
|
state.player.queue = tracks;
|
||||||
state.player.queue_pos = opened.cursor.min(state.player.queue.len() - 1);
|
state.player.queue_pos = opened.cursor.min(state.player.queue.len() - 1);
|
||||||
|
on_new_queue(state);
|
||||||
Some(Effect::PlayCurrent)
|
Some(Effect::PlayCurrent)
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -650,6 +726,16 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
|||||||
if state.active_tab == Tab::Playlists {
|
if state.active_tab == Tab::Playlists {
|
||||||
return select_playlist(state);
|
return select_playlist(state);
|
||||||
}
|
}
|
||||||
|
// Queue: jump playback to the track under the cursor. Earlier tracks
|
||||||
|
// stay in the queue as "played"; picking one of them just moves the
|
||||||
|
// playing position back.
|
||||||
|
if state.active_tab == Tab::Queue {
|
||||||
|
if state.player.queue.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
state.player.queue_pos = state.queue_tab.cursor.min(state.player.queue.len() - 1);
|
||||||
|
return Some(Effect::PlayCurrent);
|
||||||
|
}
|
||||||
if state.active_tab != Tab::Global {
|
if state.active_tab != Tab::Global {
|
||||||
not_yet(state, "Navigation in this view");
|
not_yet(state, "Navigation in this view");
|
||||||
return None;
|
return None;
|
||||||
@@ -729,12 +815,43 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
|||||||
Outcome::Play { tracks, start } => {
|
Outcome::Play { tracks, start } => {
|
||||||
state.player.queue = tracks;
|
state.player.queue = tracks;
|
||||||
state.player.queue_pos = start;
|
state.player.queue_pos = start;
|
||||||
|
on_new_queue(state);
|
||||||
Some(Effect::PlayCurrent)
|
Some(Effect::PlayCurrent)
|
||||||
}
|
}
|
||||||
Outcome::Nothing => None,
|
Outcome::Nothing => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A freshly created play context: drop the stale pre-shuffle snapshot and,
|
||||||
|
/// if shuffle is on, shuffle everything after the chosen track right away.
|
||||||
|
fn on_new_queue(state: &mut AppState) {
|
||||||
|
let player = &mut state.player;
|
||||||
|
player.original_order = None;
|
||||||
|
if player.shuffle && !player.queue.is_empty() {
|
||||||
|
player.original_order = Some(player.queue.iter().map(|t| t.id).collect());
|
||||||
|
shuffle_range(player, (player.queue_pos + 1).min(player.queue.len()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shuffle_range(player: &mut super::state::PlayerBar, start: usize) {
|
||||||
|
let tail = &mut player.queue[start..];
|
||||||
|
let mut seed = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos() as u64)
|
||||||
|
.unwrap_or(1)
|
||||||
|
| 1;
|
||||||
|
let mut next = move || {
|
||||||
|
seed ^= seed << 13;
|
||||||
|
seed ^= seed >> 7;
|
||||||
|
seed ^= seed << 17;
|
||||||
|
seed
|
||||||
|
};
|
||||||
|
for i in (1..tail.len()).rev() {
|
||||||
|
tail.swap(i, next() as usize % (i + 1));
|
||||||
|
}
|
||||||
|
player.prefetched_pos = None;
|
||||||
|
}
|
||||||
|
|
||||||
/// Esc/Backspace: pop the navigation stack; leaving a search view resets the
|
/// Esc/Backspace: pop the navigation stack; leaving a search view resets the
|
||||||
/// search so the next `:/` starts clean.
|
/// search so the next `:/` starts clean.
|
||||||
fn go_back(state: &mut AppState) {
|
fn go_back(state: &mut AppState) {
|
||||||
@@ -776,7 +893,7 @@ fn reset_tab(state: &mut AppState, tab: Tab) {
|
|||||||
state.logs.follow = true;
|
state.logs.follow = true;
|
||||||
state.logs.scroll_from_end = 0;
|
state.logs.scroll_from_end = 0;
|
||||||
}
|
}
|
||||||
Tab::Queue | Tab::Devices => {}
|
Tab::Queue => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1056,6 +1173,94 @@ mod tests {
|
|||||||
assert!(state.playlists.opened.is_none());
|
assert!(state.playlists.opened.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queue_tab_select_and_clear() {
|
||||||
|
use crate::api::models::TrackItem;
|
||||||
|
let track = |id: i64| TrackItem {
|
||||||
|
id,
|
||||||
|
title: format!("t{id}"),
|
||||||
|
track_number: None,
|
||||||
|
duration_seconds: 1.0,
|
||||||
|
artists: vec![],
|
||||||
|
featured_artists: vec![],
|
||||||
|
release_id: 1,
|
||||||
|
release_title: "r".into(),
|
||||||
|
release_year: None,
|
||||||
|
cover_url: None,
|
||||||
|
stream_url: format!("/s/{id}"),
|
||||||
|
audio_format: None,
|
||||||
|
audio_bitrate: None,
|
||||||
|
audio_sample_rate: None,
|
||||||
|
file_size_bytes: None,
|
||||||
|
lastfm_playcount: None,
|
||||||
|
};
|
||||||
|
let mut state = AppState {
|
||||||
|
active_tab: Tab::Queue,
|
||||||
|
..AppState::default()
|
||||||
|
};
|
||||||
|
state.player.queue = vec![track(1), track(2), track(3)];
|
||||||
|
state.player.queue_pos = 2;
|
||||||
|
|
||||||
|
// Cursor moves independently; enter rewinds playback to that track
|
||||||
|
// without dropping anything from the queue.
|
||||||
|
update(&mut state, Action::MoveUp);
|
||||||
|
update(&mut state, Action::MoveUp);
|
||||||
|
assert_eq!(state.queue_tab.cursor, 0);
|
||||||
|
assert_eq!(update(&mut state, Action::Select), Some(Effect::PlayCurrent));
|
||||||
|
assert_eq!(state.player.queue_pos, 0);
|
||||||
|
assert_eq!(state.player.queue.len(), 3);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
update(&mut state, Action::ClearQueue),
|
||||||
|
Some(Effect::StopPlayback)
|
||||||
|
);
|
||||||
|
assert!(state.player.queue.is_empty());
|
||||||
|
assert!(!state.player.playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shuffle_reorders_tail_and_restores() {
|
||||||
|
use crate::api::models::TrackItem;
|
||||||
|
let track = |id: i64| TrackItem {
|
||||||
|
id,
|
||||||
|
title: format!("t{id}"),
|
||||||
|
track_number: None,
|
||||||
|
duration_seconds: 1.0,
|
||||||
|
artists: vec![],
|
||||||
|
featured_artists: vec![],
|
||||||
|
release_id: 1,
|
||||||
|
release_title: "r".into(),
|
||||||
|
release_year: None,
|
||||||
|
cover_url: None,
|
||||||
|
stream_url: format!("/s/{id}"),
|
||||||
|
audio_format: None,
|
||||||
|
audio_bitrate: None,
|
||||||
|
audio_sample_rate: None,
|
||||||
|
file_size_bytes: None,
|
||||||
|
lastfm_playcount: None,
|
||||||
|
};
|
||||||
|
let mut state = AppState::default();
|
||||||
|
state.player.queue = (1..=8).map(track).collect();
|
||||||
|
state.player.queue_pos = 2;
|
||||||
|
state.player.current = Some(track(3));
|
||||||
|
|
||||||
|
update(&mut state, Action::ToggleShuffle);
|
||||||
|
assert!(state.player.shuffle);
|
||||||
|
// Played part and the current track stay in place.
|
||||||
|
let ids: Vec<i64> = state.player.queue.iter().map(|t| t.id).collect();
|
||||||
|
assert_eq!(&ids[..3], &[1, 2, 3]);
|
||||||
|
// The tail is a permutation of the original tail.
|
||||||
|
let mut tail = ids[3..].to_vec();
|
||||||
|
tail.sort_unstable();
|
||||||
|
assert_eq!(tail, vec![4, 5, 6, 7, 8]);
|
||||||
|
|
||||||
|
update(&mut state, Action::ToggleShuffle);
|
||||||
|
assert!(!state.player.shuffle);
|
||||||
|
let restored: Vec<i64> = state.player.queue.iter().map(|t| t.id).collect();
|
||||||
|
assert_eq!(restored, vec![1, 2, 3, 4, 5, 6, 7, 8]);
|
||||||
|
assert!(state.player.original_order.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn view_toggle() {
|
fn view_toggle() {
|
||||||
let mut state = AppState::default();
|
let mut state = AppState::default();
|
||||||
|
|||||||
@@ -47,10 +47,6 @@ command = { GoToTab = 2 }
|
|||||||
key_sequence = "4"
|
key_sequence = "4"
|
||||||
command = { GoToTab = 3 }
|
command = { GoToTab = 3 }
|
||||||
|
|
||||||
[[keymaps]]
|
|
||||||
key_sequence = "5"
|
|
||||||
command = { GoToTab = 4 }
|
|
||||||
|
|
||||||
[[keymaps]]
|
[[keymaps]]
|
||||||
key_sequence = "a"
|
key_sequence = "a"
|
||||||
command = "QueueAddNext"
|
command = "QueueAddNext"
|
||||||
@@ -59,6 +55,11 @@ command = "QueueAddNext"
|
|||||||
key_sequence = "shift-a"
|
key_sequence = "shift-a"
|
||||||
command = "QueueAddLast"
|
command = "QueueAddLast"
|
||||||
|
|
||||||
|
[[keymaps]]
|
||||||
|
key_sequence = "shift-c"
|
||||||
|
command = "ClearQueue"
|
||||||
|
context = "queue"
|
||||||
|
|
||||||
[[keymaps]]
|
[[keymaps]]
|
||||||
key_sequence = "j"
|
key_sequence = "j"
|
||||||
command = "MoveDown"
|
command = "MoveDown"
|
||||||
|
|||||||
+41
-47
@@ -7,6 +7,7 @@ pub mod theme;
|
|||||||
|
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Clear, Paragraph, Row, Table, Tabs};
|
use ratatui::widgets::{Block, Clear, Paragraph, Row, Table, Tabs};
|
||||||
|
|
||||||
@@ -30,7 +31,6 @@ pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
|
|||||||
Tab::Global => global::draw(frame, main_area, state),
|
Tab::Global => global::draw(frame, main_area, state),
|
||||||
Tab::Playlists => playlists::draw(frame, main_area, state),
|
Tab::Playlists => playlists::draw(frame, main_area, state),
|
||||||
Tab::Queue => draw_queue(frame, main_area, state),
|
Tab::Queue => draw_queue(frame, main_area, state),
|
||||||
Tab::Devices => draw_main(frame, main_area, state),
|
|
||||||
Tab::Logs => logs::draw(frame, main_area, state),
|
Tab::Logs => logs::draw(frame, main_area, state),
|
||||||
}
|
}
|
||||||
draw_status(frame, status_area, state);
|
draw_status(frame, status_area, state);
|
||||||
@@ -52,30 +52,6 @@ fn draw_tabs(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
frame.render_widget(tabs, area);
|
frame.render_widget(tabs, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_main(frame: &mut Frame, area: Rect, state: &AppState) {
|
|
||||||
let block = Block::bordered()
|
|
||||||
.title(format!(" {} ", state.active_tab.title()))
|
|
||||||
.title_style(theme::header())
|
|
||||||
.border_style(theme::dim());
|
|
||||||
let inner = block.inner(area);
|
|
||||||
frame.render_widget(block, area);
|
|
||||||
|
|
||||||
let (summary, milestone) = match state.active_tab {
|
|
||||||
Tab::Devices => ("Connected devices and playback transfer", "milestone 5"),
|
|
||||||
_ => ("", ""),
|
|
||||||
};
|
|
||||||
let lines = vec![
|
|
||||||
Line::default(),
|
|
||||||
Line::styled(summary, theme::accent()),
|
|
||||||
Line::styled(format!("coming in {milestone}"), theme::dim()),
|
|
||||||
Line::default(),
|
|
||||||
Line::styled("Tab / Shift-Tab or 1-5 to switch tabs", theme::dim()),
|
|
||||||
Line::styled("? keybindings q quit", theme::dim()),
|
|
||||||
];
|
|
||||||
let paragraph = Paragraph::new(lines).alignment(Alignment::Center);
|
|
||||||
frame.render_widget(paragraph, centered_vertically(inner, 6));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One track row used by every track list: ♥ marker for liked tracks, the
|
/// One track row used by every track list: ♥ marker for liked tracks, the
|
||||||
/// title and artists on the left, tech info and duration on the right.
|
/// title and artists on the left, tech info and duration on the right.
|
||||||
pub(crate) fn track_row(
|
pub(crate) fn track_row(
|
||||||
@@ -114,11 +90,15 @@ pub(crate) fn track_row(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read-only queue listing; the playing track is highlighted.
|
/// Interactive queue: its own cursor, enter plays the selected track and
|
||||||
|
/// already-played tracks stay listed, greyed out.
|
||||||
fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
|
fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
let player = &state.player;
|
let player = &state.player;
|
||||||
let block = Block::bordered()
|
let block = Block::bordered()
|
||||||
.title(format!(" Queue — {} tracks ", player.queue.len()))
|
.title(format!(
|
||||||
|
" Queue — {} tracks · enter: play · shift-c: clear ",
|
||||||
|
player.queue.len()
|
||||||
|
))
|
||||||
.title_style(theme::header())
|
.title_style(theme::header())
|
||||||
.border_style(theme::dim());
|
.border_style(theme::dim());
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
@@ -137,11 +117,14 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cursor = state.queue_tab.cursor.min(player.queue.len() - 1);
|
||||||
let visible = usize::from(inner.height.max(1));
|
let visible = usize::from(inner.height.max(1));
|
||||||
let first = player
|
let first = cursor
|
||||||
.queue_pos
|
|
||||||
.saturating_sub(visible / 2)
|
.saturating_sub(visible / 2)
|
||||||
.min(player.queue.len().saturating_sub(visible));
|
.min(player.queue.len().saturating_sub(visible));
|
||||||
|
let played_style = Style::new()
|
||||||
|
.fg(Color::DarkGray)
|
||||||
|
.bg(Color::Rgb(28, 28, 32));
|
||||||
for (index, track) in player.queue.iter().enumerate().skip(first).take(visible) {
|
for (index, track) in player.queue.iter().enumerate().skip(first).take(visible) {
|
||||||
let row = Rect {
|
let row = Rect {
|
||||||
x: inner.x,
|
x: inner.x,
|
||||||
@@ -149,14 +132,17 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
width: inner.width,
|
width: inner.width,
|
||||||
height: 1,
|
height: 1,
|
||||||
};
|
};
|
||||||
track_row(
|
let label = if index == player.queue_pos && player.playing {
|
||||||
frame,
|
"▶".to_string()
|
||||||
row,
|
} else {
|
||||||
state,
|
(index + 1).to_string()
|
||||||
track,
|
};
|
||||||
(index + 1).to_string(),
|
track_row(frame, row, state, track, label, index == cursor);
|
||||||
index == player.queue_pos,
|
// Tracks before the playing one are history: greyed out unless the
|
||||||
);
|
// cursor is on them.
|
||||||
|
if index < player.queue_pos && index != cursor {
|
||||||
|
frame.buffer_mut().set_style(row, played_style);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,17 +189,31 @@ fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<
|
|||||||
Span::styled("█".repeat(volume_cells), theme::accent()),
|
Span::styled("█".repeat(volume_cells), theme::accent()),
|
||||||
Span::styled("░".repeat(10 - volume_cells), theme::dim()),
|
Span::styled("░".repeat(10 - volume_cells), theme::dim()),
|
||||||
Span::raw(format!(" {:3}%", player.volume)),
|
Span::raw(format!(" {:3}%", player.volume)),
|
||||||
Span::styled(" shuffle ", theme::dim()),
|
Span::raw(" "),
|
||||||
Span::raw(if player.shuffle { "on" } else { "off" }.to_string()),
|
|
||||||
Span::styled(" repeat ", theme::dim()),
|
|
||||||
Span::raw(player.repeat.label().to_string()),
|
|
||||||
]);
|
]);
|
||||||
|
// Enabled modes light up as filled chips; disabled stay dim text.
|
||||||
|
if player.shuffle {
|
||||||
|
spans.push(Span::styled(" shuffle ", theme::tab_active()));
|
||||||
|
} else {
|
||||||
|
spans.push(Span::styled("shuffle off", theme::dim()));
|
||||||
|
}
|
||||||
|
spans.push(Span::raw(" "));
|
||||||
|
if player.repeat == crate::app::state::RepeatMode::Off {
|
||||||
|
spans.push(Span::styled("repeat off", theme::dim()));
|
||||||
|
} else {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!(" repeat {} ", player.repeat.label()),
|
||||||
|
theme::tab_active(),
|
||||||
|
));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
format!(" {}%", player.volume),
|
format!(" {}%", player.volume),
|
||||||
theme::dim(),
|
theme::dim(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// Keep a gap between the flags and the username block to the right.
|
||||||
|
spans.push(Span::raw(" "));
|
||||||
Line::from(spans)
|
Line::from(spans)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,9 +344,3 @@ fn centered_rect(area: Rect, width: u16, height: u16) -> Rect {
|
|||||||
rect
|
rect
|
||||||
}
|
}
|
||||||
|
|
||||||
fn centered_vertically(area: Rect, content_height: u16) -> Rect {
|
|
||||||
let [rect] = Layout::vertical([Constraint::Length(content_height)])
|
|
||||||
.flex(ratatui::layout::Flex::Center)
|
|
||||||
.areas(area);
|
|
||||||
rect
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user