14 Commits

Author SHA1 Message Date
c815adb826 Merge pull request 'Bug fix and UX improve' (#3) from dev-tauri into master
Reviewed-on: #3
2026-02-15 18:18:14 +08:00
5b0b0c7d41 Merge branch 'master' into dev-tauri 2026-02-15 18:18:06 +08:00
DaZuo0122
fa508ced8c Fix: Issue#2 2026-02-15 17:57:33 +08:00
DaZuo0122
bed7a052f3 Add: frames to each state, more smooth animation 2026-02-15 16:51:17 +08:00
DaZuo0122
832fbda04d Add: multi token management 2026-02-15 12:20:00 +08:00
DaZuo0122
f20ed1fd9d Add: global factor controlling fps base interval 2026-02-15 09:40:51 +08:00
DaZuo0122
e5417b6799 Add: backend testing script for new states 2026-02-14 22:49:28 +08:00
DaZuo0122
c0efb3915b Add: states to use all 7 rows 2026-02-14 22:15:58 +08:00
927fe6641c Merge pull request 'MVP tauri frontend - Windows' (#1) from dev-tauri into master
Reviewed-on: #1
2026-02-14 20:15:55 +08:00
DaZuo0122
eddf4b9481 Fix: scaling window size difference 2026-02-14 20:07:03 +08:00
DaZuo0122
f50243ab96 Fix: Clipping bug 2026-02-14 17:55:35 +08:00
DaZuo0122
f2954ad22b Fix: attempt for clipping bug - not fixed yet 2026-02-14 17:31:55 +08:00
DaZuo0122
1fa7080210 Fix: background splitting bug 2026-02-14 17:08:29 +08:00
DaZuo0122
901bf0ffc3 Add: setting window for tauri - bugs not fixed yet 2026-02-14 13:21:56 +08:00
32 changed files with 3013 additions and 155 deletions

1
Cargo.lock generated
View File

@@ -6089,6 +6089,7 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
"uuid",
] ]
[[package]] [[package]]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 B

View File

@@ -7,25 +7,80 @@
"animations": [ "animations": [
{ {
"name": "idle", "name": "idle",
"fps": 6, "fps": 1,
"frames": [0, 1] "frames": [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7]
}, },
{ {
"name": "active", "name": "active",
"fps": 10, "fps": 1,
"frames": [1, 0] "frames": [8, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 15, 15]
},
{
"name": "happy",
"fps": 1,
"frames": [8, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 15, 15]
},
{
"name": "love",
"fps": 1,
"frames": [8, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 15, 15]
},
{
"name": "excited",
"fps": 1,
"frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23]
},
{
"name": "celebrate",
"fps": 2,
"frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23],
"one_shot": true
}, },
{ {
"name": "success", "name": "success",
"fps": 10, "fps": 2,
"frames": [0, 1, 0], "frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23],
"one_shot": true "one_shot": true
}, },
{
"name": "sleepy",
"fps": 1,
"frames": [24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 27, 27, 27, 27, 28, 28, 28, 28, 29, 29, 29, 29, 30, 30, 30, 30, 31, 31, 31, 31]
},
{
"name": "snoring",
"fps": 1,
"frames": [24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 27, 27, 27, 27, 28, 28, 28, 28, 29, 29, 29, 29, 30, 30, 30, 30, 31, 31, 31, 31]
},
{
"name": "working",
"fps": 1,
"frames": [32, 32, 32, 33, 33, 33, 34, 34, 34, 35, 35, 35, 36, 36, 36, 37, 37, 37, 38, 38, 38, 39, 39, 39]
},
{
"name": "angry",
"fps": 1,
"frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47]
},
{
"name": "surprised",
"fps": 1,
"frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47]
},
{
"name": "shy",
"fps": 1,
"frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47]
},
{ {
"name": "error", "name": "error",
"fps": 8, "fps": 1,
"frames": [1, 0, 1], "frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47]
"one_shot": true },
{
"name": "dragging",
"fps": 1,
"frames": [48, 49, 50, 51, 52, 53, 54, 55]
} }
], ],
"anchor": { "anchor": {

View File

@@ -0,0 +1,90 @@
{
"id": "demogorgon",
"version": "1",
"image": "sprite.png",
"frame_width": 512,
"frame_height": 512,
"animations": [
{
"name": "idle",
"fps": 1,
"frames": [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7]
},
{
"name": "active",
"fps": 1,
"frames": [8, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 15, 15]
},
{
"name": "happy",
"fps": 1,
"frames": [8, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 15, 15]
},
{
"name": "love",
"fps": 1,
"frames": [8, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 15, 15]
},
{
"name": "excited",
"fps": 1,
"frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23]
},
{
"name": "celebrate",
"fps": 2,
"frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23],
"one_shot": true
},
{
"name": "success",
"fps": 2,
"frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23],
"one_shot": true
},
{
"name": "sleepy",
"fps": 1,
"frames": [24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 27, 27, 27, 27, 28, 28, 28, 28, 29, 29, 29, 29, 30, 30, 30, 30, 31, 31, 31, 31]
},
{
"name": "snoring",
"fps": 1,
"frames": [24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 27, 27, 27, 27, 28, 28, 28, 28, 29, 29, 29, 29, 30, 30, 30, 30, 31, 31, 31, 31]
},
{
"name": "working",
"fps": 1,
"frames": [32, 32, 32, 33, 33, 33, 34, 34, 34, 35, 35, 35, 36, 36, 36, 37, 37, 37, 38, 38, 38, 39, 39, 39]
},
{
"name": "angry",
"fps": 1,
"frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47]
},
{
"name": "surprised",
"fps": 1,
"frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47]
},
{
"name": "shy",
"fps": 1,
"frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47]
},
{
"name": "error",
"fps": 1,
"frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47]
},
{
"name": "dragging",
"fps": 1,
"frames": [48, 49, 50, 51, 52, 53, 54, 55]
}
],
"anchor": {
"x": 0.5,
"y": 1.0
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 MiB

View File

@@ -0,0 +1,90 @@
{
"id": "ferris",
"version": "1",
"image": "sprite.png",
"frame_width": 512,
"frame_height": 512,
"animations": [
{
"name": "idle",
"fps": 1,
"frames": [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7]
},
{
"name": "active",
"fps": 1,
"frames": [8, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 15, 15]
},
{
"name": "happy",
"fps": 1,
"frames": [8, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 15, 15]
},
{
"name": "love",
"fps": 1,
"frames": [8, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 15, 15]
},
{
"name": "excited",
"fps": 1,
"frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23]
},
{
"name": "celebrate",
"fps": 2,
"frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23],
"one_shot": true
},
{
"name": "success",
"fps": 2,
"frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23],
"one_shot": true
},
{
"name": "sleepy",
"fps": 1,
"frames": [24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 27, 27, 27, 27, 28, 28, 28, 28, 29, 29, 29, 29, 30, 30, 30, 30, 31, 31, 31, 31]
},
{
"name": "snoring",
"fps": 1,
"frames": [24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 27, 27, 27, 27, 28, 28, 28, 28, 29, 29, 29, 29, 30, 30, 30, 30, 31, 31, 31, 31]
},
{
"name": "working",
"fps": 1,
"frames": [32, 32, 32, 33, 33, 33, 34, 34, 34, 35, 35, 35, 36, 36, 36, 37, 37, 37, 38, 38, 38, 39, 39, 39]
},
{
"name": "angry",
"fps": 1,
"frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47]
},
{
"name": "surprised",
"fps": 1,
"frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47]
},
{
"name": "shy",
"fps": 1,
"frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47]
},
{
"name": "error",
"fps": 1,
"frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47]
},
{
"name": "dragging",
"fps": 1,
"frames": [48, 49, 50, 51, 52, 53, 54, 55]
}
],
"anchor": {
"x": 0.5,
"y": 1.0
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 MiB

View File

@@ -7,7 +7,7 @@ use axum::Router;
use sprimo_protocol::v1::{ use sprimo_protocol::v1::{
CommandEnvelope, ErrorResponse, FrontendStateSnapshot, HealthResponse, CommandEnvelope, ErrorResponse, FrontendStateSnapshot, HealthResponse,
}; };
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::{Arc, Mutex, RwLock}; use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -19,7 +19,7 @@ use uuid::Uuid;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ApiConfig { pub struct ApiConfig {
pub bind_addr: SocketAddr, pub bind_addr: SocketAddr,
pub auth_token: String, pub auth_tokens: Vec<String>,
pub app_version: String, pub app_version: String,
pub app_build: String, pub app_build: String,
pub dedupe_capacity: usize, pub dedupe_capacity: usize,
@@ -29,9 +29,14 @@ pub struct ApiConfig {
impl ApiConfig { impl ApiConfig {
#[must_use] #[must_use]
pub fn default_with_token(auth_token: String) -> Self { pub fn default_with_token(auth_token: String) -> Self {
Self::default_with_tokens(vec![auth_token])
}
#[must_use]
pub fn default_with_tokens(auth_tokens: Vec<String>) -> Self {
Self { Self {
bind_addr: SocketAddr::from(([127, 0, 0, 1], 32_145)), bind_addr: SocketAddr::from(([127, 0, 0, 1], 32_145)),
auth_token, auth_tokens,
app_version: env!("CARGO_PKG_VERSION").to_string(), app_version: env!("CARGO_PKG_VERSION").to_string(),
app_build: "dev".to_string(), app_build: "dev".to_string(),
dedupe_capacity: 5_000, dedupe_capacity: 5_000,
@@ -43,7 +48,7 @@ impl ApiConfig {
#[derive(Debug)] #[derive(Debug)]
pub struct ApiState { pub struct ApiState {
start_at: Instant, start_at: Instant,
auth_token: String, auth_tokens: Arc<RwLock<HashSet<String>>>,
app_version: String, app_version: String,
app_build: String, app_build: String,
dedupe_capacity: usize, dedupe_capacity: usize,
@@ -59,10 +64,14 @@ impl ApiState {
config: ApiConfig, config: ApiConfig,
snapshot: Arc<RwLock<FrontendStateSnapshot>>, snapshot: Arc<RwLock<FrontendStateSnapshot>>,
command_tx: mpsc::Sender<CommandEnvelope>, command_tx: mpsc::Sender<CommandEnvelope>,
auth_tokens: Arc<RwLock<HashSet<String>>>,
) -> Self { ) -> Self {
if let Ok(mut guard) = auth_tokens.write() {
*guard = config.auth_tokens.into_iter().collect();
}
Self { Self {
start_at: Instant::now(), start_at: Instant::now(),
auth_token: config.auth_token, auth_tokens,
app_version: config.app_version, app_version: config.app_version,
app_build: config.app_build, app_build: config.app_build,
dedupe_capacity: config.dedupe_capacity, dedupe_capacity: config.dedupe_capacity,
@@ -188,11 +197,18 @@ fn require_auth(headers: &HeaderMap, state: &ApiState) -> Result<(), ApiError> {
.get(header::AUTHORIZATION) .get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.ok_or(ApiError::Unauthorized)?; .ok_or(ApiError::Unauthorized)?;
let expected = format!("Bearer {}", state.auth_token); let Some(token) = raw.strip_prefix("Bearer ") else {
if raw == expected { return Err(ApiError::Unauthorized);
return Ok(()); };
let guard = state
.auth_tokens
.read()
.map_err(|_| ApiError::Internal("auth token lock poisoned".to_string()))?;
if guard.contains(token) {
Ok(())
} else {
Err(ApiError::Unauthorized)
} }
Err(ApiError::Unauthorized)
} }
enum ApiError { enum ApiError {
@@ -240,6 +256,7 @@ mod tests {
use sprimo_protocol::v1::{ use sprimo_protocol::v1::{
CapabilityFlags, CommandEnvelope, FrontendCommand, FrontendStateSnapshot, CapabilityFlags, CommandEnvelope, FrontendCommand, FrontendStateSnapshot,
}; };
use std::collections::HashSet;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tower::ServiceExt; use tower::ServiceExt;
@@ -255,6 +272,7 @@ mod tests {
ApiConfig::default_with_token("token".to_string()), ApiConfig::default_with_token("token".to_string()),
snapshot, snapshot,
tx, tx,
Arc::new(RwLock::new(HashSet::new())),
)), )),
rx, rx,
) )
@@ -335,6 +353,48 @@ mod tests {
assert_eq!(received.id, command.id); assert_eq!(received.id, command.id);
} }
#[tokio::test]
async fn command_accepts_with_any_configured_token() {
let snapshot =
FrontendStateSnapshot::idle(CapabilityFlags::default());
let snapshot = Arc::new(RwLock::new(snapshot));
let (tx, mut rx) = mpsc::channel(8);
let state = Arc::new(ApiState::new(
ApiConfig::default_with_tokens(vec![
"token-a".to_string(),
"token-b".to_string(),
]),
snapshot,
tx,
Arc::new(RwLock::new(HashSet::new())),
));
let app = app_router(state);
let command = CommandEnvelope {
id: Uuid::new_v4(),
ts_ms: 1,
command: FrontendCommand::Toast {
text: "hi".to_string(),
ttl_ms: None,
},
};
let body = serde_json::to_vec(&command).expect("json");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/command")
.header("content-type", "application/json")
.header("authorization", "Bearer token-b")
.body(Body::from(body))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::ACCEPTED);
let received = rx.recv().await.expect("forwarded command");
assert_eq!(received.id, command.id);
}
#[tokio::test] #[tokio::test]
async fn malformed_json_returns_bad_request() { async fn malformed_json_returns_bad_request() {
let (state, _) = build_state(); let (state, _) = build_state();

View File

@@ -1034,7 +1034,7 @@ fn default_animation_for_state(state: FrontendState) -> &'static str {
FrontendState::Active => "active", FrontendState::Active => "active",
FrontendState::Success => "success", FrontendState::Success => "success",
FrontendState::Error => "error", FrontendState::Error => "error",
FrontendState::Dragging => "idle", FrontendState::Dragging => "dragging",
FrontendState::Hidden => "idle", FrontendState::Hidden => "idle",
} }
} }

View File

@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use thiserror::Error; use thiserror::Error;
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Error)] #[derive(Debug, Error)]
@@ -106,13 +107,41 @@ impl Default for SpriteConfig {
pub struct ApiConfig { pub struct ApiConfig {
pub port: u16, pub port: u16,
pub auth_token: String, pub auth_token: String,
pub auth_tokens: Vec<ApiTokenEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ApiTokenEntry {
pub id: String,
pub label: String,
pub token: String,
pub created_at_ms: u64,
}
impl Default for ApiTokenEntry {
fn default() -> Self {
Self {
id: Uuid::new_v4().to_string(),
label: "default".to_string(),
token: Uuid::new_v4().to_string(),
created_at_ms: now_unix_ms(),
}
}
} }
impl Default for ApiConfig { impl Default for ApiConfig {
fn default() -> Self { fn default() -> Self {
let token = Uuid::new_v4().to_string();
Self { Self {
port: 32_145, port: 32_145,
auth_token: Uuid::new_v4().to_string(), auth_token: token.clone(),
auth_tokens: vec![ApiTokenEntry {
id: Uuid::new_v4().to_string(),
label: "default".to_string(),
token,
created_at_ms: now_unix_ms(),
}],
} }
} }
} }
@@ -159,6 +188,7 @@ pub enum FrontendBackend {
pub struct FrontendConfig { pub struct FrontendConfig {
pub backend: FrontendBackend, pub backend: FrontendBackend,
pub debug_overlay_visible: bool, pub debug_overlay_visible: bool,
pub tauri_animation_slowdown_factor: u8,
} }
impl Default for FrontendConfig { impl Default for FrontendConfig {
@@ -166,6 +196,7 @@ impl Default for FrontendConfig {
Self { Self {
backend: FrontendBackend::Bevy, backend: FrontendBackend::Bevy,
debug_overlay_visible: false, debug_overlay_visible: false,
tauri_animation_slowdown_factor: 3,
} }
} }
} }
@@ -204,6 +235,13 @@ pub fn save(path: &Path, config: &AppConfig) -> Result<(), ConfigError> {
Ok(()) Ok(())
} }
fn now_unix_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|v| v.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{load_or_create_at, save, AppConfig}; use super::{load_or_create_at, save, AppConfig};
@@ -225,11 +263,13 @@ mod tests {
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; config.frontend.debug_overlay_visible = true;
config.frontend.tauri_animation_slowdown_factor = 7;
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); assert!(loaded.frontend.debug_overlay_visible);
assert_eq!(loaded.frontend.tauri_animation_slowdown_factor, 7);
} }
} }

View File

@@ -14,6 +14,7 @@ sprimo-protocol = { path = "../sprimo-protocol" }
thiserror.workspace = true thiserror.workspace = true
tokio.workspace = true tokio.workspace = true
tracing.workspace = true tracing.workspace = true
uuid.workspace = true
[dev-dependencies] [dev-dependencies]
tempfile = "3.12.0" tempfile = "3.12.0"

View File

@@ -1,13 +1,18 @@
use sprimo_api::{ApiConfig, ApiServerError, ApiState}; use sprimo_api::{ApiConfig, ApiServerError, ApiState};
use sprimo_config::{save, AppConfig, ConfigError}; use sprimo_config::{save, ApiTokenEntry, AppConfig, ConfigError};
use sprimo_protocol::v1::{CapabilityFlags, CommandEnvelope, FrontendCommand, FrontendStateSnapshot}; use sprimo_protocol::v1::{CapabilityFlags, CommandEnvelope, FrontendCommand, FrontendStateSnapshot};
use std::collections::HashSet;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
use thiserror::Error; use thiserror::Error;
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
use tokio::sync::{mpsc, Mutex}; use tokio::sync::{mpsc, Mutex};
use tracing::warn; use tracing::warn;
const TAURI_ANIMATION_SLOWDOWN_FACTOR_MIN: u8 = 1;
const TAURI_ANIMATION_SLOWDOWN_FACTOR_MAX: u8 = 200;
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum RuntimeCoreError { pub enum RuntimeCoreError {
#[error("{0}")] #[error("{0}")]
@@ -16,6 +21,10 @@ pub enum RuntimeCoreError {
SnapshotPoisoned, SnapshotPoisoned,
#[error("config lock poisoned")] #[error("config lock poisoned")]
ConfigPoisoned, ConfigPoisoned,
#[error("api token not found: {0}")]
ApiTokenNotFound(String),
#[error("cannot revoke the last API token")]
LastApiToken,
} }
pub struct RuntimeCore { pub struct RuntimeCore {
@@ -23,6 +32,7 @@ pub struct RuntimeCore {
config: Arc<RwLock<AppConfig>>, config: Arc<RwLock<AppConfig>>,
snapshot: Arc<RwLock<FrontendStateSnapshot>>, snapshot: Arc<RwLock<FrontendStateSnapshot>>,
api_config: ApiConfig, api_config: ApiConfig,
auth_store: Arc<RwLock<HashSet<String>>>,
command_tx: mpsc::Sender<CommandEnvelope>, command_tx: mpsc::Sender<CommandEnvelope>,
command_rx: Arc<Mutex<mpsc::Receiver<CommandEnvelope>>>, command_rx: Arc<Mutex<mpsc::Receiver<CommandEnvelope>>>,
} }
@@ -40,6 +50,7 @@ impl RuntimeCore {
) -> Result<Self, RuntimeCoreError> { ) -> Result<Self, RuntimeCoreError> {
let click_through_was_enabled = config_value.window.click_through; let click_through_was_enabled = config_value.window.click_through;
config_value.window.click_through = false; config_value.window.click_through = false;
let tokens_changed = normalize_api_tokens(&mut config_value);
let mut snapshot = FrontendStateSnapshot::idle(capabilities); let mut snapshot = FrontendStateSnapshot::idle(capabilities);
snapshot.x = config_value.window.x; snapshot.x = config_value.window.x;
snapshot.y = config_value.window.y; snapshot.y = config_value.window.y;
@@ -49,18 +60,34 @@ impl RuntimeCore {
snapshot.flags.visible = config_value.window.visible; snapshot.flags.visible = config_value.window.visible;
snapshot.active_sprite_pack = config_value.sprite.selected_pack.clone(); snapshot.active_sprite_pack = config_value.sprite.selected_pack.clone();
let api_config = ApiConfig::default_with_token(config_value.api.auth_token.clone()); let api_config = ApiConfig::default_with_tokens(
config_value
.api
.auth_tokens
.iter()
.map(|entry| entry.token.clone())
.collect(),
);
let (command_tx, command_rx) = mpsc::channel(1_024); let (command_tx, command_rx) = mpsc::channel(1_024);
let auth_store = Arc::new(RwLock::new(
config_value
.api
.auth_tokens
.iter()
.map(|entry| entry.token.clone())
.collect(),
));
let core = Self { let core = Self {
config_path, config_path,
config: Arc::new(RwLock::new(config_value)), config: Arc::new(RwLock::new(config_value)),
snapshot: Arc::new(RwLock::new(snapshot)), snapshot: Arc::new(RwLock::new(snapshot)),
api_config, api_config,
auth_store,
command_tx, command_tx,
command_rx: Arc::new(Mutex::new(command_rx)), command_rx: Arc::new(Mutex::new(command_rx)),
}; };
if click_through_was_enabled { if click_through_was_enabled || tokens_changed {
core.persist_config()?; core.persist_config()?;
} }
Ok(core) Ok(core)
@@ -104,19 +131,138 @@ impl RuntimeCore {
self.persist_config() self.persist_config()
} }
pub fn frontend_tauri_animation_slowdown_factor(&self) -> Result<u8, RuntimeCoreError> {
let guard = self
.config
.read()
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
Ok(clamp_tauri_animation_slowdown_factor(
guard.frontend.tauri_animation_slowdown_factor,
))
}
pub fn set_frontend_tauri_animation_slowdown_factor(
&self,
value: u8,
) -> Result<u8, RuntimeCoreError> {
let clamped = clamp_tauri_animation_slowdown_factor(value);
{
let mut guard = self
.config
.write()
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
guard.frontend.tauri_animation_slowdown_factor = clamped;
}
self.persist_config()?;
Ok(clamped)
}
pub fn api_config(&self) -> ApiConfig { pub fn api_config(&self) -> ApiConfig {
self.api_config.clone() self.api_config.clone()
} }
pub fn list_api_tokens(&self) -> Result<Vec<ApiTokenEntry>, RuntimeCoreError> {
let guard = self
.config
.read()
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
Ok(guard.api.auth_tokens.clone())
}
pub fn create_api_token(
&self,
label: Option<String>,
) -> Result<ApiTokenEntry, RuntimeCoreError> {
let entry = ApiTokenEntry {
id: uuid::Uuid::new_v4().to_string(),
label: normalize_token_label(label.as_deref().unwrap_or("")),
token: uuid::Uuid::new_v4().to_string(),
created_at_ms: now_unix_ms(),
};
{
let mut guard = self
.config
.write()
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
guard.api.auth_tokens.push(entry.clone());
guard.api.auth_token = guard
.api
.auth_tokens
.first()
.map(|token| token.token.clone())
.unwrap_or_default();
}
self.persist_config()?;
self.refresh_auth_store_from_config()?;
Ok(entry)
}
pub fn rename_api_token(&self, id: &str, label: &str) -> Result<(), RuntimeCoreError> {
let mut found = false;
{
let mut guard = self
.config
.write()
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
for entry in &mut guard.api.auth_tokens {
if entry.id == id {
entry.label = normalize_token_label(label);
found = true;
break;
}
}
}
if !found {
return Err(RuntimeCoreError::ApiTokenNotFound(id.to_string()));
}
self.persist_config()?;
Ok(())
}
pub fn revoke_api_token(&self, id: &str) -> Result<(), RuntimeCoreError> {
let removed = {
let mut guard = self
.config
.write()
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
if guard.api.auth_tokens.len() <= 1 {
return Err(RuntimeCoreError::LastApiToken);
}
let before = guard.api.auth_tokens.len();
guard.api.auth_tokens.retain(|entry| entry.id != id);
let after = guard.api.auth_tokens.len();
guard.api.auth_token = guard
.api
.auth_tokens
.first()
.map(|token| token.token.clone())
.unwrap_or_default();
before != after
};
if !removed {
return Err(RuntimeCoreError::ApiTokenNotFound(id.to_string()));
}
self.persist_config()?;
self.refresh_auth_store_from_config()?;
Ok(())
}
pub fn spawn_api(&self, runtime: &Runtime) { pub fn spawn_api(&self, runtime: &Runtime) {
let mut cfg = self.api_config.clone(); let mut cfg = self.api_config.clone();
if let Ok(guard) = self.config.read() { if let Ok(guard) = self.config.read() {
cfg.bind_addr = ([127, 0, 0, 1], guard.api.port).into(); cfg.bind_addr = ([127, 0, 0, 1], guard.api.port).into();
cfg.auth_tokens = guard
.api
.auth_tokens
.iter()
.map(|entry| entry.token.clone())
.collect();
} }
let state = Arc::new(ApiState::new( let state = Arc::new(ApiState::new(
cfg.clone(), cfg.clone(),
Arc::clone(&self.snapshot), Arc::clone(&self.snapshot),
self.command_tx.clone(), self.command_tx.clone(),
Arc::clone(&self.auth_store),
)); ));
runtime.spawn(async move { runtime.spawn(async move {
if let Err(err) = sprimo_api::run_server(cfg, state).await { if let Err(err) = sprimo_api::run_server(cfg, state).await {
@@ -244,6 +390,27 @@ impl RuntimeCore {
save(&self.config_path, &guard)?; save(&self.config_path, &guard)?;
Ok(()) Ok(())
} }
fn refresh_auth_store_from_config(&self) -> Result<(), RuntimeCoreError> {
let tokens = {
let guard = self
.config
.read()
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
guard
.api
.auth_tokens
.iter()
.map(|entry| entry.token.clone())
.collect::<HashSet<_>>()
};
let mut auth = self
.auth_store
.write()
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
*auth = tokens;
Ok(())
}
} }
fn default_animation_for_state(state: sprimo_protocol::v1::FrontendState) -> &'static str { fn default_animation_for_state(state: sprimo_protocol::v1::FrontendState) -> &'static str {
@@ -252,15 +419,114 @@ fn default_animation_for_state(state: sprimo_protocol::v1::FrontendState) -> &'s
sprimo_protocol::v1::FrontendState::Active => "active", sprimo_protocol::v1::FrontendState::Active => "active",
sprimo_protocol::v1::FrontendState::Success => "success", sprimo_protocol::v1::FrontendState::Success => "success",
sprimo_protocol::v1::FrontendState::Error => "error", sprimo_protocol::v1::FrontendState::Error => "error",
sprimo_protocol::v1::FrontendState::Dragging => "idle", sprimo_protocol::v1::FrontendState::Dragging => "dragging",
sprimo_protocol::v1::FrontendState::Hidden => "idle", sprimo_protocol::v1::FrontendState::Hidden => "idle",
} }
} }
fn clamp_tauri_animation_slowdown_factor(value: u8) -> u8 {
value.clamp(
TAURI_ANIMATION_SLOWDOWN_FACTOR_MIN,
TAURI_ANIMATION_SLOWDOWN_FACTOR_MAX,
)
}
fn log_api_error(err: ApiServerError) { fn log_api_error(err: ApiServerError) {
warn!(%err, "runtime core api server exited"); warn!(%err, "runtime core api server exited");
} }
fn normalize_api_tokens(config: &mut AppConfig) -> bool {
let mut changed = false;
let mut seen = HashSet::new();
let mut normalized = Vec::new();
let legacy = config.api.auth_token.trim().to_string();
if legacy != config.api.auth_token {
config.api.auth_token = legacy.clone();
changed = true;
}
for mut entry in config.api.auth_tokens.clone() {
let original_id = entry.id.clone();
let original_label = entry.label.clone();
let original_token = entry.token.clone();
let original_created = entry.created_at_ms;
entry.id = entry.id.trim().to_string();
if entry.id.is_empty() {
entry.id = uuid::Uuid::new_v4().to_string();
}
entry.label = normalize_token_label(&entry.label);
entry.token = entry.token.trim().to_string();
if entry.created_at_ms == 0 {
entry.created_at_ms = now_unix_ms();
}
let field_changed = entry.id != original_id
|| entry.label != original_label
|| entry.token != original_token
|| entry.created_at_ms != original_created;
if field_changed {
changed = true;
}
if entry.token.is_empty() {
changed = true;
continue;
}
if !seen.insert(entry.token.clone()) {
changed = true;
continue;
}
normalized.push(entry);
}
if normalized.is_empty() {
let token = if legacy.is_empty() {
uuid::Uuid::new_v4().to_string()
} else {
legacy
};
normalized.push(ApiTokenEntry {
id: uuid::Uuid::new_v4().to_string(),
label: "default".to_string(),
token,
created_at_ms: now_unix_ms(),
});
changed = true;
}
let mirror = normalized
.first()
.map(|entry| entry.token.clone())
.unwrap_or_default();
if config.api.auth_token != mirror {
config.api.auth_token = mirror;
changed = true;
}
if config.api.auth_tokens != normalized {
config.api.auth_tokens = normalized;
changed = true;
}
changed
}
fn normalize_token_label(value: &str) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
"token".to_string()
} else {
trimmed.to_string()
}
}
fn now_unix_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|v| v.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::RuntimeCore; use super::RuntimeCore;
@@ -284,6 +550,22 @@ mod tests {
assert_eq!(snapshot.current_animation, "active"); assert_eq!(snapshot.current_animation, "active");
} }
#[test]
fn dragging_state_maps_to_dragging_animation() {
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.apply_command(&FrontendCommand::SetState {
state: FrontendState::Dragging,
ttl_ms: None,
})
.expect("apply");
let snapshot = core.snapshot().read().expect("snapshot lock").clone();
assert_eq!(snapshot.state, FrontendState::Dragging);
assert_eq!(snapshot.current_animation, "dragging");
}
#[test] #[test]
fn click_through_flag_is_ignored_and_forced_false() { fn click_through_flag_is_ignored_and_forced_false() {
let temp = TempDir::new().expect("tempdir"); let temp = TempDir::new().expect("tempdir");
@@ -315,4 +597,89 @@ mod tests {
core.set_frontend_debug_overlay_visible(true).expect("set"); core.set_frontend_debug_overlay_visible(true).expect("set");
assert!(core.frontend_debug_overlay_visible().expect("get")); assert!(core.frontend_debug_overlay_visible().expect("get"));
} }
#[test]
fn frontend_tauri_animation_slowdown_factor_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");
let persisted = core
.set_frontend_tauri_animation_slowdown_factor(6)
.expect("set");
assert_eq!(persisted, 6);
assert_eq!(
core.frontend_tauri_animation_slowdown_factor().expect("get"),
6
);
}
#[test]
fn frontend_tauri_animation_slowdown_factor_clamps() {
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");
let persisted = core
.set_frontend_tauri_animation_slowdown_factor(0)
.expect("set");
assert_eq!(persisted, 1);
assert_eq!(
core.frontend_tauri_animation_slowdown_factor().expect("get"),
1
);
let upper = core
.set_frontend_tauri_animation_slowdown_factor(201)
.expect("set high");
assert_eq!(upper, 200);
assert_eq!(
core.frontend_tauri_animation_slowdown_factor().expect("get"),
200
);
}
#[test]
fn api_token_create_rename_revoke_roundtrip() {
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");
let initial_len = core.list_api_tokens().expect("tokens").len();
let created = core
.create_api_token(Some("backend-ci".to_string()))
.expect("create token");
let after_create = core.list_api_tokens().expect("tokens");
assert_eq!(after_create.len(), initial_len + 1);
assert!(after_create.iter().any(|entry| entry.id == created.id));
core.rename_api_token(&created.id, "automation")
.expect("rename token");
let after_rename = core.list_api_tokens().expect("tokens");
assert!(
after_rename
.iter()
.any(|entry| entry.id == created.id && entry.label == "automation")
);
core.revoke_api_token(&created.id).expect("revoke token");
let after_revoke = core.list_api_tokens().expect("tokens");
assert_eq!(after_revoke.len(), initial_len);
assert!(!after_revoke.iter().any(|entry| entry.id == created.id));
}
#[test]
fn cannot_revoke_last_api_token() {
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");
let only = core.list_api_tokens().expect("tokens");
assert_eq!(only.len(), 1);
let err = core
.revoke_api_token(&only[0].id)
.expect_err("last token revoke must fail");
assert!(format!("{err}").contains("last API token"));
}
} }

View File

@@ -10,6 +10,7 @@
"core:window:allow-set-position", "core:window:allow-set-position",
"core:window:allow-inner-size", "core:window:allow-inner-size",
"core:window:allow-outer-position", "core:window:allow-outer-position",
"core:window:allow-current-monitor",
"core:event:allow-listen", "core:event:allow-listen",
"core:event:allow-unlisten" "core:event:allow-unlisten"
] ]

View File

@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capability for sprimo-tauri main window runtime APIs.","local":true,"windows":["*"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-size","core:window:allow-set-position","core:window:allow-inner-size","core:window:allow-outer-position","core:event:allow-listen","core:event:allow-unlisten"]}} {"default":{"identifier":"default","description":"Default capability for sprimo-tauri main window runtime APIs.","local":true,"windows":["*"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-size","core:window:allow-set-position","core:window:allow-inner-size","core:window:allow-outer-position","core:window:allow-current-monitor","core:event:allow-listen","core:event:allow-unlisten"]}}

View File

@@ -1,30 +1,37 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use base64::Engine; 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_protocol::v1::FrontendCommand;
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::{AnimationDefinition, SpritePackManifest, load_manifest, resolve_pack_path};
use std::fs;
use std::sync::Arc; use std::sync::Arc;
use tauri::menu::{CheckMenuItem, Menu, MenuItem}; use tauri::menu::{CheckMenuItem, Menu, MenuItem};
use tauri::tray::TrayIconBuilder; use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Emitter, Manager, Wry}; use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, 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 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 MAIN_WINDOW_LABEL: &str = "main";
const SETTINGS_WINDOW_LABEL: &str = "settings";
const TRAY_ID: &str = "main"; const TRAY_ID: &str = "main";
const MENU_ID_SETTINGS: &str = "settings";
const MENU_ID_TOGGLE_VISIBILITY: &str = "toggle_visibility"; const MENU_ID_TOGGLE_VISIBILITY: &str = "toggle_visibility";
const MENU_ID_TOGGLE_ALWAYS_ON_TOP: &str = "toggle_always_on_top"; 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_TOGGLE_DEBUG_OVERLAY: &str = "toggle_debug_overlay";
const MENU_ID_QUIT: &str = "quit"; const MENU_ID_QUIT: &str = "quit";
const EVENT_RUNTIME_SNAPSHOT: &str = "runtime:snapshot"; const EVENT_RUNTIME_SNAPSHOT: &str = "runtime:snapshot";
const EVENT_RUNTIME_DEBUG_OVERLAY_VISIBLE: &str = "runtime:debug-overlay-visible"; const EVENT_RUNTIME_DEBUG_OVERLAY_VISIBLE: &str = "runtime:debug-overlay-visible";
const EVENT_RUNTIME_ANIMATION_SLOWDOWN_FACTOR: &str = "runtime:animation-slowdown-factor";
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]
struct UiAnimationClip { struct UiAnimationClip {
@@ -50,6 +57,12 @@ struct UiSpritePack {
anchor: UiAnchor, anchor: UiAnchor,
} }
#[derive(Debug, Clone, Copy)]
struct AtlasGeometry {
frame_width: u32,
frame_height: u32,
}
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]
struct UiSnapshot { struct UiSnapshot {
state: String, state: String,
@@ -58,6 +71,31 @@ struct UiSnapshot {
y: f32, y: f32,
scale: f32, scale: f32,
active_sprite_pack: String, 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,
tauri_animation_slowdown_factor: u8,
}
#[derive(Debug, Clone, serde::Serialize)]
struct UiSpritePackOption {
id: String,
pack_id_or_path: String,
}
#[derive(Debug, Clone, serde::Serialize)]
struct UiApiToken {
id: String,
label: String,
token: String,
created_at_ms: u64,
} }
#[derive(Debug, Error)] #[derive(Debug, Error)]
@@ -74,6 +112,7 @@ enum AppError {
struct AppState { struct AppState {
runtime_core: Arc<RuntimeCore>, runtime_core: Arc<RuntimeCore>,
runtime: Arc<Runtime>, runtime: Arc<Runtime>,
tray_state: Arc<std::sync::Mutex<Option<TrayMenuState>>>,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -96,6 +135,7 @@ fn current_state(state: tauri::State<'_, AppState>) -> Result<UiSnapshot, String
#[tauri::command] #[tauri::command]
fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSpritePack, String> { 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 let config = state
.runtime_core .runtime_core
.config() .config()
@@ -103,11 +143,6 @@ fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSprite
.map_err(|_| "config lock poisoned".to_string())? .map_err(|_| "config lock poisoned".to_string())?
.clone(); .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 selected = config.sprite.selected_pack;
let pack_path = match resolve_pack_path(&root, &selected) { let pack_path = match resolve_pack_path(&root, &selected) {
Ok(path) => path, Ok(path) => path,
@@ -117,6 +152,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 manifest = load_manifest(&pack_path).map_err(|err| err.to_string())?;
let image_path = pack_path.join(&manifest.image); let image_path = pack_path.join(&manifest.image);
let image_bytes = std::fs::read(&image_path).map_err(|err| err.to_string())?; 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!( let atlas_data_url = format!(
"data:image/png;base64,{}", "data:image/png;base64,{}",
BASE64_STANDARD.encode(image_bytes) BASE64_STANDARD.encode(image_bytes)
@@ -124,14 +161,10 @@ fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSprite
Ok(UiSpritePack { Ok(UiSpritePack {
id: manifest.id, id: manifest.id,
frame_width: manifest.frame_width, frame_width: geometry.frame_width,
frame_height: manifest.frame_height, frame_height: geometry.frame_height,
atlas_data_url, atlas_data_url,
animations: manifest animations: manifest.animations.into_iter().map(to_ui_clip).collect(),
.animations
.into_iter()
.map(to_ui_clip)
.collect(),
anchor: UiAnchor { anchor: UiAnchor {
x: manifest.anchor.x, x: manifest.anchor.x,
y: manifest.anchor.y, y: manifest.anchor.y,
@@ -147,6 +180,221 @@ fn debug_overlay_visible(state: tauri::State<'_, AppState>) -> Result<bool, Stri
.map_err(|err| err.to_string()) .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_animation_slowdown_factor: state
.runtime_core
.frontend_tauri_animation_slowdown_factor()
.map_err(|err| err.to_string())?,
})
}
#[tauri::command]
fn tauri_animation_slowdown_factor(state: tauri::State<'_, AppState>) -> Result<u8, String> {
state
.runtime_core
.frontend_tauri_animation_slowdown_factor()
.map_err(|err| err.to_string())
}
#[tauri::command]
fn set_tauri_animation_slowdown_factor(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
factor: u8,
) -> Result<u8, String> {
let persisted = state
.runtime_core
.set_frontend_tauri_animation_slowdown_factor(factor)
.map_err(|err| err.to_string())?;
app_handle
.emit(EVENT_RUNTIME_ANIMATION_SLOWDOWN_FACTOR, persisted)
.map_err(|err| err.to_string())?;
Ok(persisted)
}
#[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 list_api_tokens(state: tauri::State<'_, AppState>) -> Result<Vec<UiApiToken>, String> {
let entries = state
.runtime_core
.list_api_tokens()
.map_err(|err| err.to_string())?;
Ok(entries.into_iter().map(to_ui_api_token).collect())
}
#[tauri::command]
fn create_api_token(
state: tauri::State<'_, AppState>,
label: Option<String>,
) -> Result<UiApiToken, String> {
let entry = state
.runtime_core
.create_api_token(label)
.map_err(|err| err.to_string())?;
Ok(to_ui_api_token(entry))
}
#[tauri::command]
fn rename_api_token(
state: tauri::State<'_, AppState>,
id: String,
label: String,
) -> Result<Vec<UiApiToken>, String> {
state
.runtime_core
.rename_api_token(&id, &label)
.map_err(|err| err.to_string())?;
let entries = state
.runtime_core
.list_api_tokens()
.map_err(|err| err.to_string())?;
Ok(entries.into_iter().map(to_ui_api_token).collect())
}
#[tauri::command]
fn revoke_api_token(
state: tauri::State<'_, AppState>,
id: String,
) -> Result<Vec<UiApiToken>, String> {
state
.runtime_core
.revoke_api_token(&id)
.map_err(|err| err.to_string())?;
let entries = state
.runtime_core
.list_api_tokens()
.map_err(|err| err.to_string())?;
Ok(entries.into_iter().map(to_ui_api_token).collect())
}
#[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] #[tauri::command]
fn set_debug_overlay_visible( fn set_debug_overlay_visible(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
@@ -158,6 +406,11 @@ fn set_debug_overlay_visible(
.set_frontend_debug_overlay_visible(visible) .set_frontend_debug_overlay_visible(visible)
.map_err(|err| err.to_string())?; .map_err(|err| err.to_string())?;
let _ = emit_debug_overlay_visibility(state.runtime_core.as_ref(), &app_handle); 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) Ok(visible)
} }
@@ -176,6 +429,7 @@ fn main() -> Result<(), AppError> {
let state = AppState { let state = AppState {
runtime_core: Arc::clone(&runtime_core), runtime_core: Arc::clone(&runtime_core),
runtime: Arc::clone(&runtime), runtime: Arc::clone(&runtime),
tray_state: Arc::new(std::sync::Mutex::new(None)),
}; };
tauri::Builder::default() tauri::Builder::default()
@@ -185,15 +439,34 @@ fn main() -> Result<(), AppError> {
current_state, current_state,
load_active_sprite_pack, load_active_sprite_pack,
debug_overlay_visible, debug_overlay_visible,
set_debug_overlay_visible set_debug_overlay_visible,
settings_snapshot,
tauri_animation_slowdown_factor,
set_tauri_animation_slowdown_factor,
list_sprite_packs,
list_api_tokens,
create_api_token,
rename_api_token,
revoke_api_token,
set_sprite_pack,
set_scale,
set_visibility,
set_always_on_top
]) ])
.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 tray_state_holder = Arc::clone(&app_state.tray_state);
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
let tray_state = setup_tray(&app_handle, &runtime_core)?; 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 Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
let _ = window.set_shadow(false);
}
if let Ok(snapshot) = runtime_core.snapshot().read() { 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));
} }
@@ -251,10 +524,8 @@ 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( let _ = app_handle
EVENT_RUNTIME_SNAPSHOT, .emit(EVENT_RUNTIME_SNAPSHOT, to_ui_snapshot(&snapshot));
to_ui_snapshot(&snapshot),
);
} }
let _ = refresh_tray_menu_state( let _ = refresh_tray_menu_state(
runtime_core.as_ref(), runtime_core.as_ref(),
@@ -295,9 +566,55 @@ fn to_ui_snapshot(snapshot: &FrontendStateSnapshot) -> UiSnapshot {
y: snapshot.y, y: snapshot.y,
scale: snapshot.scale, scale: snapshot.scale,
active_sprite_pack: snapshot.active_sprite_pack.clone(), active_sprite_pack: snapshot.active_sprite_pack.clone(),
visible: snapshot.flags.visible,
always_on_top: snapshot.flags.always_on_top,
} }
} }
fn to_ui_api_token(entry: sprimo_config::ApiTokenEntry) -> UiApiToken {
UiApiToken {
id: entry.id,
label: entry.label,
token: entry.token,
created_at_ms: entry.created_at_ms,
}
}
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( fn setup_tray(
app_handle: &AppHandle<Wry>, app_handle: &AppHandle<Wry>,
runtime_core: &RuntimeCore, runtime_core: &RuntimeCore,
@@ -311,6 +628,7 @@ fn setup_tray(
.frontend_debug_overlay_visible() .frontend_debug_overlay_visible()
.map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?; .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( let toggle_visibility = MenuItem::with_id(
app_handle, app_handle,
MENU_ID_TOGGLE_VISIBILITY, MENU_ID_TOGGLE_VISIBILITY,
@@ -338,6 +656,7 @@ fn setup_tray(
let menu = Menu::with_items( let menu = Menu::with_items(
app_handle, app_handle,
&[ &[
&settings,
&toggle_visibility, &toggle_visibility,
&toggle_always_on_top, &toggle_always_on_top,
&toggle_debug_overlay, &toggle_debug_overlay,
@@ -368,6 +687,10 @@ fn handle_menu_event(
menu_id: &str, menu_id: &str,
) -> Result<(), String> { ) -> Result<(), String> {
match menu_id { match menu_id {
MENU_ID_SETTINGS => {
open_settings_window(app_handle).map_err(|err| err.to_string())?;
return Ok(());
}
MENU_ID_TOGGLE_VISIBILITY => { MENU_ID_TOGGLE_VISIBILITY => {
let current = runtime_core let current = runtime_core
.snapshot() .snapshot()
@@ -408,7 +731,9 @@ fn handle_menu_event(
}) })
.map_err(|err| err.to_string())?; .map_err(|err| err.to_string())?;
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) { 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 => { MENU_ID_TOGGLE_DEBUG_OVERLAY => {
@@ -419,7 +744,8 @@ fn handle_menu_event(
runtime_core runtime_core
.set_frontend_debug_overlay_visible(next) .set_frontend_debug_overlay_visible(next)
.map_err(|err| err.to_string())?; .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 => { MENU_ID_QUIT => {
persist_current_ui_flags(runtime_core)?; persist_current_ui_flags(runtime_core)?;
@@ -436,6 +762,30 @@ fn handle_menu_event(
Ok(()) 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> { fn persist_current_ui_flags(runtime_core: &RuntimeCore) -> Result<(), String> {
let snapshot = runtime_core let snapshot = runtime_core
.snapshot() .snapshot()
@@ -496,11 +846,7 @@ fn refresh_tray_menu_state(
} }
fn visibility_menu_title(visible: bool) -> &'static str { fn visibility_menu_title(visible: bool) -> &'static str {
if visible { if visible { "Hide" } else { "Show" }
"Hide"
} else {
"Show"
}
} }
fn state_name(value: FrontendState) -> &'static str { fn state_name(value: FrontendState) -> &'static str {
@@ -522,3 +868,95 @@ fn to_ui_clip(value: AnimationDefinition) -> UiAnimationClip {
one_shot: value.one_shot.unwrap_or(false), 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))
}

View File

@@ -16,6 +16,7 @@
"height": 416, "height": 416,
"decorations": false, "decorations": false,
"transparent": true, "transparent": true,
"shadow": false,
"alwaysOnTop": true, "alwaysOnTop": true,
"resizable": false "resizable": false
} }

View File

@@ -40,6 +40,7 @@ recovery_hotkey = "Ctrl+Alt+P"
[frontend] [frontend]
backend = "bevy" backend = "bevy"
debug_overlay_visible = false debug_overlay_visible = false
tauri_animation_slowdown_factor = 3
``` ```
## Notes ## Notes
@@ -50,3 +51,6 @@ debug_overlay_visible = 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. - `frontend.debug_overlay_visible` controls whether tauri window debug diagnostics panel is shown.
- `frontend.tauri_animation_slowdown_factor` controls tauri animation pacing multiplier.
valid range: `1..200`
effective frame interval: `(1000 / clip_fps) * factor`

View File

@@ -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 | | 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 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 | | 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
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. 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.

View File

@@ -18,6 +18,13 @@ Supporting checks:
- `GET /v1/health` - `GET /v1/health`
- `GET /v1/state` (periodic sampling) - `GET /v1/state` (periodic sampling)
Animation traffic now targets row-based sprite names plus compatibility aliases:
- semantic names: `idle`, `happy`, `love`, `excited`, `celebrate`, `sleepy`, `snoring`,
`working`, `angry`, `surprised`, `shy`, `dragging`
- compatibility aliases: `active`, `success`, `error`
- one intentional unknown name is still included to keep invalid animation-path coverage
## Prerequisites ## Prerequisites
- Frontend runtime is already running (`sprimo-app` or `sprimo-tauri`). - Frontend runtime is already running (`sprimo-app` or `sprimo-tauri`).

View File

@@ -125,6 +125,26 @@ Bevy window-management verification workflow: `docs/BEVY_WINDOW_VERIFICATION.md`
9. Verify scale-fit behavior in tauri runtime: 9. Verify scale-fit behavior in tauri runtime:
- send `SetTransform.scale` values above `1.0` - send `SetTransform.scale` values above `1.0`
- confirm full sprite remains visible and window auto-resizes without top clipping - 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
12. Verify packaged tauri scale anchoring and bounds:
- repeated scale changes resize around window center (no consistent bottom-right drift)
- window remains visible on the current monitor (no off-screen drift)
- no runtime scale-path exception appears (for example monitor lookup API errors)
- no runtime position-arg exceptions appear during scale (e.g. float passed to integer position API)
- at large scale values (>= 1.8), full sprite remains visible without clipping
13. Verify packaged tauri frontend freshness:
- confirm package run reflects latest `frontend/tauri-ui` changes (no stale embedded UI bundle)
14. Verify packaged tauri overlay edge and scale consistency:
- place overlay over dark background and confirm no white strip/background bleed on left edge
- for same slider value, confirm main-window size is consistent across `default`/`ferris`/`demogorgon`
### Packaged Mode (Required Once Tauri Packaging Exists) ### Packaged Mode (Required Once Tauri Packaging Exists)

View File

@@ -64,3 +64,36 @@ Path: `<pack_dir>/manifest.json`
- `rows = image_height / frame_height` - `rows = image_height / frame_height`
- Image dimensions must be divisible by frame dimensions. - Image dimensions must be divisible by frame dimensions.
- Every animation frame index must be `< columns * rows`. - 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`).
## Recommended 7x8 Row Semantics
For `sprite.png` packs using the fixed `8x7` topology, the project convention is:
- row 1 (`0..7`): `idle`
- row 2 (`8..15`): `happy`, `love`, compatibility alias `active`
- row 3 (`16..23`): `excited`, `celebrate`, compatibility alias `success`
- row 4 (`24..31`): `sleepy`, `snoring`
- row 5 (`32..39`): `working`
- row 6 (`40..47`): `angry`, `surprised`, `shy`, compatibility alias `error`
- row 7 (`48..55`): `dragging`
Default one-shot policy:
- `celebrate` and `success` are one-shot.
- other row animations loop by default.
Recommended FPS profile for 8-frame rows:
- looping rows: `1` fps
- one-shot `celebrate`/`success`: `2` fps

View File

@@ -47,14 +47,35 @@ 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)
- 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 - `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.
- 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.
- `sprite.png` animation naming now follows row semantics with backward-compatible aliases:
- row1: `idle`
- row2: `happy`/`love` + alias `active`
- row3: `excited`/`celebrate` + alias `success`
- row4: `sleepy`/`snoring`
- row5: `working`
- row6: `angry`/`surprised`/`shy` + alias `error`
- row7: `dragging`
- 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: - 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: - Windows-first tray/menu MVP is implemented:
- `Settings` (opens/focuses pop-out settings window)
- `Show/Hide` - `Show/Hide`
- `Always on top` toggle - `Always on top` toggle
- `Debug overlay` toggle - `Debug overlay` toggle
@@ -66,3 +87,4 @@ Frontend:
1. Extend tray/menu implementation beyond Windows-first MVP and close platform parity gaps. 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).
3. Add sprite-pack previews/thumbnails in the settings window character selector.

View File

@@ -100,8 +100,59 @@ An issue touching Tauri runtime behaviors must satisfy all requirements before `
6. Validate Tauri invoke/event behavior: 6. Validate Tauri invoke/event behavior:
- `current_state` output parsed successfully. - `current_state` output parsed successfully.
- `load_active_sprite_pack` returns expected fields. - `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. - `runtime:snapshot` event received on runtime command changes.
- `debug_overlay_visible` and `set_debug_overlay_visible` invoke commands work and persist config. - `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 background/fringe remains around sprite edges in normal runtime view,
including non-exact gradient magenta atlas backgrounds (for example `ferris`/`demogorgon`)
9. Animation tempo check for 8-frame rows:
- looping row animations should feel very slow/readable (1 fps profile)
- one-shot `celebrate`/`success` should run slightly faster than loops (2 fps profile)
- tauri renderer applies an additional global slowdown factor (`x2`) over clip fps; verify perceived
playback matches this expectation
10. Scale anchor and bounds check:
- repeated scale changes should keep window centered without directional drift
- window must remain within current monitor bounds during scale adjustments
- no runtime error is allowed from monitor lookup during scale operations (e.g. `currentMonitor`
API mismatch)
- verify window resize uses consistent coordinate units (no accumulated drift over 20 scale changes)
- no runtime command/type error from position updates (e.g. `set_position` expects integer coords)
- at the same slider scale value, main window size is consistent across packs (`default`, `ferris`,
`demogorgon`) within 1px rounding tolerance
- no white strip/background bleed is visible along any overlay window edge on dark desktop background
## 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
- persisted/runtime scale value reflects the effective fitted scale after monitor/window bounds
- value persists after restart
5. Change animation speed factor slider and verify:
- runtime animation pace updates immediately in main overlay
- value is clamped to `1..200`
- value persists after restart via `frontend.tauri_animation_slowdown_factor`
6. Toggle `Visible` and verify:
- main overlay hide/show behavior
- persisted value survives restart
7. Toggle `Always on top` and verify:
- main window z-order behavior updates
- persisted value survives restart
## Evidence Requirements ## Evidence Requirements

View File

@@ -2,128 +2,344 @@ import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { LogicalPosition, LogicalSize, getCurrentWindow } from "@tauri-apps/api/window"; import {
PhysicalPosition,
PhysicalSize,
currentMonitor,
getCurrentWindow,
monitorFromPoint
} from "@tauri-apps/api/window";
import { PixiPetRenderer, type UiSpritePack, type UiSnapshot } from "./renderer/pixi_pet"; import { PixiPetRenderer, type UiSpritePack, type UiSnapshot } from "./renderer/pixi_pet";
import "./styles.css"; import "./styles.css";
type UiSettingsSnapshot = {
active_sprite_pack: string;
scale: number;
visible: boolean;
always_on_top: boolean;
tauri_animation_slowdown_factor: number;
};
type UiSpritePackOption = {
id: string;
pack_id_or_path: string;
};
type UiApiToken = {
id: string;
label: string;
token: string;
created_at_ms: number;
};
const WINDOW_PADDING = 16; const WINDOW_PADDING = 16;
const WINDOW_WORKAREA_MARGIN = 80;
const MIN_WINDOW_SIZE = 64; const MIN_WINDOW_SIZE = 64;
const SIZE_EPSILON = 0.5; const SIZE_EPSILON = 0.5;
const SCALE_EPSILON = 0.0001; const SCALE_EPSILON = 0.0001;
const SCALE_MIN = 0.5;
const SCALE_MAX = 3.0;
const LOGICAL_BASE_FRAME_WIDTH = 512;
const LOGICAL_BASE_FRAME_HEIGHT = 512;
const SLOWDOWN_FACTOR_MIN = 1;
const SLOWDOWN_FACTOR_MAX = 200;
const SLOWDOWN_FACTOR_DEFAULT = 3;
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 });
}
async function invokeAnimationSlowdownFactor(): Promise<number> {
return invoke<number>("tauri_animation_slowdown_factor");
}
async function invokeSetAnimationSlowdownFactor(factor: number): Promise<number> {
return invoke<number>("set_tauri_animation_slowdown_factor", { factor });
}
async function invokeListApiTokens(): Promise<UiApiToken[]> {
return invoke<UiApiToken[]>("list_api_tokens");
}
async function invokeCreateApiToken(label?: string): Promise<UiApiToken> {
return invoke<UiApiToken>("create_api_token", { label });
}
async function invokeRenameApiToken(id: string, label: string): Promise<UiApiToken[]> {
return invoke<UiApiToken[]>("rename_api_token", { id, label });
}
async function invokeRevokeApiToken(id: string): Promise<UiApiToken[]> {
return invoke<UiApiToken[]>("revoke_api_token", { id });
}
function fittedWindowSize( function fittedWindowSize(
frameWidth: number,
frameHeight: number,
scale: number scale: number
): { width: number; height: number } { ): { width: number; height: number } {
const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1; const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
const width = Math.max(frameWidth * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE); const width = Math.round(
const height = Math.max(frameHeight * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE); Math.max(LOGICAL_BASE_FRAME_WIDTH * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE)
);
const height = Math.round(
Math.max(LOGICAL_BASE_FRAME_HEIGHT * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE)
);
return { width, height }; return { width, height };
} }
async function fitWindowForScale(pack: UiSpritePack, scale: number): Promise<void> { function effectiveScaleForWindowSize(width: number, height: number): number {
const window = getCurrentWindow(); const availableWidth = Math.max(width - WINDOW_PADDING, MIN_WINDOW_SIZE);
const [outerPosition, innerSize] = await Promise.all([ const availableHeight = Math.max(height - WINDOW_PADDING, MIN_WINDOW_SIZE);
window.outerPosition(), const scaleByWidth = availableWidth / LOGICAL_BASE_FRAME_WIDTH;
window.innerSize() const scaleByHeight = availableHeight / LOGICAL_BASE_FRAME_HEIGHT;
]); const scale = Math.min(scaleByWidth, scaleByHeight);
return Math.max(SCALE_MIN, Math.min(scale, SCALE_MAX));
const target = fittedWindowSize(pack.frame_width, pack.frame_height, scale);
const widthChanged = Math.abs(target.width - innerSize.width) > SIZE_EPSILON;
const heightChanged = Math.abs(target.height - innerSize.height) > SIZE_EPSILON;
if (!widthChanged && !heightChanged) {
return;
}
const deltaWidth = target.width - innerSize.width;
const deltaHeight = target.height - innerSize.height;
const targetX = outerPosition.x - deltaWidth / 2;
const targetY = outerPosition.y - deltaHeight;
await window.setSize(new LogicalSize(target.width, target.height));
await window.setPosition(new LogicalPosition(targetX, targetY));
} }
function App(): JSX.Element { async function fitWindowForScale(scale: number): Promise<number> {
const window = getCurrentWindow();
const [outerPosition, innerSize] = await Promise.all([window.outerPosition(), window.innerSize()]);
const target = fittedWindowSize(scale);
const centerX = outerPosition.x + innerSize.width / 2;
const centerY = outerPosition.y + innerSize.height / 2;
let targetWidth = target.width;
let targetHeight = target.height;
let targetX = centerX - targetWidth / 2;
let targetY = centerY - targetHeight / 2;
let monitor:
| {
position: { x: number; y: number };
size: { width: number; height: number };
workArea: { position: { x: number; y: number }; size: { width: number; height: number } };
}
| null = null;
try {
monitor = (await monitorFromPoint(centerX, centerY)) ?? (await currentMonitor());
} catch {
monitor = null;
}
if (monitor !== null) {
const widthCap = Math.max(
monitor.workArea.size.width - WINDOW_WORKAREA_MARGIN,
MIN_WINDOW_SIZE
);
const heightCap = Math.max(
monitor.workArea.size.height - WINDOW_WORKAREA_MARGIN,
MIN_WINDOW_SIZE
);
targetWidth = Math.min(targetWidth, widthCap);
targetHeight = Math.min(targetHeight, heightCap);
targetX = centerX - targetWidth / 2;
targetY = centerY - targetHeight / 2;
}
const widthChanged = Math.abs(targetWidth - innerSize.width) > SIZE_EPSILON;
const heightChanged = Math.abs(targetHeight - innerSize.height) > SIZE_EPSILON;
if (widthChanged || heightChanged) {
await window.setSize(new PhysicalSize(targetWidth, targetHeight));
if (monitor !== null) {
const minX = Math.round(monitor.workArea.position.x);
const minY = Math.round(monitor.workArea.position.y);
const maxX = Math.round(
monitor.workArea.position.x + monitor.workArea.size.width - targetWidth
);
const maxY = Math.round(
monitor.workArea.position.y + monitor.workArea.size.height - targetHeight
);
targetX = maxX < minX ? minX : Math.min(Math.max(targetX, minX), maxX);
targetY = maxY < minY ? minY : Math.min(Math.max(targetY, minY), maxY);
}
await window.setPosition(new PhysicalPosition(Math.round(targetX), Math.round(targetY)));
}
return effectiveScaleForWindowSize(targetWidth, targetHeight);
}
function MainOverlayWindow(): 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 [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 activePackRef = React.useRef<UiSpritePack | null>(null);
const loadedPackKeyRef = React.useRef<string | null>(null);
const effectiveScaleSyncRef = React.useRef<number | null>(null);
const slowdownFactorRef = React.useRef<number>(SLOWDOWN_FACTOR_DEFAULT);
const loadingPackRef = React.useRef(false);
const mountedRef = React.useRef(false);
React.useEffect(() => { React.useEffect(() => {
mountedRef.current = true;
let unlisten: null | (() => void) = null; let unlisten: null | (() => void) = null;
let mounted = true;
let activePack: UiSpritePack | null = null; 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);
nextRenderer.setAnimationSlowdownFactor(slowdownFactorRef.current);
rendererRef.current = nextRenderer;
activePackRef.current = pack;
loadedPackKeyRef.current = nextSnapshot.active_sprite_pack;
previousRenderer?.dispose();
return true;
};
const tryFitWindow = async (scale: number): Promise<number | null> => {
try {
return await fitWindowForScale(scale);
} catch (err) {
if (mountedRef.current) {
setError(String(err));
}
return null;
}
};
const syncEffectiveScale = async (snapshotScale: number, effectiveScale: number): Promise<void> => {
if (Math.abs(snapshotScale - effectiveScale) < SCALE_EPSILON) {
return;
}
if (
effectiveScaleSyncRef.current !== null &&
Math.abs(effectiveScaleSyncRef.current - effectiveScale) < SCALE_EPSILON
) {
return;
}
effectiveScaleSyncRef.current = effectiveScale;
try {
await invokeSetScale(effectiveScale);
} catch (err) {
if (mountedRef.current) {
setError(String(err));
}
}
};
const processSnapshot = async (value: UiSnapshot): Promise<void> => {
if (!mountedRef.current) {
return;
}
if (
effectiveScaleSyncRef.current !== null &&
Math.abs(effectiveScaleSyncRef.current - value.scale) < SCALE_EPSILON
) {
effectiveScaleSyncRef.current = null;
}
setSnapshot(value);
rendererRef.current?.applySnapshot(value);
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) {
const effectiveScale = await tryFitWindow(value.scale);
if (effectiveScale !== null) {
await syncEffectiveScale(value.scale, effectiveScale);
}
if (mountedRef.current && effectiveScale !== null) {
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;
}
const effectiveScale = await tryFitWindow(value.scale);
if (effectiveScale !== null) {
await syncEffectiveScale(value.scale, effectiveScale);
}
if (effectiveScale !== null && mountedRef.current) {
setError(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") invoke<boolean>("debug_overlay_visible"),
invokeAnimationSlowdownFactor()
]) ])
.then(async ([pack, initialSnapshot, showDebug]) => { .then(async ([pack, initialSnapshot, showDebug, slowdownFactor]) => {
if (!mounted) { if (!mountedRef.current) {
return; return;
} }
activePack = pack;
setDebugOverlayVisible(showDebug); setDebugOverlayVisible(showDebug);
setSnapshot(initialSnapshot); slowdownFactorRef.current = Math.min(
if (hostRef.current !== null) { Math.max(Math.round(slowdownFactor), SLOWDOWN_FACTOR_MIN),
rendererRef.current = await PixiPetRenderer.create( SLOWDOWN_FACTOR_MAX
hostRef.current, );
pack, await recreateRenderer(pack, initialSnapshot);
initialSnapshot await processSnapshot(initialSnapshot);
);
}
scaleFitRef.current = initialSnapshot.scale;
await fitWindowForScale(pack, initialSnapshot.scale);
unlisten = await listen<UiSnapshot>("runtime:snapshot", (event) => { const unlistenSnapshot = await listen<UiSnapshot>("runtime:snapshot", (event) => {
if (!mounted) { void processSnapshot(event.payload);
return;
}
const value = event.payload;
setSnapshot(value);
rendererRef.current?.applySnapshot(value);
if (activePack === null) {
return;
}
if (
scaleFitRef.current !== null &&
Math.abs(scaleFitRef.current - value.scale) < SCALE_EPSILON
) {
return;
}
scaleFitRef.current = value.scale;
void fitWindowForScale(activePack, value.scale).catch((err) => {
if (mounted) {
setError(String(err));
}
});
}); });
const unlistenDebug = await listen<boolean>( const unlistenDebug = await listen<boolean>("runtime:debug-overlay-visible", (event) => {
"runtime:debug-overlay-visible", if (!mountedRef.current) {
return;
}
setDebugOverlayVisible(Boolean(event.payload));
});
const unlistenSlowdown = await listen<number>(
"runtime:animation-slowdown-factor",
(event) => { (event) => {
if (!mounted) { if (!mountedRef.current) {
return; return;
} }
setDebugOverlayVisible(Boolean(event.payload)); slowdownFactorRef.current = Math.min(
Math.max(Math.round(Number(event.payload)), SLOWDOWN_FACTOR_MIN),
SLOWDOWN_FACTOR_MAX
);
rendererRef.current?.setAnimationSlowdownFactor(slowdownFactorRef.current);
} }
); );
const previousUnlisten = unlisten;
unlisten = () => { unlisten = () => {
previousUnlisten(); unlistenSnapshot();
unlistenDebug(); unlistenDebug();
unlistenSlowdown();
}; };
}) })
.catch((err) => { .catch((err) => {
if (mounted) { if (mountedRef.current) {
setError(String(err)); setError(String(err));
} }
}); });
return () => { return () => {
mounted = false; mountedRef.current = false;
if (unlisten !== null) { if (unlisten !== null) {
unlisten(); unlisten();
} }
@@ -168,7 +384,7 @@ function App(): JSX.Element {
}, []); }, []);
return ( return (
<main className="app" onMouseDown={onMouseDown}> <main className="app overlay-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} {error !== null && !debugOverlayVisible ? <p className="error-banner">{error}</p> : null}
{debugOverlayVisible ? ( {debugOverlayVisible ? (
@@ -200,8 +416,425 @@ function App(): JSX.Element {
); );
} }
function SettingsWindow(): JSX.Element {
const [settings, setSettings] = React.useState<UiSettingsSnapshot | null>(null);
const [packs, setPacks] = React.useState<UiSpritePackOption[]>([]);
const [tokens, setTokens] = React.useState<UiApiToken[]>([]);
const [tokenDrafts, setTokenDrafts] = React.useState<Record<string, string>>({});
const [newTokenLabel, setNewTokenLabel] = React.useState("");
const [activePack, setActivePack] = React.useState<UiSpritePack | null>(null);
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"),
invoke<UiSpritePack>("load_active_sprite_pack"),
invokeListApiTokens()
])
.then(async ([snapshot, options, pack, authTokens]) => {
if (!mounted) {
return;
}
setSettings(snapshot);
setPacks(options);
setActivePack(pack);
setTokens(authTokens);
setTokenDrafts(
authTokens.reduce<Record<string, string>>((acc, token) => {
acc[token.id] = token.label;
return acc;
}, {})
);
unlisten = await listen<UiSnapshot>("runtime:snapshot", (event) => {
if (!mounted) {
return;
}
const payload = event.payload;
if (payload.active_sprite_pack !== activePack?.id) {
void invoke<UiSpritePack>("load_active_sprite_pack")
.then((nextPack) => {
if (mounted) {
setActivePack(nextPack);
}
})
.catch(() => {
// Keep existing pack metadata if reload fails.
});
}
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,
tauri_animation_slowdown_factor:
prev.tauri_animation_slowdown_factor ?? SLOWDOWN_FACTOR_DEFAULT
};
});
});
const unlistenSlowdown = await listen<number>("runtime:animation-slowdown-factor", (event) => {
if (!mounted) {
return;
}
const factor = Math.min(
Math.max(Math.round(Number(event.payload)), SLOWDOWN_FACTOR_MIN),
SLOWDOWN_FACTOR_MAX
);
setSettings((prev) =>
prev === null ? prev : { ...prev, tauri_animation_slowdown_factor: factor }
);
});
const previousUnlisten = unlisten;
unlisten = () => {
previousUnlisten();
unlistenSlowdown();
};
})
.catch((err) => {
if (mounted) {
setError(String(err));
}
});
return () => {
mounted = false;
if (unlisten !== null) {
unlisten();
}
};
}, [activePack?.id]);
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;
}
const refreshedPack = await withPending(() => invoke<UiSpritePack>("load_active_sprite_pack"));
if (refreshedPack !== null) {
setActivePack(refreshedPack);
}
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]
);
const onAnimationSlowdownFactorChange = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = Number(event.target.value);
if (!Number.isFinite(value)) {
return;
}
const clamped = Math.min(
Math.max(Math.round(value), SLOWDOWN_FACTOR_MIN),
SLOWDOWN_FACTOR_MAX
);
const persisted = await withPending(() => invokeSetAnimationSlowdownFactor(clamped));
if (persisted === null) {
return;
}
setSettings((prev) =>
prev === null
? prev
: { ...prev, tauri_animation_slowdown_factor: Number(persisted) }
);
},
[withPending]
);
const onNewTokenLabelChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setNewTokenLabel(event.target.value);
},
[]
);
const onCreateToken = React.useCallback(async () => {
const created = await withPending(() => invokeCreateApiToken(newTokenLabel || undefined));
if (created === null) {
return;
}
const refreshed = await withPending(() => invokeListApiTokens());
if (refreshed === null) {
return;
}
setTokens(refreshed);
setTokenDrafts(
refreshed.reduce<Record<string, string>>((acc, token) => {
acc[token.id] = token.label;
return acc;
}, {})
);
setNewTokenLabel("");
}, [newTokenLabel, withPending]);
const onTokenDraftChange = React.useCallback((id: string, value: string) => {
setTokenDrafts((prev) => ({
...prev,
[id]: value
}));
}, []);
const onRenameToken = React.useCallback(
async (id: string) => {
const nextLabel = tokenDrafts[id] ?? "";
const refreshed = await withPending(() => invokeRenameApiToken(id, nextLabel));
if (refreshed === null) {
return;
}
setTokens(refreshed);
setTokenDrafts(
refreshed.reduce<Record<string, string>>((acc, token) => {
acc[token.id] = token.label;
return acc;
}, {})
);
},
[tokenDrafts, withPending]
);
const onCopyToken = React.useCallback(async (token: string) => {
try {
await navigator.clipboard.writeText(token);
} catch (err) {
setError(String(err));
}
}, []);
const onRevokeToken = React.useCallback(
async (id: string) => {
if (!window.confirm("Revoke this API token?")) {
return;
}
const refreshed = await withPending(() => invokeRevokeApiToken(id));
if (refreshed === null) {
return;
}
setTokens(refreshed);
setTokenDrafts(
refreshed.reduce<Record<string, string>>((acc, token) => {
acc[token.id] = token.label;
return acc;
}, {})
);
},
[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={SCALE_MIN}
max={SCALE_MAX}
step={0.05}
value={settings.scale}
disabled={pending}
onChange={onScaleChange}
/>
</label>
<label className="field">
<span>
Animation Slowdown Factor (higher = slower): x
{settings.tauri_animation_slowdown_factor}
</span>
<input
type="range"
min={SLOWDOWN_FACTOR_MIN}
max={SLOWDOWN_FACTOR_MAX}
step={1}
value={settings.tauri_animation_slowdown_factor}
disabled={pending}
onChange={onAnimationSlowdownFactorChange}
/>
</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 className="token-section">
<h2>API Tokens</h2>
<p className="token-help">
Use any listed token as `Authorization: Bearer &lt;token&gt;`.
</p>
<div className="token-create">
<input
type="text"
value={newTokenLabel}
placeholder="New token label"
disabled={pending}
onChange={onNewTokenLabelChange}
/>
<button type="button" disabled={pending} onClick={onCreateToken}>
Create token
</button>
</div>
<div className="token-list">
{tokens.map((entry) => (
<article className="token-item" key={entry.id}>
<label className="field">
<span>Label</span>
<input
type="text"
value={tokenDrafts[entry.id] ?? ""}
disabled={pending}
onChange={(event) =>
onTokenDraftChange(entry.id, event.target.value)
}
/>
</label>
<label className="field">
<span>Token</span>
<input type="text" value={entry.token} readOnly />
</label>
<div className="token-actions">
<button
type="button"
disabled={pending}
onClick={() => onCopyToken(entry.token)}
>
Copy
</button>
<button
type="button"
disabled={pending}
onClick={() => onRenameToken(entry.id)}
>
Save label
</button>
<button
type="button"
disabled={pending || tokens.length <= 1}
onClick={() => onRevokeToken(entry.id)}
>
Revoke
</button>
</div>
</article>
))}
</div>
</section>
</>
)}
</section>
</main>
);
}
function AppRoot(): JSX.Element {
const windowLabel = getCurrentWindow().label;
if (windowLabel === "settings") {
return <SettingsWindow />;
}
return <MainOverlayWindow />;
}
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<App /> <AppRoot />
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -28,9 +28,34 @@ export type UiSnapshot = {
y: number; y: number;
scale: number; scale: number;
active_sprite_pack: string; active_sprite_pack: string;
visible: boolean;
always_on_top: boolean;
}; };
type AnimationMap = Map<string, UiAnimationClip>; type AnimationMap = Map<string, UiAnimationClip>;
const KEY_R = 0xff;
const KEY_G = 0x00;
const KEY_B = 0xff;
const FALLBACK_MIN_CONNECTED_RATIO = 0.005;
const CONNECTED_HUE_MIN = 270;
const CONNECTED_HUE_MAX = 350;
const CONNECTED_SAT_MIN = 0.25;
const CONNECTED_VAL_MIN = 0.08;
const FALLBACK_HUE_MIN = 255;
const FALLBACK_HUE_MAX = 355;
const FALLBACK_SAT_MIN = 0.15;
const FALLBACK_VAL_MIN = 0.04;
const STRONG_MAGENTA_RB_MIN = 72;
const STRONG_MAGENTA_DOMINANCE = 24;
const HALO_HUE_MIN = 245;
const HALO_HUE_MAX = 355;
const HALO_SAT_MIN = 0.15;
const HALO_VAL_MIN = 0.04;
const RENDER_FIT_PADDING = 16;
const MIN_RENDER_SCALE = 0.01;
const ANIMATION_SLOWDOWN_FACTOR_MIN = 1;
const ANIMATION_SLOWDOWN_FACTOR_MAX = 200;
const ANIMATION_SLOWDOWN_FACTOR_DEFAULT = 3;
export class PixiPetRenderer { export class PixiPetRenderer {
private app: Application; private app: Application;
@@ -41,6 +66,8 @@ export class PixiPetRenderer {
private frameCursor = 0; private frameCursor = 0;
private frameElapsedMs = 0; private frameElapsedMs = 0;
private baseTexture: BaseTexture; private baseTexture: BaseTexture;
private animationSlowdownFactor = ANIMATION_SLOWDOWN_FACTOR_DEFAULT;
private disposed = false;
private constructor( private constructor(
app: Application, app: Application,
@@ -67,8 +94,6 @@ export class PixiPetRenderer {
antialias: true, antialias: true,
resizeTo: container resizeTo: container
}); });
container.replaceChildren(app.view as HTMLCanvasElement);
const baseTexture = await PixiPetRenderer.loadBaseTexture(pack.atlas_data_url); const baseTexture = await PixiPetRenderer.loadBaseTexture(pack.atlas_data_url);
if (baseTexture.width <= 0 || baseTexture.height <= 0) { if (baseTexture.width <= 0 || baseTexture.height <= 0) {
throw new Error("Atlas image loaded with invalid dimensions."); throw new Error("Atlas image loaded with invalid dimensions.");
@@ -76,6 +101,7 @@ export class PixiPetRenderer {
const sprite = new Sprite(); const sprite = new Sprite();
sprite.anchor.set(pack.anchor.x, pack.anchor.y); sprite.anchor.set(pack.anchor.x, pack.anchor.y);
app.stage.addChild(sprite); app.stage.addChild(sprite);
container.replaceChildren(app.view as HTMLCanvasElement);
const renderer = new PixiPetRenderer(app, sprite, pack, baseTexture); const renderer = new PixiPetRenderer(app, sprite, pack, baseTexture);
renderer.layoutSprite(); renderer.layoutSprite();
@@ -99,16 +125,222 @@ export class PixiPetRenderer {
ctx.drawImage(image, 0, 0); ctx.drawImage(image, 0, 0);
const frame = ctx.getImageData(0, 0, canvas.width, canvas.height); const frame = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = frame.data; const data = frame.data;
const keyR = 0xff; const width = canvas.width;
const keyG = 0x00; const height = canvas.height;
const keyB = 0xff; const pixelCount = width * height;
const tolerance = 28; const isKeyLike = new Uint8Array(pixelCount);
for (let i = 0; i < data.length; i += 4) { const removedBg = new Uint8Array(pixelCount);
const dr = Math.abs(data[i] - keyR); const queue = new Int32Array(pixelCount);
const dg = Math.abs(data[i + 1] - keyG); let head = 0;
const db = Math.abs(data[i + 2] - keyB); let tail = 0;
if (dr <= tolerance && dg <= tolerance && db <= tolerance) {
data[i + 3] = 0; const indexFor = (x: number, y: number): number => y * width + x;
const channelOffset = (index: number): number => index * 4;
const enqueueIfKeyLike = (x: number, y: number): void => {
const idx = indexFor(x, y);
if (isKeyLike[idx] === 1 && removedBg[idx] === 0) {
removedBg[idx] = 1;
queue[tail] = idx;
tail += 1;
}
};
for (let idx = 0; idx < pixelCount; idx += 1) {
const offset = channelOffset(idx);
const [h, s, v] = PixiPetRenderer.rgbToHsv(
data[offset],
data[offset + 1],
data[offset + 2]
);
if (
PixiPetRenderer.isHueInRange(h, CONNECTED_HUE_MIN, CONNECTED_HUE_MAX) &&
s >= CONNECTED_SAT_MIN &&
v >= CONNECTED_VAL_MIN
) {
isKeyLike[idx] = 1;
continue;
}
if (
PixiPetRenderer.isStrongMagentaFamily(
data[offset],
data[offset + 1],
data[offset + 2]
)
) {
isKeyLike[idx] = 1;
}
}
for (let x = 0; x < width; x += 1) {
enqueueIfKeyLike(x, 0);
enqueueIfKeyLike(x, height - 1);
}
for (let y = 1; y < height - 1; y += 1) {
enqueueIfKeyLike(0, y);
enqueueIfKeyLike(width - 1, y);
}
while (head < tail) {
const idx = queue[head];
head += 1;
const x = idx % width;
const y = Math.floor(idx / width);
if (x > 0) {
enqueueIfKeyLike(x - 1, y);
}
if (x + 1 < width) {
enqueueIfKeyLike(x + 1, y);
}
if (y > 0) {
enqueueIfKeyLike(x, y - 1);
}
if (y + 1 < height) {
enqueueIfKeyLike(x, y + 1);
}
}
const connectedRemovedCount = tail;
for (let idx = 0; idx < pixelCount; idx += 1) {
if (removedBg[idx] !== 1) {
continue;
}
const offset = channelOffset(idx);
data[offset + 3] = 0;
}
const needsFallback =
connectedRemovedCount / Math.max(pixelCount, 1) < FALLBACK_MIN_CONNECTED_RATIO;
if (needsFallback) {
for (let idx = 0; idx < pixelCount; idx += 1) {
const offset = channelOffset(idx);
const [h, s, v] = PixiPetRenderer.rgbToHsv(
data[offset],
data[offset + 1],
data[offset + 2]
);
const maxDistanceFromHardKey = PixiPetRenderer.maxColorDistance(
data[offset],
data[offset + 1],
data[offset + 2],
KEY_R,
KEY_G,
KEY_B
);
if (
(PixiPetRenderer.isHueInRange(h, FALLBACK_HUE_MIN, FALLBACK_HUE_MAX) &&
s >= FALLBACK_SAT_MIN &&
v >= FALLBACK_VAL_MIN) ||
PixiPetRenderer.isStrongMagentaFamily(
data[offset],
data[offset + 1],
data[offset + 2]
) ||
maxDistanceFromHardKey <= 96
) {
data[offset + 3] = 0;
}
}
}
// Deterministic last pass: remove any border-connected magenta-family background.
head = 0;
tail = 0;
removedBg.fill(0);
const enqueueIfMagentaBorder = (x: number, y: number): void => {
const idx = indexFor(x, y);
if (removedBg[idx] === 1) {
return;
}
const offset = channelOffset(idx);
if (data[offset + 3] === 0) {
return;
}
if (
!PixiPetRenderer.isStrongMagentaFamily(
data[offset],
data[offset + 1],
data[offset + 2]
)
) {
return;
}
removedBg[idx] = 1;
queue[tail] = idx;
tail += 1;
};
for (let x = 0; x < width; x += 1) {
enqueueIfMagentaBorder(x, 0);
enqueueIfMagentaBorder(x, height - 1);
}
for (let y = 1; y < height - 1; y += 1) {
enqueueIfMagentaBorder(0, y);
enqueueIfMagentaBorder(width - 1, y);
}
while (head < tail) {
const idx = queue[head];
head += 1;
const x = idx % width;
const y = Math.floor(idx / width);
if (x > 0) {
enqueueIfMagentaBorder(x - 1, y);
}
if (x + 1 < width) {
enqueueIfMagentaBorder(x + 1, y);
}
if (y > 0) {
enqueueIfMagentaBorder(x, y - 1);
}
if (y + 1 < height) {
enqueueIfMagentaBorder(x, y + 1);
}
}
for (let idx = 0; idx < pixelCount; idx += 1) {
if (removedBg[idx] !== 1) {
continue;
}
const offset = channelOffset(idx);
data[offset + 3] = 0;
}
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const idx = indexFor(x, y);
if (data[channelOffset(idx) + 3] === 0) {
continue;
}
let touchesBackground = false;
if (x > 0 && data[channelOffset(indexFor(x - 1, y)) + 3] === 0) {
touchesBackground = true;
} else if (x + 1 < width && data[channelOffset(indexFor(x + 1, y)) + 3] === 0) {
touchesBackground = true;
} else if (y > 0 && data[channelOffset(indexFor(x, y - 1)) + 3] === 0) {
touchesBackground = true;
} else if (y + 1 < height && data[channelOffset(indexFor(x, y + 1)) + 3] === 0) {
touchesBackground = true;
}
if (!touchesBackground) {
continue;
}
const offset = channelOffset(idx);
const [h, s, v] = PixiPetRenderer.rgbToHsv(
data[offset],
data[offset + 1],
data[offset + 2]
);
if (
!PixiPetRenderer.isHueInRange(h, HALO_HUE_MIN, HALO_HUE_MAX) ||
s < HALO_SAT_MIN ||
v < HALO_VAL_MIN
) {
continue;
}
data[offset] = Math.round(data[offset] * 0.72);
data[offset + 2] = Math.round(data[offset + 2] * 0.72);
data[offset + 3] = Math.round(data[offset + 3] * 0.86);
} }
} }
ctx.putImageData(frame, 0, 0); ctx.putImageData(frame, 0, 0);
@@ -121,14 +353,67 @@ export class PixiPetRenderer {
}); });
} }
private static maxColorDistance(
r: number,
g: number,
b: number,
keyR: number,
keyG: number,
keyB: number
): number {
const dr = Math.abs(r - keyR);
const dg = Math.abs(g - keyG);
const db = Math.abs(b - keyB);
return Math.max(dr, dg, db);
}
private static rgbToHsv(r: number, g: number, b: number): [number, number, number] {
const rf = r / 255;
const gf = g / 255;
const bf = b / 255;
const max = Math.max(rf, gf, bf);
const min = Math.min(rf, gf, bf);
const delta = max - min;
let hue = 0;
if (delta > 0) {
if (max === rf) {
hue = 60 * (((gf - bf) / delta) % 6);
} else if (max === gf) {
hue = 60 * ((bf - rf) / delta + 2);
} else {
hue = 60 * ((rf - gf) / delta + 4);
}
}
if (hue < 0) {
hue += 360;
}
const saturation = max === 0 ? 0 : delta / max;
const value = max;
return [hue, saturation, value];
}
private static isHueInRange(hue: number, min: number, max: number): boolean {
if (min <= max) {
return hue >= min && hue <= max;
}
return hue >= min || hue <= max;
}
private static isStrongMagentaFamily(r: number, g: number, b: number): boolean {
const minRb = Math.min(r, b);
return (
r >= STRONG_MAGENTA_RB_MIN &&
b >= STRONG_MAGENTA_RB_MIN &&
g + STRONG_MAGENTA_DOMINANCE <= minRb
);
}
dispose(): void { dispose(): void {
this.app.ticker.stop(); if (this.disposed) {
this.app.ticker.destroy(); return;
this.sprite.destroy({ }
children: true, this.disposed = true;
texture: false,
baseTexture: false
});
this.app.destroy(true, { this.app.destroy(true, {
children: true, children: true,
texture: false, texture: false,
@@ -137,6 +422,9 @@ export class PixiPetRenderer {
} }
applySnapshot(snapshot: UiSnapshot): void { applySnapshot(snapshot: UiSnapshot): void {
if (this.disposed) {
return;
}
const nextClip = this.resolveClip(snapshot.current_animation); const nextClip = this.resolveClip(snapshot.current_animation);
if (nextClip.name !== this.currentClip.name) { if (nextClip.name !== this.currentClip.name) {
this.currentClip = nextClip; this.currentClip = nextClip;
@@ -144,14 +432,27 @@ export class PixiPetRenderer {
this.frameElapsedMs = 0; this.frameElapsedMs = 0;
this.applyFrameTexture(this.currentClip.frames[0] ?? 0); this.applyFrameTexture(this.currentClip.frames[0] ?? 0);
} }
this.sprite.scale.set(snapshot.scale);
this.layoutSprite(); this.layoutSprite();
} }
setAnimationSlowdownFactor(factor: number): void {
if (!Number.isFinite(factor)) {
return;
}
const rounded = Math.round(factor);
this.animationSlowdownFactor = Math.min(
Math.max(rounded, ANIMATION_SLOWDOWN_FACTOR_MIN),
ANIMATION_SLOWDOWN_FACTOR_MAX
);
}
private startTicker(): void { private startTicker(): void {
this.app.ticker.add((ticker) => { this.app.ticker.add((ticker) => {
if (this.disposed) {
return;
}
this.layoutSprite(); this.layoutSprite();
const frameMs = 1000 / Math.max(this.currentClip.fps, 1); const frameMs = (1000 / Math.max(this.currentClip.fps, 1)) * this.animationSlowdownFactor;
this.frameElapsedMs += ticker.deltaMS; this.frameElapsedMs += ticker.deltaMS;
if (this.frameElapsedMs < frameMs) { if (this.frameElapsedMs < frameMs) {
return; return;
@@ -172,6 +473,12 @@ export class PixiPetRenderer {
} }
private layoutSprite(): void { private layoutSprite(): void {
const availableWidth = Math.max(this.app.renderer.width - RENDER_FIT_PADDING, 1);
const availableHeight = Math.max(this.app.renderer.height - RENDER_FIT_PADDING, 1);
const fitScaleX = availableWidth / Math.max(this.pack.frame_width, 1);
const fitScaleY = availableHeight / Math.max(this.pack.frame_height, 1);
const fitScale = Math.max(Math.min(fitScaleX, fitScaleY), MIN_RENDER_SCALE);
this.sprite.scale.set(fitScale);
this.sprite.position.set(this.app.renderer.width / 2, this.app.renderer.height); this.sprite.position.set(this.app.renderer.width / 2, this.app.renderer.height);
} }

View File

@@ -2,13 +2,23 @@
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
} }
html,
body { body {
margin: 0; margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: transparent; background: transparent;
color: #e2e8f0; color: #e2e8f0;
overflow: hidden; overflow: hidden;
} }
#root {
width: 100%;
height: 100%;
background: transparent;
}
.app { .app {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
@@ -73,3 +83,151 @@ dd {
color: #fee2e2; color: #fee2e2;
font-size: 12px; font-size: 12px;
} }
.settings-root {
height: 100vh;
min-height: 100vh;
display: flex;
align-items: stretch;
justify-content: center;
background: linear-gradient(180deg, #f1f5f9 0%, #dbe4ee 100%);
color: #0f172a;
user-select: none;
overflow-y: auto;
overflow-x: hidden;
}
.settings-card {
width: 100%;
max-width: 480px;
margin: 0;
padding: 20px;
display: grid;
gap: 14px;
align-content: start;
box-sizing: border-box;
}
.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"],
.field input[type="text"] {
width: 100%;
}
.field select {
border: 1px solid #94a3b8;
border-radius: 8px;
padding: 8px 10px;
background: #ffffff;
color: #0f172a;
}
.field input[type="text"] {
border: 1px solid #94a3b8;
border-radius: 8px;
padding: 8px 10px;
background: #ffffff;
color: #0f172a;
box-sizing: border-box;
}
.toggle {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: #1e293b;
}
.token-section {
margin-top: 8px;
border-top: 1px solid #cbd5e1;
padding-top: 12px;
display: grid;
gap: 10px;
}
.token-section h2 {
margin: 0;
font-size: 18px;
}
.token-help {
margin: 0;
font-size: 12px;
color: #475569;
}
.token-create {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
.token-create button,
.token-actions button {
border: 1px solid #94a3b8;
border-radius: 8px;
padding: 7px 10px;
background: #ffffff;
color: #0f172a;
cursor: pointer;
}
.token-create button:disabled,
.token-actions button:disabled {
opacity: 0.5;
cursor: default;
}
.token-list {
display: grid;
gap: 10px;
}
.token-item {
border: 1px solid #cbd5e1;
border-radius: 10px;
padding: 10px;
display: grid;
gap: 8px;
background: #f8fafc;
}
.token-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}

98
issues/issue3.md Normal file
View 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:

200
issues/issue4.md Normal file
View File

@@ -0,0 +1,200 @@
## 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`
- `issues/screenshots/issue4-after-fix2-2026-02-14-145819.png`
- `issues/screenshots/issue4-after-fix4-2026-02-14-153233.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.
6. Scale fit used repeated position deltas and caused directional drift during repeated resizing.
7. API mismatch in tauri window module:
- runtime used `getCurrentWindow().currentMonitor()` but this API version exposes monitor lookup as
module function (`currentMonitor`), causing `TypeError` and skipping window fit.
8. Scale position math mixed physical window metrics (`outerPosition`/`innerSize`) with logical
set operations (`LogicalSize`/`LogicalPosition`), reintroducing cumulative drift in some DPI
contexts.
9. Ferris background keying needed adaptive key detection; fixed `#FF00FF` assumptions were still
too brittle for packaged atlas variants.
10. Scale-path physical positioning used non-integer coordinates in `setPosition`, triggering
runtime arg errors (`expected i32`) and bypassing window fit updates.
11. Monitor-fit cap remained too optimistic for large frame packs, so max scale could still exceed
practical visible bounds and appear clipped.
12. Ferris/demogorgon packaged backgrounds are gradient-magenta (not exact `#FF00FF`), requiring a
border-connected magenta-family mask instead of exact-key assumptions.
13. Clipping persisted because sprite rendering scale followed snapshot/requested scale directly
instead of fitting to the actual post-clamp window size.
## 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.
6. `frontend/tauri-ui/src/main.tsx`
- Changed scaling anchor to window center and clamped resized window position within current
monitor bounds.
7. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
- Replaced tolerance-only chroma key with border-connected `#FF00FF` background flood-fill removal
and localized edge halo suppression.
8. `crates/sprimo-tauri/capabilities/default.json`
- Added `core:window:allow-current-monitor` permission for monitor bounds clamping.
9. `frontend/tauri-ui/src/main.tsx`
- switched monitor lookup to module-level `currentMonitor()` with safe fallback so window scaling
still applies even if monitor introspection is unavailable.
10. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
- added fallback global key cleanup when border-connected background detection is too sparse.
11. `frontend/tauri-ui/src/main.tsx`
- moved scale resizing and positioning to physical units (`PhysicalSize`/`PhysicalPosition`) and
monitor selection at window-center point (`monitorFromPoint`).
12. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
- added adaptive border-derived key color selection with fallback key cleanup pass.
13. `scripts/package_windows.py`
- tauri packaging now explicitly rebuilds UI bundle to avoid stale embedded `dist` output.
14. `frontend/tauri-ui/src/main.tsx`
- enforced integer physical positioning and monitor work-area size clamping to prevent set-position
arg failures and large-scale clipping.
15. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
- switched ferris cleanup to hue/saturation/value magenta-band masking with connected background
removal and stronger fallback cleanup.
16. `frontend/tauri-ui/src/main.tsx`
- added stricter monitor work-area guard (`WINDOW_WORKAREA_MARGIN`) in both scale-cap and resize
clamp paths to prevent large-pack clipping at high scales.
17. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
- added deterministic border-connected strong-magenta flood-fill cleanup pass so non-`#FF00FF`
gradient backgrounds are removed consistently in packaged ferris/demogorgon atlases.
18. `frontend/tauri-ui/src/main.tsx`
- changed scale flow to window-driven fit semantics: scale request resizes/clamps main window and
then persists effective scale derived from applied window size.
19. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
- renderer sprite scale is now derived from current canvas/window size each layout pass, removing
clipping caused by mismatch between requested scale and bounded window dimensions.
## 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.
- `2026-02-14 00:00` - reporter - `In Progress` - remaining magenta ferris edge + scale drift reported.
- `2026-02-14 00:00` - codex - `Fix Implemented` - switched to border-connected chroma-key removal and center-anchored, monitor-clamped scale fit.
- `2026-02-14 00:00` - reporter - `In Progress` - reported `currentMonitor` TypeError and ferris magenta background still visible.
- `2026-02-14 00:00` - codex - `Fix Implemented` - corrected monitor API call and added fallback chroma cleanup pass.
- `2026-02-14 00:00` - reporter - `In Progress` - reported ferris magenta background still visible and scale drift recurrence.
- `2026-02-14 00:00` - codex - `Fix Implemented` - switched to physical-unit resize math, adaptive key detection, and packaging UI refresh enforcement.
- `2026-02-14 00:00` - reporter - `In Progress` - reported default clipping, ferris background still present, and set_position float arg error.
- `2026-02-14 00:00` - codex - `Fix Implemented` - added integer-safe physical setPosition and HSV magenta cleanup strategy.
- `2026-02-14 00:00` - reporter - `In Progress` - reported remaining default clipping and ferris magenta background persistence.
- `2026-02-14 00:00` - codex - `Fix Implemented` - tightened work-area scale guard and added border-connected strong-magenta cleanup pass.
- `2026-02-14 00:00` - reporter - `In Progress` - reported clipping still present on `default` and `demogorgon` after prior fixes.
- `2026-02-14 00:00` - codex - `Fix Implemented` - moved tauri scale to window-driven effective-fit persistence and renderer fit-to-window scaling.
## Closure
- Current Status: `Fix Implemented`
- Close Date:
- Owner:
- Linked PR/commit:

92
issues/issue5.md Normal file
View File

@@ -0,0 +1,92 @@
## Title
Tauri overlay shows left-edge white strip and same scale yields different window sizes by pack.
## Severity
P2
## Environment
- OS: Windows
- App version/build: packaged release (`sprimo-tauri.exe`)
- Evidence screenshots:
- `issues/screenshots/issue5.png`
- `issues/screenshots/issue5-b.png`
- `issues/screenshots/issue5-c.png`
## Summary
Two regressions were observed in packaged Tauri runtime:
1. A visible white strip appears on the overlay left edge on dark backgrounds.
2. At the same slider scale, main overlay window size differs by sprite pack.
## Reproduction Steps
1. Run packaged `sprimo-tauri.exe`.
2. Place overlay over a dark/black background.
3. Observe left window edge.
4. Open settings and set the same scale value on `default`, `ferris`, and `demogorgon`.
5. Compare main window footprint.
## Expected Result
- No white strip or white background bleed on transparent overlay edges.
- Same scale value produces the same main overlay window size regardless of pack.
## Actual Result
- Left edge can show a white strip.
- Window size differs across packs at same scale.
## Root Cause Analysis
1. Transparency chain was incomplete in CSS:
- `body` was transparent, but `html`/`#root` were not explicitly transparent/full-size, allowing
white background bleed at edges in transparent frameless window mode.
2. Scale-to-window mapping depended on per-pack frame size:
- window sizing used pack `frame_width/frame_height`, so identical scale values produced different
target sizes across packs.
## Implementation Notes
1. `frontend/tauri-ui/src/main.tsx`
- introduced canonical scale basis `512x512` for window sizing semantics.
- changed `fittedWindowSize` and `effectiveScaleForWindowSize` to use canonical dimensions.
- removed pack-dependent sizing from `fitWindowForScale`; pack remains used for rendering/splitting.
2. `frontend/tauri-ui/src/styles.css`
- made `html`, `body`, and `#root` explicit full-size transparent surfaces to avoid white bleed.
3. `docs/TAURI_RUNTIME_TESTING.md`
- added explicit checks for same-scale cross-pack window-size consistency and no edge white strip.
4. `docs/RELEASE_TESTING.md`
- added packaged verification steps for white-edge bleed and cross-pack same-scale size consistency.
## Verification
### Commands Run
- [x] `npm --prefix frontend/tauri-ui run build`
- [x] `cargo check -p sprimo-tauri`
- [ ] `just build-release-tauri`
- [ ] `just package-win-tauri`
- [ ] `just smoke-win-tauri`
### Visual Checklist
- [x] Before screenshot(s): `issues/screenshots/issue5.png`
- [x] Before screenshot(s): `issues/screenshots/issue5-b.png`
- [x] Before screenshot(s): `issues/screenshots/issue5-c.png`
- [ ] After screenshot(s): `issues/screenshots/issue5-after-YYYYMMDD-HHMMSS.png`
### Result
- Status: `Fix Implemented`
- Notes: packaged runtime verification pending.
## Status History
- `2026-02-14 00:00` - reporter - `Reported` - left white strip + same-scale size inconsistency reported.
- `2026-02-14 00:00` - codex - `Triaged` - identified transparency chain and scale-basis coupling root causes.
- `2026-02-14 00:00` - codex - `Fix Implemented` - switched to canonical scale basis and full-surface transparency.

68
issues/issue6.md Normal file
View File

@@ -0,0 +1,68 @@
## Title
Adopt row-based 7x8 sprite animation semantics with backward-compatible state aliases.
## Severity
P2
## Summary
Standardize `sprite.png` packs so each of the 7 rows maps to semantic animation groups, while
keeping runtime compatibility with existing state names (`active`, `success`, `error`).
Also retune 8-frame animation tempo to a slower profile for readability.
## Scope
- `assets/sprite-packs/{default,ferris,demogorgon}/manifest.json`
- `crates/sprimo-runtime-core/src/lib.rs`
- `crates/sprimo-app/src/main.rs`
- docs:
- `docs/SPRITE_PACK_SCHEMA.md`
- `docs/TAURI_FRONTEND_DESIGN.md`
## Row Mapping Contract
- row 1 (`0..7`): `idle`
- row 2 (`8..15`): `happy`, `love`, alias `active`
- row 3 (`16..23`): `excited`, `celebrate`, alias `success`
- row 4 (`24..31`): `sleepy`, `snoring`
- row 5 (`32..39`): `working`
- row 6 (`40..47`): `angry`, `surprised`, `shy`, alias `error`
- row 7 (`48..55`): `dragging`
One-shot defaults:
- `celebrate` and `success`: one-shot
- all others: loop
## Implementation Notes
1. Runtime state mapping updated:
- `Dragging` now maps to `"dragging"` instead of `"idle"` in runtime-core and Bevy frontend.
2. All bundled sprite-pack manifests now expose row-based names and compatibility aliases.
3. Added runtime-core unit test to confirm `SetState::Dragging` selects `"dragging"`.
4. Updated schema/design docs to formalize the row convention.
5. Retuned bundled pack fps profile:
- loops: `1` fps
- one-shot `celebrate` and `success`: `2` fps
6. Tauri renderer-level pacing adjusted:
- added global frame interval slowdown factor (`x2`) in Pixi ticker to further reduce perceived speed
without changing manifest schema type (`fps` remains integer).
7. Added persisted tauri slowdown factor control:
- config key: `frontend.tauri_animation_slowdown_factor` (integer, range `1..20`, default `3`)
- new tauri commands/events to read/update factor at runtime
- settings window slider added for live tuning and persistence.
## Verification
### Commands Run
- [x] `cargo test -p sprimo-runtime-core`
- [x] `cargo check -p sprimo-tauri`
- [x] `cargo check -p sprimo-app`
### Result
- Status: `Fix Implemented`
- Notes: packaged runtime visual verification pending.

View File

@@ -153,6 +153,8 @@ def sha256_file(path: Path) -> str:
def package(frontend: FrontendLayout) -> PackageLayout: def package(frontend: FrontendLayout) -> PackageLayout:
version = read_version() version = read_version()
ensure_assets() ensure_assets()
if frontend.id == "tauri":
run(["npm", "--prefix", "frontend/tauri-ui", "run", "build"])
binary = ensure_release_binary(frontend) binary = ensure_release_binary(frontend)
runtime_files = ensure_runtime_files(frontend, binary.parent) runtime_files = ensure_runtime_files(frontend, binary.parent)

View File

@@ -18,6 +18,27 @@ from typing import Any
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
ANIMATION_NAMES = (
"idle",
"happy",
"love",
"excited",
"celebrate",
"sleepy",
"snoring",
"working",
"angry",
"surprised",
"shy",
"dragging",
# Backward-compatible aliases mapped in runtime/manifests.
"active",
"success",
"error",
# Intentionally invalid to keep unknown-animation traffic coverage.
"unknown_anim",
)
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@@ -205,9 +226,7 @@ def random_valid_command(rng: random.Random) -> dict[str, Any]:
if pick == "play_animation": if pick == "play_animation":
payload = { payload = {
"name": rng.choice( "name": rng.choice(ANIMATION_NAMES),
["idle", "dance", "typing", "celebrate", "error", "unknown_anim"]
),
"priority": rng.randint(0, 10), "priority": rng.randint(0, 10),
"duration_ms": rng.choice([None, 250, 500, 1000, 3000]), "duration_ms": rng.choice([None, 250, 500, 1000, 3000]),
"interrupt": rng.choice([None, True, False]), "interrupt": rng.choice([None, True, False]),