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

@ -134,7 +134,7 @@ fn SceneDetailView(
let (show_delete_confirm, set_show_delete_confirm) = signal(false); let (show_delete_confirm, set_show_delete_confirm) = signal(false);
let (show_image_modal, set_show_image_modal) = signal(false); let (show_image_modal, set_show_image_modal) = signal(false);
// Parse dimensions from bounds_wkt // Parse dimensions from bounds_wkt for display
let (initial_width, initial_height) = parse_bounds_wkt(&scene.bounds_wkt); let (initial_width, initial_height) = parse_bounds_wkt(&scene.bounds_wkt);
// Clone scene data for view (to avoid move issues) // Clone scene data for view (to avoid move issues)
@ -145,7 +145,6 @@ fn SceneDetailView(
let scene_background_image_path = scene.background_image_path.clone(); let scene_background_image_path = scene.background_image_path.clone();
let scene_background_image_path_for_modal = scene.background_image_path.clone(); let scene_background_image_path_for_modal = scene.background_image_path.clone();
let scene_background_image_path_for_check = scene.background_image_path.clone(); let scene_background_image_path_for_check = scene.background_image_path.clone();
let scene_background_image_path_for_dimensions = scene.background_image_path.clone();
let scene_background_color_display = scene.background_color.clone(); let scene_background_color_display = scene.background_color.clone();
let scene_created_at = scene.created_at.clone(); let scene_created_at = scene.created_at.clone();
let scene_updated_at = scene.updated_at.clone(); let scene_updated_at = scene.updated_at.clone();
@ -158,42 +157,32 @@ fn SceneDetailView(
); );
let (background_image_url, set_background_image_url) = signal(String::new()); let (background_image_url, set_background_image_url) = signal(String::new());
let (clear_background_image, set_clear_background_image) = signal(false); let (clear_background_image, set_clear_background_image) = signal(false);
let (infer_dimensions, set_infer_dimensions) = signal(false);
let (width, set_width) = signal(initial_width);
let (height, set_height) = signal(initial_height);
let (dimension_mode, set_dimension_mode) = signal(scene.dimension_mode.clone());
let (sort_order, set_sort_order) = signal(scene.sort_order); let (sort_order, set_sort_order) = signal(scene.sort_order);
let (is_entry_point, set_is_entry_point) = signal(scene.is_entry_point); let (is_entry_point, set_is_entry_point) = signal(scene.is_entry_point);
let (is_hidden, set_is_hidden) = signal(scene.is_hidden); let (is_hidden, set_is_hidden) = signal(scene.is_hidden);
// UI state for dimension fetching // UI state for dimension detection (read-only display)
let (fetching_dimensions, set_fetching_dimensions) = signal(false); let (fetching_dimensions, set_fetching_dimensions) = signal(false);
let (dimension_message, set_dimension_message) = signal(Option::<(String, bool)>::None); let (detected_dimensions, set_detected_dimensions) = signal(Option::<(u32, u32)>::None);
let fetch_dimensions = move |_: leptos::ev::MouseEvent| { let fetch_dimensions = move |_: leptos::ev::MouseEvent| {
let url = background_image_url.get(); let url = background_image_url.get();
if url.is_empty() { if url.is_empty() {
set_dimension_message.set(Some(("Please enter an image URL first".to_string(), false)));
return; return;
} }
set_fetching_dimensions.set(true); set_fetching_dimensions.set(true);
set_dimension_message.set(None); set_detected_dimensions.set(None);
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
{ {
fetch_image_dimensions_client( fetch_image_dimensions_client(
url, url,
move |w, h| { move |w, h| {
set_width.set(w as i32); set_detected_dimensions.set(Some((w, h)));
set_height.set(h as i32);
set_dimension_message.set(Some((
format!("Dimensions: {}x{}", w, h),
true,
)));
}, },
move |err| { move |_err| {
set_dimension_message.set(Some((err, false))); set_detected_dimensions.set(None);
}, },
set_fetching_dimensions, set_fetching_dimensions,
); );
@ -210,17 +199,11 @@ fn SceneDetailView(
use gloo_net::http::Request; use gloo_net::http::Request;
let id = scene_id.clone(); let id = scene_id.clone();
// Build bounds WKT from width/height
let w = width.get();
let h = height.get();
let bounds_wkt = format!("POLYGON((0 0, {} 0, {} {}, 0 {}, 0 0))", w, w, h, h);
let mut data = serde_json::json!({ let mut data = serde_json::json!({
"name": name.get(), "name": name.get(),
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) }, "description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
"background_color": if background_color.get().is_empty() { None::<String> } else { Some(background_color.get()) }, "background_color": if background_color.get().is_empty() { None::<String> } else { Some(background_color.get()) },
"bounds_wkt": bounds_wkt,
"dimension_mode": dimension_mode.get(),
"sort_order": sort_order.get(), "sort_order": sort_order.get(),
"is_entry_point": is_entry_point.get(), "is_entry_point": is_entry_point.get(),
"is_hidden": is_hidden.get() "is_hidden": is_hidden.get()
@ -230,11 +213,9 @@ fn SceneDetailView(
let bg_url = background_image_url.get(); let bg_url = background_image_url.get();
if !bg_url.is_empty() { if !bg_url.is_empty() {
data["background_image_url"] = serde_json::json!(bg_url); data["background_image_url"] = serde_json::json!(bg_url);
// Include infer dimensions flag when uploading new image // Always infer dimensions from new image
if infer_dimensions.get() {
data["infer_dimensions_from_image"] = serde_json::json!(true); data["infer_dimensions_from_image"] = serde_json::json!(true);
} }
}
// Include clear flag if set // Include clear flag if set
if clear_background_image.get() { if clear_background_image.get() {
@ -447,35 +428,19 @@ fn SceneDetailView(
</div> </div>
</Show> </Show>
// Dimension fetch message // Show detected dimensions (read-only feedback for new images)
<Show when=move || dimension_message.get().is_some()> <Show when=move || detected_dimensions.get().is_some()>
{move || { {move || {
let (msg, is_success) = dimension_message.get().unwrap_or_default(); let (w, h) = detected_dimensions.get().unwrap_or((0, 0));
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
view! { view! {
<div class=class role="alert" style="margin-bottom: 1rem"> <div class="alert alert-info" role="status" style="margin-bottom: 1rem">
<p>{msg}</p> <p>"Detected Size: " <strong>{w}</strong> " × " <strong>{h}</strong> " px"</p>
<small>"Dimensions will be extracted from the new image when saved"</small>
</div> </div>
} }
}} }}
</Show> </Show>
// Infer dimensions checkbox (only shown when new URL is provided)
<Show when=move || !background_image_url.get().is_empty()>
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || infer_dimensions.get()
on:change=move |ev| set_infer_dimensions.set(event_target_checked(&ev))
/>
"Infer dimensions from image"
</label>
<small class="form-help">"If enabled, server will extract dimensions from the image when saving"</small>
</div>
</Show>
{if scene_background_image_path_for_check.is_some() { {if scene_background_image_path_for_check.is_some() {
view! { view! {
<div class="checkbox-group"> <div class="checkbox-group">
@ -494,97 +459,6 @@ fn SceneDetailView(
view! {}.into_any() view! {}.into_any()
}} }}
<h3 class="section-title">"Dimensions"</h3>
<div class="form-row">
<div class="form-group">
<label for="width" class="form-label">"Width"</label>
<input
type="number"
id="width"
min=100
max=10000
class="form-input"
prop:value=move || width.get()
on:input=move |ev| {
if let Ok(v) = event_target_value(&ev).parse() {
set_width.set(v);
}
}
/>
</div>
<div class="form-group">
<label for="height" class="form-label">"Height"</label>
<input
type="number"
id="height"
min=100
max=10000
class="form-input"
prop:value=move || height.get()
on:input=move |ev| {
if let Ok(v) = event_target_value(&ev).parse() {
set_height.set(v);
}
}
/>
</div>
<div class="form-group">
<label for="dimension_mode" class="form-label">"Dimension Mode"</label>
<select
id="dimension_mode"
class="form-select"
on:change=move |ev| set_dimension_mode.set(event_target_value(&ev))
>
<option value="fixed" selected=move || dimension_mode.get() == "fixed">"Fixed"</option>
<option value="viewport" selected=move || dimension_mode.get() == "viewport">"Viewport"</option>
</select>
</div>
</div>
// Button to set dimensions from existing background image
{if let Some(ref path) = scene_background_image_path_for_dimensions {
let path_for_closure = path.clone();
view! {
<div class="form-group">
<button
type="button"
class="btn btn-secondary"
disabled=move || fetching_dimensions.get()
on:click=move |_| {
set_fetching_dimensions.set(true);
set_dimension_message.set(None);
#[cfg(feature = "hydrate")]
{
let path = path_for_closure.clone();
fetch_image_dimensions_client(
path,
move |w, h| {
set_width.set(w as i32);
set_height.set(h as i32);
set_dimension_message.set(Some((
format!("Set from image: {}x{}", w, h),
true,
)));
},
move |err| {
set_dimension_message.set(Some((err, false)));
},
set_fetching_dimensions,
);
}
}
>
{move || if fetching_dimensions.get() { "Fetching..." } else { "Set from background image" }}
</button>
<small class="form-help">"Set dimensions to match the current background image"</small>
</div>
}.into_any()
} else {
view! {}.into_any()
}}
<h3 class="section-title">"Options"</h3> <h3 class="section-title">"Options"</h3>
<div class="form-group"> <div class="form-group">

View file

@ -21,10 +21,6 @@ pub fn SceneNewPage() -> impl IntoView {
let (description, set_description) = signal(String::new()); let (description, set_description) = signal(String::new());
let (background_color, set_background_color) = signal("#1a1a2e".to_string()); let (background_color, set_background_color) = signal("#1a1a2e".to_string());
let (background_image_url, set_background_image_url) = signal(String::new()); let (background_image_url, set_background_image_url) = signal(String::new());
let (infer_dimensions, set_infer_dimensions) = signal(false);
let (width, set_width) = signal(800i32);
let (height, set_height) = signal(600i32);
let (dimension_mode, set_dimension_mode) = signal("fixed".to_string());
let (sort_order, set_sort_order) = signal(0i32); let (sort_order, set_sort_order) = signal(0i32);
let (is_entry_point, set_is_entry_point) = signal(false); let (is_entry_point, set_is_entry_point) = signal(false);
let (is_hidden, set_is_hidden) = signal(false); let (is_hidden, set_is_hidden) = signal(false);
@ -37,7 +33,8 @@ pub fn SceneNewPage() -> impl IntoView {
let set_created_id = _set_created_id; let set_created_id = _set_created_id;
let (slug_auto, set_slug_auto) = signal(true); let (slug_auto, set_slug_auto) = signal(true);
let (fetching_dimensions, set_fetching_dimensions) = signal(false); let (fetching_dimensions, set_fetching_dimensions) = signal(false);
let (dimension_message, set_dimension_message) = signal(Option::<(String, bool)>::None); // Stores detected dimensions for display (read-only)
let (detected_dimensions, set_detected_dimensions) = signal(Option::<(u32, u32)>::None);
let update_name = move |ev: leptos::ev::Event| { let update_name = move |ev: leptos::ev::Event| {
let new_name = event_target_value(&ev); let new_name = event_target_value(&ev);
@ -57,27 +54,21 @@ pub fn SceneNewPage() -> impl IntoView {
let fetch_dimensions = move |_: leptos::ev::MouseEvent| { let fetch_dimensions = move |_: leptos::ev::MouseEvent| {
let url = background_image_url.get(); let url = background_image_url.get();
if url.is_empty() { if url.is_empty() {
set_dimension_message.set(Some(("Please enter an image URL first".to_string(), false)));
return; return;
} }
set_fetching_dimensions.set(true); set_fetching_dimensions.set(true);
set_dimension_message.set(None); set_detected_dimensions.set(None);
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
{ {
fetch_image_dimensions_client( fetch_image_dimensions_client(
url, url,
move |w, h| { move |w, h| {
set_width.set(w as i32); set_detected_dimensions.set(Some((w, h)));
set_height.set(h as i32);
set_dimension_message.set(Some((
format!("Dimensions: {}x{}", w, h),
true,
)));
}, },
move |err| { move |_err| {
set_dimension_message.set(Some((err, false))); set_detected_dimensions.set(None);
}, },
set_fetching_dimensions, set_fetching_dimensions,
); );
@ -96,10 +87,8 @@ pub fn SceneNewPage() -> impl IntoView {
{ {
use gloo_net::http::Request; use gloo_net::http::Request;
// Build bounds WKT from width/height // Default bounds - server will override from image if background_image_url is provided
let w = width.get(); let bounds_wkt = "POLYGON((0 0, 800 0, 800 600, 0 600, 0 0))";
let h = height.get();
let bounds_wkt = format!("POLYGON((0 0, {} 0, {} {}, 0 {}, 0 0))", w, w, h, h);
let data = serde_json::json!({ let data = serde_json::json!({
"name": name.get(), "name": name.get(),
@ -107,9 +96,9 @@ pub fn SceneNewPage() -> impl IntoView {
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) }, "description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
"background_color": if background_color.get().is_empty() { None::<String> } else { Some(background_color.get()) }, "background_color": if background_color.get().is_empty() { None::<String> } else { Some(background_color.get()) },
"background_image_url": if background_image_url.get().is_empty() { None::<String> } else { Some(background_image_url.get()) }, "background_image_url": if background_image_url.get().is_empty() { None::<String> } else { Some(background_image_url.get()) },
"infer_dimensions_from_image": infer_dimensions.get(), "infer_dimensions_from_image": true, // Always infer from image
"bounds_wkt": bounds_wkt, "bounds_wkt": bounds_wkt,
"dimension_mode": dimension_mode.get(), "dimension_mode": "fixed", // Default to fixed
"sort_order": sort_order.get(), "sort_order": sort_order.get(),
"is_entry_point": is_entry_point.get(), "is_entry_point": is_entry_point.get(),
"is_hidden": is_hidden.get() "is_hidden": is_hidden.get()
@ -267,80 +256,19 @@ pub fn SceneNewPage() -> impl IntoView {
</div> </div>
</Show> </Show>
// Dimension fetch message // Show detected dimensions (read-only feedback)
<Show when=move || dimension_message.get().is_some()> <Show when=move || detected_dimensions.get().is_some()>
{move || { {move || {
let (msg, is_success) = dimension_message.get().unwrap_or_default(); let (w, h) = detected_dimensions.get().unwrap_or((0, 0));
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
view! { view! {
<div class=class role="alert" style="margin-bottom: 1rem"> <div class="alert alert-info" role="status" style="margin-bottom: 1rem">
<p>{msg}</p> <p>"Detected Size: " <strong>{w}</strong> " × " <strong>{h}</strong> " px"</p>
<small>"Dimensions will be extracted from the image when the scene is created"</small>
</div> </div>
} }
}} }}
</Show> </Show>
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || infer_dimensions.get()
on:change=move |ev| set_infer_dimensions.set(event_target_checked(&ev))
/>
"Infer dimensions from image"
</label>
<small class="form-help">"If enabled, server will extract dimensions from the image when creating the scene"</small>
</div>
<h3 class="section-title">"Dimensions"</h3>
<div class="form-row">
<div class="form-group">
<label for="width" class="form-label">"Width"</label>
<input
type="number"
id="width"
min=100
max=10000
class="form-input"
prop:value=move || width.get()
on:input=move |ev| {
if let Ok(v) = event_target_value(&ev).parse() {
set_width.set(v);
}
}
/>
</div>
<div class="form-group">
<label for="height" class="form-label">"Height"</label>
<input
type="number"
id="height"
min=100
max=10000
class="form-input"
prop:value=move || height.get()
on:input=move |ev| {
if let Ok(v) = event_target_value(&ev).parse() {
set_height.set(v);
}
}
/>
</div>
<div class="form-group">
<label for="dimension_mode" class="form-label">"Dimension Mode"</label>
<select
id="dimension_mode"
class="form-select"
on:change=move |ev| set_dimension_mode.set(event_target_value(&ev))
>
<option value="fixed" selected=move || dimension_mode.get() == "fixed">"Fixed"</option>
<option value="viewport" selected=move || dimension_mode.get() == "viewport">"Viewport"</option>
</select>
</div>
</div>
<h3 class="section-title">"Options"</h3> <h3 class="section-title">"Options"</h3>
<div class="form-group"> <div class="form-group">

View file

@ -8,6 +8,8 @@ pub mod inventory;
pub mod layout; pub mod layout;
pub mod modals; pub mod modals;
pub mod scene_viewer; pub mod scene_viewer;
pub mod settings;
pub mod settings_popup;
pub mod ws_client; pub mod ws_client;
pub use chat::*; pub use chat::*;
@ -18,4 +20,6 @@ pub use inventory::*;
pub use layout::*; pub use layout::*;
pub use modals::*; pub use modals::*;
pub use scene_viewer::*; pub use scene_viewer::*;
pub use settings::*;
pub use settings_popup::*;
pub use ws_client::*; pub use ws_client::*;

View file

@ -28,8 +28,10 @@ const EMOTIONS: &[&str] = &[
enum CommandMode { enum CommandMode {
/// Normal chat mode, no command active. /// Normal chat mode, no command active.
None, None,
/// Showing command hint (`:e[mote], :l[ist]`). /// Showing command hint for colon commands (`:e[mote], :l[ist]`).
ShowingHint, ShowingColonHint,
/// Showing command hint for slash commands (`/setting`).
ShowingSlashHint,
/// Showing emotion list popup. /// Showing emotion list popup.
ShowingList, ShowingList,
} }
@ -63,32 +65,72 @@ fn parse_emote_command(cmd: &str) -> Option<String> {
/// - `ws_sender`: WebSocket sender for emotion updates /// - `ws_sender`: WebSocket sender for emotion updates
/// - `emotion_availability`: Which emotions are available for the user's avatar /// - `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) /// - `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_focus_change`: Callback when focus state changes
/// - `on_open_settings`: Callback to open settings popup
/// - `on_open_inventory`: Callback to open inventory popup
#[component] #[component]
pub fn ChatInput( pub fn ChatInput(
ws_sender: WsSenderStorage, ws_sender: WsSenderStorage,
emotion_availability: Signal<Option<EmotionAvailability>>, emotion_availability: Signal<Option<EmotionAvailability>>,
skin_preview_path: Signal<Option<String>>, skin_preview_path: Signal<Option<String>>,
focus_trigger: Signal<bool>, focus_trigger: Signal<bool>,
#[prop(default = Signal::derive(|| ':'))]
focus_prefix: Signal<char>,
on_focus_change: Callback<bool>, on_focus_change: Callback<bool>,
#[prop(optional)]
on_open_settings: Option<Callback<()>>,
#[prop(optional)]
on_open_inventory: Option<Callback<()>>,
) -> impl IntoView { ) -> impl IntoView {
let (message, set_message) = signal(String::new()); let (message, set_message) = signal(String::new());
let (command_mode, set_command_mode) = signal(CommandMode::None); 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(); 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")] #[cfg(feature = "hydrate")]
{ {
Effect::new(move |_| { Effect::new(move |_| {
if focus_trigger.get() { if focus_trigger.get() {
if let Some(input) = input_ref.get() { if let Some(input) = input_ref.get() {
let _ = input.focus(); let _ = input.focus();
// Also set the message to ':' and show the hint let prefix = focus_prefix.get();
set_message.set(":".to_string());
set_command_mode.set(CommandMode::ShowingHint); // 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 // 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 // Handle input changes to detect commands
let on_input = move |ev| { let on_input = {
move |ev| {
let value = event_target_value(&ev); let value = event_target_value(&ev);
set_message.set(value.clone()); set_message.set(value.clone());
// 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;
}
if value.starts_with(':') { if value.starts_with(':') {
let cmd = value[1..].to_lowercase(); let cmd = value[1..].to_lowercase();
// Check for list command // Show hint for colon commands
if cmd == "l" || cmd == "list" { if cmd.is_empty()
set_command_mode.set(CommandMode::ShowingList); || "list".starts_with(&cmd)
} else if cmd.is_empty() || "emote".starts_with(&cmd)
|| cmd.starts_with('e') || cmd.starts_with("e ")
|| cmd.starts_with('l') || cmd.starts_with("emote ")
|| cmd.starts_with("em")
|| cmd.starts_with("li")
{ {
// Show hint for incomplete commands set_command_mode.set(CommandMode::ShowingColonHint);
set_command_mode.set(CommandMode::ShowingHint); } else {
} else if cmd.starts_with("e ") || cmd.starts_with("emote ") { set_command_mode.set(CommandMode::None);
// Typing an emote command - keep hint visible }
set_command_mode.set(CommandMode::ShowingHint); } 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 { } else {
set_command_mode.set(CommandMode::None); set_command_mode.set(CommandMode::None);
} }
} else { } else {
set_command_mode.set(CommandMode::None); 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")] #[cfg(feature = "hydrate")]
let on_keydown = { let on_keydown = {
let apply_emotion = apply_emotion.clone(); 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| { move |ev: web_sys::KeyboardEvent| {
let key = ev.key(); let key = ev.key();
let current_mode = command_mode.get_untracked();
if key == "Escape" { if key == "Escape" {
set_command_mode.set(CommandMode::None); set_command_mode.set(CommandMode::None);
set_list_filter.set(String::new());
set_selected_index.set(0);
set_message.set(String::new()); 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(); ev.prevent_default();
return; return;
} }
if key == "Enter" { if key == "Enter" {
let msg = message.get(); let msg = message.get();
if msg.starts_with(':') {
// Try to parse as emote command // Handle slash commands - NEVER send as message
if let Some(emotion_idx) = parse_emote_command(&msg) { if msg.starts_with('/') {
apply_emotion(emotion_idx); let cmd = msg[1..].to_lowercase();
ev.prevent_default(); // /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(());
} }
} else if !msg.trim().is_empty() { set_message.set(String::new());
// Send regular chat message set_command_mode.set(CommandMode::None);
if let Some(input) = input_ref.get() {
input.set_value("");
let _ = input.blur();
}
}
// /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| { ws_sender.with_value(|sender| {
if let Some(send_fn) = sender { if let Some(send_fn) = sender {
send_fn(ClientMessage::SendChatMessage { send_fn(ClientMessage::SendChatMessage {
@ -170,6 +360,9 @@ pub fn ChatInput(
} }
}); });
set_message.set(String::new()); set_message.set(String::new());
if let Some(input) = input_ref.get() {
input.set_value("");
}
ev.prevent_default(); ev.prevent_default();
} }
} }
@ -197,17 +390,21 @@ pub fn ChatInput(
// Popup select handler // Popup select handler
let on_popup_select = Callback::new(move |emotion: String| { let on_popup_select = Callback::new(move |emotion: String| {
set_list_filter.set(String::new());
apply_emotion(emotion); apply_emotion(emotion);
}); });
let on_popup_close = Callback::new(move |_: ()| { let on_popup_close = Callback::new(move |_: ()| {
set_list_filter.set(String::new());
set_command_mode.set(CommandMode::None); set_command_mode.set(CommandMode::None);
}); });
let filter_signal = Signal::derive(move || list_filter.get());
view! { view! {
<div class="chat-input-container w-full max-w-4xl mx-auto pointer-events-auto relative"> <div class="chat-input-container w-full max-w-4xl mx-auto pointer-events-auto relative">
// Command hint bar // Colon command hint bar (:e[mote], :l[ist])
<Show when=move || command_mode.get() == CommandMode::ShowingHint> <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"> <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-gray-400">":"</span>
<span class="text-blue-400">"e"</span> <span class="text-blue-400">"e"</span>
@ -219,6 +416,19 @@ pub fn ChatInput(
</div> </div>
</Show> </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 // Emotion list popup
<Show when=move || command_mode.get() == CommandMode::ShowingList> <Show when=move || command_mode.get() == CommandMode::ShowingList>
<EmoteListPopup <EmoteListPopup
@ -226,13 +436,15 @@ pub fn ChatInput(
skin_preview_path=skin_preview_path skin_preview_path=skin_preview_path
on_select=on_popup_select on_select=on_popup_select
on_close=on_popup_close on_close=on_popup_close
emotion_filter=filter_signal
selected_idx=Signal::derive(move || selected_index.get())
/> />
</Show> </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 <input
type="text" 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" class="flex-1 bg-transparent text-white placeholder-gray-500 px-3 py-2 outline-none"
prop:value=move || message.get() prop:value=move || message.get()
on:input=on_input on:input=on_input
@ -243,13 +455,6 @@ pub fn ChatInput(
autocomplete="off" autocomplete="off"
aria-label="Chat message input" 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>
</div> </div>
} }
@ -257,17 +462,21 @@ pub fn ChatInput(
/// Emote list popup component. /// 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] #[component]
fn EmoteListPopup( fn EmoteListPopup(
emotion_availability: Signal<Option<EmotionAvailability>>, emotion_availability: Signal<Option<EmotionAvailability>>,
skin_preview_path: Signal<Option<String>>, skin_preview_path: Signal<Option<String>>,
on_select: Callback<String>, on_select: Callback<String>,
#[prop(into)] on_close: Callback<()>, #[prop(into)] on_close: Callback<()>,
#[prop(into)] emotion_filter: Signal<String>,
#[prop(into)] selected_idx: Signal<usize>,
) -> impl IntoView { ) -> impl IntoView {
let _ = on_close; // Suppress unused warning 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 available_emotions = move || {
let filter_text = emotion_filter.get().to_lowercase();
emotion_availability emotion_availability
.get() .get()
.map(|avail| { .map(|avail| {
@ -275,6 +484,10 @@ fn EmoteListPopup(
.iter() .iter()
.enumerate() .enumerate()
.filter(|(idx, _)| avail.available.get(*idx).copied().unwrap_or(false)) .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)| { .map(|(idx, name)| {
let preview = avail.preview_paths.get(idx).cloned().flatten(); let preview = avail.preview_paths.get(idx).cloned().flatten();
((*name).to_string(), preview) ((*name).to_string(), preview)
@ -284,28 +497,57 @@ fn EmoteListPopup(
.unwrap_or_default() .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! { view! {
<div <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" 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" role="listbox"
aria-label="Available emotions" 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"> <div class="grid grid-cols-3 gap-1 max-h-64 overflow-y-auto">
<For {move || {
each=move || available_emotions() indexed_emotions()
key=|(name, _): &(String, Option<String>)| name.clone() .into_iter()
children=move |(emotion_name, preview_path): (String, Option<String>)| { .map(|(idx, (emotion_name, preview_path))| {
let on_select = on_select.clone(); let on_select = on_select.clone();
let emotion_name_for_click = emotion_name.clone(); let emotion_name_for_click = emotion_name.clone();
let emotion_name_display = emotion_name.clone();
let _skin_path = skin_preview_path.get(); let _skin_path = skin_preview_path.get();
let _emotion_path = preview_path.clone(); let _emotion_path = preview_path.clone();
let is_selected = move || selected_idx.get() == idx;
view! { view! {
<button <button
type="button" type="button"
class="flex items-center gap-2 p-2 rounded hover:bg-gray-700 transition-colors text-left w-full" 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()) on:click=move |_| on_select.run(emotion_name_for_click.clone())
role="option" role="option"
aria-selected=is_selected
> >
<EmotionPreview <EmotionPreview
skin_path=_skin_path.clone() skin_path=_skin_path.clone()
@ -313,12 +555,13 @@ fn EmoteListPopup(
/> />
<span class="text-white text-sm"> <span class="text-white text-sm">
":e " ":e "
{emotion_name.clone()} {emotion_name_display}
</span> </span>
</button> </button>
} }
} })
/> .collect_view()
}}
</div> </div>
<Show when=move || available_emotions().is_empty()> <Show when=move || available_emotions().is_empty()>
<div class="text-gray-500 text-sm text-center py-4"> <div class="text-gray-500 text-sm text-center py-4">

View file

@ -3,6 +3,10 @@
//! Uses layered canvases for efficient rendering: //! Uses layered canvases for efficient rendering:
//! - Background canvas: Static, drawn once when scene loads //! - Background canvas: Static, drawn once when scene loads
//! - Avatar canvas: Dynamic, redrawn when members change //! - Avatar canvas: Dynamic, redrawn when members change
//!
//! Supports two rendering modes:
//! - **Fit mode** (default): Background scales to fit viewport with letterboxing
//! - **Pan mode**: Canvas at native resolution with optional zoom, user can scroll
use std::collections::HashMap; use std::collections::HashMap;
@ -12,6 +16,9 @@ use uuid::Uuid;
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene}; use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
use super::chat_types::{emotion_bubble_colors, ActiveBubble}; use super::chat_types::{emotion_bubble_colors, ActiveBubble};
use super::settings::{
calculate_min_zoom, ViewerSettings, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH,
};
/// Parse bounds WKT to extract width and height. /// Parse bounds WKT to extract width and height.
/// ///
@ -72,9 +79,48 @@ pub fn RealmSceneViewer(
on_move: Callback<(f64, f64)>, on_move: Callback<(f64, f64)>,
#[prop(into)] #[prop(into)]
on_prop_click: Callback<Uuid>, on_prop_click: Callback<Uuid>,
/// Viewer settings for pan/zoom/enlarge modes.
#[prop(optional)]
settings: Option<Signal<ViewerSettings>>,
/// Callback for zoom changes (from mouse wheel). Receives zoom delta.
#[prop(optional)]
on_zoom_change: Option<Callback<f64>>,
) -> impl IntoView { ) -> impl IntoView {
// Use default settings if none provided
let settings = settings.unwrap_or_else(|| Signal::derive(ViewerSettings::default));
let dimensions = parse_bounds_dimensions(&scene.bounds_wkt); let dimensions = parse_bounds_dimensions(&scene.bounds_wkt);
let (scene_width, scene_height) = dimensions.unwrap_or((800, 600)); let (scene_width, scene_height) = dimensions.unwrap_or((800, 600));
let scene_width_f = scene_width as f64;
let scene_height_f = scene_height as f64;
// Derived signals for rendering mode
let is_pan_mode = Signal::derive(move || settings.get().panning_enabled);
// Signal for viewport dimensions (outer container size)
// Used to calculate effective minimum zoom in pan mode
let (viewport_dimensions, set_viewport_dimensions) = signal((800.0_f64, 600.0_f64));
// Calculate effective minimum zoom based on scene and viewport
let effective_min_zoom = Signal::derive(move || {
let (vp_w, vp_h) = viewport_dimensions.get();
calculate_min_zoom(scene_width_f, scene_height_f, vp_w, vp_h)
});
// Zoom level clamped to effective minimum
let zoom_level = Signal::derive(move || {
let s = settings.get();
if s.panning_enabled {
let min_zoom = effective_min_zoom.get();
s.zoom_level.max(min_zoom)
} else {
1.0
}
});
let enlarge_props = Signal::derive(move || {
let s = settings.get();
!s.panning_enabled && s.enlarge_props
});
let bg_color = scene let bg_color = scene
.background_color .background_color
@ -91,6 +137,9 @@ pub fn RealmSceneViewer(
let props_canvas_ref = NodeRef::<leptos::html::Canvas>::new(); let props_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
let avatar_canvas_ref = NodeRef::<leptos::html::Canvas>::new(); let avatar_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
// Outer container ref for middle-mouse drag scrolling
let outer_container_ref = NodeRef::<leptos::html::Div>::new();
// Store scale factors for coordinate conversion (shared between both canvases) // Store scale factors for coordinate conversion (shared between both canvases)
let scale_x = StoredValue::new(1.0_f64); let scale_x = StoredValue::new(1.0_f64);
let scale_y = StoredValue::new(1.0_f64); let scale_y = StoredValue::new(1.0_f64);
@ -160,34 +209,126 @@ pub fn RealmSceneViewer(
let image_path_clone = image_path.clone(); let image_path_clone = image_path.clone();
let bg_color_clone = bg_color.clone(); let bg_color_clone = bg_color.clone();
let scene_width_f = scene_width as f64;
let scene_height_f = scene_height as f64;
// Flag to track if background has been drawn
let bg_drawn = Rc::new(RefCell::new(false));
// ========================================================= // =========================================================
// Background Effect - runs once on mount, draws static background // Viewport Dimensions Effect - tracks outer container size
// Uses setTimeout to ensure DOM is ready after mount
// ========================================================= // =========================================================
Effect::new(move |_| { Effect::new(move |_| {
// Don't track any reactive signals - this should only run once // Track pan mode to re-run when it changes (affects container layout)
let _ = is_pan_mode.get();
let Some(container) = outer_container_ref.get() else {
return;
};
let container_el: web_sys::HtmlElement = container.into();
// Use setTimeout to ensure DOM has settled after any layout changes
let update_dimensions = Closure::once(Box::new(move || {
let width = container_el.client_width() as f64;
let height = container_el.client_height() as f64;
if width > 0.0 && height > 0.0 {
set_viewport_dimensions.set((width, height));
}
}) as Box<dyn FnOnce()>);
let window = web_sys::window().unwrap();
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
update_dimensions.as_ref().unchecked_ref(),
150, // Slightly longer delay to ensure DOM settles
);
update_dimensions.forget();
});
// Track the last settings to detect changes
let last_pan_mode = Rc::new(RefCell::new(None::<bool>));
let last_zoom = Rc::new(RefCell::new(None::<f64>));
// =========================================================
// Background Effect - redraws when settings change
// =========================================================
Effect::new(move |_| {
// Track settings signals - this Effect reruns when they change
let current_pan_mode = is_pan_mode.get();
let current_zoom = zoom_level.get();
let Some(canvas) = bg_canvas_ref.get() else { let Some(canvas) = bg_canvas_ref.get() else {
return; return;
}; };
// Skip if already drawn // Check if we need to redraw (settings changed or first render)
if *bg_drawn.borrow() { let needs_redraw = {
let last_pan = *last_pan_mode.borrow();
let last_z = *last_zoom.borrow();
last_pan != Some(current_pan_mode)
|| (current_pan_mode && last_z != Some(current_zoom))
};
if !needs_redraw {
return; return;
} }
// Update last values
*last_pan_mode.borrow_mut() = Some(current_pan_mode);
*last_zoom.borrow_mut() = Some(current_zoom);
let canvas_el: &web_sys::HtmlCanvasElement = &canvas; let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
let canvas_el = canvas_el.clone(); let canvas_el = canvas_el.clone();
let bg_color = bg_color_clone.clone(); let bg_color = bg_color_clone.clone();
let image_path = image_path_clone.clone(); let image_path = image_path_clone.clone();
let bg_drawn_inner = bg_drawn.clone();
// Use setTimeout to ensure DOM is ready before drawing // Use setTimeout to ensure DOM is ready before drawing
let draw_bg = Closure::once(Box::new(move || { let draw_bg = Closure::once(Box::new(move || {
if current_pan_mode {
// Pan mode: canvas at native resolution * zoom
let canvas_width = (scene_width_f * current_zoom) as u32;
let canvas_height = (scene_height_f * current_zoom) as u32;
canvas_el.set_width(canvas_width);
canvas_el.set_height(canvas_height);
// Store scale factors (zoom level, no offset)
scale_x.set_value(current_zoom);
scale_y.set_value(current_zoom);
offset_x.set_value(0.0);
offset_y.set_value(0.0);
// Signal that scale factors are ready
set_scales_ready.set(true);
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
let ctx: web_sys::CanvasRenderingContext2d =
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
// Fill entire canvas with background color (no letterboxing)
ctx.set_fill_style_str(&bg_color);
ctx.fill_rect(0.0, 0.0, canvas_width as f64, canvas_height as f64);
// Draw background image if available
if has_background_image && !image_path.is_empty() {
let img = web_sys::HtmlImageElement::new().unwrap();
let img_clone = img.clone();
let ctx_clone = ctx.clone();
let onload = Closure::once(Box::new(move || {
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
&img_clone,
0.0,
0.0,
canvas_width as f64,
canvas_height as f64,
);
}) as Box<dyn FnOnce()>);
img.set_onload(Some(onload.as_ref().unchecked_ref()));
onload.forget();
img.set_src(&image_path);
}
}
} else {
// Fit mode: scale to viewport with letterboxing
let display_width = canvas_el.client_width() as u32; let display_width = canvas_el.client_width() as u32;
let display_height = canvas_el.client_height() as u32; let display_height = canvas_el.client_height() as u32;
@ -254,9 +395,7 @@ pub fn RealmSceneViewer(
onload.forget(); onload.forget();
img.set_src(&image_path); img.set_src(&image_path);
} }
}
// Mark background as drawn
*bg_drawn_inner.borrow_mut() = true;
} }
}) as Box<dyn FnOnce()>); }) as Box<dyn FnOnce()>);
@ -270,12 +409,15 @@ pub fn RealmSceneViewer(
}); });
// ========================================================= // =========================================================
// Avatar Effect - runs when members or bubbles change // Avatar Effect - runs when members, bubbles, or settings change
// ========================================================= // =========================================================
Effect::new(move |_| { Effect::new(move |_| {
// Track both signals - this Effect reruns when either changes // Track signals - this Effect reruns when any changes
let current_members = members.get(); let current_members = members.get();
let current_bubbles = active_bubbles.get(); let current_bubbles = active_bubbles.get();
let current_pan_mode = is_pan_mode.get();
let current_zoom = zoom_level.get();
let current_enlarge = enlarge_props.get();
// Skip drawing if scale factors haven't been calculated yet // Skip drawing if scale factors haven't been calculated yet
if !scales_ready.get() { if !scales_ready.get() {
@ -290,25 +432,19 @@ pub fn RealmSceneViewer(
let canvas_el = canvas_el.clone(); let canvas_el = canvas_el.clone();
let draw_avatars_closure = Closure::once(Box::new(move || { let draw_avatars_closure = Closure::once(Box::new(move || {
let display_width = canvas_el.client_width() as u32; let canvas_width = canvas_el.width();
let display_height = canvas_el.client_height() as u32; let canvas_height = canvas_el.height();
if display_width == 0 || display_height == 0 { if canvas_width == 0 || canvas_height == 0 {
return; return;
} }
// Resize avatar canvas to match (if needed)
if canvas_el.width() != display_width || canvas_el.height() != display_height {
canvas_el.set_width(display_width);
canvas_el.set_height(display_height);
}
if let Ok(Some(ctx)) = canvas_el.get_context("2d") { if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
let ctx: web_sys::CanvasRenderingContext2d = let ctx: web_sys::CanvasRenderingContext2d =
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap(); ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
// Clear with transparency (not fill - keeps canvas transparent) // Clear with transparency (not fill - keeps canvas transparent)
ctx.clear_rect(0.0, 0.0, display_width as f64, display_height as f64); ctx.clear_rect(0.0, 0.0, canvas_width as f64, canvas_height as f64);
// Get stored scale factors // Get stored scale factors
let sx = scale_x.get_value(); let sx = scale_x.get_value();
@ -316,8 +452,19 @@ 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();
// Calculate prop size based on mode
let prop_size = calculate_prop_size(
current_pan_mode,
current_zoom,
current_enlarge,
sx,
sy,
scene_width_f,
scene_height_f,
);
// Draw avatars first // Draw avatars first
draw_avatars(&ctx, &current_members, sx, sy, ox, oy); draw_avatars(&ctx, &current_members, sx, sy, ox, oy, prop_size);
// Draw speech bubbles on top // Draw speech bubbles on top
let current_time = js_sys::Date::now() as i64; let current_time = js_sys::Date::now() as i64;
@ -330,6 +477,7 @@ pub fn RealmSceneViewer(
ox, ox,
oy, oy,
current_time, current_time,
prop_size,
); );
} }
}) as Box<dyn FnOnce()>); }) as Box<dyn FnOnce()>);
@ -340,11 +488,14 @@ pub fn RealmSceneViewer(
}); });
// ========================================================= // =========================================================
// Props Effect - runs when loose_props changes // Props Effect - runs when loose_props or settings change
// ========================================================= // =========================================================
Effect::new(move |_| { Effect::new(move |_| {
// Track loose_props signal // Track signals
let current_props = loose_props.get(); let current_props = loose_props.get();
let current_pan_mode = is_pan_mode.get();
let current_zoom = zoom_level.get();
let current_enlarge = enlarge_props.get();
// Skip drawing if scale factors haven't been calculated yet // Skip drawing if scale factors haven't been calculated yet
if !scales_ready.get() { if !scales_ready.get() {
@ -359,25 +510,19 @@ pub fn RealmSceneViewer(
let canvas_el = canvas_el.clone(); let canvas_el = canvas_el.clone();
let draw_props_closure = Closure::once(Box::new(move || { let draw_props_closure = Closure::once(Box::new(move || {
let display_width = canvas_el.client_width() as u32; let canvas_width = canvas_el.width();
let display_height = canvas_el.client_height() as u32; let canvas_height = canvas_el.height();
if display_width == 0 || display_height == 0 { if canvas_width == 0 || canvas_height == 0 {
return; return;
} }
// Resize props canvas to match (if needed)
if canvas_el.width() != display_width || canvas_el.height() != display_height {
canvas_el.set_width(display_width);
canvas_el.set_height(display_height);
}
if let Ok(Some(ctx)) = canvas_el.get_context("2d") { if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
let ctx: web_sys::CanvasRenderingContext2d = let ctx: web_sys::CanvasRenderingContext2d =
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap(); ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
// Clear with transparency // Clear with transparency
ctx.clear_rect(0.0, 0.0, display_width as f64, display_height as f64); ctx.clear_rect(0.0, 0.0, canvas_width as f64, canvas_height as f64);
// Get stored scale factors // Get stored scale factors
let sx = scale_x.get_value(); let sx = scale_x.get_value();
@ -385,8 +530,19 @@ 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();
// Calculate prop size based on mode
let prop_size = calculate_prop_size(
current_pan_mode,
current_zoom,
current_enlarge,
sx,
sy,
scene_width_f,
scene_height_f,
);
// Draw loose props // Draw loose props
draw_loose_props(&ctx, &current_props, sx, sy, ox, oy); draw_loose_props(&ctx, &current_props, sx, sy, ox, oy, prop_size);
} }
}) as Box<dyn FnOnce()>); }) as Box<dyn FnOnce()>);
@ -394,38 +550,312 @@ pub fn RealmSceneViewer(
let _ = window.request_animation_frame(draw_props_closure.as_ref().unchecked_ref()); let _ = window.request_animation_frame(draw_props_closure.as_ref().unchecked_ref());
draw_props_closure.forget(); draw_props_closure.forget();
}); });
// =========================================================
// Sync canvas sizes when mode or zoom changes
// =========================================================
Effect::new(move |_| {
let current_pan_mode = is_pan_mode.get();
let current_zoom = zoom_level.get();
// Wait for scales to be ready (background drawn)
if !scales_ready.get() {
return;
} }
if current_pan_mode {
// Pan mode: resize props and avatar canvases to match background
let canvas_width = (scene_width_f * current_zoom) as u32;
let canvas_height = (scene_height_f * current_zoom) as u32;
if let Some(canvas) = props_canvas_ref.get() {
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
if canvas_el.width() != canvas_width || canvas_el.height() != canvas_height {
canvas_el.set_width(canvas_width);
canvas_el.set_height(canvas_height);
}
}
if let Some(canvas) = avatar_canvas_ref.get() {
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
if canvas_el.width() != canvas_width || canvas_el.height() != canvas_height {
canvas_el.set_width(canvas_width);
canvas_el.set_height(canvas_height);
}
}
} else {
// Fit mode: sync props and avatar canvases to background canvas size
if let Some(bg_canvas) = bg_canvas_ref.get() {
let bg_el: &web_sys::HtmlCanvasElement = &bg_canvas;
let canvas_width = bg_el.width();
let canvas_height = bg_el.height();
if canvas_width > 0 && canvas_height > 0 {
if let Some(canvas) = props_canvas_ref.get() {
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
if canvas_el.width() != canvas_width || canvas_el.height() != canvas_height {
canvas_el.set_width(canvas_width);
canvas_el.set_height(canvas_height);
}
}
if let Some(canvas) = avatar_canvas_ref.get() {
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
if canvas_el.width() != canvas_width || canvas_el.height() != canvas_height {
canvas_el.set_width(canvas_width);
canvas_el.set_height(canvas_height);
}
}
}
}
}
});
// =========================================================
// Middle mouse button drag-to-pan (only in pan mode)
// =========================================================
Effect::new(move |_| {
// Track pan mode - re-run when it changes
let pan_mode_enabled = is_pan_mode.get();
let Some(container) = outer_container_ref.get() else {
return;
};
let container_el: web_sys::HtmlElement = container.into();
if !pan_mode_enabled {
// Reset cursor when not in pan mode
let _ = container_el.style().set_property("cursor", "");
return;
}
use std::cell::Cell;
use std::rc::Rc;
use wasm_bindgen::closure::Closure;
let is_dragging = Rc::new(Cell::new(false));
let last_x = Rc::new(Cell::new(0i32));
let last_y = Rc::new(Cell::new(0i32));
let container_for_move = container_el.clone();
let is_dragging_move = is_dragging.clone();
let last_x_move = last_x.clone();
let last_y_move = last_y.clone();
let container_for_down = container_el.clone();
let is_dragging_down = is_dragging.clone();
let last_x_down = last_x.clone();
let last_y_down = last_y.clone();
// Middle mouse down - start drag
let onmousedown = Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
// Button 1 is middle mouse button
if ev.button() == 1 {
is_dragging_down.set(true);
last_x_down.set(ev.client_x());
last_y_down.set(ev.client_y());
let _ = container_for_down.style().set_property("cursor", "grabbing");
ev.prevent_default();
}
});
// Mouse move - drag scroll
let onmousemove = Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
if is_dragging_move.get() {
let dx = last_x_move.get() - ev.client_x();
let dy = last_y_move.get() - ev.client_y();
last_x_move.set(ev.client_x());
last_y_move.set(ev.client_y());
container_for_move.set_scroll_left(container_for_move.scroll_left() + dx);
container_for_move.set_scroll_top(container_for_move.scroll_top() + dy);
}
});
let container_for_up = container_el.clone();
let is_dragging_up = is_dragging.clone();
// Mouse up - stop drag
let onmouseup = Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |_ev: web_sys::MouseEvent| {
if is_dragging_up.get() {
is_dragging_up.set(false);
let _ = container_for_up.style().set_property("cursor", "");
}
});
// Add event listeners
let _ = container_el.add_event_listener_with_callback(
"mousedown",
onmousedown.as_ref().unchecked_ref(),
);
let _ = container_el.add_event_listener_with_callback(
"mousemove",
onmousemove.as_ref().unchecked_ref(),
);
let _ = container_el.add_event_listener_with_callback(
"mouseup",
onmouseup.as_ref().unchecked_ref(),
);
// Also listen for mouseup on window (in case mouse released outside container)
if let Some(window) = web_sys::window() {
let is_dragging_window = is_dragging.clone();
let container_for_window = container_el.clone();
let onmouseup_window = Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |_ev: web_sys::MouseEvent| {
if is_dragging_window.get() {
is_dragging_window.set(false);
let _ = container_for_window.style().set_property("cursor", "");
}
});
let _ = window.add_event_listener_with_callback(
"mouseup",
onmouseup_window.as_ref().unchecked_ref(),
);
onmouseup_window.forget();
}
// Prevent context menu on middle click
let oncontextmenu = Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
if ev.button() == 1 {
ev.prevent_default();
}
});
let _ = container_el.add_event_listener_with_callback(
"auxclick",
oncontextmenu.as_ref().unchecked_ref(),
);
// Forget closures to keep them alive
onmousedown.forget();
onmousemove.forget();
onmouseup.forget();
oncontextmenu.forget();
});
}
// Create wheel handler closure for use in view
let handle_wheel = move |ev: leptos::web_sys::WheelEvent| {
#[cfg(feature = "hydrate")]
{
// Only zoom in pan mode and without Ctrl key
if is_pan_mode.get() && !ev.ctrl_key() {
if let Some(zoom_callback) = on_zoom_change {
let delta_y = ev.delta_y();
// Normalize: scroll up (negative deltaY) = zoom in (positive delta)
// Scroll down (positive deltaY) = zoom out (negative delta)
let zoom_delta = if delta_y < 0.0 { 0.1 } else { -0.1 };
zoom_callback.run(zoom_delta);
ev.prevent_default();
}
}
}
#[cfg(not(feature = "hydrate"))]
let _ = ev;
};
let aspect_ratio = scene_width as f64 / scene_height as f64; let aspect_ratio = scene_width as f64 / scene_height as f64;
// Computed styles based on mode
let container_class = move || {
if is_pan_mode.get() {
"scene-canvas relative cursor-pointer"
} else {
"scene-canvas relative overflow-hidden cursor-pointer"
}
};
let outer_container_class = move || {
if is_pan_mode.get() {
let zoom = zoom_level.get();
let (vp_w, vp_h) = viewport_dimensions.get();
let canvas_w = scene_width_f * zoom;
let canvas_h = scene_height_f * zoom;
// Center canvas if smaller than viewport in both dimensions
if canvas_w <= vp_w && canvas_h <= vp_h {
"scene-container w-full overflow-auto flex justify-center items-center"
} else {
"scene-container w-full overflow-auto"
}
} else {
"scene-container w-full h-full flex justify-center items-center"
}
};
// Outer container needs max-height in pan mode to enable vertical scrolling
let outer_container_style = move || {
if is_pan_mode.get() {
"max-height: calc(100vh - 64px)".to_string()
} else {
String::new()
}
};
let container_style = move || {
if is_pan_mode.get() {
let zoom = zoom_level.get();
format!(
"width: {}px; height: {}px; background-color: {}",
(scene_width as f64 * zoom) as u32,
(scene_height as f64 * zoom) as u32,
bg_color
)
} else {
format!(
"aspect-ratio: {} / {}; width: min(100%, calc((100vh - 64px) * {})); max-height: calc(100vh - 64px); background-color: {}",
scene_width, scene_height, aspect_ratio, bg_color
)
}
};
let canvas_class = move || {
if is_pan_mode.get() {
"absolute inset-0"
} else {
"absolute inset-0 w-full h-full"
}
};
let canvas_style = move |z_index: i32| {
if is_pan_mode.get() {
let zoom = zoom_level.get();
format!(
"z-index: {}; width: {}px; height: {}px",
z_index,
(scene_width as f64 * zoom) as u32,
(scene_height as f64 * zoom) as u32
)
} else {
format!("z-index: {}; width: 100%; height: 100%", z_index)
}
};
view! { view! {
<div class="scene-container w-full h-full flex justify-center items-center"> <div node_ref=outer_container_ref class=outer_container_class style=outer_container_style on:wheel=handle_wheel>
<div <div
class="scene-canvas relative overflow-hidden cursor-pointer" class=container_class
style:background-color=bg_color.clone() style=container_style
style:aspect-ratio=format!("{} / {}", scene_width, scene_height)
style:width=format!("min(100%, calc((100vh - 64px) * {}))", aspect_ratio)
style:max-height="calc(100vh - 64px)"
> >
// Background layer - static, drawn once // Background layer - static, drawn once
<canvas <canvas
node_ref=bg_canvas_ref node_ref=bg_canvas_ref
class="absolute inset-0 w-full h-full" class=canvas_class
style="z-index: 0" style=move || canvas_style(0)
aria-hidden="true" aria-hidden="true"
/> />
// Props layer - loose props, redrawn on drop/pickup // Props layer - loose props, redrawn on drop/pickup
<canvas <canvas
node_ref=props_canvas_ref node_ref=props_canvas_ref
class="absolute inset-0 w-full h-full" class=canvas_class
style="z-index: 1" style=move || canvas_style(1)
aria-hidden="true" aria-hidden="true"
/> />
// Avatar layer - dynamic, transparent background // Avatar layer - dynamic, transparent background
<canvas <canvas
node_ref=avatar_canvas_ref node_ref=avatar_canvas_ref
class="absolute inset-0 w-full h-full" class=canvas_class
style="z-index: 2" style=move || canvas_style(2)
aria-label=format!("Scene: {}", scene.name) aria-label=format!("Scene: {}", scene.name)
role="img" role="img"
on:click=move |ev| { on:click=move |ev| {
@ -445,6 +875,34 @@ pub fn RealmSceneViewer(
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
/// Calculate prop/avatar size based on current rendering mode.
///
/// - Pan mode: BASE_PROP_SIZE * zoom_level
/// - Fit mode with enlarge: Reference scaling based on 1920x1080
/// - Fit mode without enlarge: BASE_PROP_SIZE * min(scale_x, scale_y)
#[cfg(feature = "hydrate")]
fn calculate_prop_size(
pan_mode: bool,
zoom_level: f64,
enlarge_props: bool,
scale_x: f64,
scale_y: f64,
scene_width: f64,
scene_height: f64,
) -> f64 {
if pan_mode {
// Pan mode: base size * zoom
BASE_PROP_SIZE * zoom_level
} else if enlarge_props {
// Reference scaling: scale props relative to 1920x1080 reference
let ref_scale = (scene_width / REFERENCE_WIDTH).max(scene_height / REFERENCE_HEIGHT);
BASE_PROP_SIZE * ref_scale * scale_x.min(scale_y)
} else {
// Default: base size scaled to viewport
BASE_PROP_SIZE * scale_x.min(scale_y)
}
}
/// Normalize an asset path to be absolute, prefixing with /static/ if needed. /// Normalize an asset path to be absolute, prefixing with /static/ if needed.
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
fn normalize_asset_path(path: &str) -> String { fn normalize_asset_path(path: &str) -> String {
@ -463,13 +921,14 @@ fn draw_avatars(
scale_y: f64, scale_y: f64,
offset_x: f64, offset_x: f64,
offset_y: f64, offset_y: f64,
prop_size: f64,
) { ) {
let avatar_size = prop_size;
for member in members { for member in members {
let x = member.member.position_x * scale_x + offset_x; let x = member.member.position_x * scale_x + offset_x;
let y = member.member.position_y * scale_y + offset_y; let y = member.member.position_y * scale_y + offset_y;
let avatar_size = 60.0 * scale_x.min(scale_y);
// Draw avatar placeholder circle // Draw avatar placeholder circle
ctx.begin_path(); ctx.begin_path();
let _ = ctx.arc(x, y - avatar_size / 2.0, avatar_size / 2.0, 0.0, std::f64::consts::PI * 2.0); let _ = ctx.arc(x, y - avatar_size / 2.0, avatar_size / 2.0, 0.0, std::f64::consts::PI * 2.0);
@ -516,11 +975,14 @@ fn draw_avatars(
img.set_src(&normalize_asset_path(emotion_path)); img.set_src(&normalize_asset_path(emotion_path));
} }
// Scale factor for text/badges relative to avatar size
let text_scale = avatar_size / BASE_PROP_SIZE;
// Draw emotion indicator on avatar // Draw emotion indicator on avatar
let emotion = member.member.current_emotion; let emotion = member.member.current_emotion;
if emotion > 0 { if emotion > 0 {
// Draw emotion number in a small badge // Draw emotion number in a small badge
let badge_size = 16.0 * scale_x.min(scale_y); let badge_size = 16.0 * text_scale;
let badge_x = x + avatar_size / 2.0 - badge_size / 2.0; let badge_x = x + avatar_size / 2.0 - badge_size / 2.0;
let badge_y = y - avatar_size - badge_size / 2.0; let badge_y = y - avatar_size - badge_size / 2.0;
@ -532,7 +994,7 @@ fn draw_avatars(
// Emotion number // Emotion number
ctx.set_fill_style_str("#000"); ctx.set_fill_style_str("#000");
ctx.set_font(&format!("bold {}px sans-serif", 10.0 * scale_x.min(scale_y))); ctx.set_font(&format!("bold {}px sans-serif", 10.0 * text_scale));
ctx.set_text_align("center"); ctx.set_text_align("center");
ctx.set_text_baseline("middle"); ctx.set_text_baseline("middle");
let _ = ctx.fill_text(&format!("{}", emotion), badge_x, badge_y); let _ = ctx.fill_text(&format!("{}", emotion), badge_x, badge_y);
@ -540,10 +1002,10 @@ fn draw_avatars(
// Draw display name // Draw display name
ctx.set_fill_style_str("#fff"); ctx.set_fill_style_str("#fff");
ctx.set_font(&format!("{}px sans-serif", 12.0 * scale_x.min(scale_y))); 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(&member.member.display_name, x, y + 10.0 * scale_y); let _ = ctx.fill_text(&member.member.display_name, x, y + 10.0 * text_scale);
} }
} }
@ -558,15 +1020,16 @@ fn draw_speech_bubbles(
offset_x: f64, offset_x: f64,
offset_y: f64, offset_y: f64,
current_time_ms: i64, current_time_ms: i64,
prop_size: f64,
) { ) {
let scale = scale_x.min(scale_y); let avatar_size = prop_size;
let avatar_size = 60.0 * scale; let text_scale = avatar_size / BASE_PROP_SIZE;
let max_bubble_width = 200.0 * scale; let max_bubble_width = 200.0 * text_scale;
let padding = 8.0 * scale; let padding = 8.0 * text_scale;
let font_size = 12.0 * scale; let font_size = 12.0 * text_scale;
let line_height = 16.0 * scale; let line_height = 16.0 * text_scale;
let tail_size = 8.0 * scale; let tail_size = 8.0 * text_scale;
let border_radius = 8.0 * scale; let border_radius = 8.0 * text_scale;
for member in members { for member in members {
let key = (member.member.user_id, member.member.guest_session_id); let key = (member.member.user_id, member.member.guest_session_id);
@ -599,12 +1062,12 @@ fn draw_speech_bubbles(
}) })
.fold(0.0_f64, |a: f64, b: f64| a.max(b)) .fold(0.0_f64, |a: f64, b: f64| a.max(b))
+ padding * 2.0; + padding * 2.0;
let bubble_width = bubble_width.max(60.0 * scale); // Minimum width let bubble_width = bubble_width.max(60.0 * text_scale); // Minimum width
let bubble_height = (lines.len() as f64) * line_height + padding * 2.0; let bubble_height = (lines.len() as f64) * line_height + padding * 2.0;
// Position bubble above avatar // Position bubble above avatar
let bubble_x = x - bubble_width / 2.0; let bubble_x = x - bubble_width / 2.0;
let bubble_y = y - avatar_size - bubble_height - tail_size - 5.0 * scale; let bubble_y = y - avatar_size - bubble_height - tail_size - 5.0 * text_scale;
// Draw bubble background with rounded corners // Draw bubble background with rounded corners
draw_rounded_rect(ctx, bubble_x, bubble_y, bubble_width, bubble_height, border_radius); draw_rounded_rect(ctx, bubble_x, bubble_y, bubble_width, bubble_height, border_radius);
@ -719,8 +1182,8 @@ fn draw_loose_props(
scale_y: f64, scale_y: f64,
offset_x: f64, offset_x: f64,
offset_y: f64, offset_y: f64,
prop_size: f64,
) { ) {
let prop_size = 60.0 * scale_x.min(scale_y);
for prop in props { for prop in props {
let x = prop.position_x * scale_x + offset_x; let x = prop.position_x * scale_x + offset_x;
@ -755,8 +1218,9 @@ fn draw_loose_props(
ctx.stroke(); ctx.stroke();
// Draw prop name below // Draw prop name below
let text_scale = prop_size / BASE_PROP_SIZE;
ctx.set_fill_style_str("#fff"); ctx.set_fill_style_str("#fff");
ctx.set_font(&format!("{}px sans-serif", 10.0 * scale_x.min(scale_y))); ctx.set_font(&format!("{}px sans-serif", 10.0 * text_scale));
ctx.set_text_align("center"); ctx.set_text_align("center");
ctx.set_text_baseline("top"); ctx.set_text_baseline("top");
let _ = ctx.fill_text(&prop.prop_name, x, y + prop_size / 2.0 + 2.0); let _ = ctx.fill_text(&prop.prop_name, x, y + prop_size / 2.0 + 2.0);

View file

@ -0,0 +1,179 @@
//! Scene viewer display settings with localStorage persistence.
use serde::{Deserialize, Serialize};
/// LocalStorage key for viewer settings.
const SETTINGS_KEY: &str = "chattyness_viewer_settings";
/// Reference resolution for enlarged props calculation.
pub const REFERENCE_WIDTH: f64 = 1920.0;
pub const REFERENCE_HEIGHT: f64 = 1080.0;
/// Base size for props and avatars in scene space.
pub const BASE_PROP_SIZE: f64 = 60.0;
/// Minimum zoom level (25%).
pub const ZOOM_MIN: f64 = 0.25;
/// Maximum zoom level (400%).
pub const ZOOM_MAX: f64 = 4.0;
/// Zoom step increment.
pub const ZOOM_STEP: f64 = 0.25;
/// Pan step in pixels for keyboard navigation.
pub const PAN_STEP: f64 = 50.0;
/// Calculate the minimum zoom level for pan mode.
///
/// - Large scenes: min zoom fills the viewport
/// - Small scenes: min zoom is 1.0 (native resolution, centered)
pub fn calculate_min_zoom(
scene_width: f64,
scene_height: f64,
viewport_width: f64,
viewport_height: f64,
) -> f64 {
if scene_width <= 0.0
|| scene_height <= 0.0
|| viewport_width <= 0.0
|| viewport_height <= 0.0
{
return 1.0;
}
let min_x = viewport_width / scene_width;
let min_y = viewport_height / scene_height;
// For large scenes: min zoom fills viewport
// For small scenes: clamp to 1.0 (native resolution)
min_x.max(min_y).min(1.0)
}
/// Scene viewer display settings.
///
/// These settings control how the scene is rendered and are persisted
/// to localStorage for user preference retention.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ViewerSettings {
/// When true, canvas shows at native resolution with scrolling.
/// When false, canvas scales to fit viewport (default).
pub panning_enabled: bool,
/// Zoom level (0.25 to 4.0). Only applicable when `panning_enabled` is true.
pub zoom_level: f64,
/// When true, props use reference scaling based on 1920x1080.
/// Only applicable when `panning_enabled` is false.
pub enlarge_props: bool,
/// Saved horizontal scroll position for pan mode.
pub scroll_x: f64,
/// Saved vertical scroll position for pan mode.
pub scroll_y: f64,
}
impl Default for ViewerSettings {
fn default() -> Self {
Self {
panning_enabled: false,
zoom_level: 1.0,
enlarge_props: false,
scroll_x: 0.0,
scroll_y: 0.0,
}
}
}
impl ViewerSettings {
/// Load settings 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(SETTINGS_KEY) else {
return Self::default();
};
serde_json::from_str(&json).unwrap_or_default()
}
/// Stub for SSR - returns default settings.
#[cfg(not(feature = "hydrate"))]
pub fn load() -> Self {
Self::default()
}
/// Save settings 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(SETTINGS_KEY, &json);
}
}
/// Stub for SSR - no-op.
#[cfg(not(feature = "hydrate"))]
pub fn save(&self) {}
/// Calculate the effective prop size based on current settings.
///
/// In pan mode, returns base size * zoom level.
/// In fit mode with enlarge_props, returns size adjusted for reference resolution.
/// Otherwise returns base size (caller should multiply by canvas scale).
pub fn calculate_prop_size(&self, scene_width: f64, scene_height: f64) -> f64 {
if self.panning_enabled {
// Pan mode: base size * zoom
BASE_PROP_SIZE * self.zoom_level
} else if self.enlarge_props {
// Reference scaling: ensure minimum size based on 1920x1080
let scale_w = scene_width / REFERENCE_WIDTH;
let scale_h = scene_height / REFERENCE_HEIGHT;
BASE_PROP_SIZE * scale_w.max(scale_h)
} else {
// Default: base size (will be scaled by canvas scale factor)
BASE_PROP_SIZE
}
}
/// Adjust zoom level by a delta, clamping to valid range.
///
/// If `min_zoom` is provided, uses that as the floor instead of `ZOOM_MIN`.
pub fn adjust_zoom(&mut self, delta: f64) {
self.zoom_level = (self.zoom_level + delta).clamp(ZOOM_MIN, ZOOM_MAX);
}
/// Adjust zoom level with a custom minimum.
pub fn adjust_zoom_with_min(&mut self, delta: f64, min_zoom: f64) {
let effective_min = min_zoom.max(ZOOM_MIN);
self.zoom_level = (self.zoom_level + delta).clamp(effective_min, ZOOM_MAX);
}
/// Clamp zoom level to an effective minimum.
pub fn clamp_zoom_min(&mut self, min_zoom: f64) {
let effective_min = min_zoom.max(ZOOM_MIN);
if self.zoom_level < effective_min {
self.zoom_level = effective_min;
}
}
/// Reset scroll position to origin.
pub fn reset_scroll(&mut self) {
self.scroll_x = 0.0;
self.scroll_y = 0.0;
}
}

View file

@ -0,0 +1,280 @@
//! Settings popup component for scene viewer configuration.
use leptos::ev::MouseEvent;
use leptos::prelude::*;
use super::settings::{calculate_min_zoom, ViewerSettings, ZOOM_MAX, ZOOM_STEP};
/// Settings popup component for scene viewer configuration.
///
/// Provides controls for:
/// - Panning mode (native resolution with scroll)
/// - Zoom level (when panning enabled)
/// - Enlarge props (when panning disabled)
///
/// Props:
/// - `open`: Signal controlling visibility
/// - `settings`: RwSignal for viewer settings (read/write)
/// - `on_close`: Callback when popup should close
#[component]
pub fn SettingsPopup(
#[prop(into)] open: Signal<bool>,
settings: RwSignal<ViewerSettings>,
on_close: Callback<()>,
/// Scene dimensions (width, height) for calculating min zoom.
#[prop(default = (800.0, 600.0))]
scene_dimensions: (f64, f64),
/// Viewport dimensions signal for calculating min zoom.
#[prop(into, optional)]
viewport_dimensions: Option<Signal<(f64, f64)>>,
) -> impl IntoView {
// Derived signals for each setting
let panning = Signal::derive(move || settings.get().panning_enabled);
let zoom = Signal::derive(move || settings.get().zoom_level);
let enlarge = Signal::derive(move || settings.get().enlarge_props);
// Calculate effective minimum zoom based on scene/viewport dimensions
let effective_min_zoom = Signal::derive(move || {
let (scene_w, scene_h) = scene_dimensions;
let (vp_w, vp_h) = viewport_dimensions
.map(|s| s.get())
.unwrap_or((800.0, 600.0));
calculate_min_zoom(scene_w, scene_h, vp_w, vp_h)
});
// Toggle handlers
let on_panning_toggle = move |_| {
settings.update(|s| {
s.panning_enabled = !s.panning_enabled;
// Reset scroll when disabling pan mode
if !s.panning_enabled {
s.reset_scroll();
}
s.save();
});
};
let on_enlarge_toggle = move |_| {
settings.update(|s| {
s.enlarge_props = !s.enlarge_props;
s.save();
});
};
let on_zoom_decrease = move |_| {
let min_zoom = effective_min_zoom.get();
settings.update(|s| {
s.adjust_zoom_with_min(-ZOOM_STEP, min_zoom);
s.save();
});
};
let on_zoom_increase = move |_| {
let min_zoom = effective_min_zoom.get();
settings.update(|s| {
s.adjust_zoom_with_min(ZOOM_STEP, min_zoom);
s.save();
});
};
let on_zoom_input = move |ev| {
let val: f64 = event_target_value(&ev).parse().unwrap_or(1.0);
let min_zoom = effective_min_zoom.get();
settings.update(|s| {
s.zoom_level = val.clamp(min_zoom, ZOOM_MAX);
s.save();
});
};
// Handle escape key to close
#[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| {
if ev.key() == "Escape" {
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="settings-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-md w-full mx-4 p-6 border border-gray-700">
// Header
<div class="flex items-center justify-between mb-6">
<h2 id="settings-modal-title" class="text-xl font-bold text-white">
"Scene Settings"
</h2>
<button
type="button"
class="text-gray-400 hover:text-white transition-colors"
on:click=move |_| on_close_button.run(())
aria-label="Close settings"
>
<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>
// Settings toggles
<div class="space-y-4">
// Panning toggle
<SettingsToggle
label="Native Resolution (Pan Mode)"
description="View scene at 1:1 pixel size, scroll to pan around"
checked=panning
on_change=on_panning_toggle
/>
// Zoom controls (only when panning enabled)
<Show when=move || panning.get()>
<div class="pl-4 border-l-2 border-gray-600 space-y-2">
<label class="block text-white font-medium">
"Zoom: " {move || format!("{}%", (zoom.get() * 100.0) as i32)}
</label>
<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_zoom_decrease
disabled=move || zoom.get() <= effective_min_zoom.get()
aria-label="Zoom out"
>
"-"
</button>
<input
type="range"
min=move || effective_min_zoom.get().to_string()
max=ZOOM_MAX.to_string()
step=ZOOM_STEP.to_string()
class="flex-1 h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
prop:value=move || zoom.get().to_string()
on:input=on_zoom_input
aria-label="Zoom level"
/>
<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_zoom_increase
disabled=move || zoom.get() >= ZOOM_MAX
aria-label="Zoom in"
>
"+"
</button>
</div>
</div>
</Show>
// Enlarge props toggle (only when panning disabled)
<Show when=move || !panning.get()>
<SettingsToggle
label="Enlarge Props"
description="Scale props relative to 1920x1080 for consistent size"
checked=enlarge
on_change=on_enlarge_toggle
/>
</Show>
</div>
// Keyboard shortcuts help
<div class="mt-6 pt-4 border-t border-gray-700 space-y-1">
<p class="text-gray-400 text-sm">
"Keyboard: "
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs font-mono">"s"</kbd>
" to open settings"
</p>
<Show when=move || panning.get()>
<p class="text-gray-400 text-sm">
"Arrow keys to pan, "
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs font-mono">"+"</kbd>
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs font-mono">"-"</kbd>
" to zoom"
</p>
</Show>
</div>
</div>
</div>
</Show>
}
}
/// Individual toggle switch component.
#[component]
fn SettingsToggle(
/// Label text for the toggle.
label: &'static str,
/// Description text shown below label.
description: &'static str,
/// Whether the toggle is currently enabled.
#[prop(into)]
checked: Signal<bool>,
/// Handler called when toggle is clicked.
on_change: impl Fn(MouseEvent) + 'static,
) -> impl IntoView {
view! {
<button
type="button"
class="w-full flex items-start gap-4 p-3 rounded-lg hover:bg-gray-700/50 transition-colors text-left"
on:click=on_change
role="switch"
aria-checked=move || checked.get().to_string()
>
// Toggle switch
<div
class=move || format!(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors {}",
if checked.get() { "bg-blue-600" } else { "bg-gray-600" }
)
aria-hidden="true"
>
<span
class=move || format!(
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition {}",
if checked.get() { "translate-x-5" } else { "translate-x-0" }
)
/>
</div>
// Label and description
<div class="flex-1 min-w-0">
<span class="block text-white font-medium">{label}</span>
<span class="block text-gray-400 text-sm">{description}</span>
</div>
</button>
}
}

View file

@ -13,7 +13,7 @@ use uuid::Uuid;
use crate::components::{ use crate::components::{
ActiveBubble, Card, ChatInput, ChatMessage, InventoryPopup, MessageLog, RealmHeader, ActiveBubble, Card, ChatInput, ChatMessage, InventoryPopup, MessageLog, RealmHeader,
RealmSceneViewer, DEFAULT_BUBBLE_TIMEOUT_MS, 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;
@ -26,6 +26,44 @@ use chattyness_db::ws_messages::ClientMessage;
use crate::components::ws_client::WsSender; use crate::components::ws_client::WsSender;
/// Parse bounds WKT to extract width and height.
///
/// Expected format: "POLYGON((0 0, WIDTH 0, WIDTH HEIGHT, 0 HEIGHT, 0 0))"
fn parse_bounds_dimensions(bounds_wkt: &str) -> Option<(u32, u32)> {
let trimmed = bounds_wkt.trim();
let coords_str = trimmed
.strip_prefix("POLYGON((")
.and_then(|s| s.strip_suffix("))"))?;
let points: Vec<&str> = coords_str.split(',').collect();
if points.len() < 4 {
return None;
}
let mut max_x: f64 = 0.0;
let mut max_y: f64 = 0.0;
for point in points.iter() {
let coords: Vec<&str> = point.trim().split_whitespace().collect();
if coords.len() >= 2 {
if let (Ok(x), Ok(y)) = (coords[0].parse::<f64>(), coords[1].parse::<f64>()) {
if x > max_x {
max_x = x;
}
if y > max_y {
max_y = y;
}
}
}
}
if max_x > 0.0 && max_y > 0.0 {
Some((max_x as u32, max_y as u32))
} else {
None
}
}
/// Realm landing page component. /// Realm landing page component.
#[component] #[component]
pub fn RealmPage() -> impl IntoView { pub fn RealmPage() -> impl IntoView {
@ -58,6 +96,16 @@ pub fn RealmPage() -> impl IntoView {
// Inventory popup state // Inventory popup state
let (inventory_open, set_inventory_open) = signal(false); let (inventory_open, set_inventory_open) = signal(false);
// Settings popup state
let (settings_open, set_settings_open) = signal(false);
let viewer_settings = RwSignal::new(ViewerSettings::load());
// Scene dimensions (extracted from bounds_wkt when scene loads)
let (scene_dimensions, set_scene_dimensions) = signal((800.0_f64, 600.0_f64));
// Chat focus prefix (: or /)
let (focus_prefix, set_focus_prefix) = signal(':');
// Loose props state // Loose props state
let (loose_props, set_loose_props) = signal(Vec::<LooseProp>::new()); let (loose_props, set_loose_props) = signal(Vec::<LooseProp>::new());
@ -205,7 +253,7 @@ pub fn RealmPage() -> impl IntoView {
on_prop_picked_up, on_prop_picked_up,
); );
// Set channel ID when scene loads (triggers WebSocket connection) // Set channel ID and scene dimensions when scene loads
// Note: Currently using scene.id as the channel_id since channel_members // Note: Currently using scene.id as the channel_id since channel_members
// uses scenes directly. Proper channel infrastructure can be added later. // uses scenes directly. Proper channel infrastructure can be added later.
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
@ -215,6 +263,11 @@ pub fn RealmPage() -> impl IntoView {
return; return;
}; };
set_channel_id.set(Some(scene.id)); set_channel_id.set(Some(scene.id));
// Extract scene dimensions from bounds_wkt
if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
set_scene_dimensions.set((w as f64, h as f64));
}
}); });
} }
@ -304,8 +357,22 @@ pub fn RealmPage() -> impl IntoView {
return; return;
} }
// Handle ':' to focus chat input // Handle space to focus chat input (no prefix)
if key == " " {
set_focus_prefix.set(' ');
set_focus_chat_trigger.set(true);
use gloo_timers::callback::Timeout;
Timeout::new(100, move || {
set_focus_chat_trigger.set(false);
})
.forget();
ev.prevent_default();
return;
}
// Handle ':' to focus chat input with colon prefix
if key == ":" { if key == ":" {
set_focus_prefix.set(':');
set_focus_chat_trigger.set(true); set_focus_chat_trigger.set(true);
// Reset trigger after a short delay so it can be triggered again // Reset trigger after a short delay so it can be triggered again
use gloo_timers::callback::Timeout; use gloo_timers::callback::Timeout;
@ -317,9 +384,68 @@ pub fn RealmPage() -> impl IntoView {
return; return;
} }
// Handle 'i' to open inventory // Handle '/' to focus chat input with slash prefix
if key == "/" {
set_focus_prefix.set('/');
set_focus_chat_trigger.set(true);
use gloo_timers::callback::Timeout;
Timeout::new(100, move || {
set_focus_chat_trigger.set(false);
})
.forget();
ev.prevent_default();
return;
}
// Handle 's' to toggle settings
if key == "s" || key == "S" {
set_settings_open.update(|v| *v = !*v);
ev.prevent_default();
return;
}
// Handle arrow keys for panning (only in pan mode)
let settings = viewer_settings.get_untracked();
if settings.panning_enabled {
let pan_step = 50.0;
let scroll_delta = match key.as_str() {
"ArrowLeft" => Some((-pan_step, 0.0)),
"ArrowRight" => Some((pan_step, 0.0)),
"ArrowUp" => Some((0.0, -pan_step)),
"ArrowDown" => Some((0.0, pan_step)),
_ => None,
};
if let Some((dx, dy)) = scroll_delta {
// Find the scene container and scroll it
if let Some(document) = web_sys::window().and_then(|w| w.document()) {
if let Some(container) = document.query_selector(".scene-container").ok().flatten() {
let container_el: web_sys::Element = container;
container_el.scroll_by_with_x_and_y(dx, dy);
}
}
ev.prevent_default();
return;
}
// Handle +/- for zoom
let zoom_delta = match key.as_str() {
"+" | "=" => Some(0.25),
"-" | "_" => Some(-0.25),
_ => None,
};
if let Some(delta) = zoom_delta {
viewer_settings.update(|s| s.adjust_zoom(delta));
viewer_settings.get_untracked().save();
ev.prevent_default();
return;
}
}
// Handle 'i' to toggle inventory
if key == "i" || key == "I" { if key == "i" || key == "I" {
set_inventory_open.set(true); set_inventory_open.update(|v| *v = !*v);
ev.prevent_default(); ev.prevent_default();
return; return;
} }
@ -502,6 +628,13 @@ pub fn RealmPage() -> impl IntoView {
let ws_for_chat: StoredValue<Option<WsSender>, LocalStorage> = StoredValue::new_local(None); let ws_for_chat: StoredValue<Option<WsSender>, LocalStorage> = StoredValue::new_local(None);
let active_bubbles_signal = Signal::derive(move || active_bubbles.get()); let active_bubbles_signal = Signal::derive(move || active_bubbles.get());
let loose_props_signal = Signal::derive(move || loose_props.get()); let loose_props_signal = Signal::derive(move || loose_props.get());
let focus_prefix_signal = Signal::derive(move || focus_prefix.get());
let on_open_settings_cb = Callback::new(move |_: ()| {
set_settings_open.set(true);
});
let on_open_inventory_cb = Callback::new(move |_: ()| {
set_inventory_open.set(true);
});
view! { view! {
<div class="relative w-full"> <div class="relative w-full">
<RealmSceneViewer <RealmSceneViewer
@ -512,6 +645,13 @@ pub fn RealmPage() -> impl IntoView {
loose_props=loose_props_signal loose_props=loose_props_signal
on_move=on_move.clone() on_move=on_move.clone()
on_prop_click=on_prop_click.clone() on_prop_click=on_prop_click.clone()
settings=Signal::derive(move || viewer_settings.get())
on_zoom_change=Callback::new(move |delta: f64| {
viewer_settings.update(|s| {
s.adjust_zoom(delta);
s.save();
});
})
/> />
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none"> <div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none">
<ChatInput <ChatInput
@ -519,7 +659,10 @@ pub fn RealmPage() -> impl IntoView {
emotion_availability=emotion_avail_signal emotion_availability=emotion_avail_signal
skin_preview_path=skin_path_signal skin_preview_path=skin_path_signal
focus_trigger=focus_trigger_signal focus_trigger=focus_trigger_signal
focus_prefix=focus_prefix_signal
on_focus_change=on_chat_focus_change.clone() on_focus_change=on_chat_focus_change.clone()
on_open_settings=on_open_settings_cb
on_open_inventory=on_open_inventory_cb
/> />
</div> </div>
</div> </div>
@ -560,6 +703,16 @@ pub fn RealmPage() -> impl IntoView {
/> />
} }
} }
// Settings popup
<SettingsPopup
open=Signal::derive(move || settings_open.get())
settings=viewer_settings
on_close=Callback::new(move |_: ()| {
set_settings_open.set(false);
})
scene_dimensions=scene_dimensions.get()
/>
} }
.into_any() .into_any()
} }