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

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