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