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
|
|
@ -5,24 +5,9 @@ use leptos::prelude::*;
|
|||
use chattyness_db::models::EmotionAvailability;
|
||||
use chattyness_db::ws_messages::ClientMessage;
|
||||
|
||||
use super::emotion_picker::{EmoteListPopup, EMOTIONS};
|
||||
use super::ws_client::WsSenderStorage;
|
||||
|
||||
/// Emotion names indexed by emotion slot (0-11).
|
||||
const EMOTIONS: &[&str] = &[
|
||||
"neutral", // 0
|
||||
"happy", // 1
|
||||
"sad", // 2
|
||||
"angry", // 3
|
||||
"surprised", // 4
|
||||
"thinking", // 5
|
||||
"laughing", // 6
|
||||
"crying", // 7
|
||||
"love", // 8
|
||||
"confused", // 9
|
||||
"sleeping", // 10
|
||||
"wink", // 11
|
||||
];
|
||||
|
||||
/// Command mode state for the chat input.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum CommandMode {
|
||||
|
|
@ -459,232 +444,3 @@ pub fn ChatInput(
|
|||
</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"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue