1#![feature(if_let_guard)]
2
3use quanta::{Clock, Instant};
4use std::collections::HashMap;
5use std::ops::DerefMut;
6use std::sync::Arc;
7use std::time::Duration;
8use tiny_skia::{Color, *};
9use tokio::sync::{OnceCell, mpsc};
10use winit::{
11 application::ApplicationHandler,
12 event::{ElementState, MouseButton, TouchPhase, WindowEvent},
13 event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
14 window::{Window, WindowAttributes, WindowId},
15};
16
17use crate::ws::LaserMessage;
18use crate::{
19 renderer::{Renderer, skia_rgba_to_bgra_u32},
20 ws::WsMessage,
21};
22
23mod renderer;
24mod utils;
25mod ws;
26
27type BoxedError = Box<dyn std::error::Error + 'static>;
28type AppResult<T> = Result<T, BoxedError>;
29
30const BASE_MAX_AGE: Duration = Duration::from_millis(200);
31const MIN_AGE: Duration = Duration::from_millis(50);
32const FAST_DECAY_THRESHOLD: usize = 30;
33#[cfg(target_arch = "wasm32")]
34const TARGET_FPS: u32 = 120;
35#[cfg(not(target_arch = "wasm32"))]
36const TARGET_FPS: u32 = 60;
37const FRAME_TIME_MS: u64 = 1000 / TARGET_FPS as u64; // ~16.67ms
38
39#[derive(Clone)]
40struct LaserPoint {
41 x: f32,
42 y: f32,
43 color: [u8; 3],
44 created_at: Instant,
45}
46
47impl LaserPoint {
48 fn new(msg: LaserMessage, now: Instant) -> Self {
49 Self {
50 x: msg.x as f32,
51 y: msg.y as f32,
52 color: utils::id_to_color(msg.id),
53 created_at: now,
54 }
55 }
56}
57
58pub type WindowHandle = Arc<OnceCell<Arc<Window>>>;
59
60pub struct Graphics {
61 window: Arc<Window>,
62 renderer: Renderer,
63 pixmap: Pixmap,
64}
65
66impl Graphics {
67 fn resize(&mut self, width: u32, height: u32) -> AppResult<()> {
68 self.renderer.resize(width, height)?;
69 self.pixmap = Pixmap::new(width, height).unwrap();
70 Ok(())
71 }
72}
73
74pub struct LaserOverlay {
75 gfx: Option<Graphics>,
76 window: WindowHandle,
77 server_mouse_pos: (f32, f32),
78 laser_points: HashMap<(u64, u8), Vec<LaserPoint>, ahash::RandomState>,
79 in_chan: (mpsc::Sender<WsMessage>, mpsc::Receiver<WsMessage>),
80 out_tx: mpsc::Sender<WsMessage>,
81 last_render: Instant,
82 last_cleanup: Instant,
83 clock: Clock,
84 client_id: u64,
85 mouse_pressed: bool,
86 mouse_pos: (f32, f32),
87 current_line_id: u8,
88 next_line_id: u8,
89 needs_redraw: bool,
90 has_any_points: bool,
91}
92
93impl LaserOverlay {
94 pub fn new() -> (mpsc::Sender<WsMessage>, mpsc::Receiver<WsMessage>, Self) {
95 let in_chan = mpsc::channel(1024);
96 let (out_tx, out_rx) = mpsc::channel(512);
97
98 let clock = Clock::new();
99 let now = clock.now();
100
101 let this = Self {
102 gfx: None,
103 window: WindowHandle::default(),
104 server_mouse_pos: (0.0, 0.0),
105 laser_points: Default::default(),
106 in_chan,
107 out_tx,
108 last_render: now,
109 last_cleanup: now,
110 clock,
111 client_id: fastrand::u64(..),
112 mouse_pressed: false,
113 mouse_pos: (0.0, 0.0),
114 current_line_id: 0,
115 next_line_id: 1,
116 needs_redraw: false,
117 has_any_points: false,
118 };
119
120 (this.in_chan.0.clone(), out_rx, this)
121 }
122
123 #[cfg(feature = "server")]
124 pub fn start_mouse_listener(send_tx: tokio::sync::broadcast::Sender<(u64, WsMessage)>) {
125 std::thread::spawn({
126 use enigo::{Enigo, Mouse, Settings};
127
128 move || {
129 let enigo = Enigo::new(&Settings::default()).unwrap();
130 loop {
131 let res = enigo.location();
132 let Ok(pos) = res else {
133 eprintln!("failed to get mouse position: {res:?}");
134 continue;
135 };
136 let msg = WsMessage::Mouse(ws::MouseMessage {
137 x: pos.0 as u32,
138 y: pos.1 as u32,
139 });
140 let _ = send_tx.send((0, msg));
141 std::thread::sleep(Duration::from_millis(1000 / 30));
142 }
143 }
144 });
145 }
146
147 pub fn window_handle(&self) -> WindowHandle {
148 self.window.clone()
149 }
150
151 pub fn init_graphics(&mut self, window: Arc<Window>) -> AppResult<()> {
152 #[cfg(target_arch = "wasm32")]
153 let size = {
154 use winit::platform::web::WindowExtWebSys;
155 let canvas = window.canvas().unwrap();
156 let (w, h) = (canvas.client_width(), canvas.client_height());
157 canvas.set_width(w.try_into().unwrap());
158 canvas.set_height(h.try_into().unwrap());
159 winit::dpi::PhysicalSize::new(w as u32, h as u32)
160 };
161 #[cfg(not(target_arch = "wasm32"))]
162 let size = window.inner_size();
163
164 let _ = self.window.set(window.clone());
165 self.gfx = Some(Graphics {
166 renderer: Renderer::new(window.clone(), size.width, size.height)?,
167 pixmap: Pixmap::new(size.width, size.height).unwrap(),
168 window,
169 });
170
171 self.needs_redraw = true;
172
173 Ok(())
174 }
175
176 fn handle_mouse_press(&mut self, position: (f32, f32)) {
177 self.mouse_pressed = true;
178 self.mouse_pos = position;
179 self.current_line_id = self.next_line_id;
180 self.next_line_id = self.next_line_id.wrapping_add(1);
181 self.needs_redraw = true;
182
183 let msg = WsMessage::Laser(LaserMessage {
184 x: position.0 as u32,
185 y: position.1 as u32,
186 id: self.client_id,
187 line_id: self.current_line_id,
188 });
189 let _ = self.in_chan.0.try_send(msg);
190 let _ = self.out_tx.try_send(msg);
191 }
192
193 fn handle_mouse_move(&mut self, position: (f32, f32)) {
194 if !self.mouse_pressed {
195 return;
196 }
197
198 let dx = position.0 - self.mouse_pos.0;
199 let dy = position.1 - self.mouse_pos.1;
200 let distance = (dx * dx + dy * dy).sqrt();
201
202 if distance < 1.0 {
203 return;
204 }
205
206 self.mouse_pos = position;
207 self.needs_redraw = true;
208 let msg = WsMessage::Laser(LaserMessage {
209 x: position.0 as u32,
210 y: position.1 as u32,
211 id: self.client_id,
212 line_id: self.current_line_id,
213 });
214 let _ = self.in_chan.0.try_send(msg);
215 let _ = self.out_tx.try_send(msg);
216 }
217
218 fn handle_mouse_release(&mut self) {
219 self.mouse_pressed = false;
220 }
221
222 fn calculate_point_max_age(point_index: usize, total_points: usize) -> Duration {
223 if total_points <= FAST_DECAY_THRESHOLD {
224 return BASE_MAX_AGE;
225 }
226
227 if point_index < FAST_DECAY_THRESHOLD {
228 let progress = point_index as f32 / FAST_DECAY_THRESHOLD as f32;
229 let age_range = BASE_MAX_AGE.as_millis() - MIN_AGE.as_millis();
230 let calculated_age = MIN_AGE.as_millis() + (age_range as f32 * progress) as u128;
231 Duration::from_millis(calculated_age as u64)
232 } else {
233 BASE_MAX_AGE
234 }
235 }
236
237 #[inline(always)]
238 fn ingest_points(&mut self) {
239 let mut has_any_points = false;
240
241 let should_cleanup = self.last_cleanup.elapsed().as_millis() >= FRAME_TIME_MS as u128;
242
243 if should_cleanup {
244 self.last_cleanup = self.clock.now();
245
246 let mut ids_to_remove = Vec::new();
247 for (id, points) in self.laser_points.iter_mut() {
248 let points_len = points.len();
249 if points_len == 0 {
250 continue;
251 }
252
253 let mut new_points = Vec::with_capacity(points_len);
254 for (index, point) in points.iter().enumerate() {
255 let max_age = Self::calculate_point_max_age(index, points_len);
256 if point.created_at.elapsed() < max_age {
257 new_points.push(point.clone());
258 }
259 }
260
261 if new_points.len() != points_len {
262 self.needs_redraw = true;
263 }
264 *points = new_points;
265
266 if points.is_empty() {
267 ids_to_remove.push(*id);
268 } else {
269 has_any_points = true;
270 }
271 }
272
273 for id in ids_to_remove {
274 self.laser_points.remove(&id);
275 }
276 } else {
277 has_any_points = self.laser_points.values().any(|points| !points.is_empty());
278 }
279
280 while let Ok(msg) = self.in_chan.1.try_recv() {
281 match msg {
282 WsMessage::Laser(msg) => {
283 self.laser_points
284 .entry((msg.id, msg.line_id))
285 .or_default()
286 .push(LaserPoint::new(msg, self.clock.now()));
287 has_any_points = true;
288 }
289 #[cfg(feature = "client")]
290 WsMessage::Mouse(msg) => {
291 self.server_mouse_pos = (msg.x as f32, msg.y as f32);
292 self.needs_redraw = true;
293 }
294 #[cfg(not(feature = "client"))]
295 WsMessage::Mouse(_) => {}
296 }
297 }
298
299 self.has_any_points = has_any_points;
300 }
301
302 #[cfg(feature = "client")]
303 #[inline(always)]
304 fn draw_server_mouse(mut pixmap: PixmapMut, mouse_pos: (f32, f32)) {
305 let (x, y) = mouse_pos;
306 let radius = 10.0;
307 let color = Color::WHITE;
308
309 let mut pb = PathBuilder::new();
310 pb.push_circle(x, y, radius);
311
312 if let Some(path) = pb.finish() {
313 let mut paint = Paint::default();
314 paint.set_color(color);
315 paint.anti_alias = true;
316 paint.blend_mode = BlendMode::Source;
317
318 let mut stroke = Stroke::default();
319 stroke.width = radius * 2.0;
320 stroke.line_cap = LineCap::Round;
321 stroke.line_join = LineJoin::Round;
322
323 pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
324 }
325 }
326
327 #[inline(always)]
328 fn draw_tapering_laser_line(mut pixmap: PixmapMut, points: &[LaserPoint], now: Instant) {
329 if points.len() < 2 {
330 return;
331 }
332
333 let max_width = 8.0;
334 let min_width = 0.5;
335 let points_len = points.len();
336
337 let data = points
338 .windows(2)
339 .enumerate()
340 .map(|(i, chunk)| {
341 let current = &chunk[0];
342 let next = &chunk[1];
343
344 let progress = i as f32 / (points_len - 2) as f32;
345 let width = min_width + (max_width - min_width) * progress;
346
347 let point_index = i;
348 let max_age = Self::calculate_point_max_age(point_index, points_len);
349 let age = now.duration_since(current.created_at);
350 let age_progress = age.as_millis() as f32 / max_age.as_millis() as f32;
351 let alpha = (255.0 * (1.0 - age_progress.clamp(0.0, 1.0))) as u8;
352
353 let mut pb = PathBuilder::new();
354 pb.move_to(current.x, current.y);
355 pb.line_to(next.x, next.y);
356
357 (pb.finish(), width, next.color, alpha)
358 })
359 .collect::<Vec<_>>();
360
361 // draw glow first so we can draw the actual line on top
362 // otherwise the glow would cover the line
363 for (path, width, color, alpha) in data.iter().filter(|p| p.1 > 2.0) {
364 let Some(path) = path else {
365 continue;
366 };
367
368 let mut glow_paint = Paint::default();
369 glow_paint.set_color_rgba8(color[0], color[1], color[2], alpha / 5);
370 glow_paint.anti_alias = true;
371 // replace the existing alpha
372 glow_paint.blend_mode = BlendMode::Source;
373
374 let mut glow_stroke = Stroke::default();
375 glow_stroke.width = width * 1.8;
376 glow_stroke.line_cap = LineCap::Round;
377 glow_stroke.line_join = LineJoin::Round;
378
379 pixmap.stroke_path(
380 &path,
381 &glow_paint,
382 &glow_stroke,
383 Transform::identity(),
384 None,
385 );
386 }
387
388 for (path, width, color, alpha) in data {
389 let Some(path) = path else {
390 continue;
391 };
392
393 let mut paint = Paint::default();
394 paint.set_color_rgba8(color[0], color[1], color[2], alpha.max(10));
395 paint.anti_alias = true;
396 // replace the existing alpha (so it doesn't blend with existing pixels, looks bad)
397 paint.blend_mode = BlendMode::Source;
398
399 let mut stroke = Stroke::default();
400 stroke.width = width;
401 stroke.line_cap = LineCap::Round;
402 stroke.line_join = LineJoin::Round;
403
404 pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
405 }
406 }
407
408 #[inline(always)]
409 pub fn render(&mut self) -> AppResult<()> {
410 let Some(gfx) = self.gfx.as_mut() else {
411 return Ok(());
412 };
413
414 let mut frame = gfx.renderer.frame_mut()?;
415
416 gfx.pixmap.fill(Color::TRANSPARENT);
417 for points in self.laser_points.values() {
418 Self::draw_tapering_laser_line(gfx.pixmap.as_mut(), points, self.clock.now());
419 }
420 #[cfg(feature = "client")]
421 Self::draw_server_mouse(gfx.pixmap.as_mut(), self.server_mouse_pos);
422 skia_rgba_to_bgra_u32(gfx.pixmap.data(), frame.deref_mut());
423
424 gfx.window.pre_present_notify();
425 gfx.renderer.present()?;
426
427 self.last_render = self.clock.now();
428 self.needs_redraw = false;
429
430 Ok(())
431 }
432
433 #[inline(always)]
434 pub fn should_render(&self) -> bool {
435 self.has_any_points || self.needs_redraw
436 }
437}
438
439impl ApplicationHandler for LaserOverlay {
440 fn resumed(&mut self, event_loop: &ActiveEventLoop) {
441 let attrs = WindowAttributes::default()
442 .with_title("laser overlay")
443 .with_transparent(true);
444
445 #[cfg(feature = "server")]
446 let attrs = attrs
447 .with_fullscreen(Some(winit::window::Fullscreen::Borderless(None)))
448 .with_window_level(winit::window::WindowLevel::AlwaysOnTop)
449 .with_decorations(false);
450
451 #[cfg(target_arch = "wasm32")]
452 let attrs = winit::platform::web::WindowAttributesExtWebSys::with_append(attrs, true);
453
454 let window = event_loop.create_window(attrs).unwrap();
455 #[cfg(feature = "server")]
456 let _ = window.set_cursor_hittest(false);
457
458 self.init_graphics(window.into()).unwrap();
459
460 event_loop.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(
461 FRAME_TIME_MS,
462 )));
463 }
464
465 fn window_event(
466 &mut self,
467 event_loop: &ActiveEventLoop,
468 _window_id: WindowId,
469 event: WindowEvent,
470 ) {
471 match event {
472 WindowEvent::CloseRequested => {
473 event_loop.exit();
474 return;
475 }
476 WindowEvent::Touch(touch) => {
477 let pos = (touch.location.x as f32, touch.location.y as f32);
478 match touch.phase {
479 TouchPhase::Started => {
480 self.handle_mouse_press(pos);
481 }
482 TouchPhase::Moved => {
483 self.handle_mouse_move(pos);
484 }
485 TouchPhase::Ended | TouchPhase::Cancelled => {
486 self.handle_mouse_release();
487 }
488 }
489 }
490 WindowEvent::MouseInput { state, button, .. } => {
491 if button == MouseButton::Left {
492 match state {
493 ElementState::Pressed => {
494 self.handle_mouse_press(self.mouse_pos);
495 }
496 ElementState::Released => {
497 self.handle_mouse_release();
498 }
499 }
500 }
501 }
502 WindowEvent::CursorMoved { position, .. } => {
503 let pos = (position.x as f32, position.y as f32);
504 self.handle_mouse_move(pos);
505 self.mouse_pos = pos;
506 }
507 WindowEvent::Resized(size) if let Some(gfx) = self.gfx.as_mut() => {
508 gfx.resize(size.width, size.height).unwrap();
509 }
510 WindowEvent::RedrawRequested => {
511 self.ingest_points();
512 self.render().unwrap();
513 }
514 _ => {}
515 }
516 }
517
518 fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
519 if self.should_render() {
520 if let Some(gfx) = self.gfx.as_ref() {
521 if self.last_render.elapsed().as_millis() >= FRAME_TIME_MS as u128 {
522 gfx.window.request_redraw();
523 }
524 }
525 event_loop.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(
526 FRAME_TIME_MS,
527 )));
528 } else {
529 event_loop.set_control_flow(ControlFlow::Wait);
530 }
531 }
532}
533
534#[allow(unused_mut)]
535fn run_app(event_loop: EventLoop<()>, mut app: LaserOverlay) -> AppResult<()> {
536 #[cfg(not(any(target_arch = "wasm32", target_arch = "wasm64")))]
537 event_loop.run_app(&mut app)?;
538 #[cfg(any(target_arch = "wasm32", target_arch = "wasm64"))]
539 {
540 console_error_panic_hook::set_once();
541 winit::platform::web::EventLoopExtWebSys::spawn_app(event_loop, app);
542 }
543 Ok(())
544}
545
546fn run() -> AppResult<()> {
547 let event_loop = EventLoop::new()?;
548 let (_tx, _rx, app) = LaserOverlay::new();
549
550 #[cfg(any(feature = "server", feature = "client"))]
551 let window = app.window_handle();
552
553 #[cfg(feature = "server")]
554 tokio::spawn({
555 let window = window.clone();
556 let tx = _tx.clone();
557 async move {
558 let (server, send_tx) = ws::server::listen(3111, window, tx).await.unwrap();
559 LaserOverlay::start_mouse_listener(send_tx);
560 server.await;
561 }
562 });
563 #[cfg(feature = "client")]
564 {
565 #[cfg(not(any(target_arch = "wasm32", target_arch = "wasm64")))]
566 let _ = tokio_rustls::rustls::crypto::ring::default_provider().install_default();
567 let client_id = app.client_id;
568 let fut = async move {
569 ws::client::connect(env!("SERVER_URL"), window, _rx, _tx, client_id)
570 .await
571 .unwrap()
572 };
573 #[cfg(not(target_arch = "wasm32"))]
574 tokio::spawn(fut);
575 #[cfg(target_arch = "wasm32")]
576 wasm_bindgen_futures::spawn_local(fut);
577 }
578
579 run_app(event_loop, app)?;
580
581 Ok(())
582}
583
584#[cfg(not(target_arch = "wasm32"))]
585#[tokio::main]
586async fn main() -> AppResult<()> {
587 run()
588}
589
590#[cfg(target_arch = "wasm32")]
591fn main() -> AppResult<()> {
592 run()
593}