minor cursor handling refactor improvemnts

This commit is contained in:
2026-03-25 02:14:55 +08:00
parent e7f9633fcc
commit 53248243e3
18 changed files with 443 additions and 302 deletions

View File

@@ -5,6 +5,7 @@ use crate::{
app_data::{init_app_data_scoped, AppDataRefreshScope},
app_state,
friends,
neko_positions,
presence_modules::models::ModuleMetadata,
sprite,
},
@@ -53,6 +54,12 @@ pub fn get_app_state() -> Result<AppState, String> {
Ok(app_state::get_snapshot())
}
#[tauri::command]
#[specta::specta]
pub fn get_neko_positions() -> Result<neko_positions::NekoPositionsDto, String> {
Ok(neko_positions::get_snapshot())
}
#[tauri::command]
#[specta::specta]
pub fn set_scene_setup_nekos_position(nekos_position: Option<NekoPosition>) {

View File

@@ -4,7 +4,7 @@ use crate::services::{
};
use commands::app::{quit_app, restart_app, retry_connection};
use commands::app_state::{
get_active_doll_sprite_base64, get_app_data, get_app_state,
get_active_doll_sprite_base64, get_app_data, get_app_state, get_neko_positions,
get_friend_active_doll_sprites_base64, get_modules, refresh_app_data,
set_scene_setup_nekos_opacity, set_scene_setup_nekos_position, set_scene_setup_nekos_scale,
};
@@ -26,11 +26,10 @@ use tauri_specta::{collect_commands, collect_events, Builder as SpectaBuilder, E
use crate::services::app_events::{
ActiveDollSpriteChanged, AppDataRefreshed, AppStateChanged, AuthFlowUpdated, CreateDoll,
CursorMoved, EditDoll, FriendActiveDollChanged, FriendActiveDollSpritesUpdated,
FriendCursorPositionsUpdated, FriendDisconnected, FriendRequestAccepted,
FriendRequestDenied, FriendRequestReceived, FriendUserStatusChanged,
InteractionDeliveryFailed, InteractionReceived, SceneInteractiveChanged,
SetInteractionOverlay, Unfriended, UserStatusChanged,
EditDoll, FriendActiveDollChanged, FriendActiveDollSpritesUpdated, FriendDisconnected,
FriendRequestAccepted, FriendRequestDenied, FriendRequestReceived, FriendUserStatusChanged,
InteractionDeliveryFailed, InteractionReceived, NekoPositionsUpdated,
SceneInteractiveChanged, SetInteractionOverlay, Unfriended, UserStatusChanged,
};
static APP_HANDLE: std::sync::OnceLock<tauri::AppHandle<tauri::Wry>> = std::sync::OnceLock::new();
@@ -68,6 +67,7 @@ pub fn run() {
.commands(collect_commands![
get_app_data,
get_app_state,
get_neko_positions,
get_active_doll_sprite_base64,
get_friend_active_doll_sprites_base64,
refresh_app_data,
@@ -108,16 +108,15 @@ pub fn run() {
set_scene_setup_nekos_scale
])
.events(collect_events![
CursorMoved,
SceneInteractiveChanged,
AppDataRefreshed,
AppStateChanged,
NekoPositionsUpdated,
ActiveDollSpriteChanged,
SetInteractionOverlay,
EditDoll,
CreateDoll,
UserStatusChanged,
FriendCursorPositionsUpdated,
FriendDisconnected,
FriendActiveDollChanged,
FriendActiveDollSpritesUpdated,

View File

@@ -11,7 +11,7 @@ use crate::{
remotes::{dolls::DollsRemote, friends::FriendRemote, user::UserRemote},
services::{
app_events::{ActiveDollSpriteChanged, AppDataRefreshed},
friends, sprite,
friends, neko_positions, sprite,
},
state::FDOLL,
};
@@ -51,6 +51,8 @@ pub async fn init_app_data_scoped(scope: AppDataRefreshScope) {
Ok(user) => {
let mut guard = lock_w!(FDOLL);
guard.user_data.user = Some(user);
drop(guard);
neko_positions::sync_from_app_data();
}
Err(error) => {
warn!("Failed to fetch user profile: {}", error);

View File

@@ -13,10 +13,7 @@ use crate::{
},
interaction::{InteractionDeliveryFailedDto, InteractionPayloadDto},
},
services::{
cursor::CursorPositions,
friends::{FriendActiveDollSpritesDto, FriendCursorPositionsDto},
},
services::{friends::FriendActiveDollSpritesDto, neko_positions::NekoPositionsDto},
};
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
@@ -36,10 +33,6 @@ pub struct AuthFlowUpdatedPayload {
pub message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "cursor-position")]
pub struct CursorMoved(pub CursorPositions);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "scene-interactive")]
pub struct SceneInteractiveChanged(pub bool);
@@ -73,8 +66,8 @@ pub struct CreateDoll;
pub struct UserStatusChanged(pub UserStatusPayload);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "friend-cursor-positions")]
pub struct FriendCursorPositionsUpdated(pub FriendCursorPositionsDto);
#[tauri_specta(event_name = "neko-positions")]
pub struct NekoPositionsUpdated(pub NekoPositionsDto);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "friend-disconnected")]

View File

@@ -6,7 +6,7 @@ use tracing::warn;
use crate::{
get_app_handle,
models::app_state::{AppState, NekoPosition},
services::app_events::AppStateChanged,
services::{app_events::AppStateChanged, neko_positions},
};
static APP_STATE: LazyLock<Arc<RwLock<AppState>>> =
@@ -21,6 +21,8 @@ pub fn set_scene_setup_nekos_position(nekos_position: Option<NekoPosition>) {
let mut guard = APP_STATE.write().expect("app state lock poisoned");
guard.scene_setup.nekos_position = nekos_position;
emit_snapshot(&guard);
drop(guard);
neko_positions::refresh_from_scene_setup();
}
pub fn set_scene_setup_nekos_opacity(nekos_opacity: f32) {

View File

@@ -7,8 +7,11 @@ use std::time::Duration;
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use crate::{get_app_handle, lock_r, services::app_events::CursorMoved, state::FDOLL};
use tauri_specta::Event as _;
use crate::{
lock_r,
services::{neko_positions, ws::report_cursor_data},
state::FDOLL,
};
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -59,8 +62,7 @@ pub fn normalized_to_absolute(normalized: &CursorPosition) -> CursorPosition {
}
}
/// Initialize cursor tracking. Broadcasts cursor
/// position changes via `cursor-position` event.
/// Initialize cursor tracking.
pub async fn init_cursor_tracking() {
info!("start_cursor_tracking called");
@@ -88,22 +90,19 @@ async fn init_cursor_tracking_i() -> Result<(), String> {
let (tx, mut rx) = mpsc::channel::<CursorPositions>(100);
// Spawn the consumer task
// This task handles WebSocket reporting and local broadcasting.
// This task handles WebSocket reporting and local position projection updates.
// It runs independently of the device event loop.
tauri::async_runtime::spawn(async move {
info!("Cursor event consumer started");
let app_handle = get_app_handle();
while let Some(positions) = rx.recv().await {
let mapped_for_ws = positions.mapped.clone();
// 1. WebSocket reporting
crate::services::ws::report_cursor_data(mapped_for_ws).await;
report_cursor_data(mapped_for_ws).await;
// 2. Broadcast to local windows
if let Err(e) = CursorMoved(positions).emit(app_handle) {
error!("Failed to emit cursor position event: {:?}", e);
}
// 2. Update unified neko positions projection
neko_positions::update_self_cursor(positions);
}
warn!("Cursor event consumer stopped (channel closed)");
});

View File

@@ -1,145 +0,0 @@
use std::{
collections::HashMap,
sync::{Arc, LazyLock, RwLock},
};
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri_specta::Event as _;
use crate::{
get_app_handle, lock_r,
services::{app_events::FriendCursorPositionsUpdated, cursor::CursorPositions},
state::FDOLL,
};
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(transparent)]
pub struct FriendCursorPositionsDto(pub HashMap<String, CursorPositions>);
#[derive(Default)]
struct FriendCursorProjection {
active_dolls: HashMap<String, bool>,
positions: HashMap<String, CursorPositions>,
}
static FRIEND_CURSOR_PROJECTION: LazyLock<Arc<RwLock<FriendCursorProjection>>> =
LazyLock::new(|| Arc::new(RwLock::new(FriendCursorProjection::default())));
pub fn sync_from_app_data() {
let friends = {
let guard = lock_r!(FDOLL);
guard.user_data.friends.clone().unwrap_or_default()
};
let mut projection = FRIEND_CURSOR_PROJECTION
.write()
.expect("friend cursor projection lock poisoned");
projection.active_dolls = friends
.into_iter()
.filter_map(|friendship| {
friendship.friend.map(|friend| {
let has_active_doll = friend.active_doll.is_some();
(friend.id, has_active_doll)
})
})
.collect();
let active_dolls = projection.active_dolls.clone();
projection
.positions
.retain(|user_id, _| active_dolls.get(user_id) == Some(&true));
emit_snapshot(&projection.positions);
}
pub fn clear() {
let mut projection = FRIEND_CURSOR_PROJECTION
.write()
.expect("friend cursor projection lock poisoned");
projection.active_dolls.clear();
projection.positions.clear();
emit_snapshot(&projection.positions);
}
pub fn update_position(user_id: String, position: CursorPositions) {
let mut projection = FRIEND_CURSOR_PROJECTION
.write()
.expect("friend cursor projection lock poisoned");
if !has_active_doll(&mut projection, &user_id) {
if projection.positions.remove(&user_id).is_some() {
emit_snapshot(&projection.positions);
}
return;
}
projection.positions.insert(user_id, position);
emit_snapshot(&projection.positions);
}
pub fn remove_friend(user_id: &str) {
let mut projection = FRIEND_CURSOR_PROJECTION
.write()
.expect("friend cursor projection lock poisoned");
let removed_active_doll = projection.active_dolls.remove(user_id).is_some();
let removed_position = projection.positions.remove(user_id).is_some();
if removed_active_doll || removed_position {
emit_snapshot(&projection.positions);
}
}
pub fn set_active_doll(user_id: &str, has_active_doll: bool) {
let mut projection = FRIEND_CURSOR_PROJECTION
.write()
.expect("friend cursor projection lock poisoned");
projection
.active_dolls
.insert(user_id.to_string(), has_active_doll);
if !has_active_doll && projection.positions.remove(user_id).is_some() {
emit_snapshot(&projection.positions);
}
}
fn has_active_doll(projection: &mut FriendCursorProjection, user_id: &str) -> bool {
if let Some(has_active_doll) = projection.active_dolls.get(user_id) {
return *has_active_doll;
}
let has_active_doll = {
let guard = lock_r!(FDOLL);
guard
.user_data
.friends
.as_ref()
.and_then(|friends| {
friends.iter().find_map(|friendship| {
let friend = friendship.friend.as_ref()?;
(friend.id == user_id).then_some(friend)
})
})
.and_then(|friend| friend.active_doll.as_ref())
.is_some()
};
projection
.active_dolls
.insert(user_id.to_string(), has_active_doll);
has_active_doll
}
fn emit_snapshot(positions: &HashMap<String, CursorPositions>) {
let payload = FriendCursorPositionsDto(positions.clone());
if let Err(err) = FriendCursorPositionsUpdated(payload).emit(get_app_handle()) {
tracing::warn!("Failed to emit friend cursor positions update: {}", err);
}
}

View File

@@ -1,33 +1,27 @@
mod active_doll_sprites;
mod cursor_positions;
use crate::{models::dolls::DollDto, services::cursor::CursorPositions};
use crate::{models::dolls::DollDto, services::neko_positions};
pub use active_doll_sprites::FriendActiveDollSpritesDto;
pub use cursor_positions::FriendCursorPositionsDto;
pub fn sync_from_app_data() {
active_doll_sprites::sync_from_app_data();
cursor_positions::sync_from_app_data();
neko_positions::sync_from_app_data();
}
pub fn clear() {
active_doll_sprites::clear();
cursor_positions::clear();
neko_positions::clear();
}
pub fn remove_friend(user_id: &str) {
active_doll_sprites::remove_friend(user_id);
cursor_positions::remove_friend(user_id);
neko_positions::remove_friend(user_id);
}
pub fn set_active_doll(user_id: &str, doll: Option<&DollDto>) {
active_doll_sprites::set_active_doll(user_id, doll);
cursor_positions::set_active_doll(user_id, doll.is_some());
}
pub fn update_cursor_position(user_id: String, position: CursorPositions) {
cursor_positions::update_position(user_id, position);
neko_positions::set_friend_active_doll(user_id, doll.is_some());
}
pub fn sync_active_doll_sprites_from_app_data() {

View File

@@ -12,6 +12,7 @@ pub mod friends;
pub mod health_manager;
pub mod health_monitor;
pub mod interaction;
pub mod neko_positions;
pub mod petpet;
pub mod presence_modules;
pub mod scene;

View File

@@ -0,0 +1,307 @@
use std::{
collections::HashMap,
sync::{Arc, LazyLock, RwLock},
};
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri_specta::Event as _;
use crate::{
get_app_handle, lock_r,
models::app_state::NekoPosition,
services::{
app_events::NekoPositionsUpdated,
app_state,
cursor::{CursorPosition, CursorPositions},
},
state::FDOLL,
};
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct NekoPositionDto {
pub user_id: String,
pub is_self: bool,
pub cursor: CursorPositions,
pub target: CursorPosition,
pub override_applied: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(transparent)]
pub struct NekoPositionsDto(pub HashMap<String, NekoPositionDto>);
#[derive(Default)]
struct NekoPositionsProjection {
self_cursor: Option<CursorPositions>,
friend_cursors: HashMap<String, CursorPositions>,
friend_active_dolls: HashMap<String, bool>,
}
static NEKO_POSITIONS: LazyLock<Arc<RwLock<NekoPositionsProjection>>> =
LazyLock::new(|| Arc::new(RwLock::new(NekoPositionsProjection::default())));
pub fn sync_from_app_data() {
let friends = {
let guard = lock_r!(FDOLL);
guard.user_data.friends.clone().unwrap_or_default()
};
let mut projection = NEKO_POSITIONS
.write()
.expect("neko positions projection lock poisoned");
projection.friend_active_dolls = friends
.into_iter()
.filter_map(|friendship| {
friendship.friend.map(|friend| {
let has_active_doll = friend.active_doll.is_some();
(friend.id, has_active_doll)
})
})
.collect();
let active_dolls = projection.friend_active_dolls.clone();
projection
.friend_cursors
.retain(|user_id, _| active_dolls.get(user_id) == Some(&true));
emit_snapshot(&projection);
}
pub fn clear() {
let mut projection = NEKO_POSITIONS
.write()
.expect("neko positions projection lock poisoned");
projection.self_cursor = None;
projection.friend_cursors.clear();
projection.friend_active_dolls.clear();
emit_snapshot(&projection);
}
pub fn update_self_cursor(position: CursorPositions) {
let mut projection = NEKO_POSITIONS
.write()
.expect("neko positions projection lock poisoned");
projection.self_cursor = Some(position);
emit_snapshot(&projection);
}
pub fn update_friend_cursor(user_id: String, position: CursorPositions) {
let mut projection = NEKO_POSITIONS
.write()
.expect("neko positions projection lock poisoned");
if !has_friend_active_doll(&mut projection, &user_id) {
if projection.friend_cursors.remove(&user_id).is_some() {
emit_snapshot(&projection);
}
return;
}
projection.friend_cursors.insert(user_id, position);
emit_snapshot(&projection);
}
pub fn remove_friend(user_id: &str) {
let mut projection = NEKO_POSITIONS
.write()
.expect("neko positions projection lock poisoned");
let removed_active_doll = projection.friend_active_dolls.remove(user_id).is_some();
let removed_position = projection.friend_cursors.remove(user_id).is_some();
if removed_active_doll || removed_position {
emit_snapshot(&projection);
}
}
pub fn set_friend_active_doll(user_id: &str, has_active_doll: bool) {
let mut projection = NEKO_POSITIONS
.write()
.expect("neko positions projection lock poisoned");
projection
.friend_active_dolls
.insert(user_id.to_string(), has_active_doll);
if !has_active_doll && projection.friend_cursors.remove(user_id).is_some() {
emit_snapshot(&projection);
}
}
pub fn refresh_from_scene_setup() {
let projection = NEKO_POSITIONS
.read()
.expect("neko positions projection lock poisoned");
emit_snapshot(&projection);
}
pub fn get_snapshot() -> NekoPositionsDto {
let projection = NEKO_POSITIONS
.read()
.expect("neko positions projection lock poisoned");
build_snapshot(&projection)
}
fn has_friend_active_doll(projection: &mut NekoPositionsProjection, user_id: &str) -> bool {
if let Some(has_active_doll) = projection.friend_active_dolls.get(user_id) {
return *has_active_doll;
}
let has_active_doll = {
let guard = lock_r!(FDOLL);
guard
.user_data
.friends
.as_ref()
.and_then(|friends| {
friends.iter().find_map(|friendship| {
let friend = friendship.friend.as_ref()?;
(friend.id == user_id).then_some(friend)
})
})
.and_then(|friend| friend.active_doll.as_ref())
.is_some()
};
projection
.friend_active_dolls
.insert(user_id.to_string(), has_active_doll);
has_active_doll
}
fn has_self_active_doll() -> bool {
let guard = lock_r!(FDOLL);
guard
.user_data
.user
.as_ref()
.and_then(|user| user.active_doll_id.as_ref())
.is_some()
}
fn get_self_user_id() -> Option<String> {
let guard = lock_r!(FDOLL);
guard.user_data.user.as_ref().map(|user| user.id.clone())
}
fn get_display_size() -> (f64, f64) {
let guard = lock_r!(FDOLL);
(
guard.user_data.scene.display.screen_width as f64,
guard.user_data.scene.display.screen_height as f64,
)
}
fn emit_snapshot(projection: &NekoPositionsProjection) {
let payload = build_snapshot(projection);
if let Err(err) = NekoPositionsUpdated(payload).emit(get_app_handle()) {
tracing::warn!("Failed to emit neko positions update: {}", err);
}
}
fn build_snapshot(projection: &NekoPositionsProjection) -> NekoPositionsDto {
let mut entries: Vec<(String, bool, CursorPositions)> = Vec::new();
if has_self_active_doll() {
if let (Some(self_user_id), Some(self_cursor)) =
(get_self_user_id(), projection.self_cursor.clone())
{
entries.push((self_user_id, true, self_cursor));
}
}
for (user_id, cursor) in &projection.friend_cursors {
if projection.friend_active_dolls.get(user_id) == Some(&true) {
entries.push((user_id.clone(), false, cursor.clone()));
}
}
entries.sort_by(|a, b| a.0.cmp(&b.0));
let app_state = app_state::get_snapshot();
let override_anchor = app_state.scene_setup.nekos_position;
let (screen_width, screen_height) = get_display_size();
let total = entries.len();
NekoPositionsDto(
entries
.into_iter()
.enumerate()
.map(|(index, (user_id, is_self, cursor))| {
let (target, override_applied) = match &override_anchor {
Some(anchor) => (
get_cluster_target(
anchor.clone(),
index,
total,
screen_width,
screen_height,
),
true,
),
None => (cursor.raw.clone(), false),
};
(
user_id.clone(),
NekoPositionDto {
user_id,
is_self,
cursor,
target,
override_applied,
},
)
})
.collect(),
)
}
fn get_cluster_target(
anchor: NekoPosition,
index: usize,
count: usize,
screen_width: f64,
screen_height: f64,
) -> CursorPosition {
let spacing = 36.0;
let margin = 28.0;
let columns = (count as f64).sqrt().ceil().max(1.0) as usize;
let rows = count.div_ceil(columns).max(1);
let col = index % columns;
let row = index / columns;
let block_width = (columns.saturating_sub(1)) as f64 * spacing;
let block_height = (rows.saturating_sub(1)) as f64 * spacing;
let start_x = match anchor {
NekoPosition::TopLeft | NekoPosition::Left | NekoPosition::BottomLeft => margin,
NekoPosition::Top | NekoPosition::Bottom => (screen_width - block_width) / 2.0,
NekoPosition::TopRight | NekoPosition::Right | NekoPosition::BottomRight => {
screen_width - margin - block_width
}
};
let start_y = match anchor {
NekoPosition::TopLeft | NekoPosition::Top | NekoPosition::TopRight => margin,
NekoPosition::Left | NekoPosition::Right => (screen_height - block_height) / 2.0,
NekoPosition::BottomLeft | NekoPosition::Bottom | NekoPosition::BottomRight => {
screen_height - margin - block_height
}
};
CursorPosition {
x: (start_x + col as f64 * spacing).clamp(0.0, screen_width),
y: (start_y + row as f64 * spacing).clamp(0.0, screen_height),
}
}

View File

@@ -13,7 +13,7 @@ use crate::services::app_events::{
};
use crate::services::{
cursor::{normalized_to_absolute, CursorPositions},
friends,
friends, neko_positions,
};
use super::{emitter, refresh, types::IncomingFriendCursorPayload, utils};
@@ -62,13 +62,12 @@ pub fn on_friend_cursor_position(payload: Payload, _socket: RawClient) {
let mapped_pos = &friend_data.position;
let raw_pos = normalized_to_absolute(mapped_pos);
friends::update_cursor_position(
friend_data.user_id,
CursorPositions {
raw: raw_pos,
mapped: mapped_pos.clone(),
},
);
let position = CursorPositions {
raw: raw_pos,
mapped: mapped_pos.clone(),
};
neko_positions::update_friend_cursor(friend_data.user_id, position);
}
}