diff --git a/crates/sprimo-tauri/Cargo.toml b/crates/sprimo-tauri/Cargo.toml index 6cdb2f0..830f745 100644 --- a/crates/sprimo-tauri/Cargo.toml +++ b/crates/sprimo-tauri/Cargo.toml @@ -16,7 +16,7 @@ sprimo-platform = { path = "../sprimo-platform" } sprimo-sprite = { path = "../sprimo-sprite" } sprimo-runtime-core = { path = "../sprimo-runtime-core" } sprimo-protocol = { path = "../sprimo-protocol" } -tauri = { version = "2.0.0", features = [] } +tauri = { version = "2.0.0", features = ["tray-icon"] } tauri-plugin-global-shortcut = "2.0.0" tauri-plugin-log = "2.0.0" thiserror.workspace = true diff --git a/crates/sprimo-tauri/src/main.rs b/crates/sprimo-tauri/src/main.rs index b1eabfd..b9ef7cb 100644 --- a/crates/sprimo-tauri/src/main.rs +++ b/crates/sprimo-tauri/src/main.rs @@ -2,18 +2,29 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use base64::Engine; +use sprimo_protocol::v1::FrontendCommand; use sprimo_platform::{create_adapter, PlatformAdapter}; use sprimo_protocol::v1::{FrontendState, FrontendStateSnapshot}; use sprimo_runtime_core::{RuntimeCore, RuntimeCoreError}; use sprimo_sprite::{load_manifest, resolve_pack_path, AnimationDefinition}; use std::sync::Arc; -use tauri::{Emitter, Manager}; +use tauri::menu::{CheckMenuItem, Menu, MenuItem}; +use tauri::tray::TrayIconBuilder; +use tauri::{AppHandle, Emitter, Manager, Wry}; use thiserror::Error; use tokio::runtime::Runtime; use tracing::warn; const APP_NAME: &str = "sprimo"; const DEFAULT_PACK: &str = "default"; +const MAIN_WINDOW_LABEL: &str = "main"; +const TRAY_ID: &str = "main"; +const MENU_ID_TOGGLE_VISIBILITY: &str = "toggle_visibility"; +const MENU_ID_TOGGLE_ALWAYS_ON_TOP: &str = "toggle_always_on_top"; +const MENU_ID_TOGGLE_DEBUG_OVERLAY: &str = "toggle_debug_overlay"; +const MENU_ID_QUIT: &str = "quit"; +const EVENT_RUNTIME_SNAPSHOT: &str = "runtime:snapshot"; +const EVENT_RUNTIME_DEBUG_OVERLAY_VISIBLE: &str = "runtime:debug-overlay-visible"; #[derive(Debug, Clone, serde::Serialize)] struct UiAnimationClip { @@ -65,6 +76,13 @@ struct AppState { runtime: Arc, } +#[derive(Clone)] +struct TrayMenuState { + toggle_visibility: MenuItem, + toggle_always_on_top: CheckMenuItem, + toggle_debug_overlay: CheckMenuItem, +} + #[tauri::command] fn current_state(state: tauri::State<'_, AppState>) -> Result { let snapshot = state @@ -131,6 +149,7 @@ fn debug_overlay_visible(state: tauri::State<'_, AppState>) -> Result, visible: bool, ) -> Result { @@ -138,6 +157,7 @@ fn set_debug_overlay_visible( .runtime_core .set_frontend_debug_overlay_visible(visible) .map_err(|err| err.to_string())?; + let _ = emit_debug_overlay_visibility(state.runtime_core.as_ref(), &app_handle); Ok(visible) } @@ -173,13 +193,17 @@ fn main() -> Result<(), AppError> { let runtime = Arc::clone(&app_state.runtime); let app_handle = app.handle().clone(); + let tray_state = setup_tray(&app_handle, &runtime_core)?; if let Ok(snapshot) = runtime_core.snapshot().read() { - let _ = app_handle.emit("runtime:snapshot", to_ui_snapshot(&snapshot)); + let _ = app_handle.emit(EVENT_RUNTIME_SNAPSHOT, to_ui_snapshot(&snapshot)); } + let _ = emit_debug_overlay_visibility(runtime_core.as_ref(), &app_handle); + let _ = refresh_tray_menu_state(runtime_core.as_ref(), &tray_state); let command_rx = runtime_core.command_receiver(); let runtime_core_for_commands = Arc::clone(&runtime_core); let app_handle_for_commands = app_handle.clone(); + let tray_state_for_commands = tray_state.clone(); runtime.spawn(async move { loop { let next = { @@ -203,7 +227,11 @@ fn main() -> Result<(), AppError> { } }; if let Some(value) = payload { - let _ = app_handle_for_commands.emit("runtime:snapshot", value); + let _ = app_handle_for_commands.emit(EVENT_RUNTIME_SNAPSHOT, value); + let _ = refresh_tray_menu_state( + runtime_core_for_commands.as_ref(), + &tray_state_for_commands, + ); } } }); @@ -211,6 +239,7 @@ fn main() -> Result<(), AppError> { if let Some(window) = app.get_webview_window("main") { let runtime_core = Arc::clone(&runtime_core); let app_handle = app_handle.clone(); + let tray_state_for_window = tray_state.clone(); window.on_window_event(move |event| { if let tauri::WindowEvent::Moved(position) = event { let command = sprimo_protocol::v1::FrontendCommand::SetTransform { @@ -222,13 +251,34 @@ fn main() -> Result<(), AppError> { }; if runtime_core.apply_command(&command).is_ok() { if let Ok(snapshot) = runtime_core.snapshot().read() { - let _ = app_handle.emit("runtime:snapshot", to_ui_snapshot(&snapshot)); + let _ = app_handle.emit( + EVENT_RUNTIME_SNAPSHOT, + to_ui_snapshot(&snapshot), + ); } + let _ = refresh_tray_menu_state( + runtime_core.as_ref(), + &tray_state_for_window, + ); } } }); } + let runtime_core_for_menu = Arc::clone(&runtime_core); + let app_handle_for_menu = app_handle.clone(); + let tray_state_for_menu = tray_state.clone(); + app.on_menu_event(move |_app, event| { + if let Err(err) = handle_menu_event( + runtime_core_for_menu.as_ref(), + &app_handle_for_menu, + &tray_state_for_menu, + event.id().as_ref(), + ) { + warn!(%err, "tray/menu action failed"); + } + }); + let _ = app; Ok(()) }) @@ -248,6 +298,211 @@ fn to_ui_snapshot(snapshot: &FrontendStateSnapshot) -> UiSnapshot { } } +fn setup_tray( + app_handle: &AppHandle, + runtime_core: &RuntimeCore, +) -> Result { + let snapshot = runtime_core + .snapshot() + .read() + .map_err(|_| tauri::Error::AssetNotFound("snapshot lock poisoned".to_string()))? + .clone(); + let debug_overlay_visible = runtime_core + .frontend_debug_overlay_visible() + .map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?; + + let toggle_visibility = MenuItem::with_id( + app_handle, + MENU_ID_TOGGLE_VISIBILITY, + visibility_menu_title(snapshot.flags.visible), + true, + None::<&str>, + )?; + let toggle_always_on_top = CheckMenuItem::with_id( + app_handle, + MENU_ID_TOGGLE_ALWAYS_ON_TOP, + "Always on top", + true, + snapshot.flags.always_on_top, + None::<&str>, + )?; + let toggle_debug_overlay = CheckMenuItem::with_id( + app_handle, + MENU_ID_TOGGLE_DEBUG_OVERLAY, + "Debug overlay", + true, + debug_overlay_visible, + None::<&str>, + )?; + let quit = MenuItem::with_id(app_handle, MENU_ID_QUIT, "Quit", true, None::<&str>)?; + let menu = Menu::with_items( + app_handle, + &[ + &toggle_visibility, + &toggle_always_on_top, + &toggle_debug_overlay, + &quit, + ], + )?; + + let mut builder = TrayIconBuilder::with_id(TRAY_ID).menu(&menu); + if let Some(icon) = app_handle.default_window_icon().cloned() { + builder = builder.icon(icon); + } + builder + .tooltip("sprimo-tauri") + .show_menu_on_left_click(true) + .build(app_handle)?; + + Ok(TrayMenuState { + toggle_visibility, + toggle_always_on_top, + toggle_debug_overlay, + }) +} + +fn handle_menu_event( + runtime_core: &RuntimeCore, + app_handle: &AppHandle, + tray_state: &TrayMenuState, + menu_id: &str, +) -> Result<(), String> { + match menu_id { + MENU_ID_TOGGLE_VISIBILITY => { + let current = runtime_core + .snapshot() + .read() + .map_err(|_| "snapshot lock poisoned".to_string())? + .flags + .visible; + let next = !current; + runtime_core + .apply_command(&FrontendCommand::SetFlags { + click_through: None, + always_on_top: None, + visible: Some(next), + }) + .map_err(|err| err.to_string())?; + if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) { + if next { + window.show().map_err(|err| err.to_string())?; + window.set_focus().map_err(|err| err.to_string())?; + } else { + window.hide().map_err(|err| err.to_string())?; + } + } + } + MENU_ID_TOGGLE_ALWAYS_ON_TOP => { + let current = runtime_core + .snapshot() + .read() + .map_err(|_| "snapshot lock poisoned".to_string())? + .flags + .always_on_top; + let next = !current; + runtime_core + .apply_command(&FrontendCommand::SetFlags { + click_through: None, + always_on_top: Some(next), + visible: None, + }) + .map_err(|err| err.to_string())?; + if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) { + window.set_always_on_top(next).map_err(|err| err.to_string())?; + } + } + MENU_ID_TOGGLE_DEBUG_OVERLAY => { + let current = runtime_core + .frontend_debug_overlay_visible() + .map_err(|err| err.to_string())?; + let next = !current; + runtime_core + .set_frontend_debug_overlay_visible(next) + .map_err(|err| err.to_string())?; + emit_debug_overlay_visibility(runtime_core, app_handle).map_err(|err| err.to_string())?; + } + MENU_ID_QUIT => { + persist_current_ui_flags(runtime_core)?; + app_handle.exit(0); + } + _ => return Ok(()), + } + + if let Ok(snapshot) = runtime_core.snapshot().read() { + let _ = app_handle.emit(EVENT_RUNTIME_SNAPSHOT, to_ui_snapshot(&snapshot)); + } + let _ = emit_debug_overlay_visibility(runtime_core, app_handle); + let _ = refresh_tray_menu_state(runtime_core, tray_state); + Ok(()) +} + +fn persist_current_ui_flags(runtime_core: &RuntimeCore) -> Result<(), String> { + let snapshot = runtime_core + .snapshot() + .read() + .map_err(|_| "snapshot lock poisoned".to_string())? + .clone(); + runtime_core + .apply_command(&FrontendCommand::SetFlags { + click_through: None, + always_on_top: Some(snapshot.flags.always_on_top), + visible: Some(snapshot.flags.visible), + }) + .map_err(|err| err.to_string())?; + let debug_overlay = runtime_core + .frontend_debug_overlay_visible() + .map_err(|err| err.to_string())?; + runtime_core + .set_frontend_debug_overlay_visible(debug_overlay) + .map_err(|err| err.to_string()) +} + +fn emit_debug_overlay_visibility( + runtime_core: &RuntimeCore, + app_handle: &AppHandle, +) -> Result<(), tauri::Error> { + let value = runtime_core + .frontend_debug_overlay_visible() + .map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?; + app_handle.emit(EVENT_RUNTIME_DEBUG_OVERLAY_VISIBLE, value) +} + +fn refresh_tray_menu_state( + runtime_core: &RuntimeCore, + tray_state: &TrayMenuState, +) -> Result<(), tauri::Error> { + let snapshot = runtime_core + .snapshot() + .read() + .map_err(|_| tauri::Error::AssetNotFound("snapshot lock poisoned".to_string()))? + .clone(); + let debug_overlay_visible = runtime_core + .frontend_debug_overlay_visible() + .map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?; + + tray_state + .toggle_visibility + .set_text(visibility_menu_title(snapshot.flags.visible)) + .map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?; + tray_state + .toggle_always_on_top + .set_checked(snapshot.flags.always_on_top) + .map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?; + tray_state + .toggle_debug_overlay + .set_checked(debug_overlay_visible) + .map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?; + Ok(()) +} + +fn visibility_menu_title(visible: bool) -> &'static str { + if visible { + "Hide" + } else { + "Show" + } +} + fn state_name(value: FrontendState) -> &'static str { match value { FrontendState::Idle => "idle", diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 33dc388..099c1b5 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -19,7 +19,7 @@ Date: 2026-02-12 | Random backend API tester | Implemented | `scripts/random_backend_tester.py` with `just random-backend-test` and strict variant | | QA/documentation workflow | Implemented | `docs/QA_WORKFLOW.md`, issue/evidence templates, and `scripts/qa_validate.py` with `just qa-validate` | | Shared runtime core | Implemented | `sprimo-runtime-core` now backs both Tauri and Bevy startup, snapshot/config ownership, and API wiring | -| Tauri alternative frontend | In progress | `sprimo-tauri` now runs runtime-core/API + PixiJS sprite rendering shell; scale auto-fit and persisted debug-overlay toggle are implemented | +| Tauri alternative frontend | In progress | `sprimo-tauri` now runs runtime-core/API + PixiJS sprite rendering shell; scale auto-fit, persisted debug-overlay toggle, and Windows-first tray/menu MVP are implemented | | Tauri runtime testing workflow | Implemented | `docs/TAURI_RUNTIME_TESTING.md` defines strict workspace testing; packaged mode pending packaging support | ## Next Major Gaps @@ -28,4 +28,4 @@ Date: 2026-02-12 2. Linux/macOS overlay behavior remains best-effort with no-op platform adapter. 3. `/v1/state` diagnostics are minimal; error history is not persisted beyond latest runtime error. 4. Issue 1 runtime after-fix screenshot evidence is still pending before closure. -5. `sprimo-tauri` still lacks tray/menu/window-behavior parity and full acceptance parity tests. +5. `sprimo-tauri` still lacks cross-platform tray/menu parity and full acceptance parity tests. diff --git a/docs/TAURI_FRONTEND_DESIGN.md b/docs/TAURI_FRONTEND_DESIGN.md index 8098196..0cf82bf 100644 --- a/docs/TAURI_FRONTEND_DESIGN.md +++ b/docs/TAURI_FRONTEND_DESIGN.md @@ -54,10 +54,15 @@ Frontend: - Tauri window drag is implemented for undecorated mode: - left-mouse drag starts native window dragging - moved position is synced into runtime-core snapshot/config state. +- Windows-first tray/menu MVP is implemented: + - `Show/Hide` + - `Always on top` toggle + - `Debug overlay` toggle + - `Quit` - Bevy frontend remains intact. - Runtime QA workflow is defined in `docs/TAURI_RUNTIME_TESTING.md`. ## Remaining Work -1. Add tray/menu parity and window behavior parity with Bevy path. +1. Extend tray/menu implementation beyond Windows-first MVP and close platform parity gaps. 2. Add frontend parity acceptance tests (Bevy vs Tauri state transitions). diff --git a/docs/TAURI_RUNTIME_TESTING.md b/docs/TAURI_RUNTIME_TESTING.md index a964bca..1c8d370 100644 --- a/docs/TAURI_RUNTIME_TESTING.md +++ b/docs/TAURI_RUNTIME_TESTING.md @@ -78,6 +78,12 @@ An issue touching Tauri runtime behaviors must satisfy all requirements before ` - default startup behavior follows `frontend.debug_overlay_visible` config - `debug_overlay_visible`/`set_debug_overlay_visible` invoke commands toggle panel at runtime - toggle state persists after restart +10. Verify Windows tray/menu controls: +- tray left-click opens menu without directly toggling visibility +- `Show/Hide` toggles window visibility and persists state +- `Always on top` toggles top-most behavior and persists state +- `Debug overlay` toggles panel visibility and persists state +- `Quit` exits cleanly and preserves current persisted visibility/top-most/debug settings ## API + Runtime Contract Checklist diff --git a/frontend/tauri-ui/src/main.tsx b/frontend/tauri-ui/src/main.tsx index bde3638..9de2732 100644 --- a/frontend/tauri-ui/src/main.tsx +++ b/frontend/tauri-ui/src/main.tsx @@ -102,6 +102,20 @@ function App(): JSX.Element { } }); }); + const unlistenDebug = await listen( + "runtime:debug-overlay-visible", + (event) => { + if (!mounted) { + return; + } + setDebugOverlayVisible(Boolean(event.payload)); + } + ); + const previousUnlisten = unlisten; + unlisten = () => { + previousUnlisten(); + unlistenDebug(); + }; }) .catch((err) => { if (mounted) {