Reworked MODE switch

This commit is contained in:
Ultradesu
2026-07-27 14:13:56 +01:00
parent 5722e5aae6
commit d36f55b3f6
13 changed files with 333 additions and 96 deletions
+6 -3
View File
@@ -48,6 +48,7 @@ pub enum Action {
NewPlaylist,
ToggleHelp,
ToggleViewMode,
CycleSourceMode,
OpenLibraryFilters,
OpenCommandLine,
OpenSearch,
@@ -128,9 +129,10 @@ impl Action {
| Action::GoToTab(_)
| Action::GoToRelease
| Action::ToggleViewMode => Category::Navigation,
Action::EditSelected | Action::DeleteSelected | Action::OpenLibraryFilters => {
Category::Library
}
Action::EditSelected
| Action::DeleteSelected
| Action::CycleSourceMode
| Action::OpenLibraryFilters => Category::Library,
Action::OpenSearch | Action::OpenCommandLine => Category::Search,
Action::ToggleHelp | Action::Quit => Category::System,
}
@@ -197,6 +199,7 @@ impl Action {
Action::NewPlaylist => "Create a playlist".into(),
Action::ToggleHelp => "Show / hide keybindings".into(),
Action::ToggleViewMode => "Toggle tiles / table view".into(),
Action::CycleSourceMode => "Cycle source mode: Local / My / Global".into(),
Action::OpenLibraryFilters => "Library filters…".into(),
Action::OpenCommandLine => "Command line (:help for commands)".into(),
Action::OpenSearch => "Search artists, releases, tracks".into(),
+34 -6
View File
@@ -425,14 +425,21 @@ fn apply_playback_state_to_ui(
wire: &crate::devices::PlaybackStateWire,
library: Option<&Library>,
) {
state.player.queue = wire
let queue: Vec<_> = wire
.queue
.iter()
.map(|track| playback_track_to_ui(track, library))
.collect();
state.player.queue_pos = wire
.queue_pos
.min(state.player.queue.len().saturating_sub(1));
let queue_pos = queue
.iter()
.take(wire.queue_pos)
.filter(|track| update::track_allowed_by_source_mode(state, track))
.count();
state.player.queue = queue
.into_iter()
.filter(|track| update::track_allowed_by_source_mode(state, track))
.collect();
state.player.queue_pos = queue_pos.min(state.player.queue.len().saturating_sub(1));
state.player.playing = wire.playing && !state.player.queue.is_empty();
state.player.paused = wire.paused;
state.device_playback.local_idle_since_ms = if state.player.playing && !state.player.paused {
@@ -1291,6 +1298,24 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
}
Effect::SetOptions => {}
Effect::PlaybackQueueChanged => {}
Effect::SourceModeChanged => {
runtime.library_network_refresh_at = None;
if let Ok(mut cursors) = runtime.library_network_cursors.lock() {
cursors.clear();
}
if let Ok(mut done) = runtime.library_network_done.lock() {
done.clear();
}
if let Ok(mut attempted) = runtime.library_network_art_attempted.lock() {
attempted.clear();
}
save_app_settings(state);
reset_artist_pagination(state);
refresh_artists(state, runtime);
if let Some(effect) = update::apply_library_filter_change(state) {
perform_effect(state, runtime, effect);
}
}
Effect::EnqueueRelease { id, next } => {
let library = Arc::clone(&runtime.library);
let tx = runtime.event_tx.clone();
@@ -3410,10 +3435,13 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
});
}
AppEvent::EnqueueTracks { tracks, next } => {
let count = tracks.len();
let previous_len = state.player.queue.len();
update::enqueue_tracks(state, tracks, next);
let count = state.player.queue.len().saturating_sub(previous_len);
record_control_playback_state(state, runtime, false);
state.status_message = Some(if next {
state.status_message = Some(if count == 0 {
"no tracks available in the current source mode".to_string()
} else if next {
format!("{count} tracks queued next")
} else {
format!("{count} tracks queued")
+8 -27
View File
@@ -404,45 +404,26 @@ fn handle_connected_devices(
fn handle_library_filters(
state: &mut AppState,
runtime: &mut Runtime,
cursor: usize,
_cursor: usize,
key: KeyEvent,
) {
let max_cursor = crate::config::settings::LibrarySourceMode::ALL.len();
let cursor = cursor.min(max_cursor);
let cursor = 0;
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {}
KeyCode::Up | KeyCode::Char('k') => {
state.popup = Some(Popup::LibraryFilters {
cursor: cursor.saturating_sub(1),
});
state.popup = Some(Popup::LibraryFilters { cursor: 0 });
}
KeyCode::Down | KeyCode::Char('j') => {
state.popup = Some(Popup::LibraryFilters {
cursor: (cursor + 1).min(max_cursor),
});
state.popup = Some(Popup::LibraryFilters { cursor: 0 });
}
KeyCode::Enter | KeyCode::Char(' ') => {
if cursor == 0 {
state.global.filters.hide_featured_only = !state.global.filters.hide_featured_only;
} else if let Some(mode) =
crate::config::settings::LibrarySourceMode::ALL.get(cursor - 1)
{
state.global.filters.source_mode = *mode;
}
runtime.library_network_refresh_at = None;
if let Ok(mut cursors) = runtime.library_network_cursors.lock() {
cursors.clear();
}
if let Ok(mut done) = runtime.library_network_done.lock() {
done.clear();
}
if let Ok(mut attempted) = runtime.library_network_art_attempted.lock() {
attempted.clear();
}
state.global.filters.hide_featured_only = !state.global.filters.hide_featured_only;
super::save_app_settings(state);
super::reset_artist_pagination(state);
super::refresh_artists(state, runtime);
super::update::apply_library_filter_change(state);
if let Some(effect) = super::update::apply_library_filter_change(state) {
super::perform_effect(state, runtime, effect);
}
}
_ => state.popup = Some(Popup::LibraryFilters { cursor }),
}
+72 -16
View File
@@ -48,6 +48,8 @@ pub enum Effect {
},
/// Queue/options changed without a direct audio engine action.
PlaybackQueueChanged,
/// Persist and apply a Local / My / Global source-mode change.
SourceModeChanged,
/// Persist the federation settings and start/stop the node.
FedApplySettings,
/// Force an immediate library publish into the DHT.
@@ -239,6 +241,18 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
}
None
}
Action::CycleSourceMode => {
if matches!(state.active_tab, Tab::Global | Tab::Playlists | Tab::Queue) {
state.global.filters.source_mode = state.global.filters.source_mode.next();
state.status_message = Some(format!(
"source mode: {}",
state.global.filters.source_mode.label()
));
Some(Effect::SourceModeChanged)
} else {
None
}
}
Action::OpenLibraryFilters => {
if state.active_tab == Tab::Global {
state.popup = Some(super::state::Popup::LibraryFilters { cursor: 0 });
@@ -920,7 +934,7 @@ fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope,
}
}
fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, &[TrackItem])> {
fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, Vec<&TrackItem>)> {
match state.active_tab {
Tab::Global => match state.global.stack.last()? {
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
@@ -931,23 +945,25 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, &
Some((
TrackSelectionScope::ArtistTop(*id),
*cursor,
&detail.top_tracks,
detail.top_tracks.iter().collect(),
))
} else {
let featured = cursor.checked_sub(tracks + releases)?;
(featured < detail.featured_tracks.len()).then_some((
TrackSelectionScope::ArtistFeatured(*id),
featured,
&detail.featured_tracks,
detail.featured_tracks.iter().collect(),
))
}
}
_ => None,
},
GlobalView::Release { id, cursor } => match state.release_views.get(id)? {
Loadable::Ready(detail) => {
Some((TrackSelectionScope::Release(*id), *cursor, &detail.tracks))
}
Loadable::Ready(detail) => Some((
TrackSelectionScope::Release(*id),
*cursor,
detail.tracks.iter().collect(),
)),
_ => None,
},
_ => None,
@@ -963,7 +979,7 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, &
Tab::Queue => Some((
TrackSelectionScope::Queue,
state.queue_tab.cursor,
&state.player.queue,
state.player.queue.iter().collect(),
)),
Tab::Federation | Tab::Logs => None,
}
@@ -987,7 +1003,7 @@ pub fn selected_tracks(state: &AppState) -> Vec<TrackItem> {
.unwrap_or_else(|| vec![cursor.min(tracks.len().saturating_sub(1))]);
indices
.into_iter()
.filter_map(|index| tracks.get(index).cloned())
.filter_map(|index| tracks.get(index).map(|track| (*track).clone()))
.collect()
}
@@ -1175,21 +1191,32 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
let opened = state.playlists.opened.as_ref()?;
playlist_tracks(state, opened.id)?
.get(opened.cursor)
.cloned()
.map(|track| (*track).clone())
}
Tab::Queue => state.player.queue.get(state.queue_tab.cursor).cloned(),
Tab::Federation | Tab::Logs => None,
}
}
/// Tracks backing an opened playlist, if loaded.
pub fn playlist_tracks(state: &AppState, id: i64) -> Option<&Vec<TrackItem>> {
/// Visible tracks backing an opened playlist. Local mode excludes pending
/// federation entries so they cannot be selected or copied into playback.
pub fn playlist_tracks(state: &AppState, id: i64) -> Option<Vec<&TrackItem>> {
match state.playlist_views.get(&id)? {
Loadable::Ready(detail) => Some(&detail.tracks),
Loadable::Ready(detail) => Some(
detail
.tracks
.iter()
.filter(|track| track_allowed_by_source_mode(state, track))
.collect(),
),
_ => None,
}
}
pub(crate) fn track_allowed_by_source_mode(state: &AppState, track: &TrackItem) -> bool {
state.global.filters.source_mode.includes_network() || !track.is_fed_pending()
}
/// A *release* under the cursor (artist-view tile/row or a search release).
fn selected_release_id(state: &AppState) -> Option<i64> {
if state.active_tab != Tab::Global {
@@ -1334,6 +1361,10 @@ pub(crate) fn track_artist_refs(track: &TrackItem) -> Vec<crate::library::models
/// Insert tracks after the playing one (`next`) or at the end. Keeps the
/// gapless prefetch index pointing at the same track if items shift.
pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool) {
let tracks: Vec<_> = tracks
.into_iter()
.filter(|track| track_allowed_by_source_mode(state, track))
.collect();
let player = &mut state.player;
if tracks.is_empty() {
return;
@@ -1790,7 +1821,7 @@ fn set_view_cursor(state: &mut AppState, value: usize) {
/// Items in the playlists tab's current view (list or opened playlist).
fn playlists_view_len(state: &AppState) -> usize {
match &state.playlists.opened {
Some(opened) => playlist_tracks(state, opened.id).map_or(0, Vec::len),
Some(opened) => playlist_tracks(state, opened.id).map_or(0, |tracks| tracks.len()),
None => match &state.playlists.list {
Some(Loadable::Ready(list)) => list.len(),
_ => 0,
@@ -1832,7 +1863,7 @@ fn current_view_len(state: &AppState) -> usize {
}
}
pub(crate) fn apply_library_filter_change(state: &mut AppState) {
pub(crate) fn apply_library_filter_change(state: &mut AppState) -> Option<Effect> {
state.track_selection.clear();
let len = current_view_len(state);
if state.active_tab == Tab::Global {
@@ -1848,11 +1879,32 @@ pub(crate) fn apply_library_filter_change(state: &mut AppState) {
};
*cursor = (*cursor).min(len.saturating_sub(1));
}
} else if state.active_tab == Tab::Playlists
&& let Some(opened) = &mut state.playlists.opened
{
opened.cursor = opened.cursor.min(len.saturating_sub(1));
}
if state.global.filters.source_mode.includes_network() {
return;
return None;
}
let remote_indices: Vec<_> = state
.player
.queue
.iter()
.enumerate()
.filter_map(|(index, track)| track.is_fed_pending().then_some(index))
.collect();
let queue_effect = if remote_indices.is_empty() {
None
} else {
let outcome = remove_queue_indices(state, &remote_indices);
Some(Effect::RemoveQueueIndices {
indices: remote_indices,
restart_paused: outcome.restart_paused,
stop: outcome.stop,
})
};
let message = match state.global.stack.last() {
Some(GlobalView::Artist { id, .. }) => match state.artist_views.get(id) {
Some(Loadable::Ready(detail))
@@ -1885,6 +1937,7 @@ pub(crate) fn apply_library_filter_change(state: &mut AppState) {
if let Some(message) = message {
state.status_message = Some(message);
}
queue_effect
}
fn jump_selection(state: &mut AppState, first: bool) {
@@ -1954,7 +2007,10 @@ fn jump_selection(state: &mut AppState, first: bool) {
fn select_playlist(state: &mut AppState) -> Option<Effect> {
match state.playlists.opened {
Some(opened) => {
let tracks = playlist_tracks(state, opened.id)?.clone();
let tracks: Vec<_> = playlist_tracks(state, opened.id)?
.into_iter()
.cloned()
.collect();
if tracks.is_empty() {
return None;
}
+159
View File
@@ -41,6 +41,23 @@ fn test_track(id: i64) -> TrackItem {
}
}
fn pending_fed_track(id: i64) -> TrackItem {
crate::federation::pending_track(&crate::federation::FedTrack {
item_id: format!("fed-{id}"),
owner: "peer".into(),
own: false,
title: format!("remote-{id}"),
artist_names: vec!["remote artist".into()],
featured_artist_names: vec![],
year: None,
duration_seconds: Some(1),
content_id: Some(format!("b3:{id:064x}")),
release_title: Some("remote release".into()),
track_number: None,
disc_number: None,
})
}
#[test]
fn quit_needs_double_press() {
let mut state = AppState::default();
@@ -115,6 +132,36 @@ fn library_filters_popup_opens_on_library_screens() {
assert!(state.popup.is_none());
}
#[test]
fn source_mode_cycles_on_library_playlists_and_queue_tabs() {
use crate::config::settings::LibrarySourceMode;
let mut state = AppState::default();
assert_eq!(
update(&mut state, Action::CycleSourceMode),
Some(Effect::SourceModeChanged)
);
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::My);
state.active_tab = Tab::Playlists;
assert_eq!(
update(&mut state, Action::CycleSourceMode),
Some(Effect::SourceModeChanged)
);
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Global);
state.active_tab = Tab::Queue;
assert_eq!(
update(&mut state, Action::CycleSourceMode),
Some(Effect::SourceModeChanged)
);
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Local);
state.active_tab = Tab::Federation;
assert_eq!(update(&mut state, Action::CycleSourceMode), None);
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Local);
}
#[test]
fn back_closes_help_first() {
let mut state = AppState::default();
@@ -387,6 +434,118 @@ fn queue_tab_select_and_clear() {
assert!(!state.player.playing);
}
#[test]
fn local_mode_hides_pending_federation_tracks_from_playlists_and_playback() {
let mut state = AppState {
active_tab: Tab::Playlists,
..AppState::default()
};
state.playlists.opened = Some(OpenedPlaylist { id: 7, cursor: 1 });
state.playlist_views.insert(
7,
Loadable::Ready(crate::library::models::PlaylistDetail {
id: 7,
title: "mixed".into(),
description: None,
tracks: vec![test_track(1), pending_fed_track(2), test_track(3)],
}),
);
assert_eq!(
playlist_tracks(&state, 7)
.unwrap()
.iter()
.map(|track| track.id)
.collect::<Vec<_>>(),
vec![1, 3]
);
assert_eq!(
update(&mut state, Action::Select),
Some(Effect::PlayCurrent)
);
assert_eq!(
state
.player
.queue
.iter()
.map(|track| track.id)
.collect::<Vec<_>>(),
vec![1, 3]
);
assert_eq!(state.player.queue_pos, 1);
}
#[test]
fn network_modes_show_pending_federation_playlist_tracks() {
let mut state = AppState::default();
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::My;
state.playlist_views.insert(
7,
Loadable::Ready(crate::library::models::PlaylistDetail {
id: 7,
title: "mixed".into(),
description: None,
tracks: vec![test_track(1), pending_fed_track(2)],
}),
);
assert_eq!(playlist_tracks(&state, 7).unwrap().len(), 2);
}
#[test]
fn local_mode_rejects_async_federation_queue_additions() {
let mut state = AppState::default();
enqueue_tracks(
&mut state,
vec![test_track(1), pending_fed_track(2), test_track(3)],
false,
);
assert_eq!(
state
.player
.queue
.iter()
.map(|track| track.id)
.collect::<Vec<_>>(),
vec![1, 3]
);
}
#[test]
fn switching_to_local_mode_removes_pending_federation_queue_tracks() {
let mut state = AppState::default();
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::My;
state.player.queue = vec![test_track(1), pending_fed_track(2), test_track(3)];
state.player.queue_pos = 1;
state.player.current = Some(state.player.queue[1].clone());
state.player.playing = true;
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::Local;
let effect = apply_library_filter_change(&mut state);
assert!(matches!(
effect,
Some(Effect::RemoveQueueIndices {
indices,
restart_paused: Some(false),
stop: false,
}) if indices == vec![1]
));
assert_eq!(
state
.player
.queue
.iter()
.map(|track| track.id)
.collect::<Vec<_>>(),
vec![1, 3]
);
assert_eq!(state.player.queue_pos, 1);
assert_eq!(state.player.current.as_ref().map(|track| track.id), Some(3));
}
#[test]
fn current_track_info_uses_now_playing_track() {
let mut state = AppState {
+4
View File
@@ -225,6 +225,10 @@ command = "DeleteSelected"
key_sequence = "v"
command = "ToggleViewMode"
[[keymaps]]
key_sequence = "m"
command = "CycleSourceMode"
[[keymaps]]
key_sequence = "f"
command = "OpenLibraryFilters"
+15
View File
@@ -426,6 +426,21 @@ mod tests {
);
}
#[test]
fn default_source_mode_key_resolves_on_content_tabs() {
let mut km = keymap_from(DEFAULT_KEYMAP);
for context in [
KeyContext::Library,
KeyContext::Playlists,
KeyContext::Queue,
] {
assert_eq!(
km.resolve(key!(m), context),
KeyResolution::Action(Action::CycleSourceMode)
);
}
}
#[test]
fn default_shift_n_is_unbound() {
let mut km = keymap_from(DEFAULT_KEYMAP);
+8 -21
View File
@@ -11,12 +11,6 @@ pub enum LibrarySourceMode {
}
impl LibrarySourceMode {
pub const ALL: [LibrarySourceMode; 3] = [
LibrarySourceMode::Local,
LibrarySourceMode::My,
LibrarySourceMode::Global,
];
pub fn label(self) -> &'static str {
match self {
LibrarySourceMode::Local => "Local",
@@ -25,14 +19,6 @@ impl LibrarySourceMode {
}
}
pub fn description(self) -> &'static str {
match self {
LibrarySourceMode::Local => "only this device",
LibrarySourceMode::My => "this device + connected devices",
LibrarySourceMode::Global => "my devices + known federation peers",
}
}
pub fn includes_network(self) -> bool {
!matches!(self, LibrarySourceMode::Local)
}
@@ -40,6 +26,14 @@ impl LibrarySourceMode {
pub fn includes_global_peers(self) -> bool {
matches!(self, LibrarySourceMode::Global)
}
pub fn next(self) -> Self {
match self {
LibrarySourceMode::Local => LibrarySourceMode::My,
LibrarySourceMode::My => LibrarySourceMode::Global,
LibrarySourceMode::Global => LibrarySourceMode::Local,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -50,12 +44,6 @@ pub struct LibraryFilters {
pub source_mode: LibrarySourceMode,
}
impl LibraryFilters {
pub fn is_active(&self) -> bool {
self.hide_featured_only || self.source_mode != LibrarySourceMode::Local
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppSettings {
#[serde(default = "default_volume")]
@@ -140,7 +128,6 @@ hide_featured_only = true
assert_eq!(settings.volume, 100);
assert!(settings.library.hide_featured_only);
assert!(settings.library.is_active());
assert_eq!(settings.library.source_mode, LibrarySourceMode::Local);
}
}
+3 -3
View File
@@ -383,15 +383,15 @@ fn draw_grid(frame: &mut Frame, area: Rect, state: &AppState) {
let global = &state.global;
let title = if global.total > 0 {
format!(
" Library — {} artists · {} ",
" Library — {} artists · Mode: {} ",
global.total,
global.filters.source_mode.label()
)
} else {
format!(" Library · {} ", global.filters.source_mode.label())
format!(" Library · Mode: {} ", global.filters.source_mode.label())
};
let mut title_spans = vec![Span::styled(title, theme::tab_active_for(state))];
if global.filters.is_active() {
if global.filters.hide_featured_only {
title_spans.push(Span::raw(" "));
title_spans.push(Span::styled(" FILTERED ", theme::tab_active_for(state)));
}
+3 -2
View File
@@ -247,8 +247,9 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
let player = &state.player;
let block = Block::bordered()
.title(format!(
" Queue — {} tracks · enter: play · d: remove · shift-v: select · :clear ",
player.queue.len()
" Queue — {} tracks · Mode: {} · enter: play · d: remove · shift-v: select · :clear ",
player.queue.len(),
state.global.filters.source_mode.label()
))
.title_style(theme::header_for(state))
.border_style(theme::border_for(state));
+18 -3
View File
@@ -38,7 +38,15 @@ fn centered_line(frame: &mut Frame, area: Rect, line: Line) {
}
fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
let inner = bordered(frame, area, state, " Playlists ".to_string());
let inner = bordered(
frame,
area,
state,
format!(
" Playlists · Mode: {} ",
state.global.filters.source_mode.label()
),
);
let selected = state.playlists.selected;
let list = match &state.playlists.list {
@@ -97,8 +105,15 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: usize) {
let loadable = state.playlist_views.get(&id);
let title = match loadable {
Some(Loadable::Ready(detail)) => format!(" Playlists ▸ {} ", detail.title),
_ => " Playlists ▸ ".to_string(),
Some(Loadable::Ready(detail)) => format!(
" Playlists ▸ {} · Mode: {} ",
detail.title,
state.global.filters.source_mode.label()
),
_ => format!(
" Playlists ▸ … · Mode: {} ",
state.global.filters.source_mode.label()
),
};
let inner = bordered(frame, area, state, title);
+2 -14
View File
@@ -598,7 +598,7 @@ fn clip_cells(text: &str, max_width: usize) -> String {
}
fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) {
let area = centered(frame.area(), 54, 9);
let area = centered(frame.area(), 44, 6);
let block = Block::bordered()
.title(" Library filters ")
.title_style(theme::header_for(state))
@@ -608,7 +608,7 @@ fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) {
frame.render_widget(block, area);
let [list_area, _, footer] = Layout::vertical([
Constraint::Length(4),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
@@ -624,18 +624,6 @@ fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) {
Span::styled(format!("{checked} "), theme::accent_for(state)),
Span::raw("Hide featured only"),
]));
for mode in crate::config::settings::LibrarySourceMode::ALL {
let marker = if state.global.filters.source_mode == mode {
"(*)"
} else {
"( )"
};
rows.push(Line::from(vec![
Span::styled(format!("{marker} "), theme::accent_for(state)),
Span::raw(mode.label()),
Span::styled(format!(" {}", mode.description()), theme::dim()),
]));
}
for (index, line) in rows.into_iter().enumerate() {
let row = Rect {
y: list_area.y + index as u16,