fix: scaling, and chat
* Chat ergonomics vastly improved. * Scaling now done through client side settings
This commit is contained in:
parent
98f38c9714
commit
b430c80000
8 changed files with 1564 additions and 439 deletions
280
crates/chattyness-user-ui/src/components/settings_popup.rs
Normal file
280
crates/chattyness-user-ui/src/components/settings_popup.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue