Add: setting window for tauri - bugs not fixed yet

This commit is contained in:
DaZuo0122
2026-02-14 13:21:56 +08:00
parent 907974e61f
commit 901bf0ffc3
13 changed files with 1105 additions and 96 deletions

View File

@@ -1,24 +1,30 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use sprimo_platform::{PlatformAdapter, create_adapter};
use sprimo_protocol::v1::FrontendCommand;
use sprimo_platform::{create_adapter, PlatformAdapter};
use sprimo_protocol::v1::{FrontendState, FrontendStateSnapshot};
use sprimo_runtime_core::{RuntimeCore, RuntimeCoreError};
use sprimo_sprite::{load_manifest, resolve_pack_path, AnimationDefinition};
use sprimo_sprite::{AnimationDefinition, SpritePackManifest, load_manifest, resolve_pack_path};
use std::fs;
use std::sync::Arc;
use tauri::menu::{CheckMenuItem, Menu, MenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Emitter, Manager, Wry};
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, Wry};
use thiserror::Error;
use tokio::runtime::Runtime;
use tracing::warn;
const APP_NAME: &str = "sprimo";
const DEFAULT_PACK: &str = "default";
const SPRITE_PNG_FILE: &str = "sprite.png";
const SPRITE_GRID_COLUMNS: u32 = 8;
const SPRITE_GRID_ROWS: u32 = 7;
const MAIN_WINDOW_LABEL: &str = "main";
const SETTINGS_WINDOW_LABEL: &str = "settings";
const TRAY_ID: &str = "main";
const MENU_ID_SETTINGS: &str = "settings";
const MENU_ID_TOGGLE_VISIBILITY: &str = "toggle_visibility";
const MENU_ID_TOGGLE_ALWAYS_ON_TOP: &str = "toggle_always_on_top";
const MENU_ID_TOGGLE_DEBUG_OVERLAY: &str = "toggle_debug_overlay";
@@ -50,6 +56,12 @@ struct UiSpritePack {
anchor: UiAnchor,
}
#[derive(Debug, Clone, Copy)]
struct AtlasGeometry {
frame_width: u32,
frame_height: u32,
}
#[derive(Debug, Clone, serde::Serialize)]
struct UiSnapshot {
state: String,
@@ -58,6 +70,22 @@ struct UiSnapshot {
y: f32,
scale: f32,
active_sprite_pack: String,
visible: bool,
always_on_top: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
struct UiSettingsSnapshot {
active_sprite_pack: String,
scale: f32,
visible: bool,
always_on_top: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
struct UiSpritePackOption {
id: String,
pack_id_or_path: String,
}
#[derive(Debug, Error)]
@@ -74,6 +102,7 @@ enum AppError {
struct AppState {
runtime_core: Arc<RuntimeCore>,
runtime: Arc<Runtime>,
tray_state: Arc<std::sync::Mutex<Option<TrayMenuState>>>,
}
#[derive(Clone)]
@@ -96,6 +125,7 @@ fn current_state(state: tauri::State<'_, AppState>) -> Result<UiSnapshot, String
#[tauri::command]
fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSpritePack, String> {
let root = sprite_pack_root(state.runtime_core.as_ref())?;
let config = state
.runtime_core
.config()
@@ -103,11 +133,6 @@ fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSprite
.map_err(|_| "config lock poisoned".to_string())?
.clone();
let root = std::env::current_dir()
.map_err(|err| err.to_string())?
.join("assets")
.join(config.sprite.sprite_packs_dir);
let selected = config.sprite.selected_pack;
let pack_path = match resolve_pack_path(&root, &selected) {
Ok(path) => path,
@@ -117,6 +142,8 @@ fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSprite
let manifest = load_manifest(&pack_path).map_err(|err| err.to_string())?;
let image_path = pack_path.join(&manifest.image);
let image_bytes = std::fs::read(&image_path).map_err(|err| err.to_string())?;
let geometry = atlas_geometry_for_manifest(&manifest, &image_bytes)?;
validate_animation_frames(&manifest)?;
let atlas_data_url = format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(image_bytes)
@@ -124,14 +151,10 @@ fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSprite
Ok(UiSpritePack {
id: manifest.id,
frame_width: manifest.frame_width,
frame_height: manifest.frame_height,
frame_width: geometry.frame_width,
frame_height: geometry.frame_height,
atlas_data_url,
animations: manifest
.animations
.into_iter()
.map(to_ui_clip)
.collect(),
animations: manifest.animations.into_iter().map(to_ui_clip).collect(),
anchor: UiAnchor {
x: manifest.anchor.x,
y: manifest.anchor.y,
@@ -147,6 +170,139 @@ fn debug_overlay_visible(state: tauri::State<'_, AppState>) -> Result<bool, Stri
.map_err(|err| err.to_string())
}
#[tauri::command]
fn settings_snapshot(state: tauri::State<'_, AppState>) -> Result<UiSettingsSnapshot, String> {
let snapshot = state
.runtime_core
.snapshot()
.read()
.map_err(|_| "snapshot lock poisoned".to_string())?
.clone();
Ok(UiSettingsSnapshot {
active_sprite_pack: snapshot.active_sprite_pack,
scale: snapshot.scale,
visible: snapshot.flags.visible,
always_on_top: snapshot.flags.always_on_top,
})
}
#[tauri::command]
fn list_sprite_packs(state: tauri::State<'_, AppState>) -> Result<Vec<UiSpritePackOption>, String> {
let root = sprite_pack_root(state.runtime_core.as_ref())?;
let mut packs = Vec::new();
let entries = fs::read_dir(root).map_err(|err| err.to_string())?;
for entry in entries {
let entry = entry.map_err(|err| err.to_string())?;
let path = entry.path();
if !path.is_dir() {
continue;
}
if load_manifest(&path).is_err() {
continue;
}
let Some(dir_name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
packs.push(UiSpritePackOption {
id: dir_name.to_string(),
pack_id_or_path: dir_name.to_string(),
});
}
packs.sort_by(|a, b| a.id.cmp(&b.id));
Ok(packs)
}
#[tauri::command]
fn set_sprite_pack(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
pack_id_or_path: String,
) -> Result<UiSnapshot, String> {
let root = sprite_pack_root(state.runtime_core.as_ref())?;
let pack_path = resolve_pack_path(&root, &pack_id_or_path).map_err(|err| err.to_string())?;
let manifest = load_manifest(&pack_path).map_err(|err| err.to_string())?;
let image_path = pack_path.join(&manifest.image);
let image_bytes = fs::read(&image_path).map_err(|err| err.to_string())?;
let _geometry = atlas_geometry_for_manifest(&manifest, &image_bytes)?;
validate_animation_frames(&manifest)?;
state
.runtime_core
.apply_command(&FrontendCommand::SetSpritePack { pack_id_or_path })
.map_err(|err| err.to_string())?;
emit_ui_refresh(state.inner(), &app_handle)
}
#[tauri::command]
fn set_scale(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
scale: f32,
) -> Result<UiSnapshot, String> {
if !scale.is_finite() || scale <= 0.0 {
return Err("scale must be a positive finite number".to_string());
}
state
.runtime_core
.apply_command(&FrontendCommand::SetTransform {
x: None,
y: None,
anchor: None,
scale: Some(scale),
opacity: None,
})
.map_err(|err| err.to_string())?;
emit_ui_refresh(state.inner(), &app_handle)
}
#[tauri::command]
fn set_visibility(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
visible: bool,
) -> Result<UiSnapshot, String> {
state
.runtime_core
.apply_command(&FrontendCommand::SetFlags {
click_through: None,
always_on_top: None,
visible: Some(visible),
})
.map_err(|err| err.to_string())?;
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
if visible {
window.show().map_err(|err| err.to_string())?;
window.set_focus().map_err(|err| err.to_string())?;
} else {
window.hide().map_err(|err| err.to_string())?;
}
}
emit_ui_refresh(state.inner(), &app_handle)
}
#[tauri::command]
fn set_always_on_top(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
always_on_top: bool,
) -> Result<UiSnapshot, String> {
state
.runtime_core
.apply_command(&FrontendCommand::SetFlags {
click_through: None,
always_on_top: Some(always_on_top),
visible: None,
})
.map_err(|err| err.to_string())?;
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
window
.set_always_on_top(always_on_top)
.map_err(|err| err.to_string())?;
}
emit_ui_refresh(state.inner(), &app_handle)
}
#[tauri::command]
fn set_debug_overlay_visible(
app_handle: tauri::AppHandle,
@@ -158,6 +314,11 @@ fn set_debug_overlay_visible(
.set_frontend_debug_overlay_visible(visible)
.map_err(|err| err.to_string())?;
let _ = emit_debug_overlay_visibility(state.runtime_core.as_ref(), &app_handle);
if let Ok(guard) = state.tray_state.lock() {
if let Some(tray_state) = guard.as_ref() {
let _ = refresh_tray_menu_state(state.runtime_core.as_ref(), tray_state);
}
}
Ok(visible)
}
@@ -176,6 +337,7 @@ fn main() -> Result<(), AppError> {
let state = AppState {
runtime_core: Arc::clone(&runtime_core),
runtime: Arc::clone(&runtime),
tray_state: Arc::new(std::sync::Mutex::new(None)),
};
tauri::Builder::default()
@@ -185,15 +347,25 @@ fn main() -> Result<(), AppError> {
current_state,
load_active_sprite_pack,
debug_overlay_visible,
set_debug_overlay_visible
set_debug_overlay_visible,
settings_snapshot,
list_sprite_packs,
set_sprite_pack,
set_scale,
set_visibility,
set_always_on_top
])
.setup(|app| {
let app_state: tauri::State<'_, AppState> = app.state();
let runtime_core = Arc::clone(&app_state.runtime_core);
let runtime = Arc::clone(&app_state.runtime);
let tray_state_holder = Arc::clone(&app_state.tray_state);
let app_handle = app.handle().clone();
let tray_state = setup_tray(&app_handle, &runtime_core)?;
if let Ok(mut guard) = tray_state_holder.lock() {
*guard = Some(tray_state.clone());
}
if let Ok(snapshot) = runtime_core.snapshot().read() {
let _ = app_handle.emit(EVENT_RUNTIME_SNAPSHOT, to_ui_snapshot(&snapshot));
}
@@ -251,10 +423,8 @@ fn main() -> Result<(), AppError> {
};
if runtime_core.apply_command(&command).is_ok() {
if let Ok(snapshot) = runtime_core.snapshot().read() {
let _ = app_handle.emit(
EVENT_RUNTIME_SNAPSHOT,
to_ui_snapshot(&snapshot),
);
let _ = app_handle
.emit(EVENT_RUNTIME_SNAPSHOT, to_ui_snapshot(&snapshot));
}
let _ = refresh_tray_menu_state(
runtime_core.as_ref(),
@@ -295,9 +465,46 @@ fn to_ui_snapshot(snapshot: &FrontendStateSnapshot) -> UiSnapshot {
y: snapshot.y,
scale: snapshot.scale,
active_sprite_pack: snapshot.active_sprite_pack.clone(),
visible: snapshot.flags.visible,
always_on_top: snapshot.flags.always_on_top,
}
}
fn sprite_pack_root(runtime_core: &RuntimeCore) -> Result<std::path::PathBuf, String> {
let sprite_packs_dir = runtime_core
.config()
.read()
.map_err(|_| "config lock poisoned".to_string())?
.sprite
.sprite_packs_dir
.clone();
let root = std::env::current_dir()
.map_err(|err| err.to_string())?
.join("assets")
.join(sprite_packs_dir);
Ok(root)
}
fn emit_ui_refresh(state: &AppState, app_handle: &AppHandle<Wry>) -> Result<UiSnapshot, String> {
let snapshot = state
.runtime_core
.snapshot()
.read()
.map_err(|_| "snapshot lock poisoned".to_string())?
.clone();
let ui_snapshot = to_ui_snapshot(&snapshot);
app_handle
.emit(EVENT_RUNTIME_SNAPSHOT, ui_snapshot.clone())
.map_err(|err| err.to_string())?;
let _ = emit_debug_overlay_visibility(state.runtime_core.as_ref(), app_handle);
if let Ok(guard) = state.tray_state.lock() {
if let Some(tray_state) = guard.as_ref() {
let _ = refresh_tray_menu_state(state.runtime_core.as_ref(), tray_state);
}
}
Ok(ui_snapshot)
}
fn setup_tray(
app_handle: &AppHandle<Wry>,
runtime_core: &RuntimeCore,
@@ -311,6 +518,7 @@ fn setup_tray(
.frontend_debug_overlay_visible()
.map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?;
let settings = MenuItem::with_id(app_handle, MENU_ID_SETTINGS, "Settings", true, None::<&str>)?;
let toggle_visibility = MenuItem::with_id(
app_handle,
MENU_ID_TOGGLE_VISIBILITY,
@@ -338,6 +546,7 @@ fn setup_tray(
let menu = Menu::with_items(
app_handle,
&[
&settings,
&toggle_visibility,
&toggle_always_on_top,
&toggle_debug_overlay,
@@ -368,6 +577,10 @@ fn handle_menu_event(
menu_id: &str,
) -> Result<(), String> {
match menu_id {
MENU_ID_SETTINGS => {
open_settings_window(app_handle).map_err(|err| err.to_string())?;
return Ok(());
}
MENU_ID_TOGGLE_VISIBILITY => {
let current = runtime_core
.snapshot()
@@ -408,7 +621,9 @@ fn handle_menu_event(
})
.map_err(|err| err.to_string())?;
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
window.set_always_on_top(next).map_err(|err| err.to_string())?;
window
.set_always_on_top(next)
.map_err(|err| err.to_string())?;
}
}
MENU_ID_TOGGLE_DEBUG_OVERLAY => {
@@ -419,7 +634,8 @@ fn handle_menu_event(
runtime_core
.set_frontend_debug_overlay_visible(next)
.map_err(|err| err.to_string())?;
emit_debug_overlay_visibility(runtime_core, app_handle).map_err(|err| err.to_string())?;
emit_debug_overlay_visibility(runtime_core, app_handle)
.map_err(|err| err.to_string())?;
}
MENU_ID_QUIT => {
persist_current_ui_flags(runtime_core)?;
@@ -436,6 +652,30 @@ fn handle_menu_event(
Ok(())
}
fn open_settings_window(app_handle: &AppHandle<Wry>) -> Result<(), tauri::Error> {
if let Some(window) = app_handle.get_webview_window(SETTINGS_WINDOW_LABEL) {
window.show()?;
window.set_focus()?;
return Ok(());
}
let window = WebviewWindowBuilder::new(
app_handle,
SETTINGS_WINDOW_LABEL,
WebviewUrl::App("index.html".into()),
)
.title("sprimo settings")
.inner_size(420.0, 520.0)
.resizable(true)
.decorations(true)
.transparent(false)
.always_on_top(false)
.visible(true)
.build()?;
window.set_focus()?;
Ok(())
}
fn persist_current_ui_flags(runtime_core: &RuntimeCore) -> Result<(), String> {
let snapshot = runtime_core
.snapshot()
@@ -496,11 +736,7 @@ fn refresh_tray_menu_state(
}
fn visibility_menu_title(visible: bool) -> &'static str {
if visible {
"Hide"
} else {
"Show"
}
if visible { "Hide" } else { "Show" }
}
fn state_name(value: FrontendState) -> &'static str {
@@ -522,3 +758,95 @@ fn to_ui_clip(value: AnimationDefinition) -> UiAnimationClip {
one_shot: value.one_shot.unwrap_or(false),
}
}
fn atlas_geometry_for_manifest(
manifest: &SpritePackManifest,
image_bytes: &[u8],
) -> Result<AtlasGeometry, String> {
let (image_width, image_height) = decode_png_dimensions(image_bytes)?;
if image_width == 0 || image_height == 0 {
return Err("atlas image dimensions must be non-zero".to_string());
}
if manifest.image.eq_ignore_ascii_case(SPRITE_PNG_FILE) {
let frame_width = image_width / SPRITE_GRID_COLUMNS;
let frame_height = image_height / SPRITE_GRID_ROWS;
if frame_width == 0 || frame_height == 0 {
return Err(format!(
"sprite atlas too small for {}x{} grid: {}x{}",
SPRITE_GRID_COLUMNS, SPRITE_GRID_ROWS, image_width, image_height
));
}
return Ok(AtlasGeometry {
frame_width,
frame_height,
});
}
if manifest.frame_width == 0 || manifest.frame_height == 0 {
return Err("manifest frame dimensions must be non-zero".to_string());
}
Ok(AtlasGeometry {
frame_width: manifest.frame_width,
frame_height: manifest.frame_height,
})
}
fn validate_animation_frames(manifest: &SpritePackManifest) -> Result<(), String> {
let total_frames = if manifest.image.eq_ignore_ascii_case(SPRITE_PNG_FILE) {
SPRITE_GRID_COLUMNS
.checked_mul(SPRITE_GRID_ROWS)
.ok_or_else(|| "sprite grid frame count overflow".to_string())?
} else {
0
};
if total_frames == 0 {
return Ok(());
}
for clip in &manifest.animations {
for &index in &clip.frames {
if index >= total_frames {
return Err(format!(
"animation '{}' references frame {} but max frame index is {}",
clip.name,
index,
total_frames.saturating_sub(1)
));
}
}
}
Ok(())
}
fn decode_png_dimensions(image_bytes: &[u8]) -> Result<(u32, u32), String> {
const PNG_SIGNATURE_LEN: usize = 8;
const PNG_IHDR_TOTAL_LEN: usize = 33;
const IHDR_TYPE_OFFSET: usize = 12;
const IHDR_DATA_OFFSET: usize = 16;
const IHDR_WIDTH_OFFSET: usize = 16;
const IHDR_HEIGHT_OFFSET: usize = 20;
if image_bytes.len() < PNG_IHDR_TOTAL_LEN {
return Err("atlas image is too small to be a valid PNG".to_string());
}
let expected_signature: [u8; PNG_SIGNATURE_LEN] = [137, 80, 78, 71, 13, 10, 26, 10];
if image_bytes[..PNG_SIGNATURE_LEN] != expected_signature {
return Err("atlas image must be PNG format".to_string());
}
if &image_bytes[IHDR_TYPE_OFFSET..IHDR_DATA_OFFSET] != b"IHDR" {
return Err("atlas PNG missing IHDR chunk".to_string());
}
let width = u32::from_be_bytes(
image_bytes[IHDR_WIDTH_OFFSET..IHDR_WIDTH_OFFSET + 4]
.try_into()
.map_err(|_| "failed to decode PNG width".to_string())?,
);
let height = u32::from_be_bytes(
image_bytes[IHDR_HEIGHT_OFFSET..IHDR_HEIGHT_OFFSET + 4]
.try_into()
.map_err(|_| "failed to decode PNG height".to_string())?,
);
Ok((width, height))
}