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