fix text sizing, and add custom hotkey support
This commit is contained in:
parent
09590edd95
commit
ee425e224e
10 changed files with 1096 additions and 280 deletions
|
|
@ -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::*;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
330
crates/chattyness-user-ui/src/components/emotion_picker.rs
Normal file
330
crates/chattyness-user-ui/src/components/emotion_picker.rs
Normal 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"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
172
crates/chattyness-user-ui/src/components/keybindings.rs
Normal file
172
crates/chattyness-user-ui/src/components/keybindings.rs
Normal 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",
|
||||||
|
_ => "??",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
421
crates/chattyness-user-ui/src/components/keybindings_popup.rs
Normal file
421
crates/chattyness-user-ui/src/components/keybindings_popup.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue