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

@ -13,7 +13,7 @@ use uuid::Uuid;
use crate::components::{
ActiveBubble, Card, ChatInput, ChatMessage, InventoryPopup, MessageLog, RealmHeader,
RealmSceneViewer, DEFAULT_BUBBLE_TIMEOUT_MS,
RealmSceneViewer, SettingsPopup, ViewerSettings, DEFAULT_BUBBLE_TIMEOUT_MS,
};
#[cfg(feature = "hydrate")]
use crate::components::use_channel_websocket;
@ -26,6 +26,44 @@ use chattyness_db::ws_messages::ClientMessage;
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.
#[component]
pub fn RealmPage() -> impl IntoView {
@ -58,6 +96,16 @@ pub fn RealmPage() -> impl IntoView {
// Inventory popup state
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
let (loose_props, set_loose_props) = signal(Vec::<LooseProp>::new());
@ -205,7 +253,7 @@ pub fn RealmPage() -> impl IntoView {
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
// uses scenes directly. Proper channel infrastructure can be added later.
#[cfg(feature = "hydrate")]
@ -215,6 +263,11 @@ pub fn RealmPage() -> impl IntoView {
return;
};
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;
}
// 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 == ":" {
set_focus_prefix.set(':');
set_focus_chat_trigger.set(true);
// Reset trigger after a short delay so it can be triggered again
use gloo_timers::callback::Timeout;
@ -317,9 +384,68 @@ pub fn RealmPage() -> impl IntoView {
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" {
set_inventory_open.set(true);
set_inventory_open.update(|v| *v = !*v);
ev.prevent_default();
return;
}
@ -502,6 +628,13 @@ pub fn RealmPage() -> impl IntoView {
let ws_for_chat: StoredValue<Option<WsSender>, LocalStorage> = StoredValue::new_local(None);
let active_bubbles_signal = Signal::derive(move || active_bubbles.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! {
<div class="relative w-full">
<RealmSceneViewer
@ -512,6 +645,13 @@ pub fn RealmPage() -> impl IntoView {
loose_props=loose_props_signal
on_move=on_move.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">
<ChatInput
@ -519,7 +659,10 @@ pub fn RealmPage() -> impl IntoView {
emotion_availability=emotion_avail_signal
skin_preview_path=skin_path_signal
focus_trigger=focus_trigger_signal
focus_prefix=focus_prefix_signal
on_focus_change=on_chat_focus_change.clone()
on_open_settings=on_open_settings_cb
on_open_inventory=on_open_inventory_cb
/>
</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()
}