fix text sizing, and add custom hotkey support

This commit is contained in:
Evan Carroll 2026-01-16 13:58:19 -06:00
parent 09590edd95
commit ee425e224e
10 changed files with 1096 additions and 280 deletions

View file

@ -4,8 +4,11 @@ pub mod avatar_canvas;
pub mod chat; pub mod chat;
pub mod chat_types; pub mod chat_types;
pub mod editor; pub mod editor;
pub mod emotion_picker;
pub mod forms; pub mod forms;
pub mod inventory; pub mod inventory;
pub mod keybindings;
pub mod keybindings_popup;
pub mod layout; pub mod layout;
pub mod modals; pub mod modals;
pub mod scene_viewer; pub mod scene_viewer;
@ -17,8 +20,11 @@ pub use avatar_canvas::*;
pub use chat::*; pub use chat::*;
pub use chat_types::*; pub use chat_types::*;
pub use editor::*; pub use editor::*;
pub use emotion_picker::*;
pub use forms::*; pub use forms::*;
pub use inventory::*; pub use inventory::*;
pub use keybindings::*;
pub use keybindings_popup::*;
pub use layout::*; pub use layout::*;
pub use modals::*; pub use modals::*;
pub use scene_viewer::*; pub use scene_viewer::*;

View file

@ -10,7 +10,9 @@ use uuid::Uuid;
use chattyness_db::models::ChannelMemberWithAvatar; use chattyness_db::models::ChannelMemberWithAvatar;
use super::chat_types::{emotion_bubble_colors, ActiveBubble}; 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). /// Get a unique key for a member (for Leptos For keying).
pub fn member_key(m: &ChannelMemberWithAvatar) -> (Option<Uuid>, Option<Uuid>) { pub fn member_key(m: &ChannelMemberWithAvatar) -> (Option<Uuid>, Option<Uuid>) {
@ -41,6 +43,9 @@ pub fn AvatarCanvas(
z_index: i32, z_index: i32,
/// Active speech bubble for this user (if any). /// Active speech bubble for this user (if any).
active_bubble: Option<ActiveBubble>, active_bubble: Option<ActiveBubble>,
/// Text size multiplier for display names, chat bubbles, and badges.
#[prop(default = 1.0)]
text_em_size: f64,
) -> impl IntoView { ) -> impl IntoView {
let canvas_ref = NodeRef::<leptos::html::Canvas>::new(); let canvas_ref = NodeRef::<leptos::html::Canvas>::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_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; let canvas_y = member.member.position_y * scale_y + offset_y - prop_size;
// Calculate canvas size (extra height for bubble and name) // Fixed text dimensions (independent of prop_size/zoom)
let bubble_extra = if active_bubble.is_some() { prop_size * 1.5 } else { 0.0 }; // Text stays readable regardless of zoom level - only affected by text_em_size slider
let name_extra = 20.0; let text_scale = text_em_size * BASE_TEXT_SCALE;
let canvas_width = prop_size.max(200.0); // Wide enough for bubble let fixed_bubble_height = if active_bubble.is_some() {
let canvas_height = prop_size + bubble_extra + name_extra; // 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 // Adjust position to account for extra space above avatar
let adjusted_y = canvas_y - bubble_extra; 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); draw_image(emotion_path, &image_cache, &ctx, avatar_cx, avatar_cy, prop_size);
} }
// Scale factor for text/badges // Text scale independent of zoom - only affected by user's text_em_size setting
let text_scale = prop_size / BASE_PROP_SIZE; let text_scale = text_em_size * BASE_TEXT_SCALE;
// Draw emotion badge if non-neutral // Draw emotion badge if non-neutral
if current_emotion > 0 { if current_emotion > 0 {
@ -205,18 +223,24 @@ pub fn AvatarCanvas(
let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y); let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y);
} }
// Draw display name below avatar // Draw display name below avatar (with black outline for readability)
ctx.set_fill_style_str("#fff");
ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale)); ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale));
ctx.set_text_align("center"); ctx.set_text_align("center");
ctx.set_text_baseline("alphabetic"); 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 // Draw speech bubble if active
if let Some(ref bubble) = active_bubble_clone { if let Some(ref bubble) = active_bubble_clone {
let current_time = js_sys::Date::now() as i64; let current_time = js_sys::Date::now() as i64;
if bubble.expires_at >= current_time { 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, bubble: &ActiveBubble,
center_x: f64, center_x: f64,
top_y: 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 max_bubble_width = 200.0 * text_scale;
let padding = 8.0 * text_scale; let padding = 8.0 * text_scale;
let font_size = 12.0 * text_scale; let font_size = 12.0 * text_scale;

View file

@ -5,24 +5,9 @@ use leptos::prelude::*;
use chattyness_db::models::EmotionAvailability; use chattyness_db::models::EmotionAvailability;
use chattyness_db::ws_messages::ClientMessage; use chattyness_db::ws_messages::ClientMessage;
use super::emotion_picker::{EmoteListPopup, EMOTIONS};
use super::ws_client::WsSenderStorage; 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. /// Command mode state for the chat input.
#[derive(Clone, Copy, PartialEq, Eq, Debug)] #[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum CommandMode { enum CommandMode {
@ -459,232 +444,3 @@ pub fn ChatInput(
</div> </div>
} }
} }
/// 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<Option<EmotionAvailability>>,
skin_preview_path: Signal<Option<String>>,
on_select: Callback<String>,
#[prop(into)] on_close: Callback<()>,
#[prop(into)] emotion_filter: Signal<String>,
#[prop(into)] selected_idx: Signal<usize>,
) -> 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::<Vec<_>>()
})
.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::<Vec<_>>()
};
view! {
<div
class="absolute bottom-full left-0 mb-2 w-full max-w-lg bg-gray-800/95 backdrop-blur-sm rounded-lg shadow-2xl border border-gray-700 p-3 z-50"
role="listbox"
aria-label="Available emotions"
>
<div class="flex justify-between items-center text-xs mb-2 px-1">
<span class="text-gray-400">"Select an emotion:"</span>
<span class="text-blue-400 italic">{filter_display}</span>
</div>
<div class="grid grid-cols-3 gap-1 max-h-64 overflow-y-auto">
{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! {
<button
type="button"
class=move || {
if is_selected() {
"flex items-center gap-2 p-2 rounded bg-blue-600 text-left w-full"
} else {
"flex items-center gap-2 p-2 rounded hover:bg-gray-700 transition-colors text-left w-full"
}
}
on:click=move |_| on_select.run(emotion_name_for_click.clone())
role="option"
aria-selected=is_selected
>
<EmotionPreview
skin_path=_skin_path.clone()
emotion_path=_emotion_path.clone()
/>
<span class="text-white text-sm">
":e "
{emotion_name_display}
</span>
</button>
}
})
.collect_view()
}}
</div>
<Show when=move || available_emotions().is_empty()>
<div class="text-gray-500 text-sm text-center py-4">
"No emotions configured for your avatar"
</div>
</Show>
</div>
}
}
/// Emotion preview component.
///
/// Renders a small canvas with the avatar skin and emotion overlay.
#[component]
fn EmotionPreview(skin_path: Option<String>, emotion_path: Option<String>) -> impl IntoView {
let canvas_ref = NodeRef::<leptos::html::Canvas>::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<dyn FnOnce()>);
emotion_img.set_onload(Some(emotion_onload.as_ref().unchecked_ref()));
emotion_onload.forget();
emotion_img.set_src(&emotion_url);
}
}) as Box<dyn FnOnce()>);
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<dyn FnOnce()>);
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! {
<canvas
node_ref=canvas_ref
width="32"
height="32"
class="w-8 h-8 rounded"
aria-hidden="true"
/>
}
}

View file

@ -0,0 +1,330 @@
//! Shared emotion picker components.
//!
//! Provides reusable components for selecting emotions with avatar previews.
//! Used by both the chat `:l` command and the keybindings popup.
use leptos::prelude::*;
use chattyness_db::models::EmotionAvailability;
/// Emotion names indexed by emotion slot (0-11).
pub 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
];
/// Emote list popup component.
///
/// Shows emotions in a grid with avatar previews.
/// Supports search-as-you-type filtering and keyboard navigation.
///
/// Props:
/// - `emotion_availability`: Which emotions are available for the user's avatar
/// - `skin_preview_path`: Path to the user's skin layer center asset (for previews)
/// - `on_select`: Callback when an emotion is selected (receives emotion name)
/// - `on_close`: Callback when popup should close
/// - `emotion_filter`: Signal containing the current filter text
/// - `selected_idx`: Signal containing the currently selected index
/// - `show_all_emotions`: When true, show all 12 emotions (for keybindings);
/// when false, only show available emotions (for chat `:l`)
/// - `popup_style`: Positioning style - "above" (default, for chat input) or "static" (for modal wrapper)
/// - `show_command_prefix`: When true, show ":e " prefix before emotion names (default true for chat)
#[component]
pub fn EmoteListPopup(
emotion_availability: Signal<Option<EmotionAvailability>>,
skin_preview_path: Signal<Option<String>>,
on_select: Callback<String>,
#[prop(into)] on_close: Callback<()>,
#[prop(into)] emotion_filter: Signal<String>,
#[prop(into)] selected_idx: Signal<usize>,
#[prop(default = false)] show_all_emotions: bool,
#[prop(default = "above")] popup_style: &'static str,
#[prop(default = true)] show_command_prefix: bool,
) -> impl IntoView {
let _ = on_close; // Suppress unused warning
// Determine container class based on popup style
// Both use the same max-w-lg width for consistency
let container_class = if popup_style == "static" {
// Static positioning for use inside a modal wrapper (keybindings)
"max-w-lg bg-gray-800/95 backdrop-blur-sm rounded-lg shadow-2xl border border-gray-700 p-3"
} else {
// Absolute positioning above parent (chat input)
"absolute bottom-full left-0 mb-2 w-full max-w-lg bg-gray-800/95 backdrop-blur-sm rounded-lg shadow-2xl border border-gray-700 p-3 z-50"
};
// Get list of emotions (name, preview_path), filtered by search text
// If show_all_emotions is true, show all regardless of availability
let available_emotions = move || {
let filter_text = emotion_filter.get().to_lowercase();
emotion_availability
.get()
.map(|avail| {
EMOTIONS
.iter()
.enumerate()
.filter(|(idx, _)| {
// If show_all_emotions is true, include all emotions
// Otherwise, only include available ones
show_all_emotions || avail.available.get(*idx).copied().unwrap_or(false)
})
.filter(|(_, name)| {
// Filter by prefix if filter is set
filter_text.is_empty() || name.starts_with(&filter_text)
})
.map(|(idx, name)| {
let preview = avail.preview_paths.get(idx).cloned().flatten();
let is_available = avail.available.get(idx).copied().unwrap_or(false);
((*name).to_string(), preview, is_available)
})
.collect::<Vec<_>>()
})
.unwrap_or_else(|| {
// If no availability info, show all emotions with no previews
if show_all_emotions {
EMOTIONS
.iter()
.filter(|name| {
filter_text.is_empty() || name.starts_with(&filter_text)
})
.map(|name| ((*name).to_string(), None, true))
.collect()
} else {
Vec::new()
}
})
};
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::<Vec<_>>()
};
view! {
<div
class=container_class
role="listbox"
aria-label="Available emotions"
>
<div class="flex justify-between items-center text-xs mb-2 px-1">
<span class="text-gray-400">"Select an emotion:"</span>
<span class="text-blue-400 italic">{filter_display}</span>
</div>
<div class="grid grid-cols-3 gap-1 max-h-64 overflow-y-auto">
{move || {
indexed_emotions()
.into_iter()
.map(|(idx, (emotion_name, preview_path, is_available))| {
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! {
<button
type="button"
class=move || {
let base = if is_selected() {
"flex items-center gap-2 p-2 rounded bg-blue-600 text-left w-full"
} else {
"flex items-center gap-2 p-2 rounded hover:bg-gray-700 transition-colors text-left w-full"
};
// Dim unavailable emotions (only when showing all)
if !is_available && !is_selected() {
format!("{} opacity-50", base)
} else {
base.to_string()
}
}
on:click=move |_| on_select.run(emotion_name_for_click.clone())
role="option"
aria-selected=is_selected
>
<EmotionPreview
skin_path=skin_path.clone()
emotion_path=emotion_path.clone()
/>
<span class="text-white text-sm capitalize">
{if show_command_prefix { ":e " } else { "" }}
{emotion_name_display}
</span>
</button>
}
})
.collect_view()
}}
</div>
<Show when=move || available_emotions().is_empty()>
<div class="text-gray-500 text-sm text-center py-4">
{move || {
if emotion_filter.get().is_empty() {
"No emotions configured for your avatar"
} else {
"No matching emotions"
}
}}
</div>
</Show>
<div class="mt-2 pt-2 border-t border-gray-700 text-xs text-gray-500">
<kbd class="px-1 py-0.5 bg-gray-700 rounded font-mono">"↑↓"</kbd>
" navigate "
<kbd class="px-1 py-0.5 bg-gray-700 rounded font-mono">"Enter"</kbd>
" select "
<kbd class="px-1 py-0.5 bg-gray-700 rounded font-mono">"Esc"</kbd>
" cancel"
</div>
</div>
}
}
/// Emotion preview component.
///
/// Renders a small canvas with the avatar skin and emotion overlay.
#[component]
pub fn EmotionPreview(skin_path: Option<String>, emotion_path: Option<String>) -> impl IntoView {
let canvas_ref = NodeRef::<leptos::html::Canvas>::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<dyn FnOnce()>);
emotion_img.set_onload(Some(emotion_onload.as_ref().unchecked_ref()));
emotion_onload.forget();
emotion_img.set_src(&emotion_url);
}
}) as Box<dyn FnOnce()>);
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<dyn FnOnce()>);
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! {
<canvas
node_ref=canvas_ref
width="32"
height="32"
class="w-8 h-8 rounded"
aria-hidden="true"
/>
}
}

View file

@ -0,0 +1,172 @@
//! Emotion keybindings with localStorage persistence.
use chattyness_db::models::EmotionState;
use serde::{Deserialize, Serialize};
/// LocalStorage key for emotion keybindings.
const KEYBINDINGS_KEY: &str = "chattyness_emotion_keybindings";
/// Key slot names for the 12 emotion keybindings.
/// Maps to e1, e2, ..., e9, e0, eq, ew
pub const KEYBINDING_SLOTS: [&str; 12] = [
"1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "q", "w",
];
/// Default emotion order for keybinding slots.
/// Slot 0 (e1) is always Happy (locked).
/// Remaining slots ordered by approximate utility/frequency.
pub const DEFAULT_EMOTION_ORDER: [EmotionState; 12] = [
EmotionState::Happy, // e1 - locked
EmotionState::Laughing, // e2
EmotionState::Love, // e3
EmotionState::Wink, // e4
EmotionState::Surprised, // e5
EmotionState::Thinking, // e6
EmotionState::Neutral, // e7
EmotionState::Confused, // e8
EmotionState::Sad, // e9
EmotionState::Angry, // e0
EmotionState::Crying, // eq
EmotionState::Sleeping, // ew
];
/// All emotions available for selection.
pub const ALL_EMOTIONS: [EmotionState; 12] = [
EmotionState::Neutral,
EmotionState::Happy,
EmotionState::Sad,
EmotionState::Angry,
EmotionState::Surprised,
EmotionState::Thinking,
EmotionState::Laughing,
EmotionState::Crying,
EmotionState::Love,
EmotionState::Confused,
EmotionState::Sleeping,
EmotionState::Wink,
];
/// Emotion keybindings configuration.
///
/// Maps 12 key slots (e1-e0, eq, ew) to emotion states.
/// Slot 0 (e1) is always locked to Happy.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct EmotionKeybindings {
/// The emotion assigned to each slot (0-11).
pub slots: [EmotionState; 12],
}
impl Default for EmotionKeybindings {
fn default() -> Self {
Self {
slots: DEFAULT_EMOTION_ORDER,
}
}
}
impl EmotionKeybindings {
/// Load keybindings from localStorage, returning defaults if not found or invalid.
#[cfg(feature = "hydrate")]
pub fn load() -> Self {
let Some(window) = web_sys::window() else {
return Self::default();
};
let Ok(Some(storage)) = window.local_storage() else {
return Self::default();
};
let Ok(Some(json)) = storage.get_item(KEYBINDINGS_KEY) else {
return Self::default();
};
let mut loaded: Self = serde_json::from_str(&json).unwrap_or_default();
// Ensure slot 0 is always Happy (enforce lock)
loaded.slots[0] = EmotionState::Happy;
loaded
}
/// Stub for SSR - returns default keybindings.
#[cfg(not(feature = "hydrate"))]
pub fn load() -> Self {
Self::default()
}
/// Save keybindings to localStorage.
#[cfg(feature = "hydrate")]
pub fn save(&self) {
let Some(window) = web_sys::window() else {
return;
};
let Ok(Some(storage)) = window.local_storage() else {
return;
};
if let Ok(json) = serde_json::to_string(self) {
let _ = storage.set_item(KEYBINDINGS_KEY, &json);
}
}
/// Stub for SSR - no-op.
#[cfg(not(feature = "hydrate"))]
pub fn save(&self) {}
/// Get the emotion for a given key (after 'e' was pressed).
///
/// Keys: "1"-"9", "0", "q", "w"
pub fn get_emotion_for_key(&self, key: &str) -> Option<EmotionState> {
let index = match key {
"1" => 0,
"2" => 1,
"3" => 2,
"4" => 3,
"5" => 4,
"6" => 5,
"7" => 6,
"8" => 7,
"9" => 8,
"0" => 9,
"q" | "Q" => 10,
"w" | "W" => 11,
_ => return None,
};
Some(self.slots[index])
}
/// Set the emotion for a slot (0-11).
///
/// Slot 0 is locked to Happy and cannot be changed.
pub fn set_slot(&mut self, index: usize, emotion: EmotionState) {
if index == 0 {
return; // Slot 0 is locked to Happy
}
if index < 12 {
self.slots[index] = emotion;
}
}
/// Reset all slots to default values.
pub fn reset_to_defaults(&mut self) {
self.slots = DEFAULT_EMOTION_ORDER;
}
/// Get the display label for a slot (e.g., "e1", "e0", "eq").
pub fn slot_label(index: usize) -> &'static str {
match index {
0 => "e1",
1 => "e2",
2 => "e3",
3 => "e4",
4 => "e5",
5 => "e6",
6 => "e7",
7 => "e8",
8 => "e9",
9 => "e0",
10 => "eq",
11 => "ew",
_ => "??",
}
}
}

View file

@ -0,0 +1,421 @@
//! Keybindings popup component for customizing emotion hotkeys.
use leptos::prelude::*;
use chattyness_db::models::{EmotionAvailability, EmotionState};
use super::emotion_picker::EmoteListPopup;
use super::keybindings::EmotionKeybindings;
/// Keybindings popup component for customizing emotion hotkeys.
///
/// Provides a tabbed interface for configuring keybindings.
/// Currently includes the "Emotions" tab for emotion hotkey customization.
///
/// Props:
/// - `open`: Signal controlling visibility
/// - `keybindings`: RwSignal for keybindings (read/write)
/// - `emotion_availability`: Signal for which emotions are available
/// - `skin_preview_path`: Path to the user's skin layer center asset (for previews)
/// - `on_close`: Callback when popup should close
#[component]
pub fn KeybindingsPopup(
#[prop(into)] open: Signal<bool>,
keybindings: RwSignal<EmotionKeybindings>,
#[prop(into)] emotion_availability: Signal<Option<EmotionAvailability>>,
#[prop(into)] skin_preview_path: Signal<Option<String>>,
on_close: Callback<()>,
) -> impl IntoView {
// Current active tab (for future extensibility)
let (active_tab, _set_active_tab) = signal("emotions");
// Handle escape key to close (only when no picker is open)
#[cfg(feature = "hydrate")]
{
use leptos::web_sys;
use wasm_bindgen::{closure::Closure, JsCast};
Effect::new(move |_| {
if !open.get() {
return;
}
let on_close_clone = on_close.clone();
let closure =
Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(move |ev: web_sys::KeyboardEvent| {
// Only close on Escape if the event target is not the picker input
if ev.key() == "Escape" {
if let Some(target) = ev.target() {
if let Ok(el) = target.dyn_into::<web_sys::HtmlElement>() {
// Check if it's the picker filter input - if so, don't close the main popup
if el.class_list().contains("emotion-picker-filter") {
return;
}
}
}
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();
});
}
let on_close_backdrop = on_close.clone();
let on_close_button = on_close.clone();
view! {
<Show when=move || open.get()>
<div
class="fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-labelledby="keybindings-modal-title"
>
// Backdrop
<div
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
on:click=move |_| on_close_backdrop.run(())
aria-hidden="true"
/>
// Modal content
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-2xl w-full mx-4 p-6 border border-gray-700">
// Header
<div class="flex items-center justify-between mb-4">
<h2 id="keybindings-modal-title" class="text-xl font-bold text-white">
"Keybindings"
</h2>
<button
type="button"
class="text-gray-400 hover:text-white transition-colors"
on:click=move |_| on_close_button.run(())
aria-label="Close keybindings"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
// Tab bar
<div class="flex border-b border-gray-700 mb-4" role="tablist">
<button
type="button"
role="tab"
aria-selected="true"
class="px-4 py-2 font-medium transition-colors border-b-2 -mb-px text-blue-400 border-blue-400"
>
"Emotions"
</button>
</div>
// Tab content
<Show when=move || active_tab.get() == "emotions">
<EmotionsTab
keybindings=keybindings
emotion_availability=emotion_availability
skin_preview_path=skin_preview_path
/>
</Show>
// Footer with keyboard hint
<div class="mt-6 pt-4 border-t border-gray-700">
<p class="text-gray-400 text-sm">
"Press "
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs font-mono">"e"</kbd>
" + key to trigger emotion (e.g., "
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs font-mono">"e1"</kbd>
" for happy)"
</p>
</div>
</div>
</div>
</Show>
}
}
/// Emotions tab content showing the 12 customizable emotion slots.
#[component]
fn EmotionsTab(
keybindings: RwSignal<EmotionKeybindings>,
#[prop(into)] emotion_availability: Signal<Option<EmotionAvailability>>,
#[prop(into)] skin_preview_path: Signal<Option<String>>,
) -> impl IntoView {
// Track which slot is being edited (None = no picker open)
let (editing_slot, set_editing_slot) = signal(Option::<usize>::None);
let on_reset = move |_| {
keybindings.update(|kb| {
kb.reset_to_defaults();
kb.save();
});
};
let on_slot_click = move |index: usize| {
if index != 0 {
// Not locked
set_editing_slot.set(Some(index));
}
};
let on_select = Callback::new(move |(index, emotion): (usize, EmotionState)| {
keybindings.update(|kb| {
kb.set_slot(index, emotion);
kb.save();
});
set_editing_slot.set(None);
});
let on_close_picker = Callback::new(move |_: ()| {
set_editing_slot.set(None);
});
view! {
<div id="emotions-panel" role="tabpanel" aria-labelledby="emotions-tab">
<p class="text-gray-400 text-sm mb-4">
"Customize which emotion is triggered by each key combination. "
"The first slot (e1) is always Happy."
</p>
// Grid of emotion slots (3 columns x 4 rows)
<div class="grid grid-cols-3 gap-3 mb-4">
{(0..12).map(|i| {
let current_emotion = Signal::derive(move || keybindings.get().slots[i]);
let is_editing = Signal::derive(move || editing_slot.get() == Some(i));
view! {
<EmotionSlot
index=i
current_emotion=current_emotion
is_editing=is_editing
emotion_availability=emotion_availability
skin_preview_path=skin_preview_path
on_click=on_slot_click
on_select=on_select
on_close=on_close_picker
/>
}
}).collect_view()}
</div>
// Reset button
<div class="flex justify-end">
<button
type="button"
class="px-4 py-2 text-sm bg-gray-700 hover:bg-gray-600 text-white rounded transition-colors"
on:click=on_reset
>
"Reset to Defaults"
</button>
</div>
</div>
}
}
/// Individual emotion slot with label, current emotion, and inline picker.
#[component]
fn EmotionSlot(
/// Slot index (0-11).
index: usize,
#[prop(into)] current_emotion: Signal<EmotionState>,
#[prop(into)] is_editing: Signal<bool>,
#[prop(into)] emotion_availability: Signal<Option<EmotionAvailability>>,
#[prop(into)] skin_preview_path: Signal<Option<String>>,
on_click: impl Fn(usize) + Clone + 'static,
on_select: Callback<(usize, EmotionState)>,
on_close: Callback<()>,
) -> impl IntoView {
let is_locked = index == 0;
let slot_label = EmotionKeybindings::slot_label(index);
// Local state for the picker filter and selection
let (filter, set_filter) = signal(String::new());
let (selected_idx, set_selected_idx) = signal(0usize);
let input_ref = NodeRef::<leptos::html::Input>::new();
// Focus input when picker opens
#[cfg(feature = "hydrate")]
{
Effect::new(move |_| {
if is_editing.get() {
if let Some(input) = input_ref.get() {
let _ = input.focus();
}
} else {
// Reset filter when closed
set_filter.set(String::new());
set_selected_idx.set(0);
}
});
}
let on_click_clone = on_click.clone();
let handle_click = move |_| {
on_click_clone(index);
};
// Convert emotion name to EmotionState and call the parent callback
let on_popup_select = {
let on_select = on_select.clone();
Callback::new(move |emotion_name: String| {
if let Ok(emotion_state) = emotion_name.parse::<EmotionState>() {
on_select.run((index, emotion_state));
}
})
};
// Filtered emotions for keyboard navigation
let filtered_count = move || {
let filter_text = filter.get().to_lowercase();
super::emotion_picker::EMOTIONS
.iter()
.filter(|name| filter_text.is_empty() || name.starts_with(&filter_text))
.count()
};
let on_input = move |ev| {
let value = event_target_value(&ev);
set_filter.set(value);
set_selected_idx.set(0);
};
#[cfg(feature = "hydrate")]
let on_keydown = {
let on_close = on_close.clone();
let on_popup_select = on_popup_select.clone();
move |ev: web_sys::KeyboardEvent| {
// Stop all key events from bubbling to prevent triggering global keybindings
ev.stop_propagation();
let key = ev.key();
let count = filtered_count();
if key == "Escape" {
on_close.run(());
ev.prevent_default();
return;
}
if key == "ArrowDown" && count > 0 {
set_selected_idx.update(|idx| {
*idx = (*idx + 1) % count;
});
ev.prevent_default();
return;
}
if key == "ArrowUp" && count > 0 {
set_selected_idx.update(|idx| {
*idx = if *idx == 0 { count - 1 } else { *idx - 1 };
});
ev.prevent_default();
return;
}
if key == "Enter" && count > 0 {
let filter_text = filter.get_untracked().to_lowercase();
let idx = selected_idx.get_untracked();
let emotion = super::emotion_picker::EMOTIONS
.iter()
.filter(|name| filter_text.is_empty() || name.starts_with(&filter_text))
.nth(idx)
.map(|s| (*s).to_string());
if let Some(emotion_name) = emotion {
on_popup_select.run(emotion_name);
}
ev.prevent_default();
}
}
};
#[cfg(not(feature = "hydrate"))]
let on_keydown = move |_ev: leptos::ev::KeyboardEvent| {};
let on_close_for_backdrop = on_close.clone();
view! {
<div class="relative">
<button
type="button"
class=move || format!(
"w-full p-3 rounded-lg border transition-colors text-left {}",
if is_locked {
"bg-gray-900 border-gray-700 cursor-not-allowed opacity-75"
} else if is_editing.get() {
"bg-gray-700 border-blue-500"
} else {
"bg-gray-700 border-gray-600 hover:border-gray-500"
}
)
disabled=is_locked
on:click=handle_click
aria-haspopup="listbox"
aria-expanded=move || is_editing.get().to_string()
>
<div class="flex items-center justify-between">
<div>
<kbd class="px-1.5 py-0.5 bg-gray-600 rounded text-xs font-mono text-gray-300">
{slot_label}
</kbd>
{if is_locked {
view! { <span class="ml-2 text-xs text-gray-500">"(locked)"</span> }.into_any()
} else {
view! { <span></span> }.into_any()
}}
</div>
<div class="flex items-center gap-2">
<span class="text-white capitalize">{move || current_emotion.get().to_string()}</span>
{if !is_locked {
view! {
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
}.into_any()
} else {
view! { <span></span> }.into_any()
}}
</div>
</div>
</button>
// Emotion picker using shared EmoteListPopup
<Show when=move || is_editing.get()>
<div class="fixed inset-0 z-[60]" on:click=move |_| on_close_for_backdrop.run(())>
<div
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
on:click=move |ev| ev.stop_propagation()
>
// Hidden input for keyboard capture
<input
type="text"
class="emotion-picker-filter sr-only"
prop:value=move || filter.get()
on:input=on_input
on:keydown=on_keydown
node_ref=input_ref
autocomplete="off"
aria-label="Filter emotions"
/>
<EmoteListPopup
emotion_availability=emotion_availability
skin_preview_path=skin_preview_path
on_select=on_popup_select
on_close=on_close
emotion_filter=Signal::derive(move || filter.get())
selected_idx=Signal::derive(move || selected_idx.get())
show_all_emotions=true
popup_style="static"
show_command_prefix=false
/>
</div>
</div>
</Show>
</div>
}
}

View file

@ -767,6 +767,9 @@ pub fn RealmSceneViewer(
} }
}); });
// Text size multiplier from settings
let text_em_size = Signal::derive(move || settings.get().text_em_size);
let scene_name = scene.name.clone(); let scene_name = scene.name.clone();
view! { view! {
@ -806,6 +809,7 @@ pub fn RealmSceneViewer(
let ox = offset_x.get_value(); let ox = offset_x.get_value();
let oy = offset_y.get_value(); let oy = offset_y.get_value();
let ps = prop_size.get(); let ps = prop_size.get();
let te = text_em_size.get();
sorted_members.get() sorted_members.get()
.into_iter() .into_iter()
@ -824,6 +828,7 @@ pub fn RealmSceneViewer(
prop_size=ps prop_size=ps
z_index=z z_index=z
active_bubble=bubble active_bubble=bubble
text_em_size=te
/> />
} }
}) })

View file

@ -24,6 +24,15 @@ pub const ZOOM_STEP: f64 = 0.25;
/// Pan step in pixels for keyboard navigation. /// Pan step in pixels for keyboard navigation.
pub const PAN_STEP: f64 = 50.0; pub const PAN_STEP: f64 = 50.0;
/// Minimum text em size (50%).
pub const TEXT_EM_MIN: f64 = 0.5;
/// Maximum text em size (200%).
pub const TEXT_EM_MAX: f64 = 2.0;
/// Text em size step increment.
pub const TEXT_EM_STEP: f64 = 0.1;
/// Calculate the minimum zoom level for pan mode. /// Calculate the minimum zoom level for pan mode.
/// ///
/// - Large scenes: min zoom fills the viewport /// - Large scenes: min zoom fills the viewport
@ -71,6 +80,10 @@ pub struct ViewerSettings {
/// Saved vertical scroll position for pan mode. /// Saved vertical scroll position for pan mode.
pub scroll_y: f64, pub scroll_y: f64,
/// Text size multiplier for display names, chat bubbles, and badges.
/// Range: 0.5 to 2.0 (50% to 200%).
pub text_em_size: f64,
} }
impl Default for ViewerSettings { impl Default for ViewerSettings {
@ -81,6 +94,7 @@ impl Default for ViewerSettings {
enlarge_props: true, enlarge_props: true,
scroll_x: 0.0, scroll_x: 0.0,
scroll_y: 0.0, scroll_y: 0.0,
text_em_size: 1.0,
} }
} }
} }
@ -181,4 +195,9 @@ impl ViewerSettings {
self.scroll_x = 0.0; self.scroll_x = 0.0;
self.scroll_y = 0.0; self.scroll_y = 0.0;
} }
/// Adjust text em size by a delta, clamping to valid range.
pub fn adjust_text_em(&mut self, delta: f64) {
self.text_em_size = (self.text_em_size + delta).clamp(TEXT_EM_MIN, TEXT_EM_MAX);
}
} }

View file

@ -3,7 +3,7 @@
use leptos::ev::MouseEvent; use leptos::ev::MouseEvent;
use leptos::prelude::*; use leptos::prelude::*;
use super::settings::{calculate_min_zoom, ViewerSettings, ZOOM_MAX, ZOOM_STEP}; use super::settings::{calculate_min_zoom, ViewerSettings, ZOOM_MAX, ZOOM_STEP, TEXT_EM_MIN, TEXT_EM_MAX, TEXT_EM_STEP};
/// Settings popup component for scene viewer configuration. /// Settings popup component for scene viewer configuration.
/// ///
@ -32,6 +32,7 @@ pub fn SettingsPopup(
let panning = Signal::derive(move || settings.get().panning_enabled); let panning = Signal::derive(move || settings.get().panning_enabled);
let zoom = Signal::derive(move || settings.get().zoom_level); let zoom = Signal::derive(move || settings.get().zoom_level);
let enlarge = Signal::derive(move || settings.get().enlarge_props); let enlarge = Signal::derive(move || settings.get().enlarge_props);
let text_em = Signal::derive(move || settings.get().text_em_size);
// Calculate effective minimum zoom based on scene/viewport dimensions // Calculate effective minimum zoom based on scene/viewport dimensions
let effective_min_zoom = Signal::derive(move || { let effective_min_zoom = Signal::derive(move || {
@ -86,6 +87,28 @@ pub fn SettingsPopup(
}); });
}; };
let on_text_em_decrease = move |_| {
settings.update(|s| {
s.adjust_text_em(-TEXT_EM_STEP);
s.save();
});
};
let on_text_em_increase = move |_| {
settings.update(|s| {
s.adjust_text_em(TEXT_EM_STEP);
s.save();
});
};
let on_text_em_input = move |ev: leptos::ev::Event| {
let val: f64 = event_target_value(&ev).parse().unwrap_or(1.0);
settings.update(|s| {
s.text_em_size = val.clamp(TEXT_EM_MIN, TEXT_EM_MAX);
s.save();
});
};
// Handle escape key to close // Handle escape key to close
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
{ {
@ -208,6 +231,46 @@ pub fn SettingsPopup(
checked=enlarge checked=enlarge
on_change=on_enlarge_toggle on_change=on_enlarge_toggle
/> />
// Text size controls
<div class="space-y-2">
<label class="block text-white font-medium">
"Text Size: " {move || format!("{}%", (text_em.get() * 100.0) as i32)}
</label>
<p class="text-gray-400 text-sm mb-2">
"Scale display names, chat bubbles, and emotion badges"
</p>
<div class="flex items-center gap-2">
<button
type="button"
class="px-3 py-1 bg-gray-700 hover:bg-gray-600 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
on:click=on_text_em_decrease
disabled={move || text_em.get() <= TEXT_EM_MIN}
aria-label="Decrease text size"
>
"-"
</button>
<input
type="range"
min=TEXT_EM_MIN.to_string()
max=TEXT_EM_MAX.to_string()
step=TEXT_EM_STEP.to_string()
class="flex-1 h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
prop:value=move || text_em.get().to_string()
on:input=on_text_em_input
aria-label="Text size"
/>
<button
type="button"
class="px-3 py-1 bg-gray-700 hover:bg-gray-600 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
on:click=on_text_em_increase
disabled={move || text_em.get() >= TEXT_EM_MAX}
aria-label="Increase text size"
>
"+"
</button>
</div>
</div>
</div> </div>
// Keyboard shortcuts help // Keyboard shortcuts help

View file

@ -12,14 +12,15 @@ use leptos_router::hooks::use_params_map;
use uuid::Uuid; use uuid::Uuid;
use crate::components::{ use crate::components::{
ActiveBubble, Card, ChatInput, ChatMessage, InventoryPopup, MessageLog, RealmHeader, ActiveBubble, Card, ChatInput, ChatMessage, EmotionKeybindings, InventoryPopup,
RealmSceneViewer, SettingsPopup, ViewerSettings, DEFAULT_BUBBLE_TIMEOUT_MS, KeybindingsPopup, MessageLog, RealmHeader, RealmSceneViewer, SettingsPopup, ViewerSettings,
DEFAULT_BUBBLE_TIMEOUT_MS,
}; };
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use crate::components::use_channel_websocket; use crate::components::use_channel_websocket;
use chattyness_db::models::{ use chattyness_db::models::{
AvatarWithPaths, ChannelMemberWithAvatar, EmotionAvailability, EmotionState, LooseProp, AvatarWithPaths, ChannelMemberWithAvatar, EmotionAvailability, LooseProp, RealmRole,
RealmRole, RealmWithUserRole, Scene, RealmWithUserRole, Scene,
}; };
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::ClientMessage; use chattyness_db::ws_messages::ClientMessage;
@ -100,6 +101,10 @@ pub fn RealmPage() -> impl IntoView {
let (settings_open, set_settings_open) = signal(false); let (settings_open, set_settings_open) = signal(false);
let viewer_settings = RwSignal::new(ViewerSettings::load()); let viewer_settings = RwSignal::new(ViewerSettings::load());
// Keybindings popup state
let keybindings = RwSignal::new(EmotionKeybindings::load());
let (keybindings_open, set_keybindings_open) = signal(false);
// Scene dimensions (extracted from bounds_wkt when scene loads) // Scene dimensions (extracted from bounds_wkt when scene loads)
let (scene_dimensions, set_scene_dimensions) = signal((800.0_f64, 600.0_f64)); let (scene_dimensions, set_scene_dimensions) = signal((800.0_f64, 600.0_f64));
@ -450,31 +455,35 @@ pub fn RealmPage() -> impl IntoView {
return; return;
} }
// Handle 'k' to toggle keybindings
if key == "k" || key == "K" {
set_keybindings_open.update(|v| *v = !*v);
ev.prevent_default();
return;
}
// Check if 'e' key was pressed // Check if 'e' key was pressed
if key == "e" || key == "E" { if key == "e" || key == "E" {
*e_pressed_clone.borrow_mut() = true; *e_pressed_clone.borrow_mut() = true;
return; return;
} }
// Check for 0-9 after 'e' was pressed // Check for 0-9, q, w after 'e' was pressed (emotion keybindings)
if *e_pressed_clone.borrow() { if *e_pressed_clone.borrow() {
*e_pressed_clone.borrow_mut() = false; // Reset regardless of outcome *e_pressed_clone.borrow_mut() = false; // Reset regardless of outcome
if key.len() == 1 { let bindings = keybindings.get_untracked();
if let Ok(digit) = key.parse::<u8>() { if let Some(emotion_state) = bindings.get_emotion_for_key(&key) {
// Convert digit to emotion name using EmotionState let emotion = emotion_state.to_string();
if let Some(emotion_state) = EmotionState::from_index(digit) { #[cfg(debug_assertions)]
let emotion = emotion_state.to_string(); web_sys::console::log_1(
#[cfg(debug_assertions)] &format!("[Emotion] Sending emotion {}", emotion).into(),
web_sys::console::log_1( );
&format!("[Emotion] Sending emotion {}", emotion).into(), ws_sender.with_value(|sender| {
); if let Some(send_fn) = sender {
ws_sender.with_value(|sender| { send_fn(ClientMessage::UpdateEmotion { emotion });
if let Some(send_fn) = sender {
send_fn(ClientMessage::UpdateEmotion { emotion });
}
});
} }
} });
ev.prevent_default();
} }
} else { } else {
// Any other key resets the 'e' state // Any other key resets the 'e' state
@ -713,6 +722,15 @@ pub fn RealmPage() -> impl IntoView {
}) })
scene_dimensions=scene_dimensions.get() scene_dimensions=scene_dimensions.get()
/> />
// Keybindings popup
<KeybindingsPopup
open=Signal::derive(move || keybindings_open.get())
keybindings=keybindings
emotion_availability=Signal::derive(move || emotion_availability.get())
skin_preview_path=Signal::derive(move || skin_preview_path.get())
on_close=Callback::new(move |_: ()| set_keybindings_open.set(false))
/>
} }
.into_any() .into_any()
} }