2 Commits

Author SHA1 Message Date
DaZuo0122
907974e61f Add: tray for tauri 2026-02-13 23:10:01 +08:00
DaZuo0122
e5e123cc84 Add: config for controlling debug overlay visibility of tauri 2026-02-13 22:31:22 +08:00
10 changed files with 435 additions and 33 deletions

View File

@@ -158,12 +158,14 @@ pub enum FrontendBackend {
#[serde(default)] #[serde(default)]
pub struct FrontendConfig { pub struct FrontendConfig {
pub backend: FrontendBackend, pub backend: FrontendBackend,
pub debug_overlay_visible: bool,
} }
impl Default for FrontendConfig { impl Default for FrontendConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
backend: FrontendBackend::Bevy, backend: FrontendBackend::Bevy,
debug_overlay_visible: false,
} }
} }
} }
@@ -222,10 +224,12 @@ mod tests {
let mut config = AppConfig::default(); let mut config = AppConfig::default();
config.window.x = 42.0; config.window.x = 42.0;
config.frontend.backend = super::FrontendBackend::Tauri; config.frontend.backend = super::FrontendBackend::Tauri;
config.frontend.debug_overlay_visible = true;
save(&path, &config).expect("save"); save(&path, &config).expect("save");
let (_, loaded) = load_or_create_at(&path).expect("reload"); let (_, loaded) = load_or_create_at(&path).expect("reload");
assert!((loaded.window.x - 42.0).abs() < f32::EPSILON); assert!((loaded.window.x - 42.0).abs() < f32::EPSILON);
assert_eq!(loaded.frontend.backend, super::FrontendBackend::Tauri); assert_eq!(loaded.frontend.backend, super::FrontendBackend::Tauri);
assert!(loaded.frontend.debug_overlay_visible);
} }
} }

View File

@@ -82,6 +82,28 @@ impl RuntimeCore {
self.command_tx.clone() self.command_tx.clone()
} }
pub fn frontend_debug_overlay_visible(&self) -> Result<bool, RuntimeCoreError> {
let guard = self
.config
.read()
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
Ok(guard.frontend.debug_overlay_visible)
}
pub fn set_frontend_debug_overlay_visible(
&self,
visible: bool,
) -> Result<(), RuntimeCoreError> {
{
let mut guard = self
.config
.write()
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
guard.frontend.debug_overlay_visible = visible;
}
self.persist_config()
}
pub fn api_config(&self) -> ApiConfig { pub fn api_config(&self) -> ApiConfig {
self.api_config.clone() self.api_config.clone()
} }
@@ -283,4 +305,14 @@ mod tests {
let config = core.config().read().expect("config lock").clone(); let config = core.config().read().expect("config lock").clone();
assert!(!config.window.click_through); assert!(!config.window.click_through);
} }
#[test]
fn frontend_debug_overlay_visibility_roundtrips() {
let temp = TempDir::new().expect("tempdir");
let path = temp.path().join("config.toml");
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
.expect("core init");
core.set_frontend_debug_overlay_visible(true).expect("set");
assert!(core.frontend_debug_overlay_visible().expect("get"));
}
} }

View File

@@ -16,7 +16,7 @@ sprimo-platform = { path = "../sprimo-platform" }
sprimo-sprite = { path = "../sprimo-sprite" } sprimo-sprite = { path = "../sprimo-sprite" }
sprimo-runtime-core = { path = "../sprimo-runtime-core" } sprimo-runtime-core = { path = "../sprimo-runtime-core" }
sprimo-protocol = { path = "../sprimo-protocol" } 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-global-shortcut = "2.0.0"
tauri-plugin-log = "2.0.0" tauri-plugin-log = "2.0.0"
thiserror.workspace = true thiserror.workspace = true

View File

@@ -2,18 +2,29 @@
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use base64::Engine; use base64::Engine;
use sprimo_protocol::v1::FrontendCommand;
use sprimo_platform::{create_adapter, PlatformAdapter}; use sprimo_platform::{create_adapter, PlatformAdapter};
use sprimo_protocol::v1::{FrontendState, FrontendStateSnapshot}; use sprimo_protocol::v1::{FrontendState, FrontendStateSnapshot};
use sprimo_runtime_core::{RuntimeCore, RuntimeCoreError}; use sprimo_runtime_core::{RuntimeCore, RuntimeCoreError};
use sprimo_sprite::{load_manifest, resolve_pack_path, AnimationDefinition}; use sprimo_sprite::{load_manifest, resolve_pack_path, AnimationDefinition};
use std::sync::Arc; 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 thiserror::Error;
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
use tracing::warn; use tracing::warn;
const APP_NAME: &str = "sprimo"; const APP_NAME: &str = "sprimo";
const DEFAULT_PACK: &str = "default"; 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)] #[derive(Debug, Clone, serde::Serialize)]
struct UiAnimationClip { struct UiAnimationClip {
@@ -65,6 +76,13 @@ struct AppState {
runtime: Arc<Runtime>, runtime: Arc<Runtime>,
} }
#[derive(Clone)]
struct TrayMenuState {
toggle_visibility: MenuItem<Wry>,
toggle_always_on_top: CheckMenuItem<Wry>,
toggle_debug_overlay: CheckMenuItem<Wry>,
}
#[tauri::command] #[tauri::command]
fn current_state(state: tauri::State<'_, AppState>) -> Result<UiSnapshot, String> { fn current_state(state: tauri::State<'_, AppState>) -> Result<UiSnapshot, String> {
let snapshot = state let snapshot = state
@@ -121,6 +139,28 @@ fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSprite
}) })
} }
#[tauri::command]
fn debug_overlay_visible(state: tauri::State<'_, AppState>) -> Result<bool, String> {
state
.runtime_core
.frontend_debug_overlay_visible()
.map_err(|err| err.to_string())
}
#[tauri::command]
fn set_debug_overlay_visible(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
visible: bool,
) -> Result<bool, String> {
state
.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)
}
fn main() -> Result<(), AppError> { fn main() -> Result<(), AppError> {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter("sprimo=info") .with_env_filter("sprimo=info")
@@ -141,20 +181,29 @@ fn main() -> Result<(), AppError> {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_global_shortcut::Builder::new().build())
.manage(state) .manage(state)
.invoke_handler(tauri::generate_handler![current_state, load_active_sprite_pack]) .invoke_handler(tauri::generate_handler![
current_state,
load_active_sprite_pack,
debug_overlay_visible,
set_debug_overlay_visible
])
.setup(|app| { .setup(|app| {
let app_state: tauri::State<'_, AppState> = app.state(); let app_state: tauri::State<'_, AppState> = app.state();
let runtime_core = Arc::clone(&app_state.runtime_core); let runtime_core = Arc::clone(&app_state.runtime_core);
let runtime = Arc::clone(&app_state.runtime); let runtime = Arc::clone(&app_state.runtime);
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
let tray_state = setup_tray(&app_handle, &runtime_core)?;
if let Ok(snapshot) = runtime_core.snapshot().read() { 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 command_rx = runtime_core.command_receiver();
let runtime_core_for_commands = Arc::clone(&runtime_core); let runtime_core_for_commands = Arc::clone(&runtime_core);
let app_handle_for_commands = app_handle.clone(); let app_handle_for_commands = app_handle.clone();
let tray_state_for_commands = tray_state.clone();
runtime.spawn(async move { runtime.spawn(async move {
loop { loop {
let next = { let next = {
@@ -178,7 +227,11 @@ fn main() -> Result<(), AppError> {
} }
}; };
if let Some(value) = payload { 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,
);
} }
} }
}); });
@@ -186,6 +239,7 @@ fn main() -> Result<(), AppError> {
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
let runtime_core = Arc::clone(&runtime_core); let runtime_core = Arc::clone(&runtime_core);
let app_handle = app_handle.clone(); let app_handle = app_handle.clone();
let tray_state_for_window = tray_state.clone();
window.on_window_event(move |event| { window.on_window_event(move |event| {
if let tauri::WindowEvent::Moved(position) = event { if let tauri::WindowEvent::Moved(position) = event {
let command = sprimo_protocol::v1::FrontendCommand::SetTransform { let command = sprimo_protocol::v1::FrontendCommand::SetTransform {
@@ -197,13 +251,34 @@ fn main() -> Result<(), AppError> {
}; };
if runtime_core.apply_command(&command).is_ok() { if runtime_core.apply_command(&command).is_ok() {
if let Ok(snapshot) = runtime_core.snapshot().read() { 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; let _ = app;
Ok(()) Ok(())
}) })
@@ -223,6 +298,211 @@ fn to_ui_snapshot(snapshot: &FrontendStateSnapshot) -> UiSnapshot {
} }
} }
fn setup_tray(
app_handle: &AppHandle<Wry>,
runtime_core: &RuntimeCore,
) -> Result<TrayMenuState, 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()))?;
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<Wry>,
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<Wry>,
) -> 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 { fn state_name(value: FrontendState) -> &'static str {
match value { match value {
FrontendState::Idle => "idle", FrontendState::Idle => "idle",

View File

@@ -39,6 +39,7 @@ recovery_hotkey = "Ctrl+Alt+P"
[frontend] [frontend]
backend = "bevy" backend = "bevy"
debug_overlay_visible = false
``` ```
## Notes ## Notes
@@ -48,3 +49,4 @@ backend = "bevy"
- `window.click_through` is deprecated and ignored at runtime; it is always forced to `false`. - `window.click_through` is deprecated and ignored at runtime; it is always forced to `false`.
- On Windows, `recovery_hotkey` now forces `visible = true` and `always_on_top = true` for recovery. - On Windows, `recovery_hotkey` now forces `visible = true` and `always_on_top = true` for recovery.
- `frontend.backend` selects runtime frontend implementation (`bevy` or `tauri`). - `frontend.backend` selects runtime frontend implementation (`bevy` or `tauri`).
- `frontend.debug_overlay_visible` controls whether tauri window debug diagnostics panel is shown.

View File

@@ -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 | | 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` | | 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 | | 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 updates now auto-fit window to avoid top clipping | | 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 | | Tauri runtime testing workflow | Implemented | `docs/TAURI_RUNTIME_TESTING.md` defines strict workspace testing; packaged mode pending packaging support |
## Next Major Gaps ## Next Major Gaps
@@ -28,4 +28,4 @@ Date: 2026-02-12
2. Linux/macOS overlay behavior remains best-effort with no-op platform adapter. 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. 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. 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.

View File

@@ -47,16 +47,22 @@ Frontend:
- Tauri backend exposes: - Tauri backend exposes:
- `current_state` command (structured snapshot DTO) - `current_state` command (structured snapshot DTO)
- `load_active_sprite_pack` command (manifest + atlas as base64 data URL) - `load_active_sprite_pack` command (manifest + atlas as base64 data URL)
- `debug_overlay_visible` / `set_debug_overlay_visible` commands for persisted debug panel control
- `runtime:snapshot` event after command application. - `runtime:snapshot` event after command application.
- React/Vite frontend now renders sprite atlas frames with PixiJS and updates animation/scale - React/Vite frontend now renders sprite atlas frames with PixiJS and updates animation/scale
from runtime snapshot events. from runtime snapshot events.
- Tauri window drag is implemented for undecorated mode: - Tauri window drag is implemented for undecorated mode:
- left-mouse drag starts native window dragging - left-mouse drag starts native window dragging
- moved position is synced into runtime-core snapshot/config state. - 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. - Bevy frontend remains intact.
- Runtime QA workflow is defined in `docs/TAURI_RUNTIME_TESTING.md`. - Runtime QA workflow is defined in `docs/TAURI_RUNTIME_TESTING.md`.
## Remaining Work ## 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). 2. Add frontend parity acceptance tests (Bevy vs Tauri state transitions).

View File

@@ -74,6 +74,16 @@ An issue touching Tauri runtime behaviors must satisfy all requirements before `
- left-mouse drag moves the window - left-mouse drag moves the window
- window remains non-resizable - window remains non-resizable
- moved position is reflected in runtime snapshot state (`x`, `y`) and persists after restart - moved position is reflected in runtime snapshot state (`x`, `y`) and persists after restart
9. Verify debug-overlay visibility control:
- 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 ## API + Runtime Contract Checklist
@@ -91,6 +101,7 @@ An issue touching Tauri runtime behaviors must satisfy all requirements before `
- `current_state` output parsed successfully. - `current_state` output parsed successfully.
- `load_active_sprite_pack` returns expected fields. - `load_active_sprite_pack` returns expected fields.
- `runtime:snapshot` event received on runtime command changes. - `runtime:snapshot` event received on runtime command changes.
- `debug_overlay_visible` and `set_debug_overlay_visible` invoke commands work and persist config.
## Evidence Requirements ## Evidence Requirements

View File

@@ -48,6 +48,7 @@ async function fitWindowForScale(pack: UiSpritePack, scale: number): Promise<voi
function App(): JSX.Element { function App(): JSX.Element {
const [snapshot, setSnapshot] = React.useState<UiSnapshot | null>(null); const [snapshot, setSnapshot] = React.useState<UiSnapshot | null>(null);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [debugOverlayVisible, setDebugOverlayVisible] = React.useState(false);
const hostRef = React.useRef<HTMLDivElement | null>(null); const hostRef = React.useRef<HTMLDivElement | null>(null);
const rendererRef = React.useRef<PixiPetRenderer | null>(null); const rendererRef = React.useRef<PixiPetRenderer | null>(null);
const scaleFitRef = React.useRef<number | null>(null); const scaleFitRef = React.useRef<number | null>(null);
@@ -58,13 +59,15 @@ function App(): JSX.Element {
let activePack: UiSpritePack | null = null; let activePack: UiSpritePack | null = null;
Promise.all([ Promise.all([
invoke<UiSpritePack>("load_active_sprite_pack"), invoke<UiSpritePack>("load_active_sprite_pack"),
invoke<UiSnapshot>("current_state") invoke<UiSnapshot>("current_state"),
invoke<boolean>("debug_overlay_visible")
]) ])
.then(async ([pack, initialSnapshot]) => { .then(async ([pack, initialSnapshot, showDebug]) => {
if (!mounted) { if (!mounted) {
return; return;
} }
activePack = pack; activePack = pack;
setDebugOverlayVisible(showDebug);
setSnapshot(initialSnapshot); setSnapshot(initialSnapshot);
if (hostRef.current !== null) { if (hostRef.current !== null) {
rendererRef.current = await PixiPetRenderer.create( rendererRef.current = await PixiPetRenderer.create(
@@ -99,6 +102,20 @@ function App(): JSX.Element {
} }
}); });
}); });
const unlistenDebug = await listen<boolean>(
"runtime:debug-overlay-visible",
(event) => {
if (!mounted) {
return;
}
setDebugOverlayVisible(Boolean(event.payload));
}
);
const previousUnlisten = unlisten;
unlisten = () => {
previousUnlisten();
unlistenDebug();
};
}) })
.catch((err) => { .catch((err) => {
if (mounted) { if (mounted) {
@@ -115,6 +132,32 @@ function App(): JSX.Element {
}; };
}, []); }, []);
const toggleDebugOverlay = React.useCallback(async () => {
try {
const next = !debugOverlayVisible;
const persisted = await invoke<boolean>("set_debug_overlay_visible", {
visible: next
});
setDebugOverlayVisible(persisted);
} catch (err) {
setError(String(err));
}
}, [debugOverlayVisible]);
React.useEffect(() => {
const onKeyDown = (event: KeyboardEvent): void => {
if (!event.ctrlKey || !event.shiftKey || event.code !== "KeyD") {
return;
}
event.preventDefault();
void toggleDebugOverlay();
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [toggleDebugOverlay]);
const onMouseDown = React.useCallback((event: React.MouseEvent<HTMLElement>) => { const onMouseDown = React.useCallback((event: React.MouseEvent<HTMLElement>) => {
if (event.button !== 0) { if (event.button !== 0) {
return; return;
@@ -127,8 +170,11 @@ function App(): JSX.Element {
return ( return (
<main className="app" onMouseDown={onMouseDown}> <main className="app" onMouseDown={onMouseDown}>
<div className="canvas-host" ref={hostRef} /> <div className="canvas-host" ref={hostRef} />
{error !== null && !debugOverlayVisible ? <p className="error-banner">{error}</p> : null}
{debugOverlayVisible ? (
<section className="debug-panel"> <section className="debug-panel">
<h1>sprimo-tauri</h1> <h1>sprimo-tauri</h1>
<p className="hint">Toggle: Ctrl+Shift+D</p>
{error !== null ? <p className="error">{error}</p> : null} {error !== null ? <p className="error">{error}</p> : null}
{snapshot === null ? ( {snapshot === null ? (
<p>Loading snapshot...</p> <p>Loading snapshot...</p>
@@ -149,6 +195,7 @@ function App(): JSX.Element {
</dl> </dl>
)} )}
</section> </section>
) : null}
</main> </main>
); );
} }

View File

@@ -53,3 +53,23 @@ dd {
.error { .error {
color: #fecaca; color: #fecaca;
} }
.hint {
margin: 0 0 8px;
font-size: 12px;
opacity: 0.8;
}
.error-banner {
position: absolute;
top: 8px;
left: 8px;
max-width: 320px;
margin: 0;
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(127, 29, 29, 0.75);
border-radius: 8px;
color: #fee2e2;
font-size: 12px;
}