Reworked settings menu

This commit is contained in:
ab
2026-09-07 13:06:46 +03:00
parent 7d6af59e97
commit 5a193d839f
10 changed files with 387 additions and 185 deletions
Generated
+1 -1
View File
@@ -1715,7 +1715,7 @@ dependencies = [
[[package]]
name = "furumi_tui"
version = "0.2.9"
version = "0.3.0"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "furumi_tui"
version = "0.2.9"
version = "0.3.0"
edition = "2024"
rust-version = "1.97"
description = "A federated P2P player for personal music libraries"
+5 -1
View File
@@ -126,7 +126,7 @@ inside the player.
### Manual updates
In **Settings → Updates**, select **Check for updates**, then **Install update**
In **Settings → Additional settings → Updates**, select **Check for updates**, then **Install update**
when a newer stable GitHub release is available. Downloads run in the background.
After installation, restart `furumi` to use the new version; playback is not
restarted automatically. Wait for an active update operation to finish before
@@ -142,6 +142,10 @@ not publisher signatures. Settings and the local library are preserved.
There are no automatic startup checks. Only the existing release asset naming
scheme is supported; missing or incompatible platform builds are rejected.
The **Additional settings** window also contains the music save directory and
visualization controls. Use Up/Down (or j/k) to navigate, Enter to select, and
Esc to return to Settings. Long lists scroll with the selection.
### Now playing in tmux
While Furumi is running, a second invocation can print a cheap, single-line
+5
View File
@@ -1967,6 +1967,11 @@ fn perform_control_playback_effect(state: &mut AppState, runtime: &mut Runtime,
}
fn clamp_settings_cursor(state: &mut AppState) {
state.additional_settings_cursor = state.additional_settings_cursor.min(
state::additional_settings_rows(state)
.len()
.saturating_sub(1),
);
let last = state::settings_rows(state).len().saturating_sub(1);
state.settings_cursor = state.settings_cursor.min(last);
}
+23 -14
View File
@@ -926,6 +926,7 @@ impl FedRow {
/// the config directory.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsRow {
AdditionalSettings,
CheckUpdate,
InstallUpdate,
MusicDirectory,
@@ -1066,24 +1067,13 @@ pub fn device_status_order(state: &AppState) -> Vec<usize> {
indices
}
pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
/// Secondary settings share actions but have independent navigation.
pub fn additional_settings_rows(state: &AppState) -> Vec<SettingsRow> {
let mut rows = vec![
SettingsRow::MusicDirectory,
SettingsRow::CheckUpdate,
SettingsRow::InstallUpdate,
];
rows.extend(SimilarityRow::ALL.into_iter().map(SettingsRow::Similarity));
rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation));
rows.push(SettingsRow::DeviceName);
rows.push(SettingsRow::DeviceInvite);
rows.push(SettingsRow::DeviceConnect);
rows.push(SettingsRow::DeviceSyncNow);
rows.push(SettingsRow::DeviceLeaveGroup);
rows.extend(
device_status_order(state)
.into_iter()
.map(SettingsRow::Device),
);
rows.push(SettingsRow::VisualizationClock);
rows.extend(
state
@@ -1097,7 +1087,23 @@ pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
if !state.visualizer.scripts.is_empty() {
rows.push(SettingsRow::VisualizationEdit);
}
rows.push(SettingsRow::StatusDetails);
rows
}
pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
let mut rows = vec![SettingsRow::AdditionalSettings, SettingsRow::StatusDetails];
rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation));
rows.push(SettingsRow::DeviceName);
rows.push(SettingsRow::DeviceInvite);
rows.push(SettingsRow::DeviceConnect);
rows.push(SettingsRow::DeviceSyncNow);
rows.push(SettingsRow::DeviceLeaveGroup);
rows.extend(
device_status_order(state)
.into_iter()
.map(SettingsRow::Device),
);
rows.extend(SimilarityRow::ALL.into_iter().map(SettingsRow::Similarity));
rows
}
@@ -1589,6 +1595,9 @@ pub struct AppState {
pub status_message: Option<String>,
pub spinner_frame: usize,
pub settings_cursor: usize,
/// Kept open underneath child dialogs and asynchronous confirmations.
pub additional_settings_open: bool,
pub additional_settings_cursor: usize,
/// Root for music permanently downloaded from federation peers.
pub music_dir: std::path::PathBuf,
pub music_dir_changing: bool,
+56 -1
View File
@@ -115,6 +115,22 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
.take()
.is_some_and(|deadline| Instant::now() <= deadline);
state.status_message = None;
if state.additional_settings_open
&& !matches!(
action,
Action::PlayPause
| Action::NextTrack
| Action::PrevTrack
| Action::SeekForward { .. }
| Action::SeekBackward { .. }
| Action::VolumeUp
| Action::VolumeDown
| Action::ToggleShuffle
| Action::CycleRepeat
)
{
return update_additional_settings(state, action);
}
match action {
// While the help window is open, quit/back just close it.
Action::Quit | Action::Back if state.help_visible => {
@@ -2814,9 +2830,48 @@ fn fed_card_featured_artist_names(track: &crate::federation::FedCardTrack) -> Ve
/// Enter on Settings: toggle switches, open text inputs, run
/// one-shot operations. The heavy lifting happens in perform_effect().
fn update_additional_settings(state: &mut AppState, action: Action) -> Option<Effect> {
if state.help_visible {
if matches!(action, Action::Back | Action::Quit | Action::ToggleHelp) {
state.help_visible = false;
}
return None;
}
let rows = super::state::additional_settings_rows(state);
let last = rows.len().saturating_sub(1);
let cursor = state.additional_settings_cursor.min(last);
state.additional_settings_cursor = match action {
Action::Back | Action::Quit => {
state.additional_settings_open = false;
return None;
}
Action::MoveUp | Action::PrevTab => cursor.saturating_sub(1),
Action::MoveDown | Action::NextTab => (cursor + 1).min(last),
Action::PageUp => cursor.saturating_sub(5),
Action::PageDown => (cursor + 5).min(last),
Action::SelectFirst => 0,
Action::SelectLast => last,
Action::Select => return select_settings_row(state, *rows.get(cursor)?),
Action::ToggleHelp => {
state.help_visible = true;
cursor
}
_ => cursor,
};
None
}
fn federation_select(state: &mut AppState) -> Option<Effect> {
select_settings_row(state, *settings_rows(state).get(state.settings_cursor)?)
}
fn select_settings_row(state: &mut AppState, row: super::state::SettingsRow) -> Option<Effect> {
use super::state::{FedInputField, FedRow, Popup, SettingsRow, SimilarityRow};
match settings_rows(state).get(state.settings_cursor).copied()? {
match row {
SettingsRow::AdditionalSettings => {
state.additional_settings_open = true;
None
}
SettingsRow::CheckUpdate => {
if state.updater.busy || state.updater.installed {
return None;
+58 -6
View File
@@ -1,25 +1,77 @@
use super::*;
use crate::library::models::{ArtistCard, ArtistDetail, TrackItem};
#[test]
fn additional_settings_preserve_main_navigation_and_child_dialog_parent() {
use crate::app::state::{FedInputField, Popup, SettingsRow, additional_settings_rows};
let mut state = AppState {
active_tab: Tab::Federation,
..AppState::default()
};
let main_rows = settings_rows(&state);
assert!(!main_rows.contains(&SettingsRow::MusicDirectory));
assert!(!main_rows.contains(&SettingsRow::CheckUpdate));
assert!(!main_rows.contains(&SettingsRow::VisualizationClock));
state.settings_cursor = main_rows
.iter()
.position(|r| *r == SettingsRow::AdditionalSettings)
.unwrap();
let main_cursor = state.settings_cursor;
update(&mut state, Action::Select);
assert!(state.additional_settings_open);
update(&mut state, Action::Select);
assert!(matches!(
state.popup,
Some(Popup::FedInput {
field: FedInputField::MusicDirectory,
..
})
));
// Child dialogs own their input; closing one leaves the parent window intact.
state.popup = None;
assert!(state.additional_settings_open);
update(&mut state, Action::SelectLast);
assert_eq!(
state.additional_settings_cursor,
additional_settings_rows(&state).len() - 1
);
update(&mut state, Action::MoveDown);
assert_eq!(
state.additional_settings_cursor,
additional_settings_rows(&state).len() - 1
);
update(&mut state, Action::ToggleHelp);
update(&mut state, Action::Back);
assert!(!state.help_visible);
assert!(state.additional_settings_open);
update(&mut state, Action::Back);
assert!(!state.additional_settings_open);
assert!(!state.should_quit);
assert_eq!(state.settings_cursor, main_cursor);
}
#[test]
fn manual_update_check_is_single_flight_and_disabled_after_install() {
let mut state = AppState::default();
state.settings_cursor = settings_rows(&state)
state.additional_settings_cursor = crate::app::state::additional_settings_rows(&state)
.iter()
.position(|row| *row == crate::app::state::SettingsRow::CheckUpdate)
.unwrap();
assert_eq!(federation_select(&mut state), Some(Effect::CheckUpdate));
assert_eq!(
update_additional_settings(&mut state, Action::Select),
Some(Effect::CheckUpdate)
);
assert!(state.updater.busy);
assert_eq!(federation_select(&mut state), None);
assert_eq!(update_additional_settings(&mut state, Action::Select), None);
state.updater.busy = false;
state.updater.installed = true;
assert_eq!(federation_select(&mut state), None);
assert_eq!(update_additional_settings(&mut state, Action::Select), None);
state.updater.installed = false;
state.settings_cursor = settings_rows(&state)
state.additional_settings_cursor = crate::app::state::additional_settings_rows(&state)
.iter()
.position(|row| *row == crate::app::state::SettingsRow::InstallUpdate)
.unwrap();
assert_eq!(federation_select(&mut state), None);
assert_eq!(update_additional_settings(&mut state, Action::Select), None);
}
fn with_artists(n: usize) -> AppState {
+191
View File
@@ -0,0 +1,191 @@
//! Secondary settings window; child dialogs are rendered above it.
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
text::Line,
widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use super::theme;
use crate::app::state::{AppState, SettingsRow, additional_settings_rows};
pub fn draw(frame: &mut Frame, state: &AppState) {
let screen = frame.area();
let width = screen.width.saturating_sub(2).min(90);
let height = screen.height.saturating_sub(2).min(26);
let area = Rect::new(
screen.x + (screen.width - width) / 2,
screen.y + (screen.height - height) / 2,
width,
height,
);
let block = Block::bordered()
.title(" Additional settings ")
.title_style(theme::header_for(state))
.border_style(theme::strong_border_for(state));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let [list_area, status_area, footer] = Layout::vertical([
Constraint::Min(1),
Constraint::Length(3),
Constraint::Length(1),
])
.areas(inner);
let rows = additional_settings_rows(state);
let mut items = Vec::new();
let mut selected = 0;
for (index, row) in rows.iter().enumerate() {
let section = match row {
SettingsRow::MusicDirectory => Some("Library"),
SettingsRow::CheckUpdate => Some("Updates"),
SettingsRow::VisualizationClock => Some("Visualizations"),
_ => None,
};
if let Some(section) = section {
items.push(ListItem::new(Line::styled(
section,
theme::header_for(state),
)));
}
if index
== state
.additional_settings_cursor
.min(rows.len().saturating_sub(1))
{
selected = items.len();
}
let (label, value, enabled) = match row {
SettingsRow::MusicDirectory => (
"Music save directory".into(),
if state.music_dir_changing {
"checking/changing...".into()
} else {
state.music_dir.to_string_lossy().into_owned()
},
!state.music_dir_changing,
),
SettingsRow::CheckUpdate => (
"Check for updates".into(),
format!("v{}", env!("CARGO_PKG_VERSION")),
!state.updater.busy && !state.updater.installed,
),
SettingsRow::InstallUpdate => (
"Install update".into(),
state
.updater
.available
.as_ref()
.map(|u| format!("v{}", u.version))
.unwrap_or_else(|| "check for updates first".into()),
state.updater.available.is_some()
&& !state.updater.busy
&& !state.updater.installed,
),
SettingsRow::VisualizationClock => (
"Show clock".into(),
if state.visualizer.config.show_clock {
"on"
} else {
"off"
}
.into(),
true,
),
SettingsRow::VisualizationScript(index) => {
let script = &state.visualizer.scripts[*index];
let mark = if state.visualizer.selected_script_index() == Some(*index) {
"* "
} else {
""
};
(
format!("{mark}{}", script.name),
script
.path
.file_name()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default(),
true,
)
}
SettingsRow::VisualizationNew => {
("+ New visualization script".into(), "enter".into(), true)
}
SettingsRow::VisualizationEdit => {
("Edit selected visualization".into(), "enter".into(), true)
}
_ => continue,
};
let item = ListItem::new(format!("{label}: {value}"));
items.push(if enabled {
item
} else {
item.style(theme::dim())
});
}
frame.render_stateful_widget(
List::new(items)
.highlight_symbol("> ")
.highlight_style(theme::selection_for(state)),
list_area,
&mut ListState::default().with_selected(Some(selected)),
);
let message = state
.status_message
.as_deref()
.filter(|message| !message.is_empty())
.unwrap_or(&state.updater.message);
frame.render_widget(
Paragraph::new(message)
.wrap(Wrap { trim: true })
.style(theme::dim()),
status_area,
);
frame.render_widget(
Paragraph::new("Up/Down: navigate | Enter: select | Esc: back").style(theme::dim()),
footer,
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn window_renders_controls_and_scrolls_to_last_row_in_small_terminal() {
for (width, height) in [(80, 30), (45, 14)] {
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
let mut state = AppState::default();
state.updater.message = "No newer stable release".into();
terminal.draw(|frame| draw(frame, &state)).unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect();
assert!(text.contains("Additional settings"));
assert!(text.contains("Music save directory"));
if height >= 30 {
assert!(text.contains("Check for updates"));
assert!(text.contains("Install update"));
assert!(text.contains("Visualizations"));
}
state.additional_settings_cursor = additional_settings_rows(&state).len() - 1;
terminal.draw(|frame| draw(frame, &state)).unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect();
assert!(text.contains("New visualization script"));
assert!(text.contains("No newer stable release"));
}
}
}
+42 -161
View File
@@ -4,7 +4,7 @@ use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Wrap};
use ratatui::widgets::{Block, Paragraph};
use super::theme;
use crate::app::state::{AppState, DevicePresenceSection, FedRow, SimilarityRow, settings_rows};
@@ -33,7 +33,7 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
}
let rows_height =
(settings_rows(state).len() + 15 + device_presence_sections(state).len()) as u16;
(settings_rows(state).len() + 6 + device_presence_sections(state).len()) as u16;
let [rows_area, _, status_area] = Layout::vertical([
Constraint::Length(rows_height.min(inner.height)),
Constraint::Length(1),
@@ -69,7 +69,6 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
let mut y = area.y;
let mut cursor = 0usize;
draw_section(frame, area, state, &mut y, "Library");
draw_row(
frame,
area,
@@ -77,98 +76,22 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
&mut y,
cursor,
state.settings_cursor,
"Music save directory",
if state.music_dir_changing {
format!("{} checking/changing…", state.spinner())
} else {
state.music_dir.to_string_lossy().into_owned()
},
"Additional settings",
"enter".into(),
);
cursor += 1;
y = y.saturating_add(1);
draw_section(frame, area, state, &mut y, "Updates");
draw_row_enabled(
draw_row(
frame,
area,
state,
&mut y,
cursor,
state.settings_cursor,
"Check for updates",
format!("v{} | enter", env!("CARGO_PKG_VERSION")),
!state.updater.busy && !state.updater.installed,
"Full status details",
"enter".to_string(),
);
cursor += 1;
draw_row_enabled(
frame,
area,
state,
&mut y,
cursor,
state.settings_cursor,
"Install update",
state
.updater
.available
.as_ref()
.map(|update| format!("v{} | enter", update.version))
.unwrap_or_else(|| "check for updates first".into()),
state.updater.available.is_some() && !state.updater.busy && !state.updater.installed,
);
cursor += 1;
if y < area.bottom() {
let height = 3.min(area.bottom() - y);
frame.render_widget(
Paragraph::new(state.updater.message.as_str())
.style(theme::dim())
.wrap(Wrap { trim: true }),
Rect::new(area.x, y, area.width, height),
);
y += height;
}
y = y.saturating_add(1);
draw_section(frame, area, state, &mut y, "Similarity Search");
let similarity = &state.similarity.settings;
for row in SimilarityRow::ALL {
let (label, value) = match row {
SimilarityRow::Toggle => ("Similarity search", on_off(similarity.enabled).to_string()),
SimilarityRow::Model => (
"Embedding model",
crate::similarity::model_by_id(&similarity.model)
.map(|model| format!("{} · {}", model.id, model.license))
.unwrap_or_else(|| similarity.model.clone()),
),
SimilarityRow::Profile => (
"Preprocessing profile",
format!("{} (enter for details)", similarity.profile),
),
SimilarityRow::MinimumScore => (
"Minimum similarity",
format!("{:.2}", similarity.minimum_score),
),
SimilarityRow::MaxTracksPerArtist => (
"Tracks per artist",
similarity.max_tracks_per_artist.to_string(),
),
SimilarityRow::Workers => ("Background workers", similarity.workers.to_string()),
SimilarityRow::Clear => ("Clear all stored embeddings", "".to_string()),
};
draw_row(
frame,
area,
state,
&mut y,
cursor,
state.settings_cursor,
label,
value,
);
cursor += 1;
}
cursor += 1;
y = y.saturating_add(1);
draw_section(frame, area, state, &mut y, "Federation");
@@ -356,39 +279,32 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
}
y = y.saturating_add(1);
draw_section(frame, area, state, &mut y, "Visualizations");
draw_row(
frame,
area,
state,
&mut y,
cursor,
state.settings_cursor,
"Show clock",
if state.visualizer.config.show_clock {
"[x]".to_string()
} else {
"[ ]".to_string()
},
);
cursor += 1;
for (index, script) in state.visualizer.scripts.iter().enumerate() {
let selected_script = state
.visualizer
.selected_script_index()
.is_some_and(|selected| selected == index);
let label = if selected_script {
format!("* {}", script.name)
} else {
format!(" {}", script.name)
draw_section(frame, area, state, &mut y, "Similarity Search");
let similarity = &state.similarity.settings;
for row in SimilarityRow::ALL {
let (label, value) = match row {
SimilarityRow::Toggle => ("Similarity search", on_off(similarity.enabled).to_string()),
SimilarityRow::Model => (
"Embedding model",
crate::similarity::model_by_id(&similarity.model)
.map(|model| format!("{} · {}", model.id, model.license))
.unwrap_or_else(|| similarity.model.clone()),
),
SimilarityRow::Profile => (
"Preprocessing profile",
format!("{} (enter for details)", similarity.profile),
),
SimilarityRow::MinimumScore => (
"Minimum similarity",
format!("{:.2}", similarity.minimum_score),
),
SimilarityRow::MaxTracksPerArtist => (
"Tracks per artist",
similarity.max_tracks_per_artist.to_string(),
),
SimilarityRow::Workers => ("Background workers", similarity.workers.to_string()),
SimilarityRow::Clear => ("Clear all stored embeddings", "".to_string()),
};
let value = script
.path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("")
.to_string();
draw_row(
frame,
area,
@@ -396,49 +312,11 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
&mut y,
cursor,
state.settings_cursor,
&label,
label,
value,
);
cursor += 1;
}
draw_row(
frame,
area,
state,
&mut y,
cursor,
state.settings_cursor,
"+ New visualization script",
"".to_string(),
);
cursor += 1;
if state.visualizer.selected_script().is_some() {
draw_row(
frame,
area,
state,
&mut y,
cursor,
state.settings_cursor,
"Edit selected visualization",
"".to_string(),
);
cursor += 1;
}
y = y.saturating_add(1);
draw_row(
frame,
area,
state,
&mut y,
cursor,
state.settings_cursor,
"Full status details",
"enter".to_string(),
);
}
fn protocol_label(id: &str) -> &str {
@@ -1642,18 +1520,21 @@ mod update_ui_tests {
.iter()
.map(|cell| cell.symbol())
.collect();
let mut previous = 0;
for expected in [
"Music save directory",
"Check for updates",
"Install update",
"No newer stable release",
"Similarity Search",
"Additional settings",
"Full status details",
"Federation",
"Connected Devices",
"Similarity Search",
] {
assert!(
text.contains(expected),
"missing {expected} at width {width}"
);
let position = text.find(expected).unwrap();
assert!(position >= previous, "incorrect order for {expected}");
previous = position;
}
}
}
+5
View File
@@ -1,3 +1,4 @@
mod additional_settings;
pub mod art;
mod federation;
mod global;
@@ -75,6 +76,10 @@ pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
}
draw_status(frame, status_area, state);
if state.additional_settings_open {
additional_settings::draw(frame, state);
}
if state.help_visible {
draw_help(frame, keymap, state);
}