1 Commits
Author SHA1 Message Date
ab 5a193d839f Reworked settings menu 2026-09-07 13:06:46 +03:00
10 changed files with 387 additions and 185 deletions
Generated
+1 -1
View File
@@ -1715,7 +1715,7 @@ dependencies = [
[[package]] [[package]]
name = "furumi_tui" name = "furumi_tui"
version = "0.2.9" version = "0.3.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "furumi_tui" name = "furumi_tui"
version = "0.2.9" version = "0.3.0"
edition = "2024" edition = "2024"
rust-version = "1.97" rust-version = "1.97"
description = "A federated P2P player for personal music libraries" description = "A federated P2P player for personal music libraries"
+5 -1
View File
@@ -126,7 +126,7 @@ inside the player.
### Manual updates ### 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. 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 After installation, restart `furumi` to use the new version; playback is not
restarted automatically. Wait for an active update operation to finish before 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 There are no automatic startup checks. Only the existing release asset naming
scheme is supported; missing or incompatible platform builds are rejected. 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 ### Now playing in tmux
While Furumi is running, a second invocation can print a cheap, single-line 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) { 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); let last = state::settings_rows(state).len().saturating_sub(1);
state.settings_cursor = state.settings_cursor.min(last); state.settings_cursor = state.settings_cursor.min(last);
} }
+23 -14
View File
@@ -926,6 +926,7 @@ impl FedRow {
/// the config directory. /// the config directory.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsRow { pub enum SettingsRow {
AdditionalSettings,
CheckUpdate, CheckUpdate,
InstallUpdate, InstallUpdate,
MusicDirectory, MusicDirectory,
@@ -1066,24 +1067,13 @@ pub fn device_status_order(state: &AppState) -> Vec<usize> {
indices 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![ let mut rows = vec![
SettingsRow::MusicDirectory, SettingsRow::MusicDirectory,
SettingsRow::CheckUpdate, SettingsRow::CheckUpdate,
SettingsRow::InstallUpdate, 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.push(SettingsRow::VisualizationClock);
rows.extend( rows.extend(
state state
@@ -1097,7 +1087,23 @@ pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
if !state.visualizer.scripts.is_empty() { if !state.visualizer.scripts.is_empty() {
rows.push(SettingsRow::VisualizationEdit); 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 rows
} }
@@ -1589,6 +1595,9 @@ pub struct AppState {
pub status_message: Option<String>, pub status_message: Option<String>,
pub spinner_frame: usize, pub spinner_frame: usize,
pub settings_cursor: 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. /// Root for music permanently downloaded from federation peers.
pub music_dir: std::path::PathBuf, pub music_dir: std::path::PathBuf,
pub music_dir_changing: bool, pub music_dir_changing: bool,
+56 -1
View File
@@ -115,6 +115,22 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
.take() .take()
.is_some_and(|deadline| Instant::now() <= deadline); .is_some_and(|deadline| Instant::now() <= deadline);
state.status_message = None; 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 { match action {
// While the help window is open, quit/back just close it. // While the help window is open, quit/back just close it.
Action::Quit | Action::Back if state.help_visible => { 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 /// Enter on Settings: toggle switches, open text inputs, run
/// one-shot operations. The heavy lifting happens in perform_effect(). /// 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> { 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}; 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 => { SettingsRow::CheckUpdate => {
if state.updater.busy || state.updater.installed { if state.updater.busy || state.updater.installed {
return None; return None;
+58 -6
View File
@@ -1,25 +1,77 @@
use super::*; use super::*;
use crate::library::models::{ArtistCard, ArtistDetail, TrackItem}; 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] #[test]
fn manual_update_check_is_single_flight_and_disabled_after_install() { fn manual_update_check_is_single_flight_and_disabled_after_install() {
let mut state = AppState::default(); let mut state = AppState::default();
state.settings_cursor = settings_rows(&state) state.additional_settings_cursor = crate::app::state::additional_settings_rows(&state)
.iter() .iter()
.position(|row| *row == crate::app::state::SettingsRow::CheckUpdate) .position(|row| *row == crate::app::state::SettingsRow::CheckUpdate)
.unwrap(); .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!(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.busy = false;
state.updater.installed = true; 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.updater.installed = false;
state.settings_cursor = settings_rows(&state) state.additional_settings_cursor = crate::app::state::additional_settings_rows(&state)
.iter() .iter()
.position(|row| *row == crate::app::state::SettingsRow::InstallUpdate) .position(|row| *row == crate::app::state::SettingsRow::InstallUpdate)
.unwrap(); .unwrap();
assert_eq!(federation_select(&mut state), None); assert_eq!(update_additional_settings(&mut state, Action::Select), None);
} }
fn with_artists(n: usize) -> AppState { 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::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Wrap}; use ratatui::widgets::{Block, Paragraph};
use super::theme; use super::theme;
use crate::app::state::{AppState, DevicePresenceSection, FedRow, SimilarityRow, settings_rows}; 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 = 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([ let [rows_area, _, status_area] = Layout::vertical([
Constraint::Length(rows_height.min(inner.height)), Constraint::Length(rows_height.min(inner.height)),
Constraint::Length(1), 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 y = area.y;
let mut cursor = 0usize; let mut cursor = 0usize;
draw_section(frame, area, state, &mut y, "Library");
draw_row( draw_row(
frame, frame,
area, area,
@@ -77,98 +76,22 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
&mut y, &mut y,
cursor, cursor,
state.settings_cursor, state.settings_cursor,
"Music save directory", "Additional settings",
if state.music_dir_changing { "enter".into(),
format!("{} checking/changing…", state.spinner())
} else {
state.music_dir.to_string_lossy().into_owned()
},
); );
cursor += 1; cursor += 1;
draw_row(
y = y.saturating_add(1);
draw_section(frame, area, state, &mut y, "Updates");
draw_row_enabled(
frame, frame,
area, area,
state, state,
&mut y, &mut y,
cursor, cursor,
state.settings_cursor, state.settings_cursor,
"Check for updates", "Full status details",
format!("v{} | enter", env!("CARGO_PKG_VERSION")), "enter".to_string(),
!state.updater.busy && !state.updater.installed,
); );
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); y = y.saturating_add(1);
draw_section(frame, area, state, &mut y, "Federation"); 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); y = y.saturating_add(1);
draw_section(frame, area, state, &mut y, "Visualizations"); draw_section(frame, area, state, &mut y, "Similarity Search");
draw_row( let similarity = &state.similarity.settings;
frame, for row in SimilarityRow::ALL {
area, let (label, value) = match row {
state, SimilarityRow::Toggle => ("Similarity search", on_off(similarity.enabled).to_string()),
&mut y, SimilarityRow::Model => (
cursor, "Embedding model",
state.settings_cursor, crate::similarity::model_by_id(&similarity.model)
"Show clock", .map(|model| format!("{} · {}", model.id, model.license))
if state.visualizer.config.show_clock { .unwrap_or_else(|| similarity.model.clone()),
"[x]".to_string() ),
} else { SimilarityRow::Profile => (
"[ ]".to_string() "Preprocessing profile",
}, format!("{} (enter for details)", similarity.profile),
); ),
cursor += 1; SimilarityRow::MinimumScore => (
"Minimum similarity",
for (index, script) in state.visualizer.scripts.iter().enumerate() { format!("{:.2}", similarity.minimum_score),
let selected_script = state ),
.visualizer SimilarityRow::MaxTracksPerArtist => (
.selected_script_index() "Tracks per artist",
.is_some_and(|selected| selected == index); similarity.max_tracks_per_artist.to_string(),
let label = if selected_script { ),
format!("* {}", script.name) SimilarityRow::Workers => ("Background workers", similarity.workers.to_string()),
} else { SimilarityRow::Clear => ("Clear all stored embeddings", "".to_string()),
format!(" {}", script.name)
}; };
let value = script
.path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("")
.to_string();
draw_row( draw_row(
frame, frame,
area, area,
@@ -396,49 +312,11 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
&mut y, &mut y,
cursor, cursor,
state.settings_cursor, state.settings_cursor,
&label, label,
value, value,
); );
cursor += 1; 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 { fn protocol_label(id: &str) -> &str {
@@ -1642,18 +1520,21 @@ mod update_ui_tests {
.iter() .iter()
.map(|cell| cell.symbol()) .map(|cell| cell.symbol())
.collect(); .collect();
let mut previous = 0;
for expected in [ for expected in [
"Music save directory", "Additional settings",
"Check for updates", "Full status details",
"Install update",
"No newer stable release",
"Similarity Search",
"Federation", "Federation",
"Connected Devices",
"Similarity Search",
] { ] {
assert!( assert!(
text.contains(expected), text.contains(expected),
"missing {expected} at width {width}" "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; pub mod art;
mod federation; mod federation;
mod global; mod global;
@@ -75,6 +76,10 @@ pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
} }
draw_status(frame, status_area, state); draw_status(frame, status_area, state);
if state.additional_settings_open {
additional_settings::draw(frame, state);
}
if state.help_visible { if state.help_visible {
draw_help(frame, keymap, state); draw_help(frame, keymap, state);
} }