diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs index c36b2e9..63827dd 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -4,8 +4,11 @@ pub mod avatar_canvas; pub mod chat; pub mod chat_types; pub mod editor; +pub mod emotion_picker; pub mod forms; pub mod inventory; +pub mod keybindings; +pub mod keybindings_popup; pub mod layout; pub mod modals; pub mod scene_viewer; @@ -17,8 +20,11 @@ pub use avatar_canvas::*; pub use chat::*; pub use chat_types::*; pub use editor::*; +pub use emotion_picker::*; pub use forms::*; pub use inventory::*; +pub use keybindings::*; +pub use keybindings_popup::*; pub use layout::*; pub use modals::*; pub use scene_viewer::*; diff --git a/crates/chattyness-user-ui/src/components/avatar_canvas.rs b/crates/chattyness-user-ui/src/components/avatar_canvas.rs index 01fc83f..6e172d7 100644 --- a/crates/chattyness-user-ui/src/components/avatar_canvas.rs +++ b/crates/chattyness-user-ui/src/components/avatar_canvas.rs @@ -10,7 +10,9 @@ use uuid::Uuid; use chattyness_db::models::ChannelMemberWithAvatar; use super::chat_types::{emotion_bubble_colors, ActiveBubble}; -use super::settings::BASE_PROP_SIZE; + +/// Base text size multiplier. Text at 100% slider = base_sizes * 1.4 +const BASE_TEXT_SCALE: f64 = 1.4; /// Get a unique key for a member (for Leptos For keying). pub fn member_key(m: &ChannelMemberWithAvatar) -> (Option, Option) { @@ -41,6 +43,9 @@ pub fn AvatarCanvas( z_index: i32, /// Active speech bubble for this user (if any). active_bubble: Option, + /// Text size multiplier for display names, chat bubbles, and badges. + #[prop(default = 1.0)] + text_em_size: f64, ) -> impl IntoView { let canvas_ref = NodeRef::::new(); @@ -54,11 +59,24 @@ pub fn AvatarCanvas( let canvas_x = member.member.position_x * scale_x + offset_x - prop_size / 2.0; let canvas_y = member.member.position_y * scale_y + offset_y - prop_size; - // Calculate canvas size (extra height for bubble and name) - let bubble_extra = if active_bubble.is_some() { prop_size * 1.5 } else { 0.0 }; - let name_extra = 20.0; - let canvas_width = prop_size.max(200.0); // Wide enough for bubble - let canvas_height = prop_size + bubble_extra + name_extra; + // Fixed text dimensions (independent of prop_size/zoom) + // Text stays readable regardless of zoom level - only affected by text_em_size slider + let text_scale = text_em_size * BASE_TEXT_SCALE; + let fixed_bubble_height = if active_bubble.is_some() { + // 4 lines * 16px line_height + 16px padding + 8px tail + 5px margin + (4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale + } else { + 0.0 + }; + let fixed_name_height = 20.0 * text_scale; + let fixed_text_width = 200.0 * text_scale; + + // Canvas must fit both avatar AND fixed-size text + let canvas_width = prop_size.max(fixed_text_width); + let canvas_height = prop_size + fixed_bubble_height + fixed_name_height; + + // Adjust bubble_extra for positioning (used later in avatar_cy calculation) + let bubble_extra = fixed_bubble_height; // Adjust position to account for extra space above avatar let adjusted_y = canvas_y - bubble_extra; @@ -184,8 +202,8 @@ pub fn AvatarCanvas( draw_image(emotion_path, &image_cache, &ctx, avatar_cx, avatar_cy, prop_size); } - // Scale factor for text/badges - let text_scale = prop_size / BASE_PROP_SIZE; + // Text scale independent of zoom - only affected by user's text_em_size setting + let text_scale = text_em_size * BASE_TEXT_SCALE; // Draw emotion badge if non-neutral if current_emotion > 0 { @@ -205,18 +223,24 @@ pub fn AvatarCanvas( let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y); } - // Draw display name below avatar - ctx.set_fill_style_str("#fff"); + // Draw display name below avatar (with black outline for readability) ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale)); ctx.set_text_align("center"); ctx.set_text_baseline("alphabetic"); - let _ = ctx.fill_text(&display_name_clone, avatar_cx, avatar_cy + prop_size / 2.0 + 15.0 * text_scale); + let name_y = avatar_cy + prop_size / 2.0 + 15.0 * text_scale; + // Black outline + ctx.set_stroke_style_str("#000"); + ctx.set_line_width(3.0); + let _ = ctx.stroke_text(&display_name_clone, avatar_cx, name_y); + // White fill + ctx.set_fill_style_str("#fff"); + let _ = ctx.fill_text(&display_name_clone, avatar_cx, name_y); // Draw speech bubble if active if let Some(ref bubble) = active_bubble_clone { let current_time = js_sys::Date::now() as i64; if bubble.expires_at >= current_time { - draw_bubble(&ctx, bubble, avatar_cx, avatar_cy - prop_size / 2.0, prop_size); + draw_bubble(&ctx, bubble, avatar_cx, avatar_cy - prop_size / 2.0, prop_size, text_em_size); } } }); @@ -248,9 +272,11 @@ fn draw_bubble( bubble: &ActiveBubble, center_x: f64, top_y: f64, - prop_size: f64, + _prop_size: f64, + text_em_size: f64, ) { - let text_scale = prop_size / BASE_PROP_SIZE; + // Text scale independent of zoom - only affected by user's text_em_size setting + let text_scale = text_em_size * BASE_TEXT_SCALE; let max_bubble_width = 200.0 * text_scale; let padding = 8.0 * text_scale; let font_size = 12.0 * text_scale; diff --git a/crates/chattyness-user-ui/src/components/chat.rs b/crates/chattyness-user-ui/src/components/chat.rs index 12a3e41..198e73b 100644 --- a/crates/chattyness-user-ui/src/components/chat.rs +++ b/crates/chattyness-user-ui/src/components/chat.rs @@ -5,24 +5,9 @@ use leptos::prelude::*; use chattyness_db::models::EmotionAvailability; use chattyness_db::ws_messages::ClientMessage; +use super::emotion_picker::{EmoteListPopup, EMOTIONS}; use super::ws_client::WsSenderStorage; -/// Emotion names indexed by emotion slot (0-11). -const EMOTIONS: &[&str] = &[ - "neutral", // 0 - "happy", // 1 - "sad", // 2 - "angry", // 3 - "surprised", // 4 - "thinking", // 5 - "laughing", // 6 - "crying", // 7 - "love", // 8 - "confused", // 9 - "sleeping", // 10 - "wink", // 11 -]; - /// Command mode state for the chat input. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum CommandMode { @@ -459,232 +444,3 @@ pub fn ChatInput( } } - -/// Emote list popup component. -/// -/// Shows available emotions in a grid with avatar previews. -/// Supports search-as-you-type filtering and keyboard navigation. -#[component] -fn EmoteListPopup( - emotion_availability: Signal>, - skin_preview_path: Signal>, - on_select: Callback, - #[prop(into)] on_close: Callback<()>, - #[prop(into)] emotion_filter: Signal, - #[prop(into)] selected_idx: Signal, -) -> impl IntoView { - let _ = on_close; // Suppress unused warning - // Get list of available emotions (name, preview_path, index), filtered by search text - let available_emotions = move || { - let filter_text = emotion_filter.get().to_lowercase(); - emotion_availability - .get() - .map(|avail| { - EMOTIONS - .iter() - .enumerate() - .filter(|(idx, _)| avail.available.get(*idx).copied().unwrap_or(false)) - .filter(|(_, name)| { - // If no filter, show all; otherwise filter by prefix - filter_text.is_empty() || name.starts_with(&filter_text) - }) - .map(|(idx, name)| { - let preview = avail.preview_paths.get(idx).cloned().flatten(); - ((*name).to_string(), preview) - }) - .collect::>() - }) - .unwrap_or_default() - }; - - let filter_display = move || { - let f = emotion_filter.get(); - if f.is_empty() { - "Type to filter...".to_string() - } else { - format!("Filter: {}", f) - } - }; - - // Indexed emotions for selection tracking - let indexed_emotions = move || { - available_emotions() - .into_iter() - .enumerate() - .collect::>() - }; - - view! { -
-
- "Select an emotion:" - {filter_display} -
-
- {move || { - indexed_emotions() - .into_iter() - .map(|(idx, (emotion_name, preview_path))| { - let on_select = on_select.clone(); - let emotion_name_for_click = emotion_name.clone(); - let emotion_name_display = emotion_name.clone(); - let _skin_path = skin_preview_path.get(); - let _emotion_path = preview_path.clone(); - let is_selected = move || selected_idx.get() == idx; - view! { - - } - }) - .collect_view() - }} -
- -
- "No emotions configured for your avatar" -
-
-
- } -} - -/// Emotion preview component. -/// -/// Renders a small canvas with the avatar skin and emotion overlay. -#[component] -fn EmotionPreview(skin_path: Option, emotion_path: Option) -> impl IntoView { - let canvas_ref = NodeRef::::new(); - - #[cfg(feature = "hydrate")] - { - use wasm_bindgen::{closure::Closure, JsCast}; - - let skin_path_clone = skin_path.clone(); - let emotion_path_clone = emotion_path.clone(); - - Effect::new(move |_| { - let Some(canvas) = canvas_ref.get() else { - return; - }; - - let canvas_el: &web_sys::HtmlCanvasElement = &canvas; - canvas_el.set_width(32); - canvas_el.set_height(32); - - let Ok(Some(ctx)) = canvas_el.get_context("2d") else { - return; - }; - let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap(); - - // Clear canvas - ctx.clear_rect(0.0, 0.0, 32.0, 32.0); - - // Helper to normalize asset paths - fn normalize_path(path: &str) -> String { - if path.starts_with('/') { - path.to_string() - } else { - format!("/static/{}", path) - } - } - - // Draw skin layer first - if let Some(ref skin) = skin_path_clone { - let skin_url = normalize_path(skin); - let ctx_clone = ctx.clone(); - - let img = web_sys::HtmlImageElement::new().unwrap(); - let img_clone = img.clone(); - - // Load and draw emotion on top after skin loads - let emotion_path_for_closure = emotion_path_clone.clone(); - - let onload = Closure::once(Box::new(move || { - ctx_clone.draw_image_with_html_image_element_and_dw_and_dh( - &img_clone, 0.0, 0.0, 32.0, 32.0, - ).ok(); - - // Now draw emotion overlay - if let Some(ref emotion) = emotion_path_for_closure { - let emotion_url = normalize_path(emotion); - let ctx_emotion = ctx_clone.clone(); - - let emotion_img = web_sys::HtmlImageElement::new().unwrap(); - let emotion_img_clone = emotion_img.clone(); - - let emotion_onload = Closure::once(Box::new(move || { - ctx_emotion.draw_image_with_html_image_element_and_dw_and_dh( - &emotion_img_clone, 0.0, 0.0, 32.0, 32.0, - ).ok(); - }) as Box); - - emotion_img.set_onload(Some(emotion_onload.as_ref().unchecked_ref())); - emotion_onload.forget(); - emotion_img.set_src(&emotion_url); - } - }) as Box); - - img.set_onload(Some(onload.as_ref().unchecked_ref())); - onload.forget(); - img.set_src(&skin_url); - } else if let Some(ref emotion) = emotion_path_clone { - // No skin, just draw emotion - let emotion_url = normalize_path(emotion); - let ctx_clone = ctx.clone(); - - let img = web_sys::HtmlImageElement::new().unwrap(); - let img_clone = img.clone(); - - let onload = Closure::once(Box::new(move || { - ctx_clone.draw_image_with_html_image_element_and_dw_and_dh( - &img_clone, 0.0, 0.0, 32.0, 32.0, - ).ok(); - }) as Box); - - img.set_onload(Some(onload.as_ref().unchecked_ref())); - onload.forget(); - img.set_src(&emotion_url); - } else { - // No assets - draw placeholder circle - ctx.set_fill_style_str("#4B5563"); - ctx.begin_path(); - ctx.arc(16.0, 16.0, 14.0, 0.0, std::f64::consts::PI * 2.0).ok(); - ctx.fill(); - } - }); - } - - view! { -