diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ac6d7b..420219c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,6 +37,7 @@ Tiny Workshop is a 3D interactive AI agent application featuring a Victorian stu - Room system with surfaces and interactables (sticky notes, image stacks) - Text rendering for sticky notes using raqote (renders to PNG textures) - Image texture rendering with LRU cache (50 images or 50MB limit) +- Settings dialog with configurable graphics, audio, and API key management **Planned:** - 3D Victorian study environment with AI character (placeholder geometry in place) @@ -333,6 +334,17 @@ The integration test framework (`game/tests/ipc_integration.rs`) includes: - [ ] Multi-instance locking prevents concurrent access - [ ] Auto-save triggers after room changes (5-second debounce) +- [ ] Settings UI: + - [ ] Open settings with Ctrl+, keyboard shortcut + - [ ] Close settings with Escape key + - [ ] All 4 tabs are clickable (Graphics, Audio, Auth, Advanced) + - [ ] Graphics tab: Quality preset, VSync checkbox, FPS limit dropdown work + - [ ] Audio tab: Enable/disable checkbox and volume slider work + - [ ] Auth tab: API key status displays, Update Key button works + - [ ] Advanced tab: Shows correct data directory path and version + - [ ] Settings persist to disk after clicking Save + - [ ] Settings reload correctly on application restart + ## Code Style ### Rust diff --git a/README.md b/README.md index 4b0fae0..fcf5c7b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Tiny Workshop creates an immersive, cozy workspace where you can interact with a - **Session Resume**: Conversations persist across app restarts with full SDK context preservation - **Image Texture Rendering**: Full texture support for room images using MaterialResource::new_embedded() with LRU cache - **Text Rendering**: Sticky notes display rendered text (title and content preview) using raqote and font_kit +- **Settings Dialog**: Configurable application settings (Graphics, Audio, API key management, Advanced info) with persistent storage **Planned:** - **3D Victorian Study Environment**: Beautiful, cozy workspace rendered in real-time (currently using placeholder geometry) @@ -69,6 +70,8 @@ tiny-workshop/ │ │ ├── chat_ui.rs # Chat interface │ │ ├── credential_manager.rs # OS keychain integration (with Linux fallback) │ │ ├── debug_server/ # HTTP debug API (feature-gated) +│ │ ├── persistence/ # Settings and state persistence +│ │ ├── settings_dialog.rs # Settings UI modal dialog │ │ └── room/ # Room system module │ │ ├── mod.rs # Surface/interactable management │ │ ├── fonts.rs # Font loading for text rendering @@ -163,6 +166,16 @@ dev.bat --debug-server # Windows This exposes endpoints at `http://localhost:8765` for sending messages and querying state. See [CONTRIBUTING.md](CONTRIBUTING.md#debug-server-automated-testing) for details. + +### Keyboard Shortcuts + +| Shortcut | Action | +|----------|--------| +| `Ctrl+,` | Open Settings dialog | +| `Escape` | Close Settings dialog (when open) | +| `Enter` | Send chat message | +| `Shift+Enter` | Insert newline in chat | + ### Testing ```bash diff --git a/docs/design/debug-server.md b/docs/design/debug-server.md index af400db..a6bcde1 100644 --- a/docs/design/debug-server.md +++ b/docs/design/debug-server.md @@ -271,6 +271,104 @@ Show or hide UI elements for testing. Useful for getting a clear view of the 3D - Hide it to see the full 3D scene for visual inspection - Hide it to click on notes that would be covered by the chat window + +### GET /settings + +Query the settings dialog state. + +**Response** `200 OK`: +```json +{ + "visible": false, + "active_tab": 0, + "settings": { + "quality_preset": "Medium", + "vsync_enabled": true, + "fps_limit": "Fps60", + "audio_enabled": true, + "volume": 0.8, + "update_channel": "Stable" + } +} +``` + +**Tab Indices**: `0` = Graphics, `1` = Audio, `2` = Auth, `3` = Advanced + +### POST /settings/action + +Perform an action on the settings dialog. + +**Open settings**: +```json +{"type": "open"} +``` + +**Close settings** (cancel): +```json +{"type": "close"} +``` + +**Save settings and close**: +```json +{"type": "save"} +``` + +**Switch to a tab** (0-3): +```json +{"type": "switch_tab", "index": 1} +``` + +**Set graphics quality** (0=Low, 1=Medium, 2=High): +```json +{"type": "set_quality", "index": 1} +``` + +**Set VSync**: +```json +{"type": "set_vsync", "enabled": true} +``` + +**Set FPS limit** (0=Unlimited, 1=30, 2=60, 3=120, 4=144): +```json +{"type": "set_fps_limit", "index": 2} +``` + +**Set audio enabled**: +```json +{"type": "set_audio_enabled", "enabled": true} +``` + +**Set volume** (0.0 to 1.0): +```json +{"type": "set_volume", "volume": 0.75} +``` + +**Set API key**: +```json +{"type": "set_api_key", "key": "sk-..."} +``` + +**Simulate Escape key** (tests pending_close_settings code path): +```json +{"type": "simulate_escape_key"} +``` + +**Response** `200 OK`: +```json +{"success": true} +``` + +**Error Response**: +```json +{"success": false, "error": "Settings dialog not open"} +``` + +**Notes**: +- Settings dialog can also be opened with Ctrl+, keyboard shortcut +- Escape key closes the dialog (simulated via `simulate_escape_key`) +- Changes are not persisted until Save is clicked +- API key updates are stored immediately via CredentialManager + ## Polling Workflow Since AI responses are asynchronous, you need to poll for them: diff --git a/docs/design/persistence-system.md b/docs/design/persistence-system.md index da37b19..4b2f898 100644 --- a/docs/design/persistence-system.md +++ b/docs/design/persistence-system.md @@ -18,6 +18,7 @@ | Session Resumption | ✅ Complete | `game/src/lib.rs` (resume flow + history display) | | Compaction Handling | ✅ Complete | `extract_sdk_session_id()` tracks latest session ID | | Project Metadata | ✅ Complete | `game/src/persistence/project_persistence.rs` | +| Application Settings | ✅ Complete | `game/src/persistence/settings.rs` | **Note**: The session logging implementation uses a simplified schema compared to this design doc. See `session_log.rs` for the actual `SessionLogEntry` enum variants. Streaming deltas and subagent logging are planned for future phases. @@ -336,6 +337,47 @@ impl GlobalConfig { --- + +### Actual Implementation (Application Settings) + +The implemented settings system uses JSON instead of TOML for consistency with other persistence files. It stores a subset of the planned configuration options. + +**File**: Platform-specific data directory + `settings.json` +- Linux: `~/.local/share/agent-study/settings.json` +- macOS: `~/Library/Application Support/agent-study/settings.json` +- Windows: `%APPDATA%/agent-study/settings.json` + +**Format**: JSON (machine-generated) + +```json +{ + "version": { "major": 1, "minor": 0 }, + "quality_preset": "Medium", + "vsync_enabled": true, + "fps_limit": "Fps60", + "audio_enabled": true, + "volume": 0.8, + "update_channel": "Stable" +} +``` + +**Rust Schema** (see `game/src/persistence/settings.rs`): + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppSettings { + pub version: SchemaVersion, + pub quality_preset: QualityPreset, // Low, Medium, High + pub vsync_enabled: bool, + pub fps_limit: FpsLimit, // Unlimited, Fps30, Fps60, Fps120, Fps144 + pub audio_enabled: bool, + pub volume: f32, // 0.0 to 1.0 + pub update_channel: UpdateChannel, // Stable, Beta +} +``` + +**Access**: Settings are loaded at startup and exposed through the Settings Dialog UI (`Ctrl+,`). The dialog has four tabs: Graphics, Audio, Authentication, and Advanced. + ## Project State ### Storage Format diff --git a/tiny-workshop/game/src/debug_server/commands.rs b/tiny-workshop/game/src/debug_server/commands.rs index 2323208..ec10eb3 100644 --- a/tiny-workshop/game/src/debug_server/commands.rs +++ b/tiny-workshop/game/src/debug_server/commands.rs @@ -52,6 +52,15 @@ pub enum DebugCommand { /// Whether to show or hide the chat UI window. chat_visible: bool, }, + + /// Query settings dialog state. + QuerySettings { request_id: u64 }, + + /// Perform an action on the settings dialog. + SettingsAction { + request_id: u64, + action: SettingsActionType, + }, } /// Types of actions that can be performed on the session picker. @@ -66,6 +75,34 @@ pub enum SessionPickerActionType { NewSession, } +/// Types of actions that can be performed on the settings dialog. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SettingsActionType { + /// Open the settings dialog. + Open, + /// Close the settings dialog (cancel). + Close, + /// Save settings and close. + Save, + /// Switch to a specific tab by index (0=Graphics, 1=Audio, 2=Auth, 3=Advanced). + SwitchTab { index: usize }, + /// Set quality preset by index (0=Low, 1=Medium, 2=High). + SetQuality { index: usize }, + /// Set VSync enabled/disabled. + SetVsync { enabled: bool }, + /// Set FPS limit by index (0=Unlimited, 1=30, 2=60, 3=120, 4=144). + SetFpsLimit { index: usize }, + /// Set audio enabled/disabled. + SetAudioEnabled { enabled: bool }, + /// Set volume (0.0 to 1.0). + SetVolume { volume: f32 }, + /// Set API key (stores and triggers update). + SetApiKey { key: String }, + /// Simulate pressing the Escape key (tests the pending_close_settings code path). + SimulateEscapeKey, +} + /// Responses sent from the game loop back to the debug server. #[derive(Debug, Clone)] pub enum DebugResponse { @@ -127,6 +164,19 @@ pub enum DebugResponse { /// Current chat UI visibility state. chat_visible: bool, }, + + /// Settings dialog state response. + SettingsDialogState { + request_id: u64, + state: SettingsDialogSnapshot, + }, + + /// Settings action completed. + SettingsActionDone { + request_id: u64, + success: bool, + error: Option, + }, } impl DebugResponse { @@ -143,6 +193,8 @@ impl DebugResponse { DebugResponse::SessionPickerActionDone { request_id, .. } => *request_id, DebugResponse::ClickResult { request_id, .. } => *request_id, DebugResponse::UiVisibilitySet { request_id, .. } => *request_id, + DebugResponse::SettingsDialogState { request_id, .. } => *request_id, + DebugResponse::SettingsActionDone { request_id, .. } => *request_id, } } } @@ -176,6 +228,41 @@ pub struct SessionPickerSession { pub preview: String, } +/// Snapshot of the settings dialog state. +#[derive(Debug, Clone, Serialize)] +pub struct SettingsDialogSnapshot { + /// Whether the settings dialog is currently visible. + pub visible: bool, + + /// Currently active tab index (0=Graphics, 1=Audio, 2=Auth, 3=Advanced). + pub active_tab: usize, + + /// Current settings values. + pub settings: SettingsValues, +} + +/// Current settings values for debug inspection. +#[derive(Debug, Clone, Serialize)] +pub struct SettingsValues { + /// Graphics quality preset. + pub quality_preset: String, + + /// Whether VSync is enabled. + pub vsync_enabled: bool, + + /// FPS limit setting. + pub fps_limit: String, + + /// Whether audio is enabled. + pub audio_enabled: bool, + + /// Audio volume (0.0 to 1.0). + pub volume: f32, + + /// Update channel. + pub update_channel: String, +} + /// Snapshot of the current game state. #[derive(Debug, Clone, Serialize)] pub struct GameStateSnapshot { @@ -336,6 +423,24 @@ pub struct UiVisibilityResponse { pub chat_visible: bool, } +/// POST /settings/action request body. +#[derive(Debug, Deserialize)] +pub struct SettingsActionRequest { + #[serde(flatten)] + pub action: SettingsActionType, +} + +/// POST /settings/action response body. +#[derive(Debug, Serialize)] +pub struct SettingsActionResponse { + /// Whether the operation succeeded. + pub success: bool, + + /// Error message if the operation failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + #[cfg(test)] mod tests { use super::*; diff --git a/tiny-workshop/game/src/debug_server/mod.rs b/tiny-workshop/game/src/debug_server/mod.rs index 1899295..a66a63f 100644 --- a/tiny-workshop/game/src/debug_server/mod.rs +++ b/tiny-workshop/game/src/debug_server/mod.rs @@ -33,6 +33,7 @@ mod server; pub use commands::{ ConversationMessage, DebugCommand, DebugResponse, GameStateSnapshot, MessageRole, MessagesResponse, SessionPickerActionType, SessionPickerSession, SessionPickerSnapshot, + SettingsActionType, SettingsDialogSnapshot, SettingsValues, }; use std::sync::atomic::{AtomicU64, Ordering}; diff --git a/tiny-workshop/game/src/debug_server/server.rs b/tiny-workshop/game/src/debug_server/server.rs index 1a6893d..26f7b01 100644 --- a/tiny-workshop/game/src/debug_server/server.rs +++ b/tiny-workshop/game/src/debug_server/server.rs @@ -13,8 +13,8 @@ use tokio::sync::{mpsc, oneshot, Mutex}; use super::commands::{ ClickHttpResponse, ClickRequest, DebugCommand, DebugResponse, HealthResponse, MessagesQuery, - SendMessageHttpResponse, SendMessageRequest, SessionPickerActionType, UiVisibilityRequest, - UiVisibilityResponse, + SendMessageHttpResponse, SendMessageRequest, SessionPickerActionType, SettingsActionRequest, + SettingsActionResponse, SettingsActionType, UiVisibilityRequest, UiVisibilityResponse, }; use super::next_request_id; @@ -69,6 +69,8 @@ pub async fn run_server( ) .route("/click", post(click_handler)) .route("/ui/visibility", post(ui_visibility_handler)) + .route("/settings", get(settings_handler)) + .route("/settings/action", post(settings_action_handler)) .with_state(state); // Bind to localhost only for security @@ -553,3 +555,116 @@ async fn ui_visibility_handler( } } } + +/// GET /settings - Get settings dialog state. +async fn settings_handler( + State(state): State>, +) -> Result { + let request_id = next_request_id(); + let (tx, rx) = oneshot::channel(); + + // Register the pending request + { + let mut pending = state.pending_requests.lock().await; + pending.insert(request_id, tx); + } + + // Send the command to the game loop + state + .command_tx + .send(DebugCommand::QuerySettings { request_id }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Wait for the response with a timeout + match tokio::time::timeout(std::time::Duration::from_secs(5), rx).await { + Ok(Ok(DebugResponse::SettingsDialogState { + state: snapshot, .. + })) => Ok(Json(snapshot)), + Ok(Ok(_)) => Err(StatusCode::INTERNAL_SERVER_ERROR), + Ok(Err(_)) => Err(StatusCode::INTERNAL_SERVER_ERROR), + Err(_) => { + let mut pending = state.pending_requests.lock().await; + pending.remove(&request_id); + Err(StatusCode::GATEWAY_TIMEOUT) + } + } +} + +/// POST /settings/action - Perform settings dialog action. +async fn settings_action_handler( + State(state): State>, + Json(payload): Json, +) -> Result)> { + let request_id = next_request_id(); + let (tx, rx) = oneshot::channel(); + + // Register the pending request + { + let mut pending = state.pending_requests.lock().await; + pending.insert(request_id, tx); + } + + // Send the command to the game loop + state + .command_tx + .send(DebugCommand::SettingsAction { + request_id, + action: payload.action, + }) + .await + .map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(SettingsActionResponse { + success: false, + error: Some("Failed to send command to game loop".to_string()), + }), + ) + })?; + + // Wait for the response with a timeout + match tokio::time::timeout(std::time::Duration::from_secs(5), rx).await { + Ok(Ok(DebugResponse::SettingsActionDone { success, error, .. })) => { + if success { + Ok(Json(SettingsActionResponse { + success: true, + error: None, + })) + } else { + Err(( + StatusCode::BAD_REQUEST, + Json(SettingsActionResponse { + success: false, + error, + }), + )) + } + } + Ok(Ok(_)) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(SettingsActionResponse { + success: false, + error: Some("Unexpected response type".to_string()), + }), + )), + Ok(Err(_)) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(SettingsActionResponse { + success: false, + error: Some("Response channel closed".to_string()), + }), + )), + Err(_) => { + let mut pending = state.pending_requests.lock().await; + pending.remove(&request_id); + Err(( + StatusCode::GATEWAY_TIMEOUT, + Json(SettingsActionResponse { + success: false, + error: Some("Request timed out".to_string()), + }), + )) + } + } +} diff --git a/tiny-workshop/game/src/lib.rs b/tiny-workshop/game/src/lib.rs index 5e74570..8a5b2f7 100644 --- a/tiny-workshop/game/src/lib.rs +++ b/tiny-workshop/game/src/lib.rs @@ -17,6 +17,7 @@ mod note_viewer; mod persistence; mod room; mod session_picker; +mod settings_dialog; /// IPC protocol types for frontend-backend communication. /// Public for integration testing. @@ -28,6 +29,7 @@ use camera_animator::CameraAnimator; use cube_animator::CubeAnimator; use note_viewer::NoteViewer; use session_picker::{SessionPicker, SessionPickerResult}; +use settings_dialog::{SettingsDialog, SettingsDialogResult}; use chrono::Utc; #[allow(unused_imports)] @@ -457,11 +459,26 @@ pub struct Game { #[reflect(hidden)] pending_send: bool, + /// Flag to trigger settings dialog open from keyboard event (Ctrl+,) + #[visit(skip)] + #[reflect(hidden)] + pending_open_settings: bool, + + /// Flag to trigger settings dialog close from Escape key + #[visit(skip)] + #[reflect(hidden)] + pending_close_settings: bool, + /// Track if Shift key is currently held #[visit(skip)] #[reflect(hidden)] shift_held: bool, + /// Track if Ctrl key is currently held (for keyboard shortcuts) + #[visit(skip)] + #[reflect(hidden)] + ctrl_held: bool, + // ======================================================================== // Animation System Fields // ======================================================================== @@ -561,6 +578,16 @@ pub struct Game { #[reflect(hidden)] note_viewer: Option, + /// Currently open settings dialog (None if no settings open) + #[visit(skip)] + #[reflect(hidden)] + settings_dialog: Option, + + /// Application settings (loaded on init, saved on change) + #[visit(skip)] + #[reflect(hidden)] + app_settings: persistence::settings::AppSettings, + // ======================================================================== // Persistence Fields // ======================================================================== @@ -690,6 +717,20 @@ impl Plugin for Game { let chat_ui = ChatUI::new(&mut ui.build_ctx()); self.chat_ui = Some(chat_ui); + // Load application settings + self.app_settings = match persistence::settings::load_settings() { + Ok(settings) => { + fyrox::core::log::Log::info("Application settings loaded"); + settings + } + Err(e) => { + fyrox::core::log::Log::warn(format!( + "Could not load settings, using defaults: {e}" + )); + persistence::settings::AppSettings::default() + } + }; + // Spawn backend process // Find the backend executable using cross-platform resolution let backend_path = match find_backend_executable() { @@ -1017,6 +1058,22 @@ impl Plugin for Game { self.do_send_message(context); } + // Handle pending settings dialog open (from Ctrl+, keyboard shortcut) + if self.pending_open_settings { + self.pending_open_settings = false; + self.open_settings_dialog(context); + } + + // Handle pending settings dialog close (from Escape key) + if self.pending_close_settings { + self.pending_close_settings = false; + if let Some(dialog) = self.settings_dialog.take() { + let ui = context.user_interfaces.first_mut(); + dialog.close(ui); + fyrox::core::log::Log::info("Settings dialog closed via Escape key"); + } + } + // Update camera focus FIRST - before animation_controller.update() processes pending speech end // This ensures the camera sees TurningToCamera for at least one frame let agent_pos = self.cube_animator.position(&self.animation_controller); @@ -1066,6 +1123,25 @@ impl Plugin for Game { self.shift_held = *state == ElementState::Pressed; } + // Track Ctrl key state (for keyboard shortcuts like Ctrl+,) + if let Event::WindowEvent { + event: + WindowEvent::KeyboardInput { + event: + KeyEvent { + physical_key: + PhysicalKey::Code(KeyCode::ControlLeft | KeyCode::ControlRight), + state, + .. + }, + .. + }, + .. + } = event + { + self.ctrl_held = *state == ElementState::Pressed; + } + // Track mouse position for note click raycasting if let Event::WindowEvent { event: WindowEvent::CursorMoved { position, .. }, @@ -1112,6 +1188,48 @@ impl Plugin for Game { self.pending_send = true; } } + + // Handle Ctrl+, to open settings dialog + if let Event::WindowEvent { + event: + WindowEvent::KeyboardInput { + event: + KeyEvent { + physical_key: PhysicalKey::Code(KeyCode::Comma), + state: ElementState::Pressed, + repeat: false, + .. + }, + .. + }, + .. + } = event + { + if self.ctrl_held && self.settings_dialog.is_none() { + self.pending_open_settings = true; + } + } + + // Handle Escape key to close settings dialog + if let Event::WindowEvent { + event: + WindowEvent::KeyboardInput { + event: + KeyEvent { + physical_key: PhysicalKey::Code(KeyCode::Escape), + state: ElementState::Pressed, + repeat: false, + .. + }, + .. + }, + .. + } = event + { + if self.settings_dialog.is_some() { + self.pending_close_settings = true; + } + } } fn on_ui_message( @@ -1131,6 +1249,51 @@ impl Plugin for Game { } } + // Handle settings dialog messages + if let Some(dialog) = &mut self.settings_dialog { + let ui = context.user_interfaces.first_mut(); + if let Some(result) = dialog.handle_message(message, ui) { + match result { + SettingsDialogResult::Save(new_settings) => { + // Apply settings to memory first + self.app_settings = new_settings; + // Try to persist to disk + if let Err(e) = persistence::settings::save_settings(&self.app_settings) { + fyrox::core::log::Log::warn(format!( + "Failed to save settings to disk: {e}. Settings applied but not persisted." + )); + // Don't close dialog - let user try again or cancel + return; + } + fyrox::core::log::Log::info("Settings saved successfully"); + // TODO: Apply graphics/audio settings to Fyrox + } + SettingsDialogResult::Cancel => { + fyrox::core::log::Log::info("Settings dialog cancelled"); + } + SettingsDialogResult::UpdateApiKey(key) => { + // Store the API key via credential manager + match CredentialManager::store_api_key(&key) { + Ok(()) => { + dialog.update_api_key_status(true, ui); + fyrox::core::log::Log::info("API key updated successfully"); + } + Err(e) => { + fyrox::core::log::Log::warn(format!( + "Failed to store API key: {e}" + )); + } + } + // Don't close dialog - user might want to continue editing + return; + } + } + dialog.close(ui); + self.settings_dialog = None; + return; + } + } + // Handle session picker messages (takes precedence when visible) if self.waiting_for_session_choice { let ui = context.user_interfaces.first_mut(); @@ -2711,6 +2874,23 @@ impl Game { } } + /// Open the settings dialog (triggered by Ctrl+,). + fn open_settings_dialog(&mut self, context: &mut PluginContext) { + // Don't open if already open + if self.settings_dialog.is_some() { + return; + } + + let ui = context.user_interfaces.first_mut(); + let dialog = SettingsDialog::new(self.app_settings.clone(), &mut ui.build_ctx()); + + // Set initial panel visibility + dialog.update_panel_visibility(ui); + + self.settings_dialog = Some(dialog); + fyrox::core::log::Log::info("Settings dialog opened"); + } + // ======================================================================== // Debug Server Methods (only present when feature enabled) // ======================================================================== @@ -2982,6 +3162,183 @@ impl Game { chat_visible, }); } + + DebugCommand::QuerySettings { request_id } => { + // Build settings snapshot + let snapshot = debug_server::SettingsDialogSnapshot { + visible: self.settings_dialog.is_some(), + active_tab: self + .settings_dialog + .as_ref() + .map(|d| d.active_tab()) + .unwrap_or(0), + settings: debug_server::SettingsValues { + quality_preset: format!("{:?}", self.app_settings.quality_preset), + vsync_enabled: self.app_settings.vsync_enabled, + fps_limit: format!("{:?}", self.app_settings.fps_limit), + audio_enabled: self.app_settings.audio_enabled, + volume: self.app_settings.volume, + update_channel: format!("{:?}", self.app_settings.update_channel), + }, + }; + + handle.send_response(DebugResponse::SettingsDialogState { + request_id, + state: snapshot, + }); + } + + DebugCommand::SettingsAction { request_id, action } => { + let ui = context.user_interfaces.first_mut(); + let (success, error) = match action { + debug_server::SettingsActionType::Open => { + if self.settings_dialog.is_none() { + let dialog = SettingsDialog::new( + self.app_settings.clone(), + &mut ui.build_ctx(), + ); + dialog.update_panel_visibility(ui); + self.settings_dialog = Some(dialog); + fyrox::core::log::Log::info("Settings dialog opened via debug server"); + (true, None) + } else { + (false, Some("Settings dialog already open".to_string())) + } + } + debug_server::SettingsActionType::Close => { + if let Some(dialog) = self.settings_dialog.take() { + dialog.close(ui); + fyrox::core::log::Log::info("Settings dialog closed via debug server"); + (true, None) + } else { + (false, Some("Settings dialog not open".to_string())) + } + } +debug_server::SettingsActionType::Save => { + if let Some(dialog) = self.settings_dialog.take() { + // Get the current settings from the dialog and apply them + self.app_settings = dialog.current_settings().clone(); + // Save to disk + if let Err(e) = + persistence::settings::save_settings(&self.app_settings) + { + fyrox::core::log::Log::warn(format!( + "Failed to save settings: {e}" + )); + } + dialog.close(ui); + fyrox::core::log::Log::info( + "Settings saved and dialog closed via debug server", + ); + (true, None) + } else { + (false, Some("Settings dialog not open".to_string())) + } + } + debug_server::SettingsActionType::SwitchTab { index } => { + if let Some(dialog) = &mut self.settings_dialog { + if index < 4 { + dialog.switch_to_tab(index, ui); + (true, None) + } else { + (false, Some(format!("Invalid tab index: {} (must be 0-3)", index))) + } + } else { + (false, Some("Settings dialog not open".to_string())) + } + } + debug_server::SettingsActionType::SetQuality { index } => { + if let Some(dialog) = &mut self.settings_dialog { + if let Some(&preset) = + crate::persistence::settings::QualityPreset::all().get(index) + { + dialog.set_quality(preset, ui); + (true, None) + } else { + (false, Some(format!("Invalid quality index: {}", index))) + } + } else { + (false, Some("Settings dialog not open".to_string())) + } + } + debug_server::SettingsActionType::SetVsync { enabled } => { + if let Some(dialog) = &mut self.settings_dialog { + dialog.set_vsync(enabled, ui); + (true, None) + } else { + (false, Some("Settings dialog not open".to_string())) + } + } + debug_server::SettingsActionType::SetFpsLimit { index } => { + if let Some(dialog) = &mut self.settings_dialog { + if let Some(&limit) = + crate::persistence::settings::FpsLimit::all().get(index) + { + dialog.set_fps_limit(limit, ui); + (true, None) + } else { + (false, Some(format!("Invalid FPS limit index: {}", index))) + } + } else { + (false, Some("Settings dialog not open".to_string())) + } + } + debug_server::SettingsActionType::SetAudioEnabled { enabled } => { + if let Some(dialog) = &mut self.settings_dialog { + dialog.set_audio_enabled(enabled, ui); + (true, None) + } else { + (false, Some("Settings dialog not open".to_string())) + } + } + debug_server::SettingsActionType::SetVolume { volume } => { + if let Some(dialog) = &mut self.settings_dialog { + dialog.set_volume(volume, ui); + (true, None) + } else { + (false, Some("Settings dialog not open".to_string())) + } + } + debug_server::SettingsActionType::SetApiKey { key } => { + if let Some(dialog) = &mut self.settings_dialog { + dialog.set_api_key(key.clone(), ui); + // Also store directly to credential manager + match CredentialManager::store_api_key(&key) { + Ok(()) => { + dialog.update_api_key_status(true, ui); + fyrox::core::log::Log::info( + "API key stored via debug server", + ); + (true, None) + } + Err(e) => { + (false, Some(format!("Failed to store API key: {}", e))) + } + } + } else { + (false, Some("Settings dialog not open".to_string())) + } + } + debug_server::SettingsActionType::SimulateEscapeKey => { + if self.settings_dialog.is_some() { + // Set the pending flag - same as when Escape key is pressed + self.pending_close_settings = true; + fyrox::core::log::Log::info( + "Simulating Escape key press via debug server", + ); + (true, None) + } else { + (false, Some("Settings dialog not open".to_string())) + } + } + }; + + handle.send_response(DebugResponse::SettingsActionDone { + request_id, + success, + error, + }); + } } } diff --git a/tiny-workshop/game/src/persistence/mod.rs b/tiny-workshop/game/src/persistence/mod.rs index 4d8e372..f308b86 100644 --- a/tiny-workshop/game/src/persistence/mod.rs +++ b/tiny-workshop/game/src/persistence/mod.rs @@ -76,6 +76,7 @@ mod project_persistence; mod room_persistence; mod schema; mod session_log; +pub mod settings; // Re-export public types pub use error::PersistenceError; diff --git a/tiny-workshop/game/src/persistence/settings.rs b/tiny-workshop/game/src/persistence/settings.rs new file mode 100644 index 0000000..912b807 --- /dev/null +++ b/tiny-workshop/game/src/persistence/settings.rs @@ -0,0 +1,351 @@ +//! Application Settings Persistence +//! +//! Manages global application settings that persist across sessions. +//! Settings are stored in the application data directory (not per-project). +//! +//! # Storage Location +//! +//! ```text +//! ~/.local/share/agent-study/settings.json (Linux) +//! ~/Library/Application Support/agent-study/settings.json (macOS) +//! %APPDATA%/agent-study/settings.json (Windows) +//! ``` + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +use super::error::PersistenceError; +use super::paths; +use super::schema::SchemaVersion; + +/// Settings file name (in app data directory). +const SETTINGS_FILE_NAME: &str = "settings.json"; + +// ============================================================================= +// Settings Enums +// ============================================================================= + +/// Graphics quality preset. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum QualityPreset { + Low, + #[default] + Medium, + High, +} + +impl QualityPreset { + /// Get display name for the preset. + pub fn display_name(&self) -> &'static str { + match self { + Self::Low => "Low", + Self::Medium => "Medium", + Self::High => "High", + } + } + + /// Get all quality presets for UI display. + pub fn all() -> &'static [QualityPreset] { + &[Self::Low, Self::Medium, Self::High] + } +} + +/// FPS limit options. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum FpsLimit { + Unlimited, + Fps30, + Fps60, + Fps120, + Fps144, +} + +impl Default for FpsLimit { + fn default() -> Self { + Self::Fps60 + } +} + +impl FpsLimit { + /// Get the actual FPS value (None for unlimited). + pub fn value(&self) -> Option { + match self { + Self::Unlimited => None, + Self::Fps30 => Some(30), + Self::Fps60 => Some(60), + Self::Fps120 => Some(120), + Self::Fps144 => Some(144), + } + } + + /// Get display name for the limit. + pub fn display_name(&self) -> &'static str { + match self { + Self::Unlimited => "Unlimited", + Self::Fps30 => "30 FPS", + Self::Fps60 => "60 FPS", + Self::Fps120 => "120 FPS", + Self::Fps144 => "144 FPS", + } + } + + /// Get all FPS limit options for UI display. + pub fn all() -> &'static [FpsLimit] { + &[ + Self::Unlimited, + Self::Fps30, + Self::Fps60, + Self::Fps120, + Self::Fps144, + ] + } +} + +/// Update channel for distribution. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum UpdateChannel { + #[default] + Stable, + Beta, +} + +impl UpdateChannel { + /// Get display name for the channel. + pub fn display_name(&self) -> &'static str { + match self { + Self::Stable => "Stable", + Self::Beta => "Beta", + } + } + + /// Get all update channels for UI display. + pub fn all() -> &'static [UpdateChannel] { + &[Self::Stable, Self::Beta] + } +} + +// ============================================================================= +// AppSettings +// ============================================================================= + +/// Application settings that persist across sessions. +/// +/// These are global settings (not per-project). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppSettings { + /// Schema version for forward compatibility. + #[serde(default)] + pub version: SchemaVersion, + + // ------------------------------------------------------------------------- + // Graphics Settings + // ------------------------------------------------------------------------- + /// Graphics quality preset (affects shadows, textures, etc.). + #[serde(default)] + pub quality_preset: QualityPreset, + + /// Whether VSync is enabled. + #[serde(default = "default_true")] + pub vsync_enabled: bool, + + /// FPS limit. + #[serde(default)] + pub fps_limit: FpsLimit, + + // ------------------------------------------------------------------------- + // Audio Settings + // ------------------------------------------------------------------------- + /// Whether audio is enabled. + #[serde(default = "default_true")] + pub audio_enabled: bool, + + /// Master volume (0.0 to 1.0). + #[serde(default = "default_volume")] + pub volume: f32, + + // ------------------------------------------------------------------------- + // Advanced Settings + // ------------------------------------------------------------------------- + /// Update channel for auto-updates. + #[serde(default)] + pub update_channel: UpdateChannel, +} + +fn default_true() -> bool { + true +} + +fn default_volume() -> f32 { + 0.8 +} + +impl Default for AppSettings { + fn default() -> Self { + Self { + version: SchemaVersion::CURRENT, + quality_preset: QualityPreset::default(), + vsync_enabled: true, + fps_limit: FpsLimit::default(), + audio_enabled: true, + volume: 0.8, + update_channel: UpdateChannel::default(), + } + } +} + +// ============================================================================= +// Load / Save Functions +// ============================================================================= + +/// Get the path to the settings file. +fn settings_path() -> Result { + let data_dir = paths::data_dir()?; + Ok(data_dir.join(SETTINGS_FILE_NAME)) +} + +/// Load application settings from disk. +/// +/// Returns default settings if the file doesn't exist. +pub fn load_settings() -> Result { + let path = settings_path()?; + + if !path.exists() { + fyrox::core::log::Log::info("No settings file found, using defaults"); + return Ok(AppSettings::default()); + } + + let content = std::fs::read_to_string(&path)?; + let settings: AppSettings = serde_json::from_str(&content).map_err(|e| { + fyrox::core::log::Log::warn(format!( + "Failed to parse settings file, using defaults: {e}" + )); + PersistenceError::Json(e) + })?; + // Validate volume is in valid range (0.0 to 1.0) + let mut settings = settings; + if settings.volume < 0.0 || settings.volume > 1.0 { + fyrox::core::log::Log::warn(format!( + "Invalid volume {} in settings, clamping to valid range", + settings.volume + )); + settings.volume = settings.volume.clamp(0.0, 1.0); + } + + fyrox::core::log::Log::info(format!("Loaded settings from {}", path.display())); + Ok(settings) +} + +/// Save application settings to disk. +/// +/// Uses atomic write pattern (write to .tmp, then rename) for crash safety. +pub fn save_settings(settings: &AppSettings) -> Result<(), PersistenceError> { + let path = settings_path()?; + let temp_path = path.with_extension("json.tmp"); + + // Serialize with pretty formatting for human readability + let content = serde_json::to_string_pretty(settings)?; + + // Write to temp file + std::fs::write(&temp_path, &content)?; + + // Atomic rename + std::fs::rename(&temp_path, &path)?; + + fyrox::core::log::Log::info(format!("Saved settings to {}", path.display())); + Ok(()) +} + +/// Get the application data directory path. +/// +/// Useful for displaying in the Advanced settings tab. +pub fn get_data_dir() -> Result { + paths::data_dir() +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_app_settings_default() { + let settings = AppSettings::default(); + assert_eq!(settings.quality_preset, QualityPreset::Medium); + assert!(settings.vsync_enabled); + assert_eq!(settings.fps_limit, FpsLimit::Fps60); + assert!(settings.audio_enabled); + assert!((settings.volume - 0.8).abs() < 0.001); + assert_eq!(settings.update_channel, UpdateChannel::Stable); + } + + #[test] + fn test_app_settings_serialization_roundtrip() { + let original = AppSettings { + quality_preset: QualityPreset::High, + vsync_enabled: false, + fps_limit: FpsLimit::Fps144, + audio_enabled: false, + volume: 0.5, + update_channel: UpdateChannel::Beta, + ..Default::default() + }; + + let json = serde_json::to_string(&original).unwrap(); + let restored: AppSettings = serde_json::from_str(&json).unwrap(); + + assert_eq!(restored.quality_preset, QualityPreset::High); + assert!(!restored.vsync_enabled); + assert_eq!(restored.fps_limit, FpsLimit::Fps144); + assert!(!restored.audio_enabled); + assert!((restored.volume - 0.5).abs() < 0.001); + assert_eq!(restored.update_channel, UpdateChannel::Beta); + } + + #[test] + fn test_quality_preset_display_names() { + assert_eq!(QualityPreset::Low.display_name(), "Low"); + assert_eq!(QualityPreset::Medium.display_name(), "Medium"); + assert_eq!(QualityPreset::High.display_name(), "High"); + } + + #[test] + fn test_fps_limit_values() { + assert_eq!(FpsLimit::Unlimited.value(), None); + assert_eq!(FpsLimit::Fps30.value(), Some(30)); + assert_eq!(FpsLimit::Fps60.value(), Some(60)); + assert_eq!(FpsLimit::Fps120.value(), Some(120)); + assert_eq!(FpsLimit::Fps144.value(), Some(144)); + } + + #[test] + fn test_fps_limit_display_names() { + assert_eq!(FpsLimit::Unlimited.display_name(), "Unlimited"); + assert_eq!(FpsLimit::Fps60.display_name(), "60 FPS"); + } + + #[test] + fn test_update_channel_display_names() { + assert_eq!(UpdateChannel::Stable.display_name(), "Stable"); + assert_eq!(UpdateChannel::Beta.display_name(), "Beta"); + } + + #[test] + fn test_backwards_compatible_deserialization() { + // Simulate loading an old settings file without new fields + let old_json = r#"{"version":{"major":1,"minor":0}}"#; + let settings: AppSettings = serde_json::from_str(old_json).unwrap(); + + // All fields should have defaults + assert_eq!(settings.quality_preset, QualityPreset::Medium); + assert!(settings.vsync_enabled); + assert_eq!(settings.fps_limit, FpsLimit::Fps60); + assert!(settings.audio_enabled); + assert!((settings.volume - 0.8).abs() < 0.001); + } +} diff --git a/tiny-workshop/game/src/settings_dialog.rs b/tiny-workshop/game/src/settings_dialog.rs new file mode 100644 index 0000000..20c67f3 --- /dev/null +++ b/tiny-workshop/game/src/settings_dialog.rs @@ -0,0 +1,978 @@ +//! Settings Dialog UI Module +//! +//! Provides a modal dialog for configuring application settings. +//! Opened with Ctrl+, keyboard shortcut. + +use fyrox::core::color::Color; +use fyrox::{ + core::pool::Handle, + gui::{ + brush::Brush, + button::{ButtonBuilder, ButtonMessage}, + check_box::{CheckBoxBuilder, CheckBoxMessage}, + dropdown_list::{DropdownListBuilder, DropdownListMessage}, + grid::{Column, GridBuilder, Row}, + message::{MessageDirection, UiMessage}, + scroll_bar::{ScrollBarBuilder, ScrollBarMessage}, + stack_panel::StackPanelBuilder, + text::{TextBuilder, TextMessage}, + text_box::TextBoxBuilder, + widget::{WidgetBuilder, WidgetMessage}, + window::{WindowBuilder, WindowTitle}, + BuildContext, HorizontalAlignment, Orientation, Thickness, UiNode, UserInterface, + VerticalAlignment, + }, +}; + +use crate::persistence::settings::{AppSettings, FpsLimit, QualityPreset}; + +// ============================================================================= +// Color Theme Constants +// ============================================================================= +// +// Matches the existing session picker and chat UI color scheme for consistency. + +/// Tab button background when not selected: dark gray +const TAB_BG_UNSELECTED: Color = Color { + r: 64, + g: 64, + b: 64, + a: 200, +}; + +/// Tab button background when selected: cornflower blue (matches user messages) +const TAB_BG_SELECTED: Color = Color { + r: 100, + g: 149, + b: 237, + a: 200, +}; + +/// Button text color: white for contrast +const BUTTON_TEXT: Color = Color { + r: 255, + g: 255, + b: 255, + a: 255, +}; + +/// Content panel background: slightly lighter than tabs +const CONTENT_BG: Color = Color { + r: 48, + g: 48, + b: 48, + a: 220, +}; + +/// Label text color: light gray +const LABEL_TEXT: Color = Color { + r: 200, + g: 200, + b: 200, + a: 255, +}; + +// ============================================================================= +// Tab Identifiers +// ============================================================================= + +/// Tab indices for the settings dialog. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsTab { + Graphics = 0, + Audio = 1, + Authentication = 2, + Advanced = 3, +} + +impl SettingsTab { + /// Get the display name for this tab. + pub fn display_name(&self) -> &'static str { + match self { + Self::Graphics => "Graphics", + Self::Audio => "Audio", + Self::Authentication => "Auth", + Self::Advanced => "Advanced", + } + } + + /// Get all tabs in order. + pub fn all() -> &'static [SettingsTab] { + &[ + Self::Graphics, + Self::Audio, + Self::Authentication, + Self::Advanced, + ] + } +} + +// ============================================================================= +// Dialog Result +// ============================================================================= + +/// Result of user interaction with the settings dialog. +#[derive(Debug, Clone)] +pub enum SettingsDialogResult { + /// User clicked Save (contains the modified settings). + Save(AppSettings), + /// User clicked Cancel or closed the window. + Cancel, + /// User clicked Update Key (contains the new API key). + UpdateApiKey(String), +} + +// ============================================================================= +// Settings Dialog Struct +// ============================================================================= + +/// Settings dialog UI for configuring application settings. +#[derive(Debug)] +pub struct SettingsDialog { + /// Root window widget. + window: Handle, + + /// Tab button handles (for detecting clicks and updating styles). + tab_buttons: Vec>, + + /// Tab content panel handles (for showing/hiding). + tab_panels: Vec>, + + /// Currently active tab index. + active_tab: usize, + + /// Save button handle. + save_button: Handle, + + /// Cancel button handle. + cancel_button: Handle, + + /// Original settings (for detecting changes / cancel). + original_settings: AppSettings, + + /// Current settings being edited. + current_settings: AppSettings, + + // ------------------------------------------------------------------------- + // Graphics Tab Controls + // ------------------------------------------------------------------------- + /// Quality preset dropdown handle. + quality_dropdown: Handle, + + /// VSync checkbox handle. + vsync_checkbox: Handle, + + /// FPS limit dropdown handle. + fps_dropdown: Handle, + + // ------------------------------------------------------------------------- + // Audio Tab Controls + // ------------------------------------------------------------------------- + /// Audio enabled checkbox handle. + audio_checkbox: Handle, + + /// Volume slider handle. + volume_slider: Handle, + + // ------------------------------------------------------------------------- + // Auth Tab Controls + // ------------------------------------------------------------------------- + /// API key status label handle. + api_key_status: Handle, + + /// API key input text box handle. + api_key_input: Handle, + + /// Update API key button handle. + update_key_button: Handle, + + /// Pending API key (entered but not yet saved). + pending_api_key: Option, +} + +impl SettingsDialog { + /// Create a text item for dropdown lists. + fn make_dropdown_item(ctx: &mut BuildContext, text: &str) -> Handle { + TextBuilder::new( + WidgetBuilder::new() + .with_foreground(Brush::Solid(LABEL_TEXT).into()) + .with_margin(Thickness::uniform(4.0)), + ) + .with_text(text) + .build(ctx) + } + + /// Create a labeled row with a control. + fn create_setting_row( + ctx: &mut BuildContext, + label: &str, + control: Handle, + row_index: usize, + ) -> Handle { + let label_widget = TextBuilder::new( + WidgetBuilder::new() + .with_foreground(Brush::Solid(LABEL_TEXT).into()) + .with_vertical_alignment(VerticalAlignment::Center) + .on_row(0) + .on_column(0), + ) + .with_text(label) + .build(ctx); + + // Re-parent the control to the correct column + GridBuilder::new( + WidgetBuilder::new() + .on_row(row_index) + .on_column(0) + .with_margin(Thickness::uniform(8.0)) + .with_child(label_widget) + .with_child(control), + ) + .add_row(Row::strict(32.0)) + .add_column(Column::strict(120.0)) // Label column + .add_column(Column::stretch()) // Control column + .build(ctx) + } + + /// Create a new settings dialog. + /// + /// # Arguments + /// - `settings` - Current application settings to display + /// - `ctx` - Build context for creating UI widgets + pub fn new(settings: AppSettings, ctx: &mut BuildContext) -> Self { + let original_settings = settings.clone(); + let current_settings = settings.clone(); + + // Create tab buttons + let mut tab_buttons = Vec::new(); + for (idx, tab) in SettingsTab::all().iter().enumerate() { + let is_selected = idx == 0; + let bg_color = if is_selected { + TAB_BG_SELECTED + } else { + TAB_BG_UNSELECTED + }; + + let button = ButtonBuilder::new( + WidgetBuilder::new() + .with_width(90.0) + .with_height(32.0) + .with_margin(Thickness::uniform(2.0)) + .with_background(Brush::Solid(bg_color).into()), + ) + .with_text(tab.display_name()) + .build(ctx); + + tab_buttons.push(button); + } + + // Tab button row + let tab_row = StackPanelBuilder::new( + WidgetBuilder::new() + .on_row(0) + .on_column(0) + .with_children(tab_buttons.iter().cloned()), + ) + .with_orientation(Orientation::Horizontal) + .build(ctx); + + // Create tab panels (content for each tab) + let mut tab_panels = Vec::new(); + + // ===================================================================== + // Graphics Tab Panel - with actual controls! + // ===================================================================== + + // Quality preset dropdown + let quality_items: Vec> = QualityPreset::all() + .iter() + .map(|q| Self::make_dropdown_item(ctx, q.display_name())) + .collect(); + + let quality_selected = QualityPreset::all() + .iter() + .position(|&q| q == settings.quality_preset) + .unwrap_or(1); // Default to Medium (index 1) + + let quality_dropdown = DropdownListBuilder::new( + WidgetBuilder::new() + .with_width(150.0) + .with_height(28.0) + .on_row(0) + .on_column(1), + ) + .with_items(quality_items) + .with_selected(quality_selected) + .with_close_on_selection(true) + .build(ctx); + + let quality_row = Self::create_setting_row(ctx, "Quality:", quality_dropdown, 0); + + // VSync checkbox + let vsync_checkbox = CheckBoxBuilder::new( + WidgetBuilder::new() + .with_width(24.0) + .with_height(24.0) + .on_row(0) + .on_column(1), + ) + .checked(Some(settings.vsync_enabled)) + .build(ctx); + + let vsync_row = Self::create_setting_row(ctx, "VSync:", vsync_checkbox, 1); + + // FPS limit dropdown + let fps_items: Vec> = FpsLimit::all() + .iter() + .map(|f| Self::make_dropdown_item(ctx, f.display_name())) + .collect(); + + let fps_selected = FpsLimit::all() + .iter() + .position(|&f| f == settings.fps_limit) + .unwrap_or(2); // Default to 60 FPS (index 2) + + let fps_dropdown = DropdownListBuilder::new( + WidgetBuilder::new() + .with_width(150.0) + .with_height(28.0) + .on_row(0) + .on_column(1), + ) + .with_items(fps_items) + .with_selected(fps_selected) + .with_close_on_selection(true) + .build(ctx); + + let fps_row = Self::create_setting_row(ctx, "FPS Limit:", fps_dropdown, 2); + + // Graphics panel layout + let graphics_panel = GridBuilder::new( + WidgetBuilder::new() + .on_row(0) + .on_column(0) + .with_margin(Thickness::uniform(8.0)) + .with_child(quality_row) + .with_child(vsync_row) + .with_child(fps_row), + ) + .add_row(Row::strict(48.0)) // Quality row + .add_row(Row::strict(48.0)) // VSync row + .add_row(Row::strict(48.0)) // FPS row + .add_row(Row::stretch()) // Spacer + .add_column(Column::stretch()) + .build(ctx); + + tab_panels.push(graphics_panel); + + // ===================================================================== + // Audio Tab Panel - with actual controls! + // ===================================================================== + + // Audio enabled checkbox + let audio_checkbox = CheckBoxBuilder::new( + WidgetBuilder::new() + .with_width(24.0) + .with_height(24.0) + .on_row(0) + .on_column(1), + ) + .checked(Some(settings.audio_enabled)) + .build(ctx); + + let audio_enabled_row = Self::create_setting_row(ctx, "Enabled:", audio_checkbox, 0); + + // Volume slider (0.0 to 1.0) + let volume_slider = ScrollBarBuilder::new( + WidgetBuilder::new() + .with_width(200.0) + .with_height(24.0) + .on_row(0) + .on_column(1), + ) + .with_min(0.0) + .with_max(1.0) + .with_value(settings.volume) + .with_step(0.05) + .show_value(true) + .with_value_precision(2) + .with_orientation(Orientation::Horizontal) + .build(ctx); + + let volume_row = Self::create_setting_row(ctx, "Volume:", volume_slider, 1); + + // Audio panel layout + let audio_panel = GridBuilder::new( + WidgetBuilder::new() + .on_row(0) + .on_column(0) + .with_margin(Thickness::uniform(8.0)) + .with_child(audio_enabled_row) + .with_child(volume_row), + ) + .add_row(Row::strict(48.0)) // Enabled row + .add_row(Row::strict(48.0)) // Volume row + .add_row(Row::stretch()) // Spacer + .add_column(Column::stretch()) + .build(ctx); + + tab_panels.push(audio_panel); + + // ===================================================================== + // Auth Tab Panel - API key management + // ===================================================================== + + // API key status label + let api_key_status = TextBuilder::new( + WidgetBuilder::new() + .with_foreground(Brush::Solid(LABEL_TEXT).into()) + .with_margin(Thickness::uniform(8.0)), + ) + .with_text("API Key: Checking...") + .build(ctx); + + // API key input (masked) + let api_key_input = TextBoxBuilder::new( + WidgetBuilder::new() + .with_width(300.0) + .with_height(28.0) + .with_margin(Thickness::uniform(8.0)), + ) + .with_text("") + .with_mask_char(Some('*')) + .build(ctx); + + // Update key button + let update_key_button = ButtonBuilder::new( + WidgetBuilder::new() + .with_width(120.0) + .with_height(32.0) + .with_margin(Thickness::uniform(8.0)), + ) + .with_text("Update Key") + .build(ctx); + + // Auth panel layout - vertical stack + let auth_panel = StackPanelBuilder::new( + WidgetBuilder::new() + .on_row(0) + .on_column(0) + .with_margin(Thickness::uniform(8.0)) + .with_child(api_key_status) + .with_child(api_key_input) + .with_child(update_key_button), + ) + .with_orientation(Orientation::Vertical) + .build(ctx); + + tab_panels.push(auth_panel); + + // Advanced tab panel + // ===================================================================== + // Advanced Tab Panel - info display + // ===================================================================== + + // Get data directory path + let data_dir_text = match crate::persistence::settings::get_data_dir() { + Ok(path) => path.display().to_string(), + Err(_) => "Unknown".to_string(), + }; + + // Data directory label + let data_dir_label = TextBuilder::new( + WidgetBuilder::new() + .with_foreground(Brush::Solid(LABEL_TEXT).into()) + .with_margin(Thickness::uniform(8.0)), + ) + .with_text("Data Directory:") + .build(ctx); + + // Data directory value + let data_dir_value = TextBuilder::new( + WidgetBuilder::new() + .with_foreground(Brush::Solid(Color::opaque(180, 180, 180)).into()) + .with_margin(Thickness::uniform(8.0)), + ) + .with_text(&data_dir_text) + .build(ctx); + + // Version label + let version_label = TextBuilder::new( + WidgetBuilder::new() + .with_foreground(Brush::Solid(LABEL_TEXT).into()) + .with_margin(Thickness::uniform(8.0)), + ) + .with_text("Version:") + .build(ctx); + + // Version value + let version_value = TextBuilder::new( + WidgetBuilder::new() + .with_foreground(Brush::Solid(Color::opaque(180, 180, 180)).into()) + .with_margin(Thickness::uniform(8.0)), + ) + .with_text(env!("CARGO_PKG_VERSION")) + .build(ctx); + + // Advanced panel layout - vertical stack + let advanced_panel = StackPanelBuilder::new( + WidgetBuilder::new() + .on_row(0) + .on_column(0) + .with_margin(Thickness::uniform(8.0)) + .with_child(data_dir_label) + .with_child(data_dir_value) + .with_child(version_label) + .with_child(version_value), + ) + .with_orientation(Orientation::Vertical) + .build(ctx); + tab_panels.push(advanced_panel); + + // Content area - stacks all panels, we'll show/hide them + let content_area = GridBuilder::new( + WidgetBuilder::new() + .on_row(1) + .on_column(0) + .with_background(Brush::Solid(CONTENT_BG).into()) + .with_children(tab_panels.iter().cloned()), + ) + .add_row(Row::stretch()) + .add_column(Column::stretch()) + .build(ctx); + + // Cancel button + let cancel_button = ButtonBuilder::new( + WidgetBuilder::new() + .with_width(100.0) + .with_height(36.0) + .with_margin(Thickness::uniform(4.0)), + ) + .with_text("Cancel") + .build(ctx); + + // Save button + let save_button = ButtonBuilder::new( + WidgetBuilder::new() + .with_width(100.0) + .with_height(36.0) + .with_margin(Thickness::uniform(4.0)), + ) + .with_text("Save") + .build(ctx); + + // Button row + let button_row = StackPanelBuilder::new( + WidgetBuilder::new() + .on_row(2) + .on_column(0) + .with_horizontal_alignment(HorizontalAlignment::Right) + .with_child(cancel_button) + .with_child(save_button), + ) + .with_orientation(Orientation::Horizontal) + .build(ctx); + + // Main layout grid + let content = GridBuilder::new( + WidgetBuilder::new() + .with_margin(Thickness::uniform(8.0)) + .with_child(tab_row) + .with_child(content_area) + .with_child(button_row), + ) + .add_row(Row::strict(40.0)) // Tab buttons + .add_row(Row::stretch()) // Content area + .add_row(Row::strict(50.0)) // Action buttons + .add_column(Column::stretch()) + .build(ctx); + + // Modal window + let window = WindowBuilder::new( + WidgetBuilder::new() + .with_width(500.0) + .with_height(400.0) + .with_desired_position(fyrox::core::algebra::Vector2::new(150.0, 80.0)), + ) + .with_title(WindowTitle::text("Settings")) + .with_content(content) + .can_minimize(false) + .build(ctx); + + let mut dialog = Self { + window, + tab_buttons, + tab_panels, + active_tab: 0, + save_button, + cancel_button, + original_settings, + current_settings, + // Graphics controls + quality_dropdown, + vsync_checkbox, + fps_dropdown, + // Audio controls + audio_checkbox, + volume_slider, + // Auth controls + api_key_status, + api_key_input, + update_key_button, + pending_api_key: None, + }; + + // Initial panel visibility (only show first tab) + // Note: We'll set this after the dialog is linked to the UI + dialog.active_tab = 0; + + dialog + } + + /// Create a placeholder panel with a message. + fn create_placeholder_panel(ctx: &mut BuildContext, message: &str) -> Handle { + let text = TextBuilder::new( + WidgetBuilder::new() + .with_foreground(Brush::Solid(LABEL_TEXT).into()) + .with_horizontal_alignment(HorizontalAlignment::Center) + .with_vertical_alignment(VerticalAlignment::Center), + ) + .with_text(message) + .build(ctx); + + GridBuilder::new( + WidgetBuilder::new() + .on_row(0) + .on_column(0) + .with_child(text), + ) + .add_row(Row::stretch()) + .add_column(Column::stretch()) + .build(ctx) + } + + /// Update panel visibility based on active tab. + pub fn update_panel_visibility(&self, ui: &mut UserInterface) { + for (idx, &panel) in self.tab_panels.iter().enumerate() { + let visible = idx == self.active_tab; + ui.send_message(WidgetMessage::visibility( + panel, + MessageDirection::ToWidget, + visible, + )); + } + } + + /// Handle UI messages and return result if user made a choice. + /// + /// Returns `Some(SettingsDialogResult)` when the user clicks Save or Cancel. + pub fn handle_message( + &mut self, + message: &UiMessage, + ui: &mut UserInterface, + ) -> Option { + let destination = message.destination(); + + // Handle button clicks + if let Some(ButtonMessage::Click) = message.data() { + // Check Cancel button + if destination == self.cancel_button { + return Some(SettingsDialogResult::Cancel); + } + + // Check Save button + if destination == self.save_button { + return Some(SettingsDialogResult::Save(self.current_settings.clone())); + } + + // Check tab buttons + for (idx, &button) in self.tab_buttons.iter().enumerate() { + if destination == button { + self.switch_tab(idx, ui); + return None; + } + } + + // Check Update Key button + if destination == self.update_key_button { + if let Some(ref key) = self.pending_api_key { + if !key.is_empty() { + let key_to_save = key.clone(); + self.pending_api_key = None; + // Clear the input field + ui.send_message(TextMessage::text( + self.api_key_input, + MessageDirection::ToWidget, + String::new(), + )); + return Some(SettingsDialogResult::UpdateApiKey(key_to_save)); + } + } + fyrox::core::log::Log::warn("No API key entered to update"); + return None; + } + } + + // Handle text input changes (for API key) + if let Some(TextMessage::Text(text)) = message.data() { + if destination == self.api_key_input { + self.pending_api_key = if text.is_empty() { + None + } else { + Some(text.clone()) + }; + } + } + + // Handle checkbox changes + if let Some(CheckBoxMessage::Check(Some(checked))) = message.data() { + if destination == self.vsync_checkbox { + self.current_settings.vsync_enabled = *checked; + fyrox::core::log::Log::info(format!("VSync toggled: {}", checked)); + } else if destination == self.audio_checkbox { + self.current_settings.audio_enabled = *checked; + fyrox::core::log::Log::info(format!("Audio enabled toggled: {}", checked)); + } + } + + // Handle slider value changes + if let Some(ScrollBarMessage::Value(value)) = message.data() { + if destination == self.volume_slider { + self.current_settings.volume = *value; + fyrox::core::log::Log::info(format!("Volume changed: {:.2}", value)); + } + } + + // Handle dropdown selection changes + if let Some(DropdownListMessage::SelectionChanged(Some(index))) = message.data() { + if destination == self.quality_dropdown { + if let Some(&preset) = QualityPreset::all().get(*index) { + self.current_settings.quality_preset = preset; + fyrox::core::log::Log::info(format!( + "Quality preset changed: {}", + preset.display_name() + )); + } + } else if destination == self.fps_dropdown { + if let Some(&limit) = FpsLimit::all().get(*index) { + self.current_settings.fps_limit = limit; + fyrox::core::log::Log::info(format!( + "FPS limit changed: {}", + limit.display_name() + )); + } + } + } + + None + } + + /// Switch to a different tab. + fn switch_tab(&mut self, new_tab: usize, ui: &mut UserInterface) { + if new_tab == self.active_tab || new_tab >= self.tab_buttons.len() { + return; + } + + // Update old tab button style + self.update_tab_style(self.active_tab, false, ui); + + // Update new tab button style + self.update_tab_style(new_tab, true, ui); + + // Update active tab + self.active_tab = new_tab; + + // Update panel visibility + self.update_panel_visibility(ui); + } + + /// Update the visual style of a tab button. + fn update_tab_style(&self, index: usize, selected: bool, ui: &mut UserInterface) { + if let Some(&button) = self.tab_buttons.get(index) { + let color = if selected { + TAB_BG_SELECTED + } else { + TAB_BG_UNSELECTED + }; + + ui.send_message(WidgetMessage::background( + button, + MessageDirection::ToWidget, + Brush::Solid(color).into(), + )); + } + } + + /// Get the window handle for visibility control. + pub fn window(&self) -> Handle { + self.window + } + + /// Get the currently active tab index. + pub fn active_tab(&self) -> usize { + self.active_tab + } + + /// Switch to a specific tab by index (for debug server use). + pub fn switch_to_tab(&mut self, index: usize, ui: &mut UserInterface) { + self.switch_tab(index, ui); + } + + /// Set the quality preset programmatically (for debug server use). + pub fn set_quality(&mut self, preset: QualityPreset, ui: &mut UserInterface) { + self.current_settings.quality_preset = preset; + // Update the dropdown selection + if let Some(index) = QualityPreset::all().iter().position(|&p| p == preset) { + ui.send_message(DropdownListMessage::selection( + self.quality_dropdown, + MessageDirection::ToWidget, + Some(index), + )); + } + fyrox::core::log::Log::info(format!( + "Quality preset set via debug server: {}", + preset.display_name() + )); + } + + /// Set VSync enabled/disabled programmatically (for debug server use). + pub fn set_vsync(&mut self, enabled: bool, ui: &mut UserInterface) { + self.current_settings.vsync_enabled = enabled; + // Update the checkbox + ui.send_message(CheckBoxMessage::checked( + self.vsync_checkbox, + MessageDirection::ToWidget, + Some(enabled), + )); + fyrox::core::log::Log::info(format!("VSync set via debug server: {}", enabled)); + } + + /// Set FPS limit programmatically (for debug server use). + pub fn set_fps_limit(&mut self, limit: FpsLimit, ui: &mut UserInterface) { + self.current_settings.fps_limit = limit; + // Update the dropdown selection + if let Some(index) = FpsLimit::all().iter().position(|&l| l == limit) { + ui.send_message(DropdownListMessage::selection( + self.fps_dropdown, + MessageDirection::ToWidget, + Some(index), + )); + } + fyrox::core::log::Log::info(format!( + "FPS limit set via debug server: {}", + limit.display_name() + )); + } + + /// Set audio enabled/disabled programmatically (for debug server use). + pub fn set_audio_enabled(&mut self, enabled: bool, ui: &mut UserInterface) { + self.current_settings.audio_enabled = enabled; + // Update the checkbox + ui.send_message(CheckBoxMessage::checked( + self.audio_checkbox, + MessageDirection::ToWidget, + Some(enabled), + )); + fyrox::core::log::Log::info(format!("Audio enabled set via debug server: {}", enabled)); + } + + /// Set volume programmatically (for debug server use). + pub fn set_volume(&mut self, volume: f32, ui: &mut UserInterface) { + let clamped = volume.clamp(0.0, 1.0); + self.current_settings.volume = clamped; + // Update the slider + ui.send_message(ScrollBarMessage::value( + self.volume_slider, + MessageDirection::ToWidget, + clamped, + )); + fyrox::core::log::Log::info(format!("Volume set via debug server: {:.2}", clamped)); + } + + /// Update the API key status label. + pub fn update_api_key_status(&self, has_key: bool, ui: &mut UserInterface) { + let status_text = if has_key { + "API Key: ✓ Set" + } else { + "API Key: ✗ Not set" + }; + ui.send_message(TextMessage::text( + self.api_key_status, + MessageDirection::ToWidget, + status_text.to_string(), + )); + } + + /// Set API key programmatically (for debug server use). + /// This simulates entering text in the input and clicking Update. + pub fn set_api_key(&mut self, key: String, ui: &mut UserInterface) { + self.pending_api_key = Some(key.clone()); + // Update the input field to show masked characters (asterisks) + let masked = "*".repeat(key.len()); + ui.send_message(TextMessage::text( + self.api_key_input, + MessageDirection::ToWidget, + masked, + )); + fyrox::core::log::Log::info("API key set via debug server (pending update)"); + } + + /// Get the pending API key (if any). + pub fn get_pending_api_key(&self) -> Option<&String> { + self.pending_api_key.as_ref() + } + + /// Close the settings window. + pub fn close(&self, ui: &mut UserInterface) { + ui.send_message(WidgetMessage::remove( + self.window, + MessageDirection::ToWidget, + )); + } + + /// Get the current settings being edited. + #[allow(dead_code)] + pub fn current_settings(&self) -> &AppSettings { + &self.current_settings + } + + /// Check if settings have been modified. + #[allow(dead_code)] + pub fn has_changes(&self) -> bool { + // For now, do a simple JSON comparison + // This is not the most efficient but works reliably + let original = serde_json::to_string(&self.original_settings).unwrap_or_default(); + let current = serde_json::to_string(&self.current_settings).unwrap_or_default(); + original != current + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_settings_tab_display_names() { + assert_eq!(SettingsTab::Graphics.display_name(), "Graphics"); + assert_eq!(SettingsTab::Audio.display_name(), "Audio"); + assert_eq!(SettingsTab::Authentication.display_name(), "Auth"); + assert_eq!(SettingsTab::Advanced.display_name(), "Advanced"); + } + + #[test] + fn test_settings_tab_all() { + let all = SettingsTab::all(); + assert_eq!(all.len(), 4); + assert_eq!(all[0], SettingsTab::Graphics); + assert_eq!(all[3], SettingsTab::Advanced); + } + + #[test] + fn test_settings_dialog_result_variants() { + let save_result = SettingsDialogResult::Save(AppSettings::default()); + let cancel_result = SettingsDialogResult::Cancel; + + assert!(matches!(save_result, SettingsDialogResult::Save(_))); + assert!(matches!(cancel_result, SettingsDialogResult::Cancel)); + } +}