Reworked settings menu
This commit is contained in:
Generated
+1
-1
@@ -1715,7 +1715,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "furumi_tui"
|
||||
version = "0.2.9"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user