1use gpui::{
8 Anchor, AnyElement, App, Bounds, Display, Element, GlobalElementId, Half as _, HitboxBehavior,
9 InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Point, Position, Size, Style,
10 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 resolve(
157 strategy: Strategy,
158 popup_size: Size<Pixels>,
159 viewport_size: Size<Pixels>,
160 margin: Pixels,
161) -> ResolvedPosition {
162 match strategy {
163 Strategy::Corner { anchor, position } => ResolvedPosition {
164 bounds: clamp(
165 Bounds::from_anchor_and_size(anchor, position, popup_size),
166 viewport_size,
167 margin,
168 ),
169 placement: None,
170 },
171 Strategy::Side {
172 trigger_bounds,
173 placement,
174 align,
175 offset,
176 } => {
177 let placement =
178 resolve_placement(trigger_bounds, popup_size, viewport_size, margin, placement);
179 let origin = side_origin(trigger_bounds, popup_size, placement, align, offset);
180 ResolvedPosition {
181 bounds: clamp(Bounds::new(origin, popup_size), viewport_size, margin),
182 placement: Some(placement),
183 }
184 }
185 }
186}
187
188fn resolve_placement(
189 trigger_bounds: Bounds<Pixels>,
190 popup_size: Size<Pixels>,
191 viewport_size: Size<Pixels>,
192 margin: Pixels,
193 preferred: Option<Placement>,
194) -> Placement {
195 let right_limit = (viewport_size.width - margin).max(margin);
196 let bottom_limit = (viewport_size.height - margin).max(margin);
197 let available_left = (trigger_bounds.left() - margin).max(px(0.));
198 let available_right = (right_limit - trigger_bounds.right()).max(px(0.));
199 let available_above = (trigger_bounds.top() - margin).max(px(0.));
200 let available_below = (bottom_limit - trigger_bounds.bottom()).max(px(0.));
201
202 match preferred {
203 Some(Placement::Right) if popup_size.width <= available_right => Placement::Right,
204 Some(Placement::Right) if popup_size.width <= available_left => Placement::Left,
205 Some(Placement::Right) if available_right >= available_left => Placement::Right,
206 Some(Placement::Right) => Placement::Left,
207 Some(Placement::Left) if popup_size.width <= available_left => Placement::Left,
208 Some(Placement::Left) if popup_size.width <= available_right => Placement::Right,
209 Some(Placement::Left) if available_left >= available_right => Placement::Left,
210 Some(Placement::Left) => Placement::Right,
211 Some(Placement::Bottom) if popup_size.height <= available_below => Placement::Bottom,
212 Some(Placement::Bottom) if popup_size.height <= available_above => Placement::Top,
213 Some(Placement::Bottom) if available_below >= available_above => Placement::Bottom,
214 Some(Placement::Bottom) => Placement::Top,
215 Some(Placement::Top) | None if popup_size.height <= available_above => Placement::Top,
216 Some(Placement::Top) | None if popup_size.height <= available_below => Placement::Bottom,
217 Some(Placement::Top) | None if available_below >= available_above => Placement::Bottom,
218 Some(Placement::Top) | None => Placement::Top,
219 }
220}
221
222fn side_origin(
223 trigger_bounds: Bounds<Pixels>,
224 popup_size: Size<Pixels>,
225 placement: Placement,
226 align: Align,
227 offset: Pixels,
228) -> Point<Pixels> {
229 let aligned_x = match align {
230 Align::Start => trigger_bounds.left(),
231 Align::Center => trigger_bounds.center().x - popup_size.width.half(),
232 Align::End => trigger_bounds.right() - popup_size.width,
233 };
234 let aligned_y = match align {
235 Align::Start => trigger_bounds.top(),
236 Align::Center => trigger_bounds.center().y - popup_size.height.half(),
237 Align::End => trigger_bounds.bottom() - popup_size.height,
238 };
239
240 match placement {
241 Placement::Top => point(aligned_x, trigger_bounds.top() - popup_size.height - offset),
242 Placement::Bottom => point(aligned_x, trigger_bounds.bottom() + offset),
243 Placement::Left => point(trigger_bounds.left() - popup_size.width - offset, aligned_y),
244 Placement::Right => point(trigger_bounds.right() + offset, aligned_y),
245 }
246}
247
248fn clamp(
249 mut bounds: Bounds<Pixels>,
250 viewport_size: Size<Pixels>,
251 margin: Pixels,
252) -> Bounds<Pixels> {
253 let right_limit = (viewport_size.width - margin).max(margin);
254 let bottom_limit = (viewport_size.height - margin).max(margin);
255
256 if bounds.right() > right_limit {
257 bounds.origin.x -= bounds.right() - right_limit;
258 }
259 if bounds.left() < margin {
260 bounds.origin.x = margin;
261 }
262 if bounds.bottom() > bottom_limit {
263 bounds.origin.y -= bounds.bottom() - bottom_limit;
264 }
265 if bounds.top() < margin {
266 bounds.origin.y = margin;
267 }
268
269 bounds
270}
271
272impl ParentElement for Positioner {
273 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
274 self.children.extend(elements);
275 }
276}
277
278impl Element for Positioner {
279 type RequestLayoutState = PositionerState;
280 type PrepaintState = ();
281
282 fn id(&self) -> Option<gpui::ElementId> {
283 None
284 }
285
286 fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
287 None
288 }
289
290 fn request_layout(
291 &mut self,
292 _: Option<&GlobalElementId>,
293 _: Option<&InspectorElementId>,
294 window: &mut Window,
295 cx: &mut App,
296 ) -> (LayoutId, Self::RequestLayoutState) {
297 let child_layout_ids = self
298 .children
299 .iter_mut()
300 .map(|child| child.request_layout(window, cx))
301 .collect::<Vec<_>>();
302 let layout_id = window.request_layout(
303 Style {
304 position: Position::Absolute,
305 display: Display::Flex,
306 ..Style::default()
307 },
308 child_layout_ids.iter().copied(),
309 cx,
310 );
311
312 (layout_id, PositionerState { child_layout_ids })
313 }
314
315 fn prepaint(
316 &mut self,
317 _: Option<&GlobalElementId>,
318 _: Option<&InspectorElementId>,
319 bounds: Bounds<Pixels>,
320 request_layout: &mut Self::RequestLayoutState,
321 window: &mut Window,
322 cx: &mut App,
323 ) {
324 if request_layout.child_layout_ids.is_empty() {
325 return;
326 }
327
328 let mut child_min = point(Pixels::MAX, Pixels::MAX);
329 let mut child_max = Point::default();
330 for child_layout_id in &request_layout.child_layout_ids {
331 let child_bounds = window.layout_bounds(*child_layout_id);
332 child_min = child_min.min(&child_bounds.origin);
333 child_max = child_max.max(&child_bounds.bottom_right());
334 }
335
336 let popup_size = (child_max - child_min).into();
337 let client_inset = window.client_inset().unwrap_or(px(0.));
338 let position = resolve(
339 self.strategy,
340 popup_size,
341 window.viewport_size(),
342 self.margin + client_inset,
343 );
344 if self.occlude {
347 window.insert_hitbox(position.bounds, HitboxBehavior::BlockMouse);
348 }
349
350 let offset = position.bounds.origin - bounds.origin;
351 let offset = point(offset.x.round(), offset.y.round());
352
353 window.with_element_offset(offset, |window| {
354 for child in &mut self.children {
355 child.prepaint(window, cx);
356 }
357 });
358 }
359
360 fn paint(
361 &mut self,
362 _: Option<&GlobalElementId>,
363 _: Option<&InspectorElementId>,
364 _: Bounds<Pixels>,
365 _: &mut Self::RequestLayoutState,
366 _: &mut Self::PrepaintState,
367 window: &mut Window,
368 cx: &mut App,
369 ) {
370 for child in &mut self.children {
371 child.paint(window, cx);
372 }
373 }
374}
375
376impl IntoElement for Positioner {
377 type Element = Self;
378
379 fn into_element(self) -> Self::Element {
380 self
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 const MARGIN: Pixels = px(4.);
389
390 fn trigger(x: f32, y: f32, w: f32, h: f32) -> Bounds<Pixels> {
391 Bounds::new(point(px(x), px(y)), Size::new(px(w), px(h)))
392 }
393
394 fn viewport() -> Size<Pixels> {
395 Size::new(px(500.), px(400.))
396 }
397
398 fn side(
399 trigger_bounds: Bounds<Pixels>,
400 placement: Option<Placement>,
401 align: Align,
402 popup: Size<Pixels>,
403 ) -> ResolvedPosition {
404 resolve(
405 Strategy::Side {
406 trigger_bounds,
407 placement,
408 align,
409 offset: px(0.),
410 },
411 popup,
412 viewport(),
413 MARGIN,
414 )
415 }
416
417 #[test]
418 fn prefers_the_requested_side_when_it_fits() {
419 let position = side(
420 trigger(200., 200., 40., 20.),
421 Some(Placement::Top),
422 Align::Center,
423 Size::new(px(80.), px(30.)),
424 );
425
426 assert_eq!(position.placement, Some(Placement::Top));
427 assert_eq!(position.bounds.bottom(), px(200.));
428 }
429
430 #[test]
431 fn flips_to_the_opposite_side_when_the_preferred_side_does_not_fit() {
432 let position = side(
433 trigger(200., 10., 40., 20.),
434 Some(Placement::Top),
435 Align::Center,
436 Size::new(px(80.), px(60.)),
437 );
438
439 assert_eq!(position.placement, Some(Placement::Bottom));
440 assert_eq!(position.bounds.top(), px(30.));
441 }
442
443 #[test]
444 fn clamps_into_the_viewport_while_keeping_the_flipped_side() {
445 let position = side(
446 trigger(480., 200., 40., 20.),
447 Some(Placement::Bottom),
448 Align::Center,
449 Size::new(px(120.), px(30.)),
450 );
451
452 assert_eq!(position.placement, Some(Placement::Bottom));
453 assert_eq!(position.bounds.right(), viewport().width - MARGIN);
454 }
455
456 #[test]
457 fn alignment_selects_the_leading_center_or_trailing_edge() {
458 let trigger_bounds = trigger(200., 200., 100., 20.);
459 let popup = Size::new(px(40.), px(30.));
460
461 let start = side(trigger_bounds, Some(Placement::Bottom), Align::Start, popup);
462 let center = side(
463 trigger_bounds,
464 Some(Placement::Bottom),
465 Align::Center,
466 popup,
467 );
468 let end = side(trigger_bounds, Some(Placement::Bottom), Align::End, popup);
469
470 assert_eq!(start.bounds.left(), px(200.));
471 assert_eq!(center.bounds.left(), px(230.));
472 assert_eq!(end.bounds.left(), px(260.));
473 }
474
475 #[test]
476 fn side_offset_adds_a_gap_between_trigger_and_popup() {
477 let position = resolve(
478 Strategy::Side {
479 trigger_bounds: trigger(200., 200., 40., 20.),
480 placement: Some(Placement::Bottom),
481 align: Align::Center,
482 offset: px(8.),
483 },
484 Size::new(px(40.), px(30.)),
485 viewport(),
486 MARGIN,
487 );
488
489 assert_eq!(position.bounds.top(), px(228.));
490 }
491
492 #[test]
493 fn corner_positioning_places_the_named_corner_and_never_reports_a_side() {
494 let position = resolve(
495 Strategy::Corner {
496 anchor: Anchor::TopLeft,
497 position: point(px(100.), px(100.)),
498 },
499 Size::new(px(40.), px(30.)),
500 viewport(),
501 MARGIN,
502 );
503
504 assert_eq!(position.placement, None);
505 assert_eq!(position.bounds.origin, point(px(100.), px(100.)));
506 }
507
508 #[test]
509 fn corner_positioning_clamps_but_does_not_flip() {
510 let position = resolve(
511 Strategy::Corner {
512 anchor: Anchor::TopLeft,
513 position: point(px(480.), px(390.)),
514 },
515 Size::new(px(40.), px(30.)),
516 viewport(),
517 MARGIN,
518 );
519
520 assert_eq!(position.placement, None);
521 assert_eq!(position.bounds.right(), viewport().width - MARGIN);
522 assert_eq!(position.bounds.bottom(), viewport().height - MARGIN);
523 }
524
525 const WINDOW_MARGIN: Pixels = px(4.);
529
530 fn tooltip_placement(
531 trigger_bounds: Bounds<Pixels>,
532 popup: Size<Pixels>,
533 viewport: Size<Pixels>,
534 margin: Pixels,
535 placement: Option<Placement>,
536 ) -> (Bounds<Pixels>, Placement) {
537 let resolved = resolve(
538 Strategy::Side {
539 trigger_bounds,
540 placement,
541 align: Align::Center,
542 offset: px(0.),
543 },
544 popup,
545 viewport,
546 margin,
547 );
548 (resolved.bounds, resolved.placement.unwrap())
549 }
550
551 fn bounds(x: f32, y: f32, width: f32, height: f32) -> Bounds<Pixels> {
552 Bounds::new(point(px(x), px(y)), Size::new(px(width), px(height)))
553 }
554
555 fn size_px(width: f32, height: f32) -> Size<Pixels> {
556 Size::new(px(width), px(height))
557 }
558
559 #[test]
560 fn prefers_above_when_space_allows() {
561 let trigger = bounds(100., 80., 80., 24.);
562 let position = tooltip_placement(
563 trigger,
564 size_px(120., 30.),
565 size_px(300., 200.),
566 WINDOW_MARGIN,
567 None,
568 );
569 assert_eq!(position.1, Placement::Top);
570 assert_eq!(position.0.origin, point(px(80.), px(50.)));
571 }
572 #[test]
573 fn flips_and_clamps_on_each_axis() {
574 let top = tooltip_placement(
575 bounds(24., 4., 120., 32.),
576 size_px(240., 32.),
577 size_px(520., 260.),
578 WINDOW_MARGIN,
579 None,
580 );
581 assert_eq!(top.1, Placement::Bottom);
582
583 let right = tooltip_placement(
584 bounds(260., 60., 32., 32.),
585 size_px(120., 30.),
586 size_px(300., 200.),
587 WINDOW_MARGIN,
588 Some(Placement::Right),
589 );
590 assert_eq!(right.1, Placement::Left);
591
592 let left_edge = tooltip_placement(
593 bounds(4., 80., 24., 24.),
594 size_px(120., 30.),
595 size_px(300., 200.),
596 WINDOW_MARGIN,
597 None,
598 );
599 assert_eq!(left_edge.0.left(), WINDOW_MARGIN);
600 }
601 #[test]
602 fn places_tooltip_to_the_right() {
603 let trigger = bounds(20., 60., 32., 32.);
604 let position = tooltip_placement(
605 trigger,
606 size_px(120., 30.),
607 size_px(300., 200.),
608 WINDOW_MARGIN,
609 Some(Placement::Right),
610 );
611 assert_eq!(position.1, Placement::Right);
612 assert_eq!(position.0.left(), trigger.right());
613 assert_eq!(position.0.center().y, trigger.center().y);
614 }
615 #[test]
616 fn right_placement_clamps_vertical_edges() {
617 let trigger = bounds(20., 2., 32., 20.);
618 let position = tooltip_placement(
619 trigger,
620 size_px(120., 40.),
621 size_px(300., 200.),
622 WINDOW_MARGIN,
623 Some(Placement::Right),
624 );
625 assert_eq!(position.1, Placement::Right);
626 assert_eq!(position.0.top(), WINDOW_MARGIN);
627 assert_eq!(position.0.left(), trigger.right());
628 }
629 #[test]
630 fn uses_larger_side_when_neither_vertical_side_fits() {
631 let position = tooltip_placement(
632 bounds(120., 20., 40., 20.),
633 size_px(160., 120.),
634 size_px(300., 100.),
635 WINDOW_MARGIN,
636 None,
637 );
638 assert_eq!(position.1, Placement::Bottom);
639 assert_eq!(position.0.top(), WINDOW_MARGIN);
640 assert_eq!(position.0.left(), px(60.));
641 }
642}