From 5a193d839ff69779b48c341c151c1b5ba7a05286 Mon Sep 17 00:00:00 2001 From: AB Date: Mon, 7 Sep 2026 13:06:46 +0300 Subject: [PATCH] Reworked settings menu --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 6 +- src/app/mod.rs | 5 + src/app/state.rs | 37 ++++--- src/app/update.rs | 57 +++++++++- src/app/update_tests.rs | 64 ++++++++++- src/ui/additional_settings.rs | 191 ++++++++++++++++++++++++++++++++ src/ui/federation.rs | 203 +++++++--------------------------- src/ui/mod.rs | 5 + 10 files changed, 387 insertions(+), 185 deletions(-) create mode 100644 src/ui/additional_settings.rs diff --git a/Cargo.lock b/Cargo.lock index 6ddec46..9bea237 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1715,7 +1715,7 @@ dependencies = [ [[package]] name = "furumi_tui" -version = "0.2.9" +version = "0.3.0" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index 885f363..5439ef9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 9acdb0d..99098f9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/app/mod.rs b/src/app/mod.rs index 76ca644..371f752 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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); } diff --git a/src/app/state.rs b/src/app/state.rs index 118cc22..b665f1e 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -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 { indices } -pub fn settings_rows(state: &AppState) -> Vec { +/// Secondary settings share actions but have independent navigation. +pub fn additional_settings_rows(state: &AppState) -> Vec { 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 { if !state.visualizer.scripts.is_empty() { rows.push(SettingsRow::VisualizationEdit); } - rows.push(SettingsRow::StatusDetails); + rows +} + +pub fn settings_rows(state: &AppState) -> Vec { + 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, 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, diff --git a/src/app/update.rs b/src/app/update.rs index c3a58b1..5c4ad38 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -115,6 +115,22 @@ pub fn update(state: &mut AppState, action: Action) -> Option { .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 { + 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 { + select_settings_row(state, *settings_rows(state).get(state.settings_cursor)?) +} + +fn select_settings_row(state: &mut AppState, row: super::state::SettingsRow) -> Option { 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; diff --git a/src/app/update_tests.rs b/src/app/update_tests.rs index 219f52e..9a0ee8a 100644 --- a/src/app/update_tests.rs +++ b/src/app/update_tests.rs @@ -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 { diff --git a/src/ui/additional_settings.rs b/src/ui/additional_settings.rs new file mode 100644 index 0000000..8497b15 --- /dev/null +++ b/src/ui/additional_settings.rs @@ -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")); + } + } +} diff --git a/src/ui/federation.rs b/src/ui/federation.rs index 3cc1ad9..5bec0e4 100644 --- a/src/ui/federation.rs +++ b/src/ui/federation.rs @@ -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; } } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 3de9f71..76fa2ab 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -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); }