1use dioxus::prelude::*;
2
3const OVERLAY_WATCH_SCRIPT: &str = r##"
15(function() {
16 const state = window.__dbcssOverlayAnchor = window.__dbcssOverlayAnchor || {
17 revision: 0,
18 installed: false
19 };
20
21 if (state.installed) {
22 return;
23 }
24 state.installed = true;
25
26 let frame = null;
27 const bump = function() {
28 if (frame !== null) {
29 return;
30 }
31 frame = requestAnimationFrame(function() {
32 frame = null;
33 state.revision += 1;
34 window.dispatchEvent(new CustomEvent("dbcss:overlay-anchor"));
35 });
36 };
37
38 window.addEventListener("scroll", bump, { passive: true, capture: true });
39 window.addEventListener("resize", bump, { passive: true });
40})();
41"##;
42
43const OVERLAY_EVENT_SCRIPT: &str = r##"
45const last = __LAST__;
46
47return new Promise(function(resolve) {
48 const state = window.__dbcssOverlayAnchor;
49 if (!state) {
50 resolve(last);
51 return;
52 }
53 if (state.revision !== last) {
54 resolve(state.revision);
55 return;
56 }
57
58 const handler = function() {
59 window.removeEventListener("dbcss:overlay-anchor", handler);
60 resolve(window.__dbcssOverlayAnchor.revision);
61 };
62
63 window.addEventListener("dbcss:overlay-anchor", handler);
64});
65"##;
66
67pub fn install_overlay_anchor_watch() {
69 let _ = document::eval(OVERLAY_WATCH_SCRIPT);
70}
71
72pub async fn next_overlay_anchor_revision(last: u64) -> Option<u64> {
75 let script = OVERLAY_EVENT_SCRIPT.replace("__LAST__", &last.to_string());
76 let value = document::eval(&script).await.ok()?;
77 value.as_f64().map(|revision| revision as u64)
78}
79
80#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
82pub enum OverlayPlacement {
83 Auto,
85 #[default]
87 Top,
88 Bottom,
90 Start,
92 End,
94}
95
96impl OverlayPlacement {
97 pub const DEFAULT_FALLBACKS: [OverlayPlacement; 4] = [
99 OverlayPlacement::Top,
100 OverlayPlacement::End,
101 OverlayPlacement::Bottom,
102 OverlayPlacement::Start,
103 ];
104}
105
106#[derive(Clone, Copy, Debug, Default, PartialEq)]
108pub struct OverlayRect {
109 pub x: f64,
110 pub y: f64,
111 pub width: f64,
112 pub height: f64,
113}
114
115impl OverlayRect {
116 pub const fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
117 Self {
118 x,
119 y,
120 width,
121 height,
122 }
123 }
124
125 pub fn right(self) -> f64 {
126 self.x + self.width
127 }
128
129 pub fn intersects(self, other: Self) -> bool {
132 self.x < other.right()
133 && other.x < self.right()
134 && self.y < other.bottom()
135 && other.y < self.bottom()
136 }
137
138 pub fn bottom(self) -> f64 {
139 self.y + self.height
140 }
141
142 pub fn center_x(self) -> f64 {
143 self.x + self.width / 2.0
144 }
145
146 pub fn center_y(self) -> f64 {
147 self.y + self.height / 2.0
148 }
149}
150
151#[derive(Clone, Copy, Debug, Default, PartialEq)]
153pub struct OverlayOffset {
154 pub skidding: f64,
156 pub distance: f64,
158}
159
160impl OverlayOffset {
161 pub const ZERO: Self = Self {
162 skidding: 0.0,
163 distance: 0.0,
164 };
165
166 pub const TOOLTIP: Self = Self {
168 skidding: 0.0,
169 distance: 6.0,
170 };
171
172 pub const POPOVER: Self = Self {
174 skidding: 0.0,
175 distance: 8.0,
176 };
177}
178
179const ARROW_SIZE: f64 = 16.0;
181const ARROW_EDGE_INSET: f64 = 8.0;
184
185#[derive(Clone, Copy, Debug, PartialEq)]
187pub struct OverlayPosition {
188 pub x: f64,
189 pub y: f64,
190 pub placement: OverlayPlacement,
191 pub fits: bool,
193 pub trigger_visible: bool,
201 pub arrow: f64,
206}
207
208impl OverlayPosition {
209 pub fn rect(self, overlay_size: OverlayRect) -> OverlayRect {
210 OverlayRect::new(self.x, self.y, overlay_size.width, overlay_size.height)
211 }
212}
213
214fn arrow_offset(trigger: OverlayRect, rect: OverlayRect, placement: OverlayPlacement) -> f64 {
217 let (target, cross_size) = match placement {
218 OverlayPlacement::Start | OverlayPlacement::End => {
219 (trigger.center_y() - rect.y, rect.height)
220 }
221 _ => (trigger.center_x() - rect.x, rect.width),
223 };
224 let lo = ARROW_EDGE_INSET + ARROW_SIZE / 2.0;
225 let hi = (cross_size - ARROW_EDGE_INSET - ARROW_SIZE / 2.0).max(lo);
226 target.clamp(lo, hi)
227}
228
229pub fn calculate_overlay_position(
235 trigger: OverlayRect,
236 overlay_size: OverlayRect,
237 boundary: OverlayRect,
238 requested: OverlayPlacement,
239 fallback_placements: &[OverlayPlacement],
240 offset: OverlayOffset,
241 boundary_padding: f64,
242) -> OverlayPosition {
243 let trigger_visible = trigger.intersects(boundary);
244 let candidates = candidate_placements(requested, fallback_placements);
245 let mut best: Option<(OverlayPlacement, OverlayRect, f64)> = None;
246
247 for placement in candidates {
248 let rect = placed_rect(trigger, overlay_size, placement, offset);
249 if fits_boundary(rect, boundary, boundary_padding) {
250 return OverlayPosition {
251 x: rect.x,
252 y: rect.y,
253 placement,
254 fits: true,
255 trigger_visible,
256 arrow: arrow_offset(trigger, rect, placement),
257 };
258 }
259
260 let visible = visible_area(rect, boundary, boundary_padding);
261 if best
262 .map(|(_, _, best_visible)| visible > best_visible)
263 .unwrap_or(true)
264 {
265 best = Some((placement, rect, visible));
266 }
267 }
268
269 let (placement, rect, _) = best.unwrap_or_else(|| {
270 let placement = OverlayPlacement::Top;
271 (
272 placement,
273 placed_rect(trigger, overlay_size, placement, offset),
274 0.0,
275 )
276 });
277 let clamped = clamp_to_boundary(rect, boundary, boundary_padding);
278
279 OverlayPosition {
280 x: clamped.x,
281 y: clamped.y,
282 placement,
283 fits: false,
284 trigger_visible,
285 arrow: arrow_offset(trigger, clamped, placement),
286 }
287}
288
289fn candidate_placements(
290 requested: OverlayPlacement,
291 fallback_placements: &[OverlayPlacement],
292) -> Vec<OverlayPlacement> {
293 let mut candidates = Vec::new();
294
295 if requested == OverlayPlacement::Auto {
296 push_candidates(&mut candidates, fallback_placements);
297 if candidates.is_empty() {
298 push_candidates(&mut candidates, &OverlayPlacement::DEFAULT_FALLBACKS);
299 }
300 } else {
301 candidates.push(requested);
302 push_candidates(&mut candidates, fallback_placements);
303 }
304
305 candidates
306}
307
308fn push_candidates(candidates: &mut Vec<OverlayPlacement>, placements: &[OverlayPlacement]) {
309 for placement in placements {
310 if *placement != OverlayPlacement::Auto && !candidates.contains(placement) {
311 candidates.push(*placement);
312 }
313 }
314}
315
316fn placed_rect(
317 trigger: OverlayRect,
318 overlay_size: OverlayRect,
319 placement: OverlayPlacement,
320 offset: OverlayOffset,
321) -> OverlayRect {
322 match placement {
323 OverlayPlacement::Auto => placed_rect(trigger, overlay_size, OverlayPlacement::Top, offset),
324 OverlayPlacement::Top => OverlayRect::new(
325 trigger.center_x() - overlay_size.width / 2.0 + offset.skidding,
326 trigger.y - overlay_size.height - offset.distance,
327 overlay_size.width,
328 overlay_size.height,
329 ),
330 OverlayPlacement::Bottom => OverlayRect::new(
331 trigger.center_x() - overlay_size.width / 2.0 + offset.skidding,
332 trigger.bottom() + offset.distance,
333 overlay_size.width,
334 overlay_size.height,
335 ),
336 OverlayPlacement::Start => OverlayRect::new(
337 trigger.x - overlay_size.width - offset.distance,
338 trigger.center_y() - overlay_size.height / 2.0 + offset.skidding,
339 overlay_size.width,
340 overlay_size.height,
341 ),
342 OverlayPlacement::End => OverlayRect::new(
343 trigger.right() + offset.distance,
344 trigger.center_y() - overlay_size.height / 2.0 + offset.skidding,
345 overlay_size.width,
346 overlay_size.height,
347 ),
348 }
349}
350
351fn fits_boundary(rect: OverlayRect, boundary: OverlayRect, padding: f64) -> bool {
352 rect.x >= boundary.x + padding
353 && rect.y >= boundary.y + padding
354 && rect.right() <= boundary.right() - padding
355 && rect.bottom() <= boundary.bottom() - padding
356}
357
358fn visible_area(rect: OverlayRect, boundary: OverlayRect, padding: f64) -> f64 {
359 let min_x = boundary.x + padding;
360 let min_y = boundary.y + padding;
361 let max_x = boundary.right() - padding;
362 let max_y = boundary.bottom() - padding;
363
364 let width = (rect.right().min(max_x) - rect.x.max(min_x)).max(0.0);
365 let height = (rect.bottom().min(max_y) - rect.y.max(min_y)).max(0.0);
366 width * height
367}
368
369fn clamp_to_boundary(rect: OverlayRect, boundary: OverlayRect, padding: f64) -> OverlayRect {
370 let min_x = boundary.x + padding;
371 let min_y = boundary.y + padding;
372 let max_x = (boundary.right() - padding - rect.width).max(min_x);
373 let max_y = (boundary.bottom() - padding - rect.height).max(min_y);
374
375 OverlayRect::new(
376 rect.x.clamp(min_x, max_x),
377 rect.y.clamp(min_y, max_y),
378 rect.width,
379 rect.height,
380 )
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 #[test]
394 fn trigger_inside_the_boundary_is_visible() {
395 let position = calculate_overlay_position(
396 OverlayRect::new(100.0, 100.0, 40.0, 20.0),
397 OverlayRect::new(0.0, 0.0, 80.0, 30.0),
398 OverlayRect::new(0.0, 0.0, 300.0, 300.0),
399 OverlayPlacement::Top,
400 &[],
401 OverlayOffset::default(),
402 8.0,
403 );
404 assert!(position.trigger_visible);
405 }
406
407 #[test]
408 fn trigger_below_the_fold_is_not_visible() {
409 let position = calculate_overlay_position(
412 OverlayRect::new(225.0, 3389.0, 128.0, 38.0),
413 OverlayRect::new(0.0, 0.0, 200.0, 29.0),
414 OverlayRect::new(0.0, 0.0, 1280.0, 800.0),
415 OverlayPlacement::Bottom,
416 &[],
417 OverlayOffset::default(),
418 8.0,
419 );
420 assert!(
421 !position.trigger_visible,
422 "a trigger 3389px down a 800px viewport is off-screen"
423 );
424 }
425
426 #[test]
427 fn trigger_above_the_fold_is_not_visible() {
428 let position = calculate_overlay_position(
429 OverlayRect::new(100.0, -400.0, 40.0, 20.0),
430 OverlayRect::new(0.0, 0.0, 80.0, 30.0),
431 OverlayRect::new(0.0, 0.0, 300.0, 300.0),
432 OverlayPlacement::Top,
433 &[],
434 OverlayOffset::default(),
435 8.0,
436 );
437 assert!(!position.trigger_visible);
438 }
439
440 #[test]
441 fn trigger_scrolled_off_to_the_side_is_not_visible() {
442 let position = calculate_overlay_position(
443 OverlayRect::new(-500.0, 100.0, 40.0, 20.0),
444 OverlayRect::new(0.0, 0.0, 80.0, 30.0),
445 OverlayRect::new(0.0, 0.0, 300.0, 300.0),
446 OverlayPlacement::Top,
447 &[],
448 OverlayOffset::default(),
449 8.0,
450 );
451 assert!(!position.trigger_visible);
452 }
453
454 #[test]
455 fn a_trigger_flush_against_the_edge_does_not_count_as_visible() {
456 let position = calculate_overlay_position(
459 OverlayRect::new(100.0, 300.0, 40.0, 20.0),
460 OverlayRect::new(0.0, 0.0, 80.0, 30.0),
461 OverlayRect::new(0.0, 0.0, 300.0, 300.0),
462 OverlayPlacement::Top,
463 &[],
464 OverlayOffset::default(),
465 8.0,
466 );
467 assert!(!position.trigger_visible);
468 }
469
470 #[test]
471 fn partially_visible_trigger_still_counts() {
472 let position = calculate_overlay_position(
474 OverlayRect::new(100.0, 290.0, 40.0, 20.0),
475 OverlayRect::new(0.0, 0.0, 80.0, 30.0),
476 OverlayRect::new(0.0, 0.0, 300.0, 300.0),
477 OverlayPlacement::Top,
478 &[],
479 OverlayOffset::default(),
480 8.0,
481 );
482 assert!(position.trigger_visible);
483 }
484
485 fn trigger() -> OverlayRect {
486 OverlayRect::new(100.0, 100.0, 40.0, 20.0)
487 }
488
489 fn overlay() -> OverlayRect {
490 OverlayRect::new(0.0, 0.0, 80.0, 30.0)
491 }
492
493 fn boundary() -> OverlayRect {
494 OverlayRect::new(0.0, 0.0, 300.0, 300.0)
495 }
496
497 #[test]
498 fn requested_top_fits() {
499 let position = calculate_overlay_position(
500 trigger(),
501 overlay(),
502 boundary(),
503 OverlayPlacement::Top,
504 &OverlayPlacement::DEFAULT_FALLBACKS,
505 OverlayOffset::TOOLTIP,
506 0.0,
507 );
508
509 assert_eq!(position.placement, OverlayPlacement::Top);
510 assert!(position.fits);
511 assert_eq!(position.x, 80.0);
512 assert_eq!(position.y, 64.0);
513 }
514
515 #[test]
516 fn offset_skids_on_cross_axis() {
517 let position = calculate_overlay_position(
518 trigger(),
519 overlay(),
520 boundary(),
521 OverlayPlacement::Bottom,
522 &[],
523 OverlayOffset {
524 skidding: 10.0,
525 distance: 12.0,
526 },
527 0.0,
528 );
529
530 assert_eq!(position.placement, OverlayPlacement::Bottom);
531 assert!(position.fits);
532 assert_eq!(position.x, 90.0);
533 assert_eq!(position.y, 132.0);
534 }
535
536 #[test]
537 fn falls_back_when_requested_placement_overflows() {
538 let edge_trigger = OverlayRect::new(100.0, 10.0, 40.0, 20.0);
539 let position = calculate_overlay_position(
540 edge_trigger,
541 overlay(),
542 boundary(),
543 OverlayPlacement::Top,
544 &[OverlayPlacement::Bottom, OverlayPlacement::End],
545 OverlayOffset::TOOLTIP,
546 0.0,
547 );
548
549 assert_eq!(position.placement, OverlayPlacement::Bottom);
550 assert!(position.fits);
551 assert_eq!(position.y, 36.0);
552 }
553
554 #[test]
555 fn auto_uses_first_fitting_fallback() {
556 let edge_trigger = OverlayRect::new(100.0, 10.0, 40.0, 20.0);
557 let position = calculate_overlay_position(
558 edge_trigger,
559 overlay(),
560 boundary(),
561 OverlayPlacement::Auto,
562 &[
563 OverlayPlacement::Top,
564 OverlayPlacement::Bottom,
565 OverlayPlacement::End,
566 ],
567 OverlayOffset::TOOLTIP,
568 0.0,
569 );
570
571 assert_eq!(position.placement, OverlayPlacement::Bottom);
572 assert!(position.fits);
573 }
574
575 #[test]
576 fn start_and_end_place_on_inline_axis() {
577 let start = calculate_overlay_position(
578 trigger(),
579 overlay(),
580 boundary(),
581 OverlayPlacement::Start,
582 &[],
583 OverlayOffset::POPOVER,
584 0.0,
585 );
586 let end = calculate_overlay_position(
587 trigger(),
588 overlay(),
589 boundary(),
590 OverlayPlacement::End,
591 &[],
592 OverlayOffset::POPOVER,
593 0.0,
594 );
595
596 assert_eq!(start.x, 12.0);
597 assert_eq!(start.y, 95.0);
598 assert_eq!(end.x, 148.0);
599 assert_eq!(end.y, 95.0);
600 }
601
602 #[test]
603 fn clamps_best_candidate_to_boundary_padding() {
604 let edge_trigger = OverlayRect::new(0.0, 120.0, 20.0, 20.0);
605 let position = calculate_overlay_position(
606 edge_trigger,
607 overlay(),
608 boundary(),
609 OverlayPlacement::Top,
610 &[],
611 OverlayOffset::ZERO,
612 8.0,
613 );
614
615 assert_eq!(position.placement, OverlayPlacement::Top);
616 assert!(!position.fits);
617 assert_eq!(position.x, 8.0);
618 assert_eq!(position.y, 90.0);
619 }
620
621 #[test]
622 fn arrow_is_centred_when_overlay_fits() {
623 let position = calculate_overlay_position(
625 OverlayRect::new(130.0, 20.0, 40.0, 20.0), OverlayRect::new(0.0, 0.0, 80.0, 30.0),
627 boundary(),
628 OverlayPlacement::Bottom,
629 &[],
630 OverlayOffset::ZERO,
631 0.0,
632 );
633 assert!(position.fits);
634 assert_eq!(position.x, 110.0);
635 assert_eq!(position.arrow, 40.0);
637 }
638
639 #[test]
640 fn arrow_tracks_trigger_after_horizontal_clamp() {
641 let position = calculate_overlay_position(
645 OverlayRect::new(280.0, 20.0, 20.0, 20.0), OverlayRect::new(0.0, 0.0, 120.0, 60.0),
647 boundary(), OverlayPlacement::Bottom,
649 &[],
650 OverlayOffset::ZERO,
651 0.0,
652 );
653 assert_eq!(position.placement, OverlayPlacement::Bottom);
654 assert!(!position.fits);
655 assert_eq!(position.x, 180.0); assert_eq!(position.arrow, 104.0);
659 }
660
661 #[test]
662 fn clamps_oversized_overlay_to_boundary_start() {
663 let oversized = OverlayRect::new(0.0, 0.0, 400.0, 400.0);
664 let position = calculate_overlay_position(
665 trigger(),
666 oversized,
667 boundary(),
668 OverlayPlacement::Bottom,
669 &[],
670 OverlayOffset::ZERO,
671 8.0,
672 );
673
674 assert!(!position.fits);
675 assert_eq!(position.x, 8.0);
676 assert_eq!(position.y, 8.0);
677 }
678}