fix: avatar center to cursor, and cleanup.

Lots of cleanup, went in with this too
This commit is contained in:
Evan Carroll 2026-01-17 22:32:34 -06:00
parent c3320ddcce
commit fe65835f4a
14 changed files with 769 additions and 708 deletions

View file

@ -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<String>; 9],
clothes: &[Option<String>; 9],
accessories: &[Option<String>; 9],
emotion: &[Option<String>; 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<Uuid>, Option<Uuid>) {
(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));