1use gpui::{
8 Anchor, AnyElement, App, Bounds, Decorations, Display, Edges, Element, GlobalElementId,
9 Half as _, HitboxBehavior, InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels,
10 Point, Position, Size, Style, Window, point, px,
11};
12
13use crate::Placement;
14
15#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
17pub enum Align {
18 Start,
20 #[default]
22 Center,
23 End,
25}
26
27#[derive(Clone, Copy, Debug, PartialEq)]
29enum Strategy {
30 Corner {
34 anchor: Anchor,
35 position: Point<Pixels>,
36 },
37 Side {
40 trigger_bounds: Bounds<Pixels>,
41 placement: Option<Placement>,
42 align: Align,
43 offset: Pixels,
44 },
45}
46
47#[derive(Clone, Copy, Debug, PartialEq)]
49pub struct ResolvedPosition {
50 pub bounds: Bounds<Pixels>,
52 pub placement: Option<Placement>,
55}
56
57pub struct Positioner {
63 strategy: Strategy,
64 margin: Pixels,
65 occlude: bool,
66 children: Vec<AnyElement>,
67}
68
69#[doc(hidden)]
70pub struct PositionerState {
71 child_layout_ids: Vec<LayoutId>,
72}
73
74impl Positioner {
75 pub fn side(trigger_bounds: Bounds<Pixels>) -> Self {
77 Self {
78 strategy: Strategy::Side {
79 trigger_bounds,
80 placement: None,
81 align: Align::Center,
82 offset: px(0.),
83 },
84 margin: px(4.),
85 occlude: false,
86 children: Vec::new(),
87 }
88 }
89
90 pub fn corner(anchor: Anchor, position: Point<Pixels>) -> Self {
96 Self {
97 strategy: Strategy::Corner { anchor, position },
98 margin: px(4.),
99 occlude: false,
100 children: Vec::new(),
101 }
102 }
103
104 pub fn placement(mut self, placement: Placement) -> Self {
106 if let Strategy::Side {
107 placement: slot, ..
108 } = &mut self.strategy
109 {
110 *slot = Some(placement);
111 }
112 self
113 }
114
115 pub fn align(mut self, align: Align) -> Self {
118 if let Strategy::Side { align: slot, .. } = &mut self.strategy {
119 *slot = align;
120 }
121 self
122 }
123
124 pub fn offset(mut self, offset: Pixels) -> Self {
127 if let Strategy::Side { offset: slot, .. } = &mut self.strategy {
128 *slot = offset;
129 }
130 self
131 }
132
133 pub fn occlude(mut self) -> Self {
140 self.occlude = true;
141 self
142 }
143
144 pub fn margin(mut self, margin: Pixels) -> Self {
146 self.margin = margin;
147 self
148 }
149}
150
151fn frame_insets(decorations: Decorations, client_inset: Pixels) -> Edges<Pixels> {
162 match decorations {
163 Decorations::Server => Edges::default(),
164 Decorations::Client { tiling } => Edges {
165 top: if tiling.top { px(0.) } else { client_inset },
166 right: if tiling.right { px(0.) } else { client_inset },
167 bottom: if tiling.bottom { px(0.) } else { client_inset },
168 left: if tiling.left { px(0.) } else { client_inset },
169 },
170 }
171}
172
173fn resolve(
179 strategy: Strategy,
180 popup_size: Size<Pixels>,
181 viewport_size: Size<Pixels>,
182 margin: Edges<Pixels>,
183) -> ResolvedPosition {
184 match strategy {
185 Strategy::Corner { anchor, position } => ResolvedPosition {
186 bounds: clamp(
187 Bounds::from_anchor_and_size(anchor, position, popup_size),
188 viewport_size,
189 margin,
190 ),
191 placement: None,
192 },
193 Strategy::Side {
194 trigger_bounds,
195 placement,
196 align,
197 offset,
198 } => {
199 let placement =
200 resolve_placement(trigger_bounds, popup_size, viewport_size, margin, placement);
201 let origin = side_origin(trigger_bounds, popup_size, placement, align, offset);
202 ResolvedPosition {
203 bounds: clamp(Bounds::new(origin, popup_size), viewport_size, margin),
204 placement: Some(placement),
205 }
206 }
207 }
208}
209
210fn resolve_placement(
211 trigger_bounds: Bounds<Pixels>,
212 popup_size: Size<Pixels>,
213 viewport_size: Size<Pixels>,
214 margin: Edges<Pixels>,
215 preferred: Option<Placement>,
216) -> Placement {
217 let right_limit = (viewport_size.width - margin.right).max(margin.left);
218 let bottom_limit = (viewport_size.height - margin.bottom).max(margin.top);
219 let available_left = (trigger_bounds.left() - margin.left).max(px(0.));
220 let available_right = (right_limit - trigger_bounds.right()).max(px(0.));
221 let available_above = (trigger_bounds.top() - margin.top).max(px(0.));
222 let available_below = (bottom_limit - trigger_bounds.bottom()).max(px(0.));
223
224 match preferred {
225 Some(Placement::Right) if popup_size.width <= available_right => Placement::Right,
226 Some(Placement::Right) if popup_size.width <= available_left => Placement::Left,
227 Some(Placement::Right) if available_right >= available_left => Placement::Right,
228 Some(Placement::Right) => Placement::Left,
229 Some(Placement::Left) if popup_size.width <= available_left => Placement::Left,
230 Some(Placement::Left) if popup_size.width <= available_right => Placement::Right,
231 Some(Placement::Left) if available_left >= available_right => Placement::Left,
232 Some(Placement::Left) => Placement::Right,
233 Some(Placement::Bottom) if popup_size.height <= available_below => Placement::Bottom,
234 Some(Placement::Bottom) if popup_size.height <= available_above => Placement::Top,
235 Some(Placement::Bottom) if available_below >= available_above => Placement::Bottom,
236 Some(Placement::Bottom) => Placement::Top,
237 Some(Placement::Top) | None if popup_size.height <= available_above => Placement::Top,
238 Some(Placement::Top) | None if popup_size.height <= available_below => Placement::Bottom,
239 Some(Placement::Top) | None if available_below >= available_above => Placement::Bottom,
240 Some(Placement::Top) | None => Placement::Top,
241 }
242}
243
244fn side_origin(
245 trigger_bounds: Bounds<Pixels>,
246 popup_size: Size<Pixels>,
247 placement: Placement,
248 align: Align,
249 offset: Pixels,
250) -> Point<Pixels> {
251 let aligned_x = match align {
252 Align::Start => trigger_bounds.left(),
253 Align::Center => trigger_bounds.center().x - popup_size.width.half(),
254 Align::End => trigger_bounds.right() - popup_size.width,
255 };
256 let aligned_y = match align {
257 Align::Start => trigger_bounds.top(),
258 Align::Center => trigger_bounds.center().y - popup_size.height.half(),
259 Align::End => trigger_bounds.bottom() - popup_size.height,
260 };
261
262 match placement {
263 Placement::Top => point(aligned_x, trigger_bounds.top() - popup_size.height - offset),
264 Placement::Bottom => point(aligned_x, trigger_bounds.bottom() + offset),
265 Placement::Left => point(trigger_bounds.left() - popup_size.width - offset, aligned_y),
266 Placement::Right => point(trigger_bounds.right() + offset, aligned_y),
267 }
268}
269
270fn clamp(
271 mut bounds: Bounds<Pixels>,
272 viewport_size: Size<Pixels>,
273 margin: Edges<Pixels>,
274) -> Bounds<Pixels> {
275 let right_limit = (viewport_size.width - margin.right).max(margin.left);
276 let bottom_limit = (viewport_size.height - margin.bottom).max(margin.top);
277
278 if bounds.right() > right_limit {
279 bounds.origin.x -= bounds.right() - right_limit;
280 }
281 if bounds.left() < margin.left {
282 bounds.origin.x = margin.left;
283 }
284 if bounds.bottom() > bottom_limit {
285 bounds.origin.y -= bounds.bottom() - bottom_limit;
286 }
287 if bounds.top() < margin.top {
288 bounds.origin.y = margin.top;
289 }
290
291 bounds
292}
293
294impl ParentElement for Positioner {
295 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
296 self.children.extend(elements);
297 }
298}
299
300impl Element for Positioner {
301 type RequestLayoutState = PositionerState;
302 type PrepaintState = ();
303
304 fn id(&self) -> Option<gpui::ElementId> {
305 None
306 }
307
308 fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
309 None
310 }
311
312 fn request_layout(
313 &mut self,
314 _: Option<&GlobalElementId>,
315 _: Option<&InspectorElementId>,
316 window: &mut Window,
317 cx: &mut App,
318 ) -> (LayoutId, Self::RequestLayoutState) {
319 let child_layout_ids = self
320 .children
321 .iter_mut()
322 .map(|child| child.request_layout(window, cx))
323 .collect::<Vec<_>>();
324 let layout_id = window.request_layout(
325 Style {
326 position: Position::Absolute,
327 display: Display::Flex,
328 ..Style::default()
329 },
330 child_layout_ids.iter().copied(),
331 cx,
332 );
333
334 (layout_id, PositionerState { child_layout_ids })
335 }
336
337 fn prepaint(
338 &mut self,
339 _: Option<&GlobalElementId>,
340 _: Option<&InspectorElementId>,
341 bounds: Bounds<Pixels>,
342 request_layout: &mut Self::RequestLayoutState,
343 window: &mut Window,
344 cx: &mut App,
345 ) {
346 if request_layout.child_layout_ids.is_empty() {
347 return;
348 }
349
350 let mut child_min = point(Pixels::MAX, Pixels::MAX);
351 let mut child_max = Point::default();
352 for child_layout_id in &request_layout.child_layout_ids {
353 let child_bounds = window.layout_bounds(*child_layout_id);
354 child_min = child_min.min(&child_bounds.origin);
355 child_max = child_max.max(&child_bounds.bottom_right());
356 }
357
358 let popup_size = (child_max - child_min).into();
359 let frame = frame_insets(
360 window.window_decorations(),
361 window.client_inset().unwrap_or(px(0.)),
362 );
363 let position = resolve(
364 self.strategy,
365 popup_size,
366 window.viewport_size(),
367 frame.map(|inset| *inset + self.margin),
368 );
369 if self.occlude {
372 window.insert_hitbox(position.bounds, HitboxBehavior::BlockMouse);
373 }
374
375 let offset = position.bounds.origin - bounds.origin;
376 let offset = point(offset.x.round(), offset.y.round());
377
378 window.with_element_offset(offset, |window| {
379 for child in &mut self.children {
380 child.prepaint(window, cx);
381 }
382 });
383 }
384
385 fn paint(
386 &mut self,
387 _: Option<&GlobalElementId>,
388 _: Option<&InspectorElementId>,
389 _: Bounds<Pixels>,
390 _: &mut Self::RequestLayoutState,
391 _: &mut Self::PrepaintState,
392 window: &mut Window,
393 cx: &mut App,
394 ) {
395 for child in &mut self.children {
396 child.paint(window, cx);
397 }
398 }
399}
400
401impl IntoElement for Positioner {
402 type Element = Self;
403
404 fn into_element(self) -> Self::Element {
405 self
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use gpui::Tiling;
413
414 const MARGIN: Pixels = px(4.);
415
416 fn trigger(x: f32, y: f32, w: f32, h: f32) -> Bounds<Pixels> {
417 Bounds::new(point(px(x), px(y)), Size::new(px(w), px(h)))
418 }
419
420 fn viewport() -> Size<Pixels> {
421 Size::new(px(500.), px(400.))
422 }
423
424 fn side(
425 trigger_bounds: Bounds<Pixels>,
426 placement: Option<Placement>,
427 align: Align,
428 popup: Size<Pixels>,
429 ) -> ResolvedPosition {
430 resolve(
431 Strategy::Side {
432 trigger_bounds,
433 placement,
434 align,
435 offset: px(0.),
436 },
437 popup,
438 viewport(),
439 Edges::all(MARGIN),
440 )
441 }
442
443 #[test]
444 fn prefers_the_requested_side_when_it_fits() {
445 let position = side(
446 trigger(200., 200., 40., 20.),
447 Some(Placement::Top),
448 Align::Center,
449 Size::new(px(80.), px(30.)),
450 );
451
452 assert_eq!(position.placement, Some(Placement::Top));
453 assert_eq!(position.bounds.bottom(), px(200.));
454 }
455
456 #[test]
457 fn flips_to_the_opposite_side_when_the_preferred_side_does_not_fit() {
458 let position = side(
459 trigger(200., 10., 40., 20.),
460 Some(Placement::Top),
461 Align::Center,
462 Size::new(px(80.), px(60.)),
463 );
464
465 assert_eq!(position.placement, Some(Placement::Bottom));
466 assert_eq!(position.bounds.top(), px(30.));
467 }
468
469 #[test]
470 fn clamps_into_the_viewport_while_keeping_the_flipped_side() {
471 let position = side(
472 trigger(480., 200., 40., 20.),
473 Some(Placement::Bottom),
474 Align::Center,
475 Size::new(px(120.), px(30.)),
476 );
477
478 assert_eq!(position.placement, Some(Placement::Bottom));
479 assert_eq!(position.bounds.right(), viewport().width - MARGIN);
480 }
481
482 #[test]
483 fn alignment_selects_the_leading_center_or_trailing_edge() {
484 let trigger_bounds = trigger(200., 200., 100., 20.);
485 let popup = Size::new(px(40.), px(30.));
486
487 let start = side(trigger_bounds, Some(Placement::Bottom), Align::Start, popup);
488 let center = side(
489 trigger_bounds,
490 Some(Placement::Bottom),
491 Align::Center,
492 popup,
493 );
494 let end = side(trigger_bounds, Some(Placement::Bottom), Align::End, popup);
495
496 assert_eq!(start.bounds.left(), px(200.));
497 assert_eq!(center.bounds.left(), px(230.));
498 assert_eq!(end.bounds.left(), px(260.));
499 }
500
501 #[test]
502 fn side_offset_adds_a_gap_between_trigger_and_popup() {
503 let position = resolve(
504 Strategy::Side {
505 trigger_bounds: trigger(200., 200., 40., 20.),
506 placement: Some(Placement::Bottom),
507 align: Align::Center,
508 offset: px(8.),
509 },
510 Size::new(px(40.), px(30.)),
511 viewport(),
512 Edges::all(MARGIN),
513 );
514
515 assert_eq!(position.bounds.top(), px(228.));
516 }
517
518 #[test]
519 fn corner_positioning_places_the_named_corner_and_never_reports_a_side() {
520 let position = resolve(
521 Strategy::Corner {
522 anchor: Anchor::TopLeft,
523 position: point(px(100.), px(100.)),
524 },
525 Size::new(px(40.), px(30.)),
526 viewport(),
527 Edges::all(MARGIN),
528 );
529
530 assert_eq!(position.placement, None);
531 assert_eq!(position.bounds.origin, point(px(100.), px(100.)));
532 }
533
534 #[test]
535 fn corner_positioning_clamps_but_does_not_flip() {
536 let position = resolve(
537 Strategy::Corner {
538 anchor: Anchor::TopLeft,
539 position: point(px(480.), px(390.)),
540 },
541 Size::new(px(40.), px(30.)),
542 viewport(),
543 Edges::all(MARGIN),
544 );
545
546 assert_eq!(position.placement, None);
547 assert_eq!(position.bounds.right(), viewport().width - MARGIN);
548 assert_eq!(position.bounds.bottom(), viewport().height - MARGIN);
549 }
550
551 #[test]
554 fn frame_insets_apply_the_client_inset_only_on_untiled_edges() {
555 let inset = px(20.);
556
557 assert_eq!(
558 frame_insets(Decorations::Server, inset),
559 Edges::default(),
560 "a server-decorated window has no frame to keep clear of"
561 );
562 assert_eq!(
563 frame_insets(
564 Decorations::Client {
565 tiling: Tiling::tiled()
566 },
567 inset
568 ),
569 Edges::default(),
570 "a window tiled on every side draws no shadow padding"
571 );
572 assert_eq!(
573 frame_insets(
574 Decorations::Client {
575 tiling: Tiling {
576 top: false,
577 left: true,
578 right: false,
579 bottom: true,
580 }
581 },
582 inset
583 ),
584 Edges {
585 top: inset,
586 right: inset,
587 bottom: px(0.),
588 left: px(0.),
589 }
590 );
591 }
592
593 #[test]
597 fn clamping_keeps_only_the_margin_on_a_tiled_edge() {
598 let popup = Size::new(px(120.), px(30.));
599 let corner = Strategy::Corner {
601 anchor: Anchor::TopRight,
602 position: point(viewport().width - px(10.), px(100.)),
603 };
604 let margin = Edges::all(MARGIN);
605 let inset = px(20.);
606
607 let tiled = resolve(corner, popup, viewport(), margin);
608 assert_eq!(tiled.bounds.right(), viewport().width - px(10.));
609
610 let untiled = resolve(
611 corner,
612 popup,
613 viewport(),
614 margin.map(|margin| *margin + inset),
615 );
616 assert_eq!(untiled.bounds.right(), viewport().width - MARGIN - inset);
617 }
618
619 const WINDOW_MARGIN: Pixels = px(4.);
623
624 fn tooltip_placement(
625 trigger_bounds: Bounds<Pixels>,
626 popup: Size<Pixels>,
627 viewport: Size<Pixels>,
628 margin: Pixels,
629 placement: Option<Placement>,
630 ) -> (Bounds<Pixels>, Placement) {
631 let resolved = resolve(
632 Strategy::Side {
633 trigger_bounds,
634 placement,
635 align: Align::Center,
636 offset: px(0.),
637 },
638 popup,
639 viewport,
640 Edges::all(margin),
641 );
642 (resolved.bounds, resolved.placement.unwrap())
643 }
644
645 fn bounds(x: f32, y: f32, width: f32, height: f32) -> Bounds<Pixels> {
646 Bounds::new(point(px(x), px(y)), Size::new(px(width), px(height)))
647 }
648
649 fn size_px(width: f32, height: f32) -> Size<Pixels> {
650 Size::new(px(width), px(height))
651 }
652
653 #[test]
654 fn prefers_above_when_space_allows() {
655 let trigger = bounds(100., 80., 80., 24.);
656 let position = tooltip_placement(
657 trigger,
658 size_px(120., 30.),
659 size_px(300., 200.),
660 WINDOW_MARGIN,
661 None,
662 );
663 assert_eq!(position.1, Placement::Top);
664 assert_eq!(position.0.origin, point(px(80.), px(50.)));
665 }
666 #[test]
667 fn flips_and_clamps_on_each_axis() {
668 let top = tooltip_placement(
669 bounds(24., 4., 120., 32.),
670 size_px(240., 32.),
671 size_px(520., 260.),
672 WINDOW_MARGIN,
673 None,
674 );
675 assert_eq!(top.1, Placement::Bottom);
676
677 let right = tooltip_placement(
678 bounds(260., 60., 32., 32.),
679 size_px(120., 30.),
680 size_px(300., 200.),
681 WINDOW_MARGIN,
682 Some(Placement::Right),
683 );
684 assert_eq!(right.1, Placement::Left);
685
686 let left_edge = tooltip_placement(
687 bounds(4., 80., 24., 24.),
688 size_px(120., 30.),
689 size_px(300., 200.),
690 WINDOW_MARGIN,
691 None,
692 );
693 assert_eq!(left_edge.0.left(), WINDOW_MARGIN);
694 }
695 #[test]
696 fn places_tooltip_to_the_right() {
697 let trigger = bounds(20., 60., 32., 32.);
698 let position = tooltip_placement(
699 trigger,
700 size_px(120., 30.),
701 size_px(300., 200.),
702 WINDOW_MARGIN,
703 Some(Placement::Right),
704 );
705 assert_eq!(position.1, Placement::Right);
706 assert_eq!(position.0.left(), trigger.right());
707 assert_eq!(position.0.center().y, trigger.center().y);
708 }
709 #[test]
710 fn right_placement_clamps_vertical_edges() {
711 let trigger = bounds(20., 2., 32., 20.);
712 let position = tooltip_placement(
713 trigger,
714 size_px(120., 40.),
715 size_px(300., 200.),
716 WINDOW_MARGIN,
717 Some(Placement::Right),
718 );
719 assert_eq!(position.1, Placement::Right);
720 assert_eq!(position.0.top(), WINDOW_MARGIN);
721 assert_eq!(position.0.left(), trigger.right());
722 }
723 #[test]
724 fn uses_larger_side_when_neither_vertical_side_fits() {
725 let position = tooltip_placement(
726 bounds(120., 20., 40., 20.),
727 size_px(160., 120.),
728 size_px(300., 100.),
729 WINDOW_MARGIN,
730 None,
731 );
732 assert_eq!(position.1, Placement::Bottom);
733 assert_eq!(position.0.top(), WINDOW_MARGIN);
734 assert_eq!(position.0.left(), px(60.));
735 }
736}