Skip to content

Commit 4ce9e52

Browse files
committed
app-web: move keyboard and pointer handlers to events.rs; wire from lib.rs
1 parent ce6bd63 commit 4ce9e52

File tree

3 files changed

+219
-136
lines changed

3 files changed

+219
-136
lines changed

.cursor/rules/project-rules.mdc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ Improve the tests as you go, make sure the tests are clear and focused. Don't cr
88
Improve the docs and diagramsas you go.
99
Remove dead and commented out code.
1010
Use whitespace to make the code more readable.
11-
Try and keep functions less that 30 lines, the shorter the better.
12-
Try and keep files less that 300 lines, the smaller the better.
11+
Try and keep functions less that 20 lines, the shorter the better.
12+
Try and keep files less that 200 lines, the smaller the better.
1313

1414
Do not block for approvals; follow `docs/SPEC.md` and `docs/TODO.md` and keep momentum.
1515
Prefer WebGPU via `wgpu` v24.0 and avoid WebGL2 fallbacks.

crates/app-web/src/events.rs

Lines changed: 194 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
use app_core::MusicEngine;
2-
use app_core::{AEOLIAN, DORIAN, IONIAN, LOCRIAN, LYDIAN, MIXOLYDIAN, PHRYGIAN};
2+
use app_core::{
3+
midi_to_hz, z_offset_vec3, AEOLIAN, DORIAN, ENGINE_DRAG_MAX_RADIUS, IONIAN, LOCRIAN, LYDIAN,
4+
MIXOLYDIAN, PHRYGIAN, PICK_SPHERE_RADIUS, SPREAD,
5+
};
6+
use crate::audio;
7+
use crate::input;
8+
use crate::render;
9+
use app_core::Waveform;
310
use std::cell::RefCell;
411
use std::rc::Rc;
12+
use wasm_bindgen::JsCast;
513
use web_sys as web;
614

715
#[inline]
@@ -136,3 +144,188 @@ pub fn wire_overlay_toggle_h(document: &web::Document) {
136144
closure.forget();
137145
}
138146
}
147+
148+
pub fn wire_global_keydown(
149+
engine: Rc<RefCell<MusicEngine>>,
150+
paused: Rc<RefCell<bool>>,
151+
master_gain: web::GainNode,
152+
canvas: web::HtmlCanvasElement,
153+
) {
154+
if let Some(window) = web::window() {
155+
let closure = wasm_bindgen::closure::Closure::wrap(Box::new(move |ev: web::KeyboardEvent| {
156+
super::events::handle_global_keydown(&ev, &engine, &paused, &master_gain, &canvas);
157+
}) as Box<dyn FnMut(_)>);
158+
let _ = window.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref());
159+
closure.forget();
160+
}
161+
}
162+
163+
pub struct InputWiring {
164+
pub canvas: web::HtmlCanvasElement,
165+
pub engine: Rc<RefCell<MusicEngine>>,
166+
pub mouse_state: Rc<RefCell<input::MouseState>>,
167+
pub hover_index: Rc<RefCell<Option<usize>>>,
168+
pub drag_state: Rc<RefCell<input::DragState>>,
169+
pub voice_gains: Rc<Vec<web::GainNode>>,
170+
pub delay_sends: Rc<Vec<web::GainNode>>,
171+
pub reverb_sends: Rc<Vec<web::GainNode>>,
172+
pub audio_ctx: web::AudioContext,
173+
pub queued_ripple_uv: Rc<RefCell<Option<[f32; 2]>>>,
174+
}
175+
176+
pub fn wire_input_handlers(w: InputWiring) {
177+
// pointermove
178+
{
179+
let mouse_state_m = w.mouse_state.clone();
180+
let hover_m = w.hover_index.clone();
181+
let drag_m = w.drag_state.clone();
182+
let engine_m = w.engine.clone();
183+
let canvas_mouse = w.canvas.clone();
184+
let canvas_connected = canvas_mouse.is_connected();
185+
let closure = wasm_bindgen::closure::Closure::wrap(Box::new(move |ev: web::PointerEvent| {
186+
let pos = input::pointer_canvas_px(&ev, &canvas_mouse);
187+
if !canvas_connected {
188+
return;
189+
}
190+
{
191+
let mut ms = mouse_state_m.borrow_mut();
192+
ms.x = pos.x;
193+
ms.y = pos.y;
194+
}
195+
let (ro, rd) = render::screen_to_world_ray(&canvas_mouse, pos.x, pos.y, super::CAMERA_Z);
196+
let mut best = None::<(usize, f32)>;
197+
let z_offset = z_offset_vec3();
198+
for (i, v) in engine_m.borrow().voices.iter().enumerate() {
199+
let center_world = v.position * SPREAD + z_offset;
200+
if let Some(t) = input::ray_sphere(ro, rd, center_world, PICK_SPHERE_RADIUS) {
201+
if t >= 0.0 {
202+
match best {
203+
Some((_, bt)) if t >= bt => {}
204+
_ => best = Some((i, t)),
205+
}
206+
}
207+
}
208+
}
209+
if drag_m.borrow().active {
210+
let plane_z = drag_m.borrow().plane_z_world;
211+
if rd.z.abs() > 1e-6 {
212+
let t = (plane_z - ro.z) / rd.z;
213+
if t >= 0.0 {
214+
let hit_world = ro + rd * t;
215+
let mut eng_pos = (hit_world - z_offset_vec3()) / SPREAD;
216+
let max_r = ENGINE_DRAG_MAX_RADIUS;
217+
let len = (eng_pos.x * eng_pos.x + eng_pos.z * eng_pos.z).sqrt();
218+
if len > max_r {
219+
let scale = max_r / len;
220+
eng_pos.x *= scale;
221+
eng_pos.z *= scale;
222+
}
223+
let mut eng = engine_m.borrow_mut();
224+
let vi = drag_m.borrow().voice;
225+
eng.set_voice_position(vi, glam::Vec3::new(eng_pos.x, 0.0, eng_pos.z));
226+
}
227+
}
228+
} else {
229+
match best {
230+
Some((i, _t)) => {
231+
*hover_m.borrow_mut() = Some(i);
232+
}
233+
None => {
234+
*hover_m.borrow_mut() = None;
235+
}
236+
}
237+
}
238+
}) as Box<dyn FnMut(_)>);
239+
if let Some(wnd) = web::window() {
240+
let _ = wnd.add_event_listener_with_callback("pointermove", closure.as_ref().unchecked_ref());
241+
}
242+
closure.forget();
243+
}
244+
245+
// pointerdown
246+
{
247+
let hover_m = w.hover_index.clone();
248+
let drag_m = w.drag_state.clone();
249+
let mouse_m = w.mouse_state.clone();
250+
let engine_m = w.engine.clone();
251+
let canvas_target = w.canvas.clone();
252+
let closure = wasm_bindgen::closure::Closure::wrap(Box::new(move |ev: web::PointerEvent| {
253+
if let Some(i) = *hover_m.borrow() {
254+
let mut ds = drag_m.borrow_mut();
255+
ds.active = true;
256+
ds.voice = i;
257+
ds.plane_z_world = engine_m.borrow().voices[i].position.z * SPREAD + z_offset_vec3().z;
258+
log::info!("[mouse] begin drag on voice {}", i);
259+
}
260+
mouse_m.borrow_mut().down = true;
261+
let _ = canvas_target.set_pointer_capture(ev.pointer_id());
262+
ev.prevent_default();
263+
}) as Box<dyn FnMut(_)>);
264+
let _ = w.canvas.add_event_listener_with_callback("pointerdown", closure.as_ref().unchecked_ref());
265+
closure.forget();
266+
}
267+
268+
// pointerup
269+
{
270+
let hover_m = w.hover_index.clone();
271+
let drag_m = w.drag_state.clone();
272+
let mouse_m = w.mouse_state.clone();
273+
let engine_m = w.engine.clone();
274+
let voice_gains_click = w.voice_gains.clone();
275+
let delay_sends_click = w.delay_sends.clone();
276+
let reverb_sends_click = w.reverb_sends.clone();
277+
let canvas_click = w.canvas.clone();
278+
let audio_ctx_click = w.audio_ctx.clone();
279+
let ripple_queue = w.queued_ripple_uv.clone();
280+
let closure = wasm_bindgen::closure::Closure::wrap(Box::new(move |ev: web::PointerEvent| {
281+
let was_dragging = drag_m.borrow().active;
282+
if was_dragging {
283+
drag_m.borrow_mut().active = false;
284+
} else if let Some(i) = *hover_m.borrow() {
285+
let shift = ev.shift_key();
286+
let alt = ev.alt_key();
287+
if alt {
288+
engine_m.borrow_mut().toggle_solo(i);
289+
} else if shift {
290+
engine_m.borrow_mut().reseed_voice(i, None);
291+
} else {
292+
engine_m.borrow_mut().toggle_mute(i);
293+
}
294+
} else {
295+
let [uvx, uvy] = input::pointer_canvas_uv(&ev, &canvas_click);
296+
if uvx.is_finite() && uvy.is_finite() {
297+
let midi = 60.0 + uvx * 24.0;
298+
let freq = midi_to_hz(midi as f32);
299+
let vel = (0.35 + 0.65 * uvy) as f32;
300+
let eng = engine_m.borrow();
301+
let norm_xs: Vec<f32> = eng
302+
.voices
303+
.iter()
304+
.map(|v| (v.position.x / 3.0).clamp(-1.0, 1.0) * 0.5 + 0.5)
305+
.collect();
306+
let best_i = crate::input::nearest_index_by_uvx(&norm_xs, uvx);
307+
let dur = 0.35 + 0.25 * (1.0 - uvy as f64);
308+
let wf = eng.configs[best_i].waveform;
309+
drop(eng);
310+
audio::trigger_one_shot(
311+
&audio_ctx_click,
312+
wf,
313+
freq,
314+
vel,
315+
dur,
316+
&voice_gains_click[best_i],
317+
&delay_sends_click[best_i],
318+
&reverb_sends_click[best_i],
319+
);
320+
*ripple_queue.borrow_mut() = Some([uvx, uvy]);
321+
}
322+
}
323+
mouse_m.borrow_mut().down = false;
324+
ev.prevent_default();
325+
}) as Box<dyn FnMut(_)>);
326+
if let Some(wnd) = web::window() {
327+
let _ = wnd.add_event_listener_with_callback("pointerup", closure.as_ref().unchecked_ref());
328+
}
329+
closure.forget();
330+
}
331+
}

crates/app-web/src/lib.rs

Lines changed: 23 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,8 @@ fn wire_canvas_resize(canvas: &web::HtmlCanvasElement) {
3434
dom::sync_canvas_backing_size(&canvas_resize);
3535
}) as Box<dyn FnMut()>);
3636
if let Some(window) = web::window() {
37-
let _ = window.add_event_listener_with_callback(
38-
"resize",
39-
resize_closure.as_ref().unchecked_ref(),
40-
);
37+
let _ = window
38+
.add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref());
4139
}
4240
resize_closure.forget();
4341
}
@@ -49,7 +47,7 @@ struct InitParts {
4947
paused: Rc<RefCell<bool>>,
5048
}
5149

52-
async fn build_audio_and_engine(document: web::Document) -> anyhow::Result<InitParts> {
50+
async fn build_audio_and_engine(_document: web::Document) -> anyhow::Result<InitParts> {
5351
let audio_ctx = web::AudioContext::new().map_err(|e| anyhow::anyhow!("{:?}", e))?;
5452
let _ = audio_ctx.resume();
5553
let listener = audio_ctx.listener();
@@ -756,135 +754,27 @@ async fn init() -> anyhow::Result<()> {
756754
closure.forget();
757755
}
758756

759-
// Keyboard controls: R reseed all, Space pause, +/- bpm adjust, ArrowUp/Down volume, F/Escape fullscreen
760-
{
761-
let engine_k = engine.clone();
762-
let paused_k = paused.clone();
763-
let canvas_k = canvas_for_click_inner.clone();
764-
let master_gain_k = master_gain.clone();
765-
let window = web::window().unwrap();
766-
let closure = Closure::wrap(Box::new(move |ev: web::KeyboardEvent| {
767-
events::handle_global_keydown(
768-
&ev,
769-
&engine_k,
770-
&paused_k,
771-
&master_gain_k,
772-
&canvas_k,
773-
);
774-
}) as Box<dyn FnMut(_)>);
775-
window
776-
.add_event_listener_with_callback(
777-
"keydown",
778-
closure.as_ref().unchecked_ref(),
779-
)
780-
.ok();
781-
closure.forget();
782-
}
757+
// Keyboard controls
758+
events::wire_global_keydown(
759+
engine.clone(),
760+
paused.clone(),
761+
master_gain.clone(),
762+
canvas_for_click_inner.clone(),
763+
);
783764

784-
// Mousedown: begin drag if over a voice
785-
{
786-
let hover_m = hover_index.clone();
787-
let drag_m = drag_state.clone();
788-
let mouse_m = mouse_state.clone();
789-
let engine_m = engine.clone();
790-
let canvas_target = canvas_for_click_inner.clone();
791-
let closure = Closure::wrap(Box::new(move |ev: web::PointerEvent| {
792-
if let Some(i) = *hover_m.borrow() {
793-
let mut ds = drag_m.borrow_mut();
794-
ds.active = true;
795-
ds.voice = i;
796-
ds.plane_z_world =
797-
engine_m.borrow().voices[i].position.z * SPREAD + z_offset_vec3().z;
798-
log::info!("[mouse] begin drag on voice {}", i);
799-
}
800-
mouse_m.borrow_mut().down = true;
801-
let _ = canvas_target.set_pointer_capture(ev.pointer_id());
802-
// noisy pointer down debug log removed
803-
ev.prevent_default();
804-
}) as Box<dyn FnMut(_)>);
805-
canvas_for_click_inner
806-
.add_event_listener_with_callback(
807-
"pointerdown",
808-
closure.as_ref().unchecked_ref(),
809-
)
810-
.ok();
811-
closure.forget();
812-
}
813-
814-
// Mouseup: click actions or end drag; also trigger background tap note+ripple
815-
{
816-
let hover_m = hover_index.clone();
817-
let drag_m = drag_state.clone();
818-
let mouse_m = mouse_state.clone();
819-
let engine_m = engine.clone();
820-
let voice_gains_click = voice_gains.clone();
821-
let delay_sends_click = delay_sends.clone();
822-
let reverb_sends_click = reverb_sends.clone();
823-
let canvas_click = canvas_for_click_inner.clone();
824-
let audio_ctx_click = audio_ctx.clone();
825-
let ripple_queue = queued_ripple_uv.clone();
826-
let closure = Closure::wrap(Box::new(move |ev: web::PointerEvent| {
827-
let was_dragging = drag_m.borrow().active;
828-
if was_dragging {
829-
drag_m.borrow_mut().active = false;
830-
} else if let Some(i) = *hover_m.borrow() {
831-
// Click without drag: modifiers
832-
let shift = ev.shift_key();
833-
let alt = ev.alt_key();
834-
if alt {
835-
engine_m.borrow_mut().toggle_solo(i);
836-
// noisy click debug log removed
837-
} else if shift {
838-
engine_m.borrow_mut().reseed_voice(i, None);
839-
// noisy click debug log removed
840-
} else {
841-
engine_m.borrow_mut().toggle_mute(i);
842-
// noisy click debug log removed
843-
}
844-
} else {
845-
// Background click: synth one-shot via WebAudio and request a ripple
846-
let [uvx, uvy] = input::pointer_canvas_uv(&ev, &canvas_click);
847-
if uvx.is_finite() && uvy.is_finite() {
848-
let midi = 60.0 + uvx * 24.0;
849-
let freq = midi_to_hz(midi as f32);
850-
let vel = (0.35 + 0.65 * uvy) as f32;
851-
// Precompute normalized voice x for nearest-voice pick
852-
let eng = engine_m.borrow();
853-
let norm_xs: Vec<f32> = eng
854-
.voices
855-
.iter()
856-
.map(|v| (v.position.x / 3.0).clamp(-1.0, 1.0) * 0.5 + 0.5)
857-
.collect();
858-
let best_i = input::nearest_index_by_uvx(&norm_xs, uvx);
859-
let dur = 0.35 + 0.25 * (1.0 - uvy as f64);
860-
let wf = eng.configs[best_i].waveform;
861-
drop(eng);
862-
audio::trigger_one_shot(
863-
&audio_ctx_click,
864-
wf,
865-
freq,
866-
vel,
867-
dur,
868-
&voice_gains_click[best_i],
869-
&delay_sends_click[best_i],
870-
&reverb_sends_click[best_i],
871-
);
872-
*ripple_queue.borrow_mut() = Some([uvx, uvy]);
873-
}
874-
}
875-
// noisy pointer up debug log removed
876-
mouse_m.borrow_mut().down = false;
877-
ev.prevent_default();
878-
}) as Box<dyn FnMut(_)>);
879-
if let Some(w) = web::window() {
880-
w.add_event_listener_with_callback(
881-
"pointerup",
882-
closure.as_ref().unchecked_ref(),
883-
)
884-
.ok();
885-
}
886-
closure.forget();
887-
}
765+
// Pointer handlers (move/down/up)
766+
events::wire_input_handlers(events::InputWiring {
767+
canvas: canvas_for_click_inner.clone(),
768+
engine: engine.clone(),
769+
mouse_state: mouse_state.clone(),
770+
hover_index: hover_index.clone(),
771+
drag_state: drag_state.clone(),
772+
voice_gains: voice_gains.clone(),
773+
delay_sends: delay_sends.clone(),
774+
reverb_sends: reverb_sends.clone(),
775+
audio_ctx: audio_ctx.clone(),
776+
queued_ripple_uv: queued_ripple_uv.clone(),
777+
});
888778

889779
// Scheduler + renderer loop driven by requestAnimationFrame
890780
let frame_ctx = Rc::new(RefCell::new(frame::FrameContext {

0 commit comments

Comments
 (0)