fix: scaling, and chat

* Chat ergonomics vastly improved.
* Scaling now done through client side settings
This commit is contained in:
Evan Carroll 2026-01-14 12:53:16 -06:00
parent 98f38c9714
commit b430c80000
8 changed files with 1564 additions and 439 deletions

View file

@ -28,8 +28,10 @@ const EMOTIONS: &[&str] = &[
enum CommandMode {
/// Normal chat mode, no command active.
None,
/// Showing command hint (`:e[mote], :l[ist]`).
ShowingHint,
/// Showing command hint for colon commands (`:e[mote], :l[ist]`).
ShowingColonHint,
/// Showing command hint for slash commands (`/setting`).
ShowingSlashHint,
/// Showing emotion list popup.
ShowingList,
}
@ -63,32 +65,72 @@ fn parse_emote_command(cmd: &str) -> Option<String> {
/// - `ws_sender`: WebSocket sender for emotion updates
/// - `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)
/// - `focus_trigger`: Signal that triggers focus when set to true
/// - `focus_trigger`: Signal that triggers focus when set to true (prefix char in value)
/// - `focus_prefix`: The prefix character that triggered focus (':' or '/')
/// - `on_focus_change`: Callback when focus state changes
/// - `on_open_settings`: Callback to open settings popup
/// - `on_open_inventory`: Callback to open inventory popup
#[component]
pub fn ChatInput(
ws_sender: WsSenderStorage,
emotion_availability: Signal<Option<EmotionAvailability>>,
skin_preview_path: Signal<Option<String>>,
focus_trigger: Signal<bool>,
#[prop(default = Signal::derive(|| ':'))]
focus_prefix: Signal<char>,
on_focus_change: Callback<bool>,
#[prop(optional)]
on_open_settings: Option<Callback<()>>,
#[prop(optional)]
on_open_inventory: Option<Callback<()>>,
) -> impl IntoView {
let (message, set_message) = signal(String::new());
let (command_mode, set_command_mode) = signal(CommandMode::None);
let (list_filter, set_list_filter) = signal(String::new());
let (selected_index, set_selected_index) = signal(0usize);
let input_ref = NodeRef::<leptos::html::Input>::new();
// Handle focus trigger from parent (when ':' is pressed globally)
// Compute filtered emotions for keyboard navigation
let filtered_emotions = move || {
let filter_text = list_filter.get().to_lowercase();
emotion_availability
.get()
.map(|avail| {
EMOTIONS
.iter()
.enumerate()
.filter(|(idx, _)| avail.available.get(*idx).copied().unwrap_or(false))
.filter(|(_, name)| filter_text.is_empty() || name.starts_with(&filter_text))
.map(|(_, name)| (*name).to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default()
};
// Handle focus trigger from parent (when space, ':' or '/' is pressed globally)
#[cfg(feature = "hydrate")]
{
Effect::new(move |_| {
if focus_trigger.get() {
if let Some(input) = input_ref.get() {
let _ = input.focus();
// Also set the message to ':' and show the hint
set_message.set(":".to_string());
set_command_mode.set(CommandMode::ShowingHint);
let prefix = focus_prefix.get();
// Space means just focus, no prefix
if prefix == ' ' {
return;
}
let prefix_str = prefix.to_string();
set_message.set(prefix_str.clone());
// Show appropriate hint based on prefix
set_command_mode.set(if prefix == '/' {
CommandMode::ShowingSlashHint
} else {
CommandMode::ShowingColonHint
});
// Update the input value directly
input.set_value(":");
input.set_value(&prefix_str);
}
}
});
@ -109,59 +151,207 @@ pub fn ChatInput(
};
// Handle input changes to detect commands
let on_input = move |ev| {
let value = event_target_value(&ev);
set_message.set(value.clone());
let on_input = {
move |ev| {
let value = event_target_value(&ev);
set_message.set(value.clone());
if value.starts_with(':') {
let cmd = value[1..].to_lowercase();
// If list is showing, update filter (input is the filter text)
if command_mode.get_untracked() == CommandMode::ShowingList {
set_list_filter.set(value.clone());
set_selected_index.set(0); // Reset selection when filter changes
return;
}
// Check for list command
if cmd == "l" || cmd == "list" {
set_command_mode.set(CommandMode::ShowingList);
} else if cmd.is_empty()
|| cmd.starts_with('e')
|| cmd.starts_with('l')
|| cmd.starts_with("em")
|| cmd.starts_with("li")
{
// Show hint for incomplete commands
set_command_mode.set(CommandMode::ShowingHint);
} else if cmd.starts_with("e ") || cmd.starts_with("emote ") {
// Typing an emote command - keep hint visible
set_command_mode.set(CommandMode::ShowingHint);
if value.starts_with(':') {
let cmd = value[1..].to_lowercase();
// Show hint for colon commands
if cmd.is_empty()
|| "list".starts_with(&cmd)
|| "emote".starts_with(&cmd)
|| cmd.starts_with("e ")
|| cmd.starts_with("emote ")
{
set_command_mode.set(CommandMode::ShowingColonHint);
} else {
set_command_mode.set(CommandMode::None);
}
} else if value.starts_with('/') {
let cmd = value[1..].to_lowercase();
// Show hint for slash commands (don't execute until Enter)
if cmd.is_empty()
|| "setting".starts_with(&cmd)
|| "inventory".starts_with(&cmd)
|| cmd == "setting"
|| cmd == "settings"
|| cmd == "inventory"
{
set_command_mode.set(CommandMode::ShowingSlashHint);
} else {
set_command_mode.set(CommandMode::None);
}
} else {
set_command_mode.set(CommandMode::None);
}
} else {
set_command_mode.set(CommandMode::None);
}
};
// Handle key presses (Enter to execute, Escape to close)
// Handle key presses (Tab for autocomplete, Enter to execute, Escape to close and blur)
#[cfg(feature = "hydrate")]
let on_keydown = {
let apply_emotion = apply_emotion.clone();
let on_open_settings = on_open_settings.clone();
let on_open_inventory = on_open_inventory.clone();
move |ev: web_sys::KeyboardEvent| {
let key = ev.key();
let current_mode = command_mode.get_untracked();
if key == "Escape" {
set_command_mode.set(CommandMode::None);
set_list_filter.set(String::new());
set_selected_index.set(0);
set_message.set(String::new());
// Blur the input to unfocus chat
if let Some(input) = input_ref.get() {
let _ = input.blur();
}
ev.prevent_default();
return;
}
// Arrow key navigation when list is showing
if current_mode == CommandMode::ShowingList {
let emotions = filtered_emotions();
let count = emotions.len();
if key == "ArrowDown" && count > 0 {
set_selected_index.update(|idx| {
*idx = (*idx + 1) % count;
});
ev.prevent_default();
return;
}
if key == "ArrowUp" && count > 0 {
set_selected_index.update(|idx| {
*idx = if *idx == 0 { count - 1 } else { *idx - 1 };
});
ev.prevent_default();
return;
}
if key == "Enter" && count > 0 {
// Select the currently highlighted emotion
let idx = selected_index.get_untracked();
if let Some(emotion) = emotions.get(idx) {
set_list_filter.set(String::new());
set_selected_index.set(0);
apply_emotion(emotion.clone());
}
ev.prevent_default();
return;
}
// Any other key in list mode is handled by on_input
if key == "Enter" {
ev.prevent_default();
return;
}
}
// Tab for autocomplete
if key == "Tab" {
let msg = message.get();
if msg.starts_with('/') {
let cmd = msg[1..].to_lowercase();
// Autocomplete to /setting if /s, /se, /set, etc.
if !cmd.is_empty() && "setting".starts_with(&cmd) && cmd != "setting" {
set_message.set("/setting".to_string());
if let Some(input) = input_ref.get() {
input.set_value("/setting");
}
ev.prevent_default();
return;
}
// Autocomplete to /inventory if /i, /in, /inv, etc.
if !cmd.is_empty() && "inventory".starts_with(&cmd) && cmd != "inventory" {
set_message.set("/inventory".to_string());
if let Some(input) = input_ref.get() {
input.set_value("/inventory");
}
ev.prevent_default();
return;
}
}
// Always prevent Tab from moving focus when in input
ev.prevent_default();
return;
}
if key == "Enter" {
let msg = message.get();
if msg.starts_with(':') {
// Try to parse as emote command
if let Some(emotion_idx) = parse_emote_command(&msg) {
apply_emotion(emotion_idx);
ev.prevent_default();
// Handle slash commands - NEVER send as message
if msg.starts_with('/') {
let cmd = msg[1..].to_lowercase();
// /s, /se, /set, /sett, /setti, /settin, /setting, /settings
if !cmd.is_empty() && ("setting".starts_with(&cmd) || cmd == "settings") {
if let Some(ref callback) = on_open_settings {
callback.run(());
}
set_message.set(String::new());
set_command_mode.set(CommandMode::None);
if let Some(input) = input_ref.get() {
input.set_value("");
let _ = input.blur();
}
}
} else if !msg.trim().is_empty() {
// Send regular chat message
// /i, /in, /inv, /inve, /inven, /invent, /invento, /inventor, /inventory
else if !cmd.is_empty() && "inventory".starts_with(&cmd) {
if let Some(ref callback) = on_open_inventory {
callback.run(());
}
set_message.set(String::new());
set_command_mode.set(CommandMode::None);
if let Some(input) = input_ref.get() {
input.set_value("");
let _ = input.blur();
}
}
// Invalid slash command - just ignore, don't send
ev.prevent_default();
return;
}
// Handle colon commands - NEVER send as message
if msg.starts_with(':') {
let cmd = msg[1..].to_lowercase();
// :l, :li, :lis, :list - open the emotion list
if !cmd.is_empty() && "list".starts_with(&cmd) {
set_command_mode.set(CommandMode::ShowingList);
set_list_filter.set(String::new());
set_message.set(String::new());
if let Some(input) = input_ref.get() {
input.set_value("");
}
ev.prevent_default();
return;
}
// :e <name> or :emote <name> - apply emotion if valid
if let Some(emotion) = parse_emote_command(&msg) {
apply_emotion(emotion);
}
// Invalid colon command - just ignore, don't send
ev.prevent_default();
return;
}
// Send regular chat message (only if not a command)
if !msg.trim().is_empty() {
ws_sender.with_value(|sender| {
if let Some(send_fn) = sender {
send_fn(ClientMessage::SendChatMessage {
@ -170,6 +360,9 @@ pub fn ChatInput(
}
});
set_message.set(String::new());
if let Some(input) = input_ref.get() {
input.set_value("");
}
ev.prevent_default();
}
}
@ -197,17 +390,21 @@ pub fn ChatInput(
// Popup select handler
let on_popup_select = Callback::new(move |emotion: String| {
set_list_filter.set(String::new());
apply_emotion(emotion);
});
let on_popup_close = Callback::new(move |_: ()| {
set_list_filter.set(String::new());
set_command_mode.set(CommandMode::None);
});
let filter_signal = Signal::derive(move || list_filter.get());
view! {
<div class="chat-input-container w-full max-w-4xl mx-auto pointer-events-auto relative">
// Command hint bar
<Show when=move || command_mode.get() == CommandMode::ShowingHint>
// Colon command hint bar (:e[mote], :l[ist])
<Show when=move || command_mode.get() == CommandMode::ShowingColonHint>
<div class="absolute bottom-full left-0 mb-1 px-3 py-1 bg-gray-700/90 backdrop-blur-sm rounded text-sm">
<span class="text-gray-400">":"</span>
<span class="text-blue-400">"e"</span>
@ -219,6 +416,19 @@ pub fn ChatInput(
</div>
</Show>
// Slash command hint bar (/s[etting], /i[nventory])
<Show when=move || command_mode.get() == CommandMode::ShowingSlashHint>
<div class="absolute bottom-full left-0 mb-1 px-3 py-1 bg-gray-700/90 backdrop-blur-sm rounded text-sm">
<span class="text-gray-400">"/"</span>
<span class="text-blue-400">"s"</span>
<span class="text-gray-500">"[etting]"</span>
<span class="text-gray-600 mx-2">"|"</span>
<span class="text-gray-400">"/"</span>
<span class="text-blue-400">"i"</span>
<span class="text-gray-500">"[nventory]"</span>
</div>
</Show>
// Emotion list popup
<Show when=move || command_mode.get() == CommandMode::ShowingList>
<EmoteListPopup
@ -226,13 +436,15 @@ pub fn ChatInput(
skin_preview_path=skin_preview_path
on_select=on_popup_select
on_close=on_popup_close
emotion_filter=filter_signal
selected_idx=Signal::derive(move || selected_index.get())
/>
</Show>
<div class="flex gap-2 items-center bg-gray-900/80 backdrop-blur-sm rounded-lg p-2 border border-gray-600">
<div class="flex items-center bg-gray-900/80 backdrop-blur-sm rounded-lg p-2 border border-gray-600">
<input
type="text"
placeholder="Type a message... (: for commands)"
placeholder="Type a message... (: or / for commands)"
class="flex-1 bg-transparent text-white placeholder-gray-500 px-3 py-2 outline-none"
prop:value=move || message.get()
on:input=on_input
@ -243,13 +455,6 @@ pub fn ChatInput(
autocomplete="off"
aria-label="Chat message input"
/>
<button
type="button"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled=move || message.get().trim().is_empty()
>
"Send"
</button>
</div>
</div>
}
@ -257,17 +462,21 @@ pub fn ChatInput(
/// Emote list popup component.
///
/// Shows available emotions in a 2-column grid with avatar previews.
/// 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)
// 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| {
@ -275,6 +484,10 @@ fn EmoteListPopup(
.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)
@ -284,41 +497,71 @@ fn EmoteListPopup(
.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="text-gray-400 text-xs mb-2 px-1">"Select an emotion:"</div>
<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">
<For
each=move || available_emotions()
key=|(name, _): &(String, Option<String>)| name.clone()
children=move |(emotion_name, preview_path): (String, Option<String>)| {
let on_select = on_select.clone();
let emotion_name_for_click = emotion_name.clone();
let _skin_path = skin_preview_path.get();
let _emotion_path = preview_path.clone();
view! {
<button
type="button"
class="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"
>
<EmotionPreview
skin_path=_skin_path.clone()
emotion_path=_emotion_path.clone()
/>
<span class="text-white text-sm">
":e "
{emotion_name.clone()}
</span>
</button>
}
}
/>
{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">