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

@ -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"
/>
}
}