fix: scaling, and chat
* Chat ergonomics vastly improved. * Scaling now done through client side settings
This commit is contained in:
parent
98f38c9714
commit
b430c80000
8 changed files with 1564 additions and 439 deletions
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue