From fe65835f4ac223a545b0b17fc5a390257f8a0d2543b4deb6a8745a0e64a835b3 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Sat, 17 Jan 2026 22:32:34 -0600 Subject: [PATCH] fix: avatar center to cursor, and cleanup. Lots of cleanup, went in with this too --- crates/chattyness-user-ui/src/components.rs | 2 + .../src/components/avatar_canvas.rs | 158 ++++++---- .../src/components/avatar_editor.rs | 41 +-- .../src/components/inventory.rs | 209 ++++--------- .../src/components/keybindings.rs | 60 +--- .../src/components/keybindings_popup.rs | 147 +++------ .../src/components/modals.rs | 116 +++++++ .../src/components/scene_viewer.rs | 39 +-- .../src/components/settings.rs | 52 +--- .../src/components/settings_popup.rs | 289 +++++++----------- .../chattyness-user-ui/src/components/tabs.rs | 93 ++++++ crates/chattyness-user-ui/src/lib.rs | 1 + crates/chattyness-user-ui/src/pages/realm.rs | 39 +-- crates/chattyness-user-ui/src/utils.rs | 231 ++++++++++++++ 14 files changed, 769 insertions(+), 708 deletions(-) create mode 100644 crates/chattyness-user-ui/src/components/tabs.rs create mode 100644 crates/chattyness-user-ui/src/utils.rs diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs index 65b6c5a..eaedec0 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -15,6 +15,7 @@ pub mod modals; pub mod scene_viewer; pub mod settings; pub mod settings_popup; +pub mod tabs; pub mod ws_client; pub use avatar_canvas::*; @@ -32,4 +33,5 @@ pub use modals::*; pub use scene_viewer::*; pub use settings::*; pub use settings_popup::*; +pub use tabs::*; pub use ws_client::*; diff --git a/crates/chattyness-user-ui/src/components/avatar_canvas.rs b/crates/chattyness-user-ui/src/components/avatar_canvas.rs index f6fb5f0..14809ae 100644 --- a/crates/chattyness-user-ui/src/components/avatar_canvas.rs +++ b/crates/chattyness-user-ui/src/components/avatar_canvas.rs @@ -14,6 +14,75 @@ use super::chat_types::{emotion_bubble_colors, ActiveBubble}; /// Base text size multiplier. Text at 100% slider = base_sizes * 1.4 const BASE_TEXT_SCALE: f64 = 1.4; +/// Content bounds for a 3x3 avatar grid. +/// Tracks which rows/columns contain actual content for centering calculations. +struct ContentBounds { + min_col: usize, + max_col: usize, + min_row: usize, + max_row: usize, +} + +impl ContentBounds { + /// Calculate content bounds from 4 layers (9 positions each). + fn from_layers( + skin: &[Option; 9], + clothes: &[Option; 9], + accessories: &[Option; 9], + emotion: &[Option; 9], + ) -> Self { + let has_content_at = |pos: usize| -> bool { + skin[pos].is_some() + || clothes[pos].is_some() + || accessories[pos].is_some() + || emotion[pos].is_some() + }; + + // Columns: 0 (left), 1 (middle), 2 (right) + let left_col = [0, 3, 6].iter().any(|&p| has_content_at(p)); + let mid_col = [1, 4, 7].iter().any(|&p| has_content_at(p)); + let right_col = [2, 5, 8].iter().any(|&p| has_content_at(p)); + + let min_col = if left_col { 0 } else if mid_col { 1 } else { 2 }; + let max_col = if right_col { 2 } else if mid_col { 1 } else { 0 }; + + // Rows: 0 (top), 1 (middle), 2 (bottom) + let top_row = [0, 1, 2].iter().any(|&p| has_content_at(p)); + let mid_row = [3, 4, 5].iter().any(|&p| has_content_at(p)); + let bot_row = [6, 7, 8].iter().any(|&p| has_content_at(p)); + + let min_row = if top_row { 0 } else if mid_row { 1 } else { 2 }; + let max_row = if bot_row { 2 } else if mid_row { 1 } else { 0 }; + + Self { min_col, max_col, min_row, max_row } + } + + /// Content center column (0.0 to 2.0, grid center is 1.0). + fn center_col(&self) -> f64 { + (self.min_col + self.max_col) as f64 / 2.0 + } + + /// Content center row (0.0 to 2.0, grid center is 1.0). + fn center_row(&self) -> f64 { + (self.min_row + self.max_row) as f64 / 2.0 + } + + /// X offset from grid center to content center. + fn x_offset(&self, cell_size: f64) -> f64 { + (self.center_col() - 1.0) * cell_size + } + + /// Y offset from grid center to content center. + fn y_offset(&self, cell_size: f64) -> f64 { + (self.center_row() - 1.0) * cell_size + } + + /// Number of empty rows at the bottom (for name positioning). + fn empty_bottom_rows(&self) -> usize { + 2 - self.max_row + } +} + /// Get a unique key for a member (for Leptos For keying). pub fn member_key(m: &ChannelMemberWithAvatar) -> (Option, Option) { (m.member.user_id, m.member.guest_session_id) @@ -57,39 +126,27 @@ pub fn AvatarCanvas( let display_name = member.member.display_name.clone(); let current_emotion = member.member.current_emotion; - // Helper to check if any layer has content at a position - let has_content_at = |pos: usize| -> bool { - skin_layer[pos].is_some() - || clothes_layer[pos].is_some() - || accessories_layer[pos].is_some() - || emotion_layer[pos].is_some() - }; + // Calculate content bounds for centering on actual content + let content_bounds = ContentBounds::from_layers( + &skin_layer, + &clothes_layer, + &accessories_layer, + &emotion_layer, + ); - // Calculate content bounds for positioning - // X-axis: which columns have content - let left_col_has_content = [0, 3, 6].iter().any(|&p| has_content_at(p)); - let middle_col_has_content = [1, 4, 7].iter().any(|&p| has_content_at(p)); - let right_col_has_content = [2, 5, 8].iter().any(|&p| has_content_at(p)); - - let min_col = if left_col_has_content { 0 } else if middle_col_has_content { 1 } else { 2 }; - let max_col = if right_col_has_content { 2 } else if middle_col_has_content { 1 } else { 0 }; - let content_center_col = (min_col + max_col) as f64 / 2.0; - let x_content_offset = (content_center_col - 1.0) * prop_size; - - // Y-axis: which rows have content - let bottom_row_has_content = [6, 7, 8].iter().any(|&p| has_content_at(p)); - let middle_row_has_content = [3, 4, 5].iter().any(|&p| has_content_at(p)); - - let max_row = if bottom_row_has_content { 2 } else if middle_row_has_content { 1 } else { 0 }; - let empty_bottom_rows = 2 - max_row; - let y_content_offset = empty_bottom_rows as f64 * prop_size; + // Get offsets from grid center to content center + let x_content_offset = content_bounds.x_offset(prop_size); + let y_content_offset = content_bounds.y_offset(prop_size); + let empty_bottom_rows = content_bounds.empty_bottom_rows(); // Avatar is a 3x3 grid of props, each prop is prop_size let avatar_size = prop_size * 3.0; // Calculate canvas position from scene coordinates, adjusted for content bounds - let canvas_x = member.member.position_x * scale_x + offset_x - avatar_size / 2.0 - x_content_offset * scale_x; - let canvas_y = member.member.position_y * scale_y + offset_y - avatar_size + y_content_offset * scale_y; + // Both X and Y center the avatar content on the click point + // Note: x_content_offset and y_content_offset are already in viewport pixels (prop_size includes scale) + let canvas_x = member.member.position_x * scale_x + offset_x - avatar_size / 2.0 - x_content_offset; + let canvas_y = member.member.position_y * scale_y + offset_y - avatar_size / 2.0 - y_content_offset; // Fixed text dimensions (independent of prop_size/zoom) // Text stays readable regardless of zoom level - only affected by text_em_size slider @@ -289,44 +346,15 @@ pub fn AvatarCanvas( let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y); } - // Helper to check if any layer has content at a position - let has_content_at = |pos: usize| -> bool { - skin_layer_clone[pos].is_some() - || clothes_layer_clone[pos].is_some() - || accessories_layer_clone[pos].is_some() - || emotion_layer_clone[pos].is_some() - }; - - // Calculate empty bottom rows to adjust name Y position - let mut empty_bottom_rows = 0; - - // Check row 2 (positions 6, 7, 8) - let row2_has_content = (6..=8).any(&has_content_at); - if !row2_has_content { - empty_bottom_rows += 1; - // Check row 1 (positions 3, 4, 5) - let row1_has_content = (3..=5).any(&has_content_at); - if !row1_has_content { - empty_bottom_rows += 1; - } - } - - // Calculate X offset to center name on columns with content - // Column 0 (left): positions 0, 3, 6 - // Column 1 (middle): positions 1, 4, 7 - // Column 2 (right): positions 2, 5, 8 - let left_col_has_content = [0, 3, 6].iter().any(|&p| has_content_at(p)); - let middle_col_has_content = [1, 4, 7].iter().any(|&p| has_content_at(p)); - let right_col_has_content = [2, 5, 8].iter().any(|&p| has_content_at(p)); - - // Find leftmost and rightmost columns with content - let min_col = if left_col_has_content { 0 } else if middle_col_has_content { 1 } else { 2 }; - let max_col = if right_col_has_content { 2 } else if middle_col_has_content { 1 } else { 0 }; - - // Calculate center of content columns (grid center is column 1) - let content_center_col = (min_col + max_col) as f64 / 2.0; - let x_offset = (content_center_col - 1.0) * cell_size; - let name_x = avatar_cx + x_offset; + // Calculate content bounds for name positioning + let content_bounds = ContentBounds::from_layers( + &skin_layer_clone, + &clothes_layer_clone, + &accessories_layer_clone, + &emotion_layer_clone, + ); + let name_x = avatar_cx + content_bounds.x_offset(cell_size); + let empty_bottom_rows = content_bounds.empty_bottom_rows(); // Draw display name below avatar (with black outline for readability) ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale)); diff --git a/crates/chattyness-user-ui/src/components/avatar_editor.rs b/crates/chattyness-user-ui/src/components/avatar_editor.rs index 987cfff..18a5329 100644 --- a/crates/chattyness-user-ui/src/components/avatar_editor.rs +++ b/crates/chattyness-user-ui/src/components/avatar_editor.rs @@ -13,6 +13,9 @@ use chattyness_db::models::{AvatarWithPaths, InventoryItem}; use chattyness_db::ws_messages::ClientMessage; use super::ws_client::WsSenderStorage; +#[cfg(feature = "hydrate")] +use crate::utils::normalize_asset_path; +use crate::utils::use_escape_key; /// Tab selection for the editor #[derive(Clone, Copy, PartialEq, Eq)] @@ -203,16 +206,6 @@ fn RenderedPreview(#[prop(into)] avatar: Signal>) -> imp } } -/// Normalize an asset path to be absolute, prefixing with /static/ if needed. -#[cfg(feature = "hydrate")] -fn normalize_asset_path(path: &str) -> String { - if path.starts_with('/') { - path.to_string() - } else { - format!("/static/{}", path) - } -} - /// Avatar Editor popup component. /// /// Props: @@ -312,33 +305,7 @@ pub fn AvatarEditorPopup( } // Handle escape key to close - #[cfg(feature = "hydrate")] - { - use wasm_bindgen::{closure::Closure, JsCast}; - - Effect::new(move |_| { - if !open.get() { - return; - } - - let on_close_clone = on_close.clone(); - let closure = - Closure::::new(move |ev: web_sys::KeyboardEvent| { - if ev.key() == "Escape" { - on_close_clone.run(()); - } - }); - - if let Some(window) = web_sys::window() { - let _ = window.add_event_listener_with_callback( - "keydown", - closure.as_ref().unchecked_ref(), - ); - } - - closure.forget(); - }); - } + use_escape_key(open, on_close.clone()); // Close context menu when clicking elsewhere let close_context_menu = move |_| { diff --git a/crates/chattyness-user-ui/src/components/inventory.rs b/crates/chattyness-user-ui/src/components/inventory.rs index 97b7620..b39b7e3 100644 --- a/crates/chattyness-user-ui/src/components/inventory.rs +++ b/crates/chattyness-user-ui/src/components/inventory.rs @@ -8,6 +8,8 @@ use chattyness_db::models::{InventoryItem, PublicProp}; #[cfg(feature = "hydrate")] use chattyness_db::ws_messages::ClientMessage; +use super::modals::Modal; +use super::tabs::{Tab, TabBar}; use super::ws_client::WsSender; /// Inventory popup component. @@ -191,36 +193,6 @@ pub fn InventoryPopup( }); } - // Handle escape key to close - #[cfg(feature = "hydrate")] - { - use wasm_bindgen::{closure::Closure, JsCast}; - - Effect::new(move |_| { - if !open.get() { - return; - } - - let on_close_clone = on_close.clone(); - let closure = - Closure::::new(move |ev: web_sys::KeyboardEvent| { - if ev.key() == "Escape" { - on_close_clone.run(()); - } - }); - - if let Some(window) = web_sys::window() { - let _ = window.add_event_listener_with_callback( - "keydown", - closure.as_ref().unchecked_ref(), - ); - } - - // Intentionally not cleaning up - closure lives for session - closure.forget(); - }); - } - // Handle drop action via WebSocket #[cfg(feature = "hydrate")] let handle_drop = { @@ -250,135 +222,64 @@ pub fn InventoryPopup( #[cfg(not(feature = "hydrate"))] let handle_drop = |_item_id: Uuid| {}; - let on_close_backdrop = on_close.clone(); - let on_close_button = on_close.clone(); - view! { - -