MVP tauri frontend - Windows #1
35
assets/sprite-packs/ferris/manifest.json
Normal file
35
assets/sprite-packs/ferris/manifest.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"id": "ferris",
|
||||
"version": "1",
|
||||
"image": "sprite.png",
|
||||
"frame_width": 512,
|
||||
"frame_height": 512,
|
||||
"animations": [
|
||||
{
|
||||
"name": "idle",
|
||||
"fps": 6,
|
||||
"frames": [0, 1]
|
||||
},
|
||||
{
|
||||
"name": "active",
|
||||
"fps": 10,
|
||||
"frames": [1, 0]
|
||||
},
|
||||
{
|
||||
"name": "success",
|
||||
"fps": 10,
|
||||
"frames": [0, 1, 0],
|
||||
"one_shot": true
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"fps": 8,
|
||||
"frames": [1, 0, 1],
|
||||
"one_shot": true
|
||||
}
|
||||
],
|
||||
"anchor": {
|
||||
"x": 0.5,
|
||||
"y": 1.0
|
||||
}
|
||||
}
|
||||
BIN
assets/sprite-packs/ferris/sprite.png
Normal file
BIN
assets/sprite-packs/ferris/sprite.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 MiB |
@@ -1,24 +1,30 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use sprimo_platform::{PlatformAdapter, create_adapter};
|
||||
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 sprimo_sprite::{AnimationDefinition, SpritePackManifest, load_manifest, resolve_pack_path};
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
use tauri::menu::{CheckMenuItem, Menu, MenuItem};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
use tauri::{AppHandle, Emitter, Manager, Wry};
|
||||
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, Wry};
|
||||
use thiserror::Error;
|
||||
use tokio::runtime::Runtime;
|
||||
use tracing::warn;
|
||||
|
||||
const APP_NAME: &str = "sprimo";
|
||||
const DEFAULT_PACK: &str = "default";
|
||||
const SPRITE_PNG_FILE: &str = "sprite.png";
|
||||
const SPRITE_GRID_COLUMNS: u32 = 8;
|
||||
const SPRITE_GRID_ROWS: u32 = 7;
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
const SETTINGS_WINDOW_LABEL: &str = "settings";
|
||||
const TRAY_ID: &str = "main";
|
||||
const MENU_ID_SETTINGS: &str = "settings";
|
||||
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";
|
||||
@@ -50,6 +56,12 @@ struct UiSpritePack {
|
||||
anchor: UiAnchor,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct AtlasGeometry {
|
||||
frame_width: u32,
|
||||
frame_height: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
struct UiSnapshot {
|
||||
state: String,
|
||||
@@ -58,6 +70,22 @@ struct UiSnapshot {
|
||||
y: f32,
|
||||
scale: f32,
|
||||
active_sprite_pack: String,
|
||||
visible: bool,
|
||||
always_on_top: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
struct UiSettingsSnapshot {
|
||||
active_sprite_pack: String,
|
||||
scale: f32,
|
||||
visible: bool,
|
||||
always_on_top: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
struct UiSpritePackOption {
|
||||
id: String,
|
||||
pack_id_or_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -74,6 +102,7 @@ enum AppError {
|
||||
struct AppState {
|
||||
runtime_core: Arc<RuntimeCore>,
|
||||
runtime: Arc<Runtime>,
|
||||
tray_state: Arc<std::sync::Mutex<Option<TrayMenuState>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -96,6 +125,7 @@ fn current_state(state: tauri::State<'_, AppState>) -> Result<UiSnapshot, String
|
||||
|
||||
#[tauri::command]
|
||||
fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSpritePack, String> {
|
||||
let root = sprite_pack_root(state.runtime_core.as_ref())?;
|
||||
let config = state
|
||||
.runtime_core
|
||||
.config()
|
||||
@@ -103,11 +133,6 @@ fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSprite
|
||||
.map_err(|_| "config lock poisoned".to_string())?
|
||||
.clone();
|
||||
|
||||
let root = std::env::current_dir()
|
||||
.map_err(|err| err.to_string())?
|
||||
.join("assets")
|
||||
.join(config.sprite.sprite_packs_dir);
|
||||
|
||||
let selected = config.sprite.selected_pack;
|
||||
let pack_path = match resolve_pack_path(&root, &selected) {
|
||||
Ok(path) => path,
|
||||
@@ -117,6 +142,8 @@ fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSprite
|
||||
let manifest = load_manifest(&pack_path).map_err(|err| err.to_string())?;
|
||||
let image_path = pack_path.join(&manifest.image);
|
||||
let image_bytes = std::fs::read(&image_path).map_err(|err| err.to_string())?;
|
||||
let geometry = atlas_geometry_for_manifest(&manifest, &image_bytes)?;
|
||||
validate_animation_frames(&manifest)?;
|
||||
let atlas_data_url = format!(
|
||||
"data:image/png;base64,{}",
|
||||
BASE64_STANDARD.encode(image_bytes)
|
||||
@@ -124,14 +151,10 @@ fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSprite
|
||||
|
||||
Ok(UiSpritePack {
|
||||
id: manifest.id,
|
||||
frame_width: manifest.frame_width,
|
||||
frame_height: manifest.frame_height,
|
||||
frame_width: geometry.frame_width,
|
||||
frame_height: geometry.frame_height,
|
||||
atlas_data_url,
|
||||
animations: manifest
|
||||
.animations
|
||||
.into_iter()
|
||||
.map(to_ui_clip)
|
||||
.collect(),
|
||||
animations: manifest.animations.into_iter().map(to_ui_clip).collect(),
|
||||
anchor: UiAnchor {
|
||||
x: manifest.anchor.x,
|
||||
y: manifest.anchor.y,
|
||||
@@ -147,6 +170,139 @@ fn debug_overlay_visible(state: tauri::State<'_, AppState>) -> Result<bool, Stri
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn settings_snapshot(state: tauri::State<'_, AppState>) -> Result<UiSettingsSnapshot, String> {
|
||||
let snapshot = state
|
||||
.runtime_core
|
||||
.snapshot()
|
||||
.read()
|
||||
.map_err(|_| "snapshot lock poisoned".to_string())?
|
||||
.clone();
|
||||
Ok(UiSettingsSnapshot {
|
||||
active_sprite_pack: snapshot.active_sprite_pack,
|
||||
scale: snapshot.scale,
|
||||
visible: snapshot.flags.visible,
|
||||
always_on_top: snapshot.flags.always_on_top,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_sprite_packs(state: tauri::State<'_, AppState>) -> Result<Vec<UiSpritePackOption>, String> {
|
||||
let root = sprite_pack_root(state.runtime_core.as_ref())?;
|
||||
let mut packs = Vec::new();
|
||||
|
||||
let entries = fs::read_dir(root).map_err(|err| err.to_string())?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|err| err.to_string())?;
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if load_manifest(&path).is_err() {
|
||||
continue;
|
||||
}
|
||||
let Some(dir_name) = path.file_name().and_then(|s| s.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
packs.push(UiSpritePackOption {
|
||||
id: dir_name.to_string(),
|
||||
pack_id_or_path: dir_name.to_string(),
|
||||
});
|
||||
}
|
||||
packs.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
Ok(packs)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_sprite_pack(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
pack_id_or_path: String,
|
||||
) -> Result<UiSnapshot, String> {
|
||||
let root = sprite_pack_root(state.runtime_core.as_ref())?;
|
||||
let pack_path = resolve_pack_path(&root, &pack_id_or_path).map_err(|err| err.to_string())?;
|
||||
let manifest = load_manifest(&pack_path).map_err(|err| err.to_string())?;
|
||||
let image_path = pack_path.join(&manifest.image);
|
||||
let image_bytes = fs::read(&image_path).map_err(|err| err.to_string())?;
|
||||
let _geometry = atlas_geometry_for_manifest(&manifest, &image_bytes)?;
|
||||
validate_animation_frames(&manifest)?;
|
||||
|
||||
state
|
||||
.runtime_core
|
||||
.apply_command(&FrontendCommand::SetSpritePack { pack_id_or_path })
|
||||
.map_err(|err| err.to_string())?;
|
||||
emit_ui_refresh(state.inner(), &app_handle)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_scale(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
scale: f32,
|
||||
) -> Result<UiSnapshot, String> {
|
||||
if !scale.is_finite() || scale <= 0.0 {
|
||||
return Err("scale must be a positive finite number".to_string());
|
||||
}
|
||||
state
|
||||
.runtime_core
|
||||
.apply_command(&FrontendCommand::SetTransform {
|
||||
x: None,
|
||||
y: None,
|
||||
anchor: None,
|
||||
scale: Some(scale),
|
||||
opacity: None,
|
||||
})
|
||||
.map_err(|err| err.to_string())?;
|
||||
emit_ui_refresh(state.inner(), &app_handle)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_visibility(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
visible: bool,
|
||||
) -> Result<UiSnapshot, String> {
|
||||
state
|
||||
.runtime_core
|
||||
.apply_command(&FrontendCommand::SetFlags {
|
||||
click_through: None,
|
||||
always_on_top: None,
|
||||
visible: Some(visible),
|
||||
})
|
||||
.map_err(|err| err.to_string())?;
|
||||
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
if visible {
|
||||
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())?;
|
||||
}
|
||||
}
|
||||
emit_ui_refresh(state.inner(), &app_handle)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_always_on_top(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
always_on_top: bool,
|
||||
) -> Result<UiSnapshot, String> {
|
||||
state
|
||||
.runtime_core
|
||||
.apply_command(&FrontendCommand::SetFlags {
|
||||
click_through: None,
|
||||
always_on_top: Some(always_on_top),
|
||||
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(always_on_top)
|
||||
.map_err(|err| err.to_string())?;
|
||||
}
|
||||
emit_ui_refresh(state.inner(), &app_handle)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_debug_overlay_visible(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -158,6 +314,11 @@ fn set_debug_overlay_visible(
|
||||
.set_frontend_debug_overlay_visible(visible)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let _ = emit_debug_overlay_visibility(state.runtime_core.as_ref(), &app_handle);
|
||||
if let Ok(guard) = state.tray_state.lock() {
|
||||
if let Some(tray_state) = guard.as_ref() {
|
||||
let _ = refresh_tray_menu_state(state.runtime_core.as_ref(), tray_state);
|
||||
}
|
||||
}
|
||||
Ok(visible)
|
||||
}
|
||||
|
||||
@@ -176,6 +337,7 @@ fn main() -> Result<(), AppError> {
|
||||
let state = AppState {
|
||||
runtime_core: Arc::clone(&runtime_core),
|
||||
runtime: Arc::clone(&runtime),
|
||||
tray_state: Arc::new(std::sync::Mutex::new(None)),
|
||||
};
|
||||
|
||||
tauri::Builder::default()
|
||||
@@ -185,15 +347,25 @@ fn main() -> Result<(), AppError> {
|
||||
current_state,
|
||||
load_active_sprite_pack,
|
||||
debug_overlay_visible,
|
||||
set_debug_overlay_visible
|
||||
set_debug_overlay_visible,
|
||||
settings_snapshot,
|
||||
list_sprite_packs,
|
||||
set_sprite_pack,
|
||||
set_scale,
|
||||
set_visibility,
|
||||
set_always_on_top
|
||||
])
|
||||
.setup(|app| {
|
||||
let app_state: tauri::State<'_, AppState> = app.state();
|
||||
let runtime_core = Arc::clone(&app_state.runtime_core);
|
||||
let runtime = Arc::clone(&app_state.runtime);
|
||||
let tray_state_holder = Arc::clone(&app_state.tray_state);
|
||||
let app_handle = app.handle().clone();
|
||||
|
||||
let tray_state = setup_tray(&app_handle, &runtime_core)?;
|
||||
if let Ok(mut guard) = tray_state_holder.lock() {
|
||||
*guard = Some(tray_state.clone());
|
||||
}
|
||||
if let Ok(snapshot) = runtime_core.snapshot().read() {
|
||||
let _ = app_handle.emit(EVENT_RUNTIME_SNAPSHOT, to_ui_snapshot(&snapshot));
|
||||
}
|
||||
@@ -251,10 +423,8 @@ fn main() -> Result<(), AppError> {
|
||||
};
|
||||
if runtime_core.apply_command(&command).is_ok() {
|
||||
if let Ok(snapshot) = runtime_core.snapshot().read() {
|
||||
let _ = app_handle.emit(
|
||||
EVENT_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(),
|
||||
@@ -295,9 +465,46 @@ fn to_ui_snapshot(snapshot: &FrontendStateSnapshot) -> UiSnapshot {
|
||||
y: snapshot.y,
|
||||
scale: snapshot.scale,
|
||||
active_sprite_pack: snapshot.active_sprite_pack.clone(),
|
||||
visible: snapshot.flags.visible,
|
||||
always_on_top: snapshot.flags.always_on_top,
|
||||
}
|
||||
}
|
||||
|
||||
fn sprite_pack_root(runtime_core: &RuntimeCore) -> Result<std::path::PathBuf, String> {
|
||||
let sprite_packs_dir = runtime_core
|
||||
.config()
|
||||
.read()
|
||||
.map_err(|_| "config lock poisoned".to_string())?
|
||||
.sprite
|
||||
.sprite_packs_dir
|
||||
.clone();
|
||||
let root = std::env::current_dir()
|
||||
.map_err(|err| err.to_string())?
|
||||
.join("assets")
|
||||
.join(sprite_packs_dir);
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
fn emit_ui_refresh(state: &AppState, app_handle: &AppHandle<Wry>) -> Result<UiSnapshot, String> {
|
||||
let snapshot = state
|
||||
.runtime_core
|
||||
.snapshot()
|
||||
.read()
|
||||
.map_err(|_| "snapshot lock poisoned".to_string())?
|
||||
.clone();
|
||||
let ui_snapshot = to_ui_snapshot(&snapshot);
|
||||
app_handle
|
||||
.emit(EVENT_RUNTIME_SNAPSHOT, ui_snapshot.clone())
|
||||
.map_err(|err| err.to_string())?;
|
||||
let _ = emit_debug_overlay_visibility(state.runtime_core.as_ref(), app_handle);
|
||||
if let Ok(guard) = state.tray_state.lock() {
|
||||
if let Some(tray_state) = guard.as_ref() {
|
||||
let _ = refresh_tray_menu_state(state.runtime_core.as_ref(), tray_state);
|
||||
}
|
||||
}
|
||||
Ok(ui_snapshot)
|
||||
}
|
||||
|
||||
fn setup_tray(
|
||||
app_handle: &AppHandle<Wry>,
|
||||
runtime_core: &RuntimeCore,
|
||||
@@ -311,6 +518,7 @@ fn setup_tray(
|
||||
.frontend_debug_overlay_visible()
|
||||
.map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?;
|
||||
|
||||
let settings = MenuItem::with_id(app_handle, MENU_ID_SETTINGS, "Settings", true, None::<&str>)?;
|
||||
let toggle_visibility = MenuItem::with_id(
|
||||
app_handle,
|
||||
MENU_ID_TOGGLE_VISIBILITY,
|
||||
@@ -338,6 +546,7 @@ fn setup_tray(
|
||||
let menu = Menu::with_items(
|
||||
app_handle,
|
||||
&[
|
||||
&settings,
|
||||
&toggle_visibility,
|
||||
&toggle_always_on_top,
|
||||
&toggle_debug_overlay,
|
||||
@@ -368,6 +577,10 @@ fn handle_menu_event(
|
||||
menu_id: &str,
|
||||
) -> Result<(), String> {
|
||||
match menu_id {
|
||||
MENU_ID_SETTINGS => {
|
||||
open_settings_window(app_handle).map_err(|err| err.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
MENU_ID_TOGGLE_VISIBILITY => {
|
||||
let current = runtime_core
|
||||
.snapshot()
|
||||
@@ -408,7 +621,9 @@ fn handle_menu_event(
|
||||
})
|
||||
.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())?;
|
||||
window
|
||||
.set_always_on_top(next)
|
||||
.map_err(|err| err.to_string())?;
|
||||
}
|
||||
}
|
||||
MENU_ID_TOGGLE_DEBUG_OVERLAY => {
|
||||
@@ -419,7 +634,8 @@ fn handle_menu_event(
|
||||
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())?;
|
||||
emit_debug_overlay_visibility(runtime_core, app_handle)
|
||||
.map_err(|err| err.to_string())?;
|
||||
}
|
||||
MENU_ID_QUIT => {
|
||||
persist_current_ui_flags(runtime_core)?;
|
||||
@@ -436,6 +652,30 @@ fn handle_menu_event(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_settings_window(app_handle: &AppHandle<Wry>) -> Result<(), tauri::Error> {
|
||||
if let Some(window) = app_handle.get_webview_window(SETTINGS_WINDOW_LABEL) {
|
||||
window.show()?;
|
||||
window.set_focus()?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let window = WebviewWindowBuilder::new(
|
||||
app_handle,
|
||||
SETTINGS_WINDOW_LABEL,
|
||||
WebviewUrl::App("index.html".into()),
|
||||
)
|
||||
.title("sprimo settings")
|
||||
.inner_size(420.0, 520.0)
|
||||
.resizable(true)
|
||||
.decorations(true)
|
||||
.transparent(false)
|
||||
.always_on_top(false)
|
||||
.visible(true)
|
||||
.build()?;
|
||||
window.set_focus()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_current_ui_flags(runtime_core: &RuntimeCore) -> Result<(), String> {
|
||||
let snapshot = runtime_core
|
||||
.snapshot()
|
||||
@@ -496,11 +736,7 @@ fn refresh_tray_menu_state(
|
||||
}
|
||||
|
||||
fn visibility_menu_title(visible: bool) -> &'static str {
|
||||
if visible {
|
||||
"Hide"
|
||||
} else {
|
||||
"Show"
|
||||
}
|
||||
if visible { "Hide" } else { "Show" }
|
||||
}
|
||||
|
||||
fn state_name(value: FrontendState) -> &'static str {
|
||||
@@ -522,3 +758,95 @@ fn to_ui_clip(value: AnimationDefinition) -> UiAnimationClip {
|
||||
one_shot: value.one_shot.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn atlas_geometry_for_manifest(
|
||||
manifest: &SpritePackManifest,
|
||||
image_bytes: &[u8],
|
||||
) -> Result<AtlasGeometry, String> {
|
||||
let (image_width, image_height) = decode_png_dimensions(image_bytes)?;
|
||||
if image_width == 0 || image_height == 0 {
|
||||
return Err("atlas image dimensions must be non-zero".to_string());
|
||||
}
|
||||
|
||||
if manifest.image.eq_ignore_ascii_case(SPRITE_PNG_FILE) {
|
||||
let frame_width = image_width / SPRITE_GRID_COLUMNS;
|
||||
let frame_height = image_height / SPRITE_GRID_ROWS;
|
||||
if frame_width == 0 || frame_height == 0 {
|
||||
return Err(format!(
|
||||
"sprite atlas too small for {}x{} grid: {}x{}",
|
||||
SPRITE_GRID_COLUMNS, SPRITE_GRID_ROWS, image_width, image_height
|
||||
));
|
||||
}
|
||||
return Ok(AtlasGeometry {
|
||||
frame_width,
|
||||
frame_height,
|
||||
});
|
||||
}
|
||||
|
||||
if manifest.frame_width == 0 || manifest.frame_height == 0 {
|
||||
return Err("manifest frame dimensions must be non-zero".to_string());
|
||||
}
|
||||
Ok(AtlasGeometry {
|
||||
frame_width: manifest.frame_width,
|
||||
frame_height: manifest.frame_height,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_animation_frames(manifest: &SpritePackManifest) -> Result<(), String> {
|
||||
let total_frames = if manifest.image.eq_ignore_ascii_case(SPRITE_PNG_FILE) {
|
||||
SPRITE_GRID_COLUMNS
|
||||
.checked_mul(SPRITE_GRID_ROWS)
|
||||
.ok_or_else(|| "sprite grid frame count overflow".to_string())?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if total_frames == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for clip in &manifest.animations {
|
||||
for &index in &clip.frames {
|
||||
if index >= total_frames {
|
||||
return Err(format!(
|
||||
"animation '{}' references frame {} but max frame index is {}",
|
||||
clip.name,
|
||||
index,
|
||||
total_frames.saturating_sub(1)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode_png_dimensions(image_bytes: &[u8]) -> Result<(u32, u32), String> {
|
||||
const PNG_SIGNATURE_LEN: usize = 8;
|
||||
const PNG_IHDR_TOTAL_LEN: usize = 33;
|
||||
const IHDR_TYPE_OFFSET: usize = 12;
|
||||
const IHDR_DATA_OFFSET: usize = 16;
|
||||
const IHDR_WIDTH_OFFSET: usize = 16;
|
||||
const IHDR_HEIGHT_OFFSET: usize = 20;
|
||||
|
||||
if image_bytes.len() < PNG_IHDR_TOTAL_LEN {
|
||||
return Err("atlas image is too small to be a valid PNG".to_string());
|
||||
}
|
||||
let expected_signature: [u8; PNG_SIGNATURE_LEN] = [137, 80, 78, 71, 13, 10, 26, 10];
|
||||
if image_bytes[..PNG_SIGNATURE_LEN] != expected_signature {
|
||||
return Err("atlas image must be PNG format".to_string());
|
||||
}
|
||||
if &image_bytes[IHDR_TYPE_OFFSET..IHDR_DATA_OFFSET] != b"IHDR" {
|
||||
return Err("atlas PNG missing IHDR chunk".to_string());
|
||||
}
|
||||
let width = u32::from_be_bytes(
|
||||
image_bytes[IHDR_WIDTH_OFFSET..IHDR_WIDTH_OFFSET + 4]
|
||||
.try_into()
|
||||
.map_err(|_| "failed to decode PNG width".to_string())?,
|
||||
);
|
||||
let height = u32::from_be_bytes(
|
||||
image_bytes[IHDR_HEIGHT_OFFSET..IHDR_HEIGHT_OFFSET + 4]
|
||||
.try_into()
|
||||
.map_err(|_| "failed to decode PNG height".to_string())?,
|
||||
);
|
||||
Ok((width, height))
|
||||
}
|
||||
|
||||
@@ -19,12 +19,12 @@ 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, persisted debug-overlay toggle, and Windows-first tray/menu MVP are implemented |
|
||||
| Tauri alternative frontend | In progress | `sprimo-tauri` now runs runtime-core/API + PixiJS sprite rendering shell; scale auto-fit, pop-out settings window (character/scale/visibility/always-on-top), 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
|
||||
|
||||
1. Tray/menu controls are still not implemented.
|
||||
1. Tauri tray/menu behavior still needs Linux/macOS parity validation beyond Windows-first implementation.
|
||||
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.
|
||||
|
||||
@@ -125,6 +125,15 @@ Bevy window-management verification workflow: `docs/BEVY_WINDOW_VERIFICATION.md`
|
||||
9. Verify scale-fit behavior in tauri runtime:
|
||||
- send `SetTransform.scale` values above `1.0`
|
||||
- confirm full sprite remains visible and window auto-resizes without top clipping
|
||||
10. Verify pop-out settings window behavior:
|
||||
- open via tray `Settings`
|
||||
- switch character and confirm immediate renderer reload + persistence after restart
|
||||
- change scale slider and confirm runtime resize + persistence
|
||||
- toggle `Visible` and `Always on top` and confirm both runtime behavior + persistence
|
||||
11. Verify packaged tauri reload stability:
|
||||
- run repeated character switch cycles (`default` <-> `ferris`) and move scale slider each cycle
|
||||
- ensure no runtime frontend exception is shown (debug overlay/console)
|
||||
- ensure no visible magenta fringe remains around sprite edges after chroma-key conversion
|
||||
|
||||
### Packaged Mode (Required Once Tauri Packaging Exists)
|
||||
|
||||
|
||||
@@ -64,3 +64,14 @@ Path: `<pack_dir>/manifest.json`
|
||||
- `rows = image_height / frame_height`
|
||||
- Image dimensions must be divisible by frame dimensions.
|
||||
- Every animation frame index must be `< columns * rows`.
|
||||
|
||||
## Tauri `sprite.png` Override
|
||||
|
||||
For `sprimo-tauri`, when manifest `image` is exactly `sprite.png`:
|
||||
|
||||
- runtime uses a fixed grid topology of `8` columns x `7` rows.
|
||||
- frame size is derived from actual image dimensions:
|
||||
- `frame_width = image_width / 8`
|
||||
- `frame_height = image_height / 7`
|
||||
- manifest `frame_width` and `frame_height` are ignored for this case.
|
||||
- animation frame indices are validated against the fixed grid frame count (`56`).
|
||||
|
||||
@@ -47,14 +47,27 @@ Frontend:
|
||||
- Tauri backend exposes:
|
||||
- `current_state` command (structured snapshot DTO)
|
||||
- `load_active_sprite_pack` command (manifest + atlas as base64 data URL)
|
||||
- settings commands:
|
||||
- `settings_snapshot`
|
||||
- `list_sprite_packs`
|
||||
- `set_sprite_pack`
|
||||
- `set_scale`
|
||||
- `set_visibility`
|
||||
- `set_always_on_top`
|
||||
- `debug_overlay_visible` / `set_debug_overlay_visible` commands for persisted debug panel control
|
||||
- `runtime:snapshot` event after command application.
|
||||
- React/Vite frontend now renders sprite atlas frames with PixiJS and updates animation/scale
|
||||
from runtime snapshot events.
|
||||
- For `sprite.png` packs in tauri runtime, frame size is now derived from atlas dimensions with a
|
||||
fixed `8x7` grid topology to keep splitting stable across packaged asset resolutions.
|
||||
- React/Vite frontend now supports two window modes:
|
||||
- `main`: transparent overlay sprite renderer
|
||||
- `settings`: pop-out settings window for character and window controls
|
||||
- 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:
|
||||
- `Settings` (opens/focuses pop-out settings window)
|
||||
- `Show/Hide`
|
||||
- `Always on top` toggle
|
||||
- `Debug overlay` toggle
|
||||
@@ -66,3 +79,4 @@ Frontend:
|
||||
|
||||
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).
|
||||
3. Add sprite-pack previews/thumbnails in the settings window character selector.
|
||||
|
||||
@@ -100,8 +100,38 @@ An issue touching Tauri runtime behaviors must satisfy all requirements before `
|
||||
6. Validate Tauri invoke/event behavior:
|
||||
- `current_state` output parsed successfully.
|
||||
- `load_active_sprite_pack` returns expected fields.
|
||||
- `settings_snapshot` returns valid persisted settings payload.
|
||||
- `list_sprite_packs` returns valid manifest-backed pack options.
|
||||
- `set_sprite_pack` changes active pack and persists.
|
||||
- `set_scale` updates scale and persists.
|
||||
- `set_visibility` updates main window visibility and persists.
|
||||
- `set_always_on_top` updates top-most behavior and persists.
|
||||
- `runtime:snapshot` event received on runtime command changes.
|
||||
- `debug_overlay_visible` and `set_debug_overlay_visible` invoke commands work and persist config.
|
||||
7. Stress runtime reload stability:
|
||||
- perform at least 10 cycles of character switch (`default` <-> `ferris`) with scale adjustments
|
||||
- no frontend runtime exception (including `TypeError`) is allowed
|
||||
- scaling behavior remains responsive after each pack switch
|
||||
8. Chroma-key quality check:
|
||||
- verify no visible magenta (`#FF00FF`) fringe remains around sprite edges in normal runtime view
|
||||
|
||||
## Settings Window Checklist
|
||||
|
||||
1. Open settings from tray `Settings` item.
|
||||
2. Confirm repeated tray clicks focus existing settings window instead of creating duplicates.
|
||||
3. Change character in settings and verify:
|
||||
- active pack changes immediately in main overlay
|
||||
- selection persists after restart
|
||||
4. Change scale via slider and verify:
|
||||
- runtime scale changes immediately
|
||||
- main overlay auto-fits without clipping
|
||||
- value persists after restart
|
||||
5. Toggle `Visible` and verify:
|
||||
- main overlay hide/show behavior
|
||||
- persisted value survives restart
|
||||
6. Toggle `Always on top` and verify:
|
||||
- main window z-order behavior updates
|
||||
- persisted value survives restart
|
||||
|
||||
## Evidence Requirements
|
||||
|
||||
|
||||
@@ -6,11 +6,39 @@ import { LogicalPosition, LogicalSize, getCurrentWindow } from "@tauri-apps/api/
|
||||
import { PixiPetRenderer, type UiSpritePack, type UiSnapshot } from "./renderer/pixi_pet";
|
||||
import "./styles.css";
|
||||
|
||||
type UiSettingsSnapshot = {
|
||||
active_sprite_pack: string;
|
||||
scale: number;
|
||||
visible: boolean;
|
||||
always_on_top: boolean;
|
||||
};
|
||||
|
||||
type UiSpritePackOption = {
|
||||
id: string;
|
||||
pack_id_or_path: string;
|
||||
};
|
||||
|
||||
const WINDOW_PADDING = 16;
|
||||
const MIN_WINDOW_SIZE = 64;
|
||||
const SIZE_EPSILON = 0.5;
|
||||
const SCALE_EPSILON = 0.0001;
|
||||
|
||||
async function invokeSetSpritePack(packIdOrPath: string): Promise<UiSnapshot> {
|
||||
return invoke<UiSnapshot>("set_sprite_pack", { packIdOrPath });
|
||||
}
|
||||
|
||||
async function invokeSetScale(scale: number): Promise<UiSnapshot> {
|
||||
return invoke<UiSnapshot>("set_scale", { scale });
|
||||
}
|
||||
|
||||
async function invokeSetVisibility(visible: boolean): Promise<UiSnapshot> {
|
||||
return invoke<UiSnapshot>("set_visibility", { visible });
|
||||
}
|
||||
|
||||
async function invokeSetAlwaysOnTop(alwaysOnTop: boolean): Promise<UiSnapshot> {
|
||||
return invoke<UiSnapshot>("set_always_on_top", { alwaysOnTop });
|
||||
}
|
||||
|
||||
function fittedWindowSize(
|
||||
frameWidth: number,
|
||||
frameHeight: number,
|
||||
@@ -24,10 +52,7 @@ function fittedWindowSize(
|
||||
|
||||
async function fitWindowForScale(pack: UiSpritePack, scale: number): Promise<void> {
|
||||
const window = getCurrentWindow();
|
||||
const [outerPosition, innerSize] = await Promise.all([
|
||||
window.outerPosition(),
|
||||
window.innerSize()
|
||||
]);
|
||||
const [outerPosition, innerSize] = await Promise.all([window.outerPosition(), window.innerSize()]);
|
||||
|
||||
const target = fittedWindowSize(pack.frame_width, pack.frame_height, scale);
|
||||
const widthChanged = Math.abs(target.width - innerSize.width) > SIZE_EPSILON;
|
||||
@@ -45,48 +70,84 @@ async function fitWindowForScale(pack: UiSpritePack, scale: number): Promise<voi
|
||||
await window.setPosition(new LogicalPosition(targetX, targetY));
|
||||
}
|
||||
|
||||
function App(): JSX.Element {
|
||||
function MainOverlayWindow(): JSX.Element {
|
||||
const [snapshot, setSnapshot] = React.useState<UiSnapshot | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [debugOverlayVisible, setDebugOverlayVisible] = React.useState(false);
|
||||
const hostRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const rendererRef = React.useRef<PixiPetRenderer | null>(null);
|
||||
const activePackRef = React.useRef<UiSpritePack | null>(null);
|
||||
const loadedPackKeyRef = React.useRef<string | null>(null);
|
||||
const scaleFitRef = React.useRef<number | null>(null);
|
||||
const loadingPackRef = React.useRef(false);
|
||||
const mountedRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
let unlisten: null | (() => void) = null;
|
||||
let mounted = true;
|
||||
let activePack: UiSpritePack | null = null;
|
||||
Promise.all([
|
||||
invoke<UiSpritePack>("load_active_sprite_pack"),
|
||||
invoke<UiSnapshot>("current_state"),
|
||||
invoke<boolean>("debug_overlay_visible")
|
||||
])
|
||||
.then(async ([pack, initialSnapshot, showDebug]) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
activePack = pack;
|
||||
setDebugOverlayVisible(showDebug);
|
||||
setSnapshot(initialSnapshot);
|
||||
if (hostRef.current !== null) {
|
||||
rendererRef.current = await PixiPetRenderer.create(
|
||||
hostRef.current,
|
||||
pack,
|
||||
initialSnapshot
|
||||
);
|
||||
}
|
||||
scaleFitRef.current = initialSnapshot.scale;
|
||||
await fitWindowForScale(pack, initialSnapshot.scale);
|
||||
|
||||
unlisten = await listen<UiSnapshot>("runtime:snapshot", (event) => {
|
||||
if (!mounted) {
|
||||
const recreateRenderer = async (
|
||||
pack: UiSpritePack,
|
||||
nextSnapshot: UiSnapshot
|
||||
): Promise<boolean> => {
|
||||
if (!mountedRef.current || hostRef.current === null) {
|
||||
return false;
|
||||
}
|
||||
const previousRenderer = rendererRef.current;
|
||||
const nextRenderer = await PixiPetRenderer.create(hostRef.current, pack, nextSnapshot);
|
||||
rendererRef.current = nextRenderer;
|
||||
activePackRef.current = pack;
|
||||
loadedPackKeyRef.current = nextSnapshot.active_sprite_pack;
|
||||
previousRenderer?.dispose();
|
||||
return true;
|
||||
};
|
||||
|
||||
const tryFitWindow = async (pack: UiSpritePack, scale: number): Promise<boolean> => {
|
||||
try {
|
||||
await fitWindowForScale(pack, scale);
|
||||
scaleFitRef.current = scale;
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
setError(String(err));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const processSnapshot = async (value: UiSnapshot): Promise<void> => {
|
||||
if (!mountedRef.current) {
|
||||
return;
|
||||
}
|
||||
const value = event.payload;
|
||||
setSnapshot(value);
|
||||
rendererRef.current?.applySnapshot(value);
|
||||
if (activePack === null) {
|
||||
|
||||
const activePack = activePackRef.current;
|
||||
const needsReload =
|
||||
activePack === null || loadedPackKeyRef.current !== value.active_sprite_pack;
|
||||
if (needsReload && !loadingPackRef.current) {
|
||||
loadingPackRef.current = true;
|
||||
let reloaded = false;
|
||||
try {
|
||||
const pack = await invoke<UiSpritePack>("load_active_sprite_pack");
|
||||
reloaded = await recreateRenderer(pack, value);
|
||||
if (reloaded) {
|
||||
await tryFitWindow(pack, value.scale);
|
||||
if (mountedRef.current) {
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
console.error("reload_pack_failed", err);
|
||||
setError(String(err));
|
||||
}
|
||||
} finally {
|
||||
loadingPackRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (activePackRef.current === null) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
@@ -95,35 +156,47 @@ function App(): JSX.Element {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
scaleFitRef.current = value.scale;
|
||||
void fitWindowForScale(activePack, value.scale).catch((err) => {
|
||||
if (mounted) {
|
||||
setError(String(err));
|
||||
const fitApplied = await tryFitWindow(activePackRef.current, value.scale);
|
||||
if (fitApplied && mountedRef.current) {
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
Promise.all([
|
||||
invoke<UiSpritePack>("load_active_sprite_pack"),
|
||||
invoke<UiSnapshot>("current_state"),
|
||||
invoke<boolean>("debug_overlay_visible")
|
||||
])
|
||||
.then(async ([pack, initialSnapshot, showDebug]) => {
|
||||
if (!mountedRef.current) {
|
||||
return;
|
||||
}
|
||||
setDebugOverlayVisible(showDebug);
|
||||
await recreateRenderer(pack, initialSnapshot);
|
||||
await processSnapshot(initialSnapshot);
|
||||
|
||||
const unlistenSnapshot = await listen<UiSnapshot>("runtime:snapshot", (event) => {
|
||||
void processSnapshot(event.payload);
|
||||
});
|
||||
});
|
||||
const unlistenDebug = await listen<boolean>(
|
||||
"runtime:debug-overlay-visible",
|
||||
(event) => {
|
||||
if (!mounted) {
|
||||
const unlistenDebug = await listen<boolean>("runtime:debug-overlay-visible", (event) => {
|
||||
if (!mountedRef.current) {
|
||||
return;
|
||||
}
|
||||
setDebugOverlayVisible(Boolean(event.payload));
|
||||
}
|
||||
);
|
||||
const previousUnlisten = unlisten;
|
||||
});
|
||||
unlisten = () => {
|
||||
previousUnlisten();
|
||||
unlistenSnapshot();
|
||||
unlistenDebug();
|
||||
};
|
||||
})
|
||||
.catch((err) => {
|
||||
if (mounted) {
|
||||
if (mountedRef.current) {
|
||||
setError(String(err));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
mountedRef.current = false;
|
||||
if (unlisten !== null) {
|
||||
unlisten();
|
||||
}
|
||||
@@ -168,7 +241,7 @@ function App(): JSX.Element {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="app" onMouseDown={onMouseDown}>
|
||||
<main className="app overlay-app" onMouseDown={onMouseDown}>
|
||||
<div className="canvas-host" ref={hostRef} />
|
||||
{error !== null && !debugOverlayVisible ? <p className="error-banner">{error}</p> : null}
|
||||
{debugOverlayVisible ? (
|
||||
@@ -200,8 +273,198 @@ function App(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsWindow(): JSX.Element {
|
||||
const [settings, setSettings] = React.useState<UiSettingsSnapshot | null>(null);
|
||||
const [packs, setPacks] = React.useState<UiSpritePackOption[]>([]);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [pending, setPending] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let unlisten: null | (() => void) = null;
|
||||
let mounted = true;
|
||||
Promise.all([
|
||||
invoke<UiSettingsSnapshot>("settings_snapshot"),
|
||||
invoke<UiSpritePackOption[]>("list_sprite_packs")
|
||||
])
|
||||
.then(async ([snapshot, options]) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setSettings(snapshot);
|
||||
setPacks(options);
|
||||
unlisten = await listen<UiSnapshot>("runtime:snapshot", (event) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
const payload = event.payload;
|
||||
setSettings((prev) => {
|
||||
if (prev === null) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
active_sprite_pack: payload.active_sprite_pack,
|
||||
scale: payload.scale,
|
||||
visible: payload.visible,
|
||||
always_on_top: payload.always_on_top
|
||||
};
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
if (mounted) {
|
||||
setError(String(err));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (unlisten !== null) {
|
||||
unlisten();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const withPending = React.useCallback(async <T,>(fn: () => Promise<T>): Promise<T | null> => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
return null;
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onPackChange = React.useCallback(
|
||||
async (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = event.target.value;
|
||||
const next = await withPending(() => invokeSetSpritePack(value));
|
||||
if (next === null) {
|
||||
return;
|
||||
}
|
||||
setSettings((prev) =>
|
||||
prev === null
|
||||
? prev
|
||||
: {
|
||||
...prev,
|
||||
active_sprite_pack: next.active_sprite_pack
|
||||
}
|
||||
);
|
||||
},
|
||||
[withPending]
|
||||
);
|
||||
|
||||
const onScaleChange = React.useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = Number(event.target.value);
|
||||
if (!Number.isFinite(value)) {
|
||||
return;
|
||||
}
|
||||
const next = await withPending(() => invokeSetScale(value));
|
||||
if (next === null) {
|
||||
return;
|
||||
}
|
||||
setSettings((prev) => (prev === null ? prev : { ...prev, scale: next.scale }));
|
||||
},
|
||||
[withPending]
|
||||
);
|
||||
|
||||
const onVisibleChange = React.useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.checked;
|
||||
const next = await withPending(() => invokeSetVisibility(value));
|
||||
if (next === null) {
|
||||
return;
|
||||
}
|
||||
setSettings((prev) => (prev === null ? prev : { ...prev, visible: value }));
|
||||
},
|
||||
[withPending]
|
||||
);
|
||||
|
||||
const onAlwaysOnTopChange = React.useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.checked;
|
||||
const next = await withPending(() => invokeSetAlwaysOnTop(value));
|
||||
if (next === null) {
|
||||
return;
|
||||
}
|
||||
setSettings((prev) => (prev === null ? prev : { ...prev, always_on_top: value }));
|
||||
},
|
||||
[withPending]
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="settings-root">
|
||||
<section className="settings-card">
|
||||
<h1>Settings</h1>
|
||||
<p className="settings-subtitle">Character and window controls</p>
|
||||
{error !== null ? <p className="settings-error">{error}</p> : null}
|
||||
{settings === null ? (
|
||||
<p>Loading settings...</p>
|
||||
) : (
|
||||
<>
|
||||
<label className="field">
|
||||
<span>Character</span>
|
||||
<select
|
||||
value={settings.active_sprite_pack}
|
||||
disabled={pending}
|
||||
onChange={onPackChange}
|
||||
>
|
||||
{packs.map((pack) => (
|
||||
<option key={pack.pack_id_or_path} value={pack.pack_id_or_path}>
|
||||
{pack.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Scale: {settings.scale.toFixed(2)}x</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={3.0}
|
||||
step={0.05}
|
||||
value={settings.scale}
|
||||
disabled={pending}
|
||||
onChange={onScaleChange}
|
||||
/>
|
||||
</label>
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.visible}
|
||||
disabled={pending}
|
||||
onChange={onVisibleChange}
|
||||
/>
|
||||
<span>Visible</span>
|
||||
</label>
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.always_on_top}
|
||||
disabled={pending}
|
||||
onChange={onAlwaysOnTopChange}
|
||||
/>
|
||||
<span>Always on top</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function AppRoot(): JSX.Element {
|
||||
const windowLabel = getCurrentWindow().label;
|
||||
if (windowLabel === "settings") {
|
||||
return <SettingsWindow />;
|
||||
}
|
||||
return <MainOverlayWindow />;
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<AppRoot />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,8 @@ export type UiSnapshot = {
|
||||
y: number;
|
||||
scale: number;
|
||||
active_sprite_pack: string;
|
||||
visible: boolean;
|
||||
always_on_top: boolean;
|
||||
};
|
||||
|
||||
type AnimationMap = Map<string, UiAnimationClip>;
|
||||
@@ -41,6 +43,7 @@ export class PixiPetRenderer {
|
||||
private frameCursor = 0;
|
||||
private frameElapsedMs = 0;
|
||||
private baseTexture: BaseTexture;
|
||||
private disposed = false;
|
||||
|
||||
private constructor(
|
||||
app: Application,
|
||||
@@ -67,8 +70,6 @@ export class PixiPetRenderer {
|
||||
antialias: true,
|
||||
resizeTo: container
|
||||
});
|
||||
container.replaceChildren(app.view as HTMLCanvasElement);
|
||||
|
||||
const baseTexture = await PixiPetRenderer.loadBaseTexture(pack.atlas_data_url);
|
||||
if (baseTexture.width <= 0 || baseTexture.height <= 0) {
|
||||
throw new Error("Atlas image loaded with invalid dimensions.");
|
||||
@@ -76,6 +77,7 @@ export class PixiPetRenderer {
|
||||
const sprite = new Sprite();
|
||||
sprite.anchor.set(pack.anchor.x, pack.anchor.y);
|
||||
app.stage.addChild(sprite);
|
||||
container.replaceChildren(app.view as HTMLCanvasElement);
|
||||
|
||||
const renderer = new PixiPetRenderer(app, sprite, pack, baseTexture);
|
||||
renderer.layoutSprite();
|
||||
@@ -102,13 +104,25 @@ export class PixiPetRenderer {
|
||||
const keyR = 0xff;
|
||||
const keyG = 0x00;
|
||||
const keyB = 0xff;
|
||||
const tolerance = 28;
|
||||
const hardTolerance = 22;
|
||||
const softTolerance = 46;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const dr = Math.abs(data[i] - keyR);
|
||||
const dg = Math.abs(data[i + 1] - keyG);
|
||||
const db = Math.abs(data[i + 2] - keyB);
|
||||
if (dr <= tolerance && dg <= tolerance && db <= tolerance) {
|
||||
const maxDistance = Math.max(dr, dg, db);
|
||||
if (maxDistance <= hardTolerance) {
|
||||
data[i + 3] = 0;
|
||||
continue;
|
||||
}
|
||||
if (maxDistance <= softTolerance) {
|
||||
const alphaScale =
|
||||
(maxDistance - hardTolerance) / (softTolerance - hardTolerance);
|
||||
const suppress = 1 - alphaScale;
|
||||
data[i + 3] = Math.round(data[i + 3] * alphaScale);
|
||||
// Remove magenta spill from antialiased edges after alpha reduction.
|
||||
data[i] = Math.round(data[i] * (1 - 0.4 * suppress));
|
||||
data[i + 2] = Math.round(data[i + 2] * (1 - 0.4 * suppress));
|
||||
}
|
||||
}
|
||||
ctx.putImageData(frame, 0, 0);
|
||||
@@ -122,13 +136,10 @@ export class PixiPetRenderer {
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.app.ticker.stop();
|
||||
this.app.ticker.destroy();
|
||||
this.sprite.destroy({
|
||||
children: true,
|
||||
texture: false,
|
||||
baseTexture: false
|
||||
});
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
this.disposed = true;
|
||||
this.app.destroy(true, {
|
||||
children: true,
|
||||
texture: false,
|
||||
@@ -137,6 +148,9 @@ export class PixiPetRenderer {
|
||||
}
|
||||
|
||||
applySnapshot(snapshot: UiSnapshot): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
const nextClip = this.resolveClip(snapshot.current_animation);
|
||||
if (nextClip.name !== this.currentClip.name) {
|
||||
this.currentClip = nextClip;
|
||||
@@ -150,6 +164,9 @@ export class PixiPetRenderer {
|
||||
|
||||
private startTicker(): void {
|
||||
this.app.ticker.add((ticker) => {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
this.layoutSprite();
|
||||
const frameMs = 1000 / Math.max(this.currentClip.fps, 1);
|
||||
this.frameElapsedMs += ticker.deltaMS;
|
||||
|
||||
@@ -73,3 +73,76 @@ dd {
|
||||
color: #fee2e2;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
background: linear-gradient(180deg, #f1f5f9 0%, #dbe4ee 100%);
|
||||
color: #0f172a;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.settings-card h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.settings-subtitle {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-error {
|
||||
margin: 0;
|
||||
color: #991b1b;
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fca5a5;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field span {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.field select,
|
||||
.field input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field select {
|
||||
border: 1px solid #94a3b8;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
98
issues/issue3.md
Normal file
98
issues/issue3.md
Normal file
@@ -0,0 +1,98 @@
|
||||
## Title
|
||||
|
||||
`sprimo-tauri` settings window invoke errors for sprite-pack switch and always-on-top toggle.
|
||||
|
||||
## Severity
|
||||
|
||||
P1
|
||||
|
||||
## Environment
|
||||
|
||||
- OS: Windows
|
||||
- App version/build: `sprimo-tauri` workspace runtime
|
||||
- Renderer/backend details: Tauri UI settings pop-out window
|
||||
- Evidence screenshots:
|
||||
- `issues/screenshots/issue3.png`
|
||||
- `issues/screenshots/issue3-b.png`
|
||||
|
||||
## Summary
|
||||
|
||||
When using the settings pop-out window, changing character or toggling always-on-top shows
|
||||
invoke argument validation errors and the action fails to apply.
|
||||
|
||||
## Reproduction Steps
|
||||
|
||||
1. Start `sprimo-tauri` and open tray menu `Settings`.
|
||||
2. Change `Character` selection.
|
||||
3. Toggle `Always on top`.
|
||||
4. Observe red error banner in settings window.
|
||||
|
||||
## Expected Result
|
||||
|
||||
- Character change applies successfully and updates active sprite pack.
|
||||
- Always-on-top toggle applies successfully and updates main window z-order state.
|
||||
- No invoke argument error appears.
|
||||
|
||||
## Actual Result
|
||||
|
||||
- Character change fails with:
|
||||
- `invalid args 'packIdOrPath' for command 'set_sprite_pack'`
|
||||
- Always-on-top toggle fails with:
|
||||
- `invalid args 'alwaysOnTop' for command 'set_always_on_top'`
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
- Frontend invoke payload keys were sent in snake_case (`pack_id_or_path`, `always_on_top`).
|
||||
- Tauri JS invoke argument mapping for these Rust command parameter names expects camelCase
|
||||
keys (`packIdOrPath`, `alwaysOnTop`).
|
||||
- Because the required keys were missing from Tauri's perspective, command handlers were not run.
|
||||
|
||||
## Fix Plan
|
||||
|
||||
1. Update settings invoke payload keys to Tauri-compatible camelCase names.
|
||||
2. Add typed helper wrappers for settings invokes to centralize argument naming.
|
||||
3. Rebuild UI and run Rust compile checks.
|
||||
4. Perform runtime manual validation and capture after screenshot evidence.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Implemented in `frontend/tauri-ui/src/main.tsx`:
|
||||
|
||||
1. Added typed invoke wrappers:
|
||||
- `invokeSetSpritePack(packIdOrPath)`
|
||||
- `invokeSetScale(scale)`
|
||||
- `invokeSetVisibility(visible)`
|
||||
- `invokeSetAlwaysOnTop(alwaysOnTop)`
|
||||
|
||||
2. Updated failing settings call sites to use wrappers and camelCase payload keys.
|
||||
|
||||
## Verification
|
||||
|
||||
### Commands Run
|
||||
|
||||
- [x] `npm --prefix frontend/tauri-ui run build`
|
||||
- [x] `cargo check -p sprimo-tauri`
|
||||
|
||||
### Visual Checklist
|
||||
|
||||
- [x] Before screenshot(s): `issues/screenshots/issue3.png`
|
||||
- [x] Before screenshot(s): `issues/screenshots/issue3-b.png`
|
||||
- [ ] After screenshot(s): `issues/screenshots/issue3-after-YYYYMMDD-HHMMSS.png`
|
||||
|
||||
### Result
|
||||
|
||||
- Status: `Fix Implemented`
|
||||
- Notes: compile/build checks pass; runtime visual verification still required.
|
||||
|
||||
## Status History
|
||||
|
||||
- `2026-02-14 00:00` - reporter - `Reported` - settings errors captured in `issue3.png` and `issue3-b.png`.
|
||||
- `2026-02-14 00:00` - codex - `Triaged` - localized to invoke argument key mismatch.
|
||||
- `2026-02-14 00:00` - codex - `Fix Implemented` - updated settings invoke keys to camelCase via typed wrappers.
|
||||
|
||||
## Closure
|
||||
|
||||
- Current Status: `Fix Implemented`
|
||||
- Close Date:
|
||||
- Owner:
|
||||
- Linked PR/commit:
|
||||
131
issues/issue4.md
Normal file
131
issues/issue4.md
Normal file
@@ -0,0 +1,131 @@
|
||||
## Title
|
||||
|
||||
Packaged `sprimo-tauri` sprite rendering breaks after pack switch; default switch errors and
|
||||
scaling stops applying.
|
||||
|
||||
## Severity
|
||||
|
||||
P1
|
||||
|
||||
## Environment
|
||||
|
||||
- OS: Windows
|
||||
- App version/build: packaged release (`sprimo-tauri.exe`)
|
||||
- Renderer/backend details: Tauri main overlay + settings pop-out
|
||||
- Evidence screenshots:
|
||||
- `issues/screenshots/issue4.png`
|
||||
- `issues/screenshots/issue4-b.png`
|
||||
- `issues/screenshots/issue4-c.png`
|
||||
|
||||
## Summary
|
||||
|
||||
In packaged runtime, sprite display is incorrectly split/tiled, switching to `default` can fail,
|
||||
and scaling becomes ineffective after the error.
|
||||
|
||||
## Reproduction Steps
|
||||
|
||||
1. Run packaged `sprimo-tauri.exe` from ZIP extract.
|
||||
2. Open settings window.
|
||||
3. Switch character between `ferris` and `default`.
|
||||
4. Observe main overlay rendering and debug output.
|
||||
5. Change scale slider.
|
||||
|
||||
## Expected Result
|
||||
|
||||
- Sprite sheet is split into the correct frame grid regardless of image resolution.
|
||||
- Pack switching works for both `ferris` and `default`.
|
||||
- Scale changes continue to apply after pack changes.
|
||||
|
||||
## Actual Result
|
||||
|
||||
- Main overlay shows incorrectly split/tiled sprite sheet.
|
||||
- Pack switch can produce runtime error and break subsequent behavior.
|
||||
- Scale update stops working reliably after the error.
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
1. Existing splitting logic relied on fixed pixel frame metadata that did not generalize to
|
||||
packaged `sprite.png` dimension variants.
|
||||
2. Pack metadata inconsistency:
|
||||
- `assets/sprite-packs/ferris/manifest.json` used duplicated `id` (`default`), causing pack
|
||||
identity ambiguity.
|
||||
3. Settings/runtime flow then entered an unstable state after pack switch failures.
|
||||
4. Renderer reload lifecycle in tauri UI was unsafe:
|
||||
- `PixiPetRenderer::dispose` performed duplicate teardown (`ticker.destroy` + `app.destroy`),
|
||||
which could trigger runtime `TypeError` during pack reload.
|
||||
- Renderer replacement disposed previous renderer before new renderer creation succeeded, leaving
|
||||
the view in a broken/cropped state on creation failures.
|
||||
5. Chroma-key conversion tolerance removed most `#FF00FF` background but still left magenta fringe
|
||||
on anti-aliased edges.
|
||||
|
||||
## Fix Plan
|
||||
|
||||
1. Introduce generic splitter policy for `sprite.png`:
|
||||
- fixed topology: `8` columns x `7` rows
|
||||
- derive frame size from actual image dimensions
|
||||
- keep chroma-key background handling (`#FF00FF`) in renderer
|
||||
2. Validate animation frame indices against fixed frame count (`56`) for `sprite.png`.
|
||||
3. Ensure pack apply path validates atlas geometry before committing `SetSpritePack`.
|
||||
4. Fix ferris manifest ID uniqueness.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Implemented:
|
||||
|
||||
1. `crates/sprimo-tauri/src/main.rs`
|
||||
- Added `sprite.png`-specific frame derivation (`8x7`) from PNG dimensions.
|
||||
- Added PNG header dimension decoding utility.
|
||||
- Added animation frame index validation against fixed `56` frames for `sprite.png`.
|
||||
- Applied validation in both `load_active_sprite_pack` and `set_sprite_pack`.
|
||||
|
||||
2. `assets/sprite-packs/ferris/manifest.json`
|
||||
- Changed manifest `id` from `default` to `ferris`.
|
||||
|
||||
3. `docs/SPRITE_PACK_SCHEMA.md`
|
||||
- Documented Tauri `sprite.png` override behavior and 8x7 derived frame policy.
|
||||
|
||||
4. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
|
||||
- Made renderer disposal idempotent and removed duplicate ticker destruction.
|
||||
- Delayed DOM canvas replacement until atlas load succeeds.
|
||||
- Improved chroma-key edge handling with soft alpha + magenta spill suppression.
|
||||
|
||||
5. `frontend/tauri-ui/src/main.tsx`
|
||||
- Made pack reload transactional (keep old renderer until new renderer creation succeeds).
|
||||
- Improved fit-window flow so scale apply continues after reload retries.
|
||||
- Added targeted diagnostics for reload failures.
|
||||
|
||||
## Verification
|
||||
|
||||
### Commands Run
|
||||
|
||||
- [ ] `just build-release-tauri`
|
||||
- [ ] `just package-win-tauri`
|
||||
- [ ] `just smoke-win-tauri`
|
||||
- [x] `cargo check -p sprimo-tauri`
|
||||
|
||||
### Visual Checklist
|
||||
|
||||
- [x] Before screenshot(s): `issues/screenshots/issue4.png`
|
||||
- [x] Before screenshot(s): `issues/screenshots/issue4-b.png`
|
||||
- [x] Before screenshot(s): `issues/screenshots/issue4-c.png`
|
||||
- [ ] After screenshot(s): `issues/screenshots/issue4-after-YYYYMMDD-HHMMSS.png`
|
||||
|
||||
### Result
|
||||
|
||||
- Status: `Fix Implemented`
|
||||
- Notes: packaged runtime validation and after screenshots for this round are pending.
|
||||
|
||||
## Status History
|
||||
|
||||
- `2026-02-14 00:00` - reporter - `Reported` - packaged runtime failure screenshots attached.
|
||||
- `2026-02-14 00:00` - codex - `Triaged` - localized to sprite splitting/pack identity behavior.
|
||||
- `2026-02-14 00:00` - codex - `Fix Implemented` - applied 8x7 generic splitter policy and pack-ID correction.
|
||||
- `2026-02-14 00:00` - reporter - `In Progress` - reported `issue4-after-fix1` still failing in packaged runtime.
|
||||
- `2026-02-14 00:00` - codex - `Fix Implemented` - hardened renderer reload/dispose and chroma-key edge cleanup.
|
||||
|
||||
## Closure
|
||||
|
||||
- Current Status: `Fix Implemented`
|
||||
- Close Date:
|
||||
- Owner:
|
||||
- Linked PR/commit:
|
||||
Reference in New Issue
Block a user