1#![forbid(unsafe_code)]
40
41use ftui_layout::Rect;
42use ftui_render::frame::Frame;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Placement {
47 Above,
49 Below,
51 Left,
53 Right,
55 AboveCentered,
57 BelowCentered,
59}
60
61impl Placement {
62 fn flip(self) -> Self {
64 match self {
65 Self::Above | Self::AboveCentered => Self::Below,
66 Self::Below | Self::BelowCentered => Self::Above,
67 Self::Left => Self::Right,
68 Self::Right => Self::Left,
69 }
70 }
71
72 fn is_vertical(self) -> bool {
74 matches!(
75 self,
76 Self::Above | Self::Below | Self::AboveCentered | Self::BelowCentered
77 )
78 }
79}
80
81#[derive(Debug, Clone)]
83pub struct Popover {
84 pub anchor: Rect,
86 pub placement: Placement,
88 pub width: Option<u16>,
90 pub max_height: Option<u16>,
92 pub bordered: bool,
94 pub gap: u16,
96 pub auto_flip: bool,
98}
99
100impl Popover {
101 pub fn new(anchor: Rect, placement: Placement) -> Self {
103 Self {
104 anchor,
105 placement,
106 width: None,
107 max_height: None,
108 bordered: false,
109 gap: 0,
110 auto_flip: true,
111 }
112 }
113
114 #[must_use]
116 pub fn width(mut self, w: u16) -> Self {
117 self.width = Some(w);
118 self
119 }
120
121 #[must_use]
123 pub fn max_height(mut self, h: u16) -> Self {
124 self.max_height = Some(h);
125 self
126 }
127
128 #[must_use]
130 pub fn with_border(mut self, bordered: bool) -> Self {
131 self.bordered = bordered;
132 self
133 }
134
135 #[must_use]
137 pub fn gap(mut self, gap: u16) -> Self {
138 self.gap = gap;
139 self
140 }
141
142 #[must_use]
144 pub fn auto_flip(mut self, flip: bool) -> Self {
145 self.auto_flip = flip;
146 self
147 }
148
149 pub fn compute_area(&self, viewport: Rect) -> Option<Rect> {
155 let content_width = self.width.unwrap_or(self.anchor.width);
156 if content_width == 0 {
157 return None;
158 }
159
160 let placement = if self.auto_flip {
161 self.resolve_placement(viewport, content_width)
162 } else {
163 self.placement
164 };
165
166 let (x, y, w, h) = self.layout(placement, viewport, content_width);
167 if w == 0 || h == 0 {
168 return None;
169 }
170 Some(Rect::new(x, y, w, h))
171 }
172
173 pub fn render_with<F>(&self, viewport: Rect, frame: &mut Frame, render_content: F)
177 where
178 F: FnOnce(Rect, &mut Frame),
179 {
180 let Some(area) = self.compute_area(viewport) else {
181 return;
182 };
183
184 if self.bordered {
185 let buf = &mut frame.buffer;
187 draw_border(buf, area);
188
189 let inner = if area.width >= 2 && area.height >= 2 {
191 Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2)
192 } else {
193 area
194 };
195 if !inner.is_empty() {
196 buf.fill(inner, ftui_render::cell::Cell::from_char(' '));
197 }
198 render_content(inner, frame);
199 } else {
200 render_content(area, frame);
201 }
202 }
203
204 fn resolve_placement(&self, viewport: Rect, content_width: u16) -> Placement {
206 let primary = self.placement;
207 let available = self.available_space(primary, viewport);
208 let needed = self.needed_space(primary, content_width);
209
210 if available >= needed {
211 return primary;
212 }
213
214 let flipped = primary.flip();
216 let flipped_available = self.available_space(flipped, viewport);
217 if flipped_available >= needed {
218 return flipped;
219 }
220
221 if flipped_available > available {
223 flipped
224 } else {
225 primary
226 }
227 }
228
229 fn available_space(&self, placement: Placement, viewport: Rect) -> u16 {
231 match placement {
232 Placement::Above | Placement::AboveCentered => self.anchor.y.saturating_sub(viewport.y),
233 Placement::Below | Placement::BelowCentered => {
234 let bottom = viewport.y.saturating_add(viewport.height);
235 let anchor_bottom = self.anchor.y.saturating_add(self.anchor.height);
236 bottom.saturating_sub(anchor_bottom)
237 }
238 Placement::Left => self.anchor.x.saturating_sub(viewport.x),
239 Placement::Right => {
240 let right = viewport.x.saturating_add(viewport.width);
241 let anchor_right = self.anchor.x.saturating_add(self.anchor.width);
242 right.saturating_sub(anchor_right)
243 }
244 }
245 }
246
247 fn needed_space(&self, placement: Placement, content_width: u16) -> u16 {
249 let border_overhead = if self.bordered { 2 } else { 0 };
250 if placement.is_vertical() {
251 let height = self.max_height.unwrap_or(1);
253 height
254 .saturating_add(border_overhead)
255 .saturating_add(self.gap)
256 } else {
257 content_width
259 .saturating_add(border_overhead)
260 .saturating_add(self.gap)
261 }
262 }
263
264 fn layout(
266 &self,
267 placement: Placement,
268 viewport: Rect,
269 content_width: u16,
270 ) -> (u16, u16, u16, u16) {
271 let border_overhead = if self.bordered { 2 } else { 0 };
272 let total_width = content_width.saturating_add(border_overhead);
273
274 let x = match placement {
276 Placement::Above | Placement::Below => clamp_x(self.anchor.x, total_width, viewport),
277 Placement::AboveCentered | Placement::BelowCentered => {
278 let center = self.anchor.x.saturating_add(self.anchor.width / 2);
279 let start = center.saturating_sub(total_width / 2);
280 clamp_x(start, total_width, viewport)
281 }
282 Placement::Left => {
283 let end = self.anchor.x.saturating_sub(self.gap);
284 end.saturating_sub(total_width)
285 }
286 Placement::Right => self
287 .anchor
288 .x
289 .saturating_add(self.anchor.width)
290 .saturating_add(self.gap),
291 };
292
293 let (y, available_height) = match placement {
295 Placement::Above | Placement::AboveCentered => {
296 let space_above = self
297 .anchor
298 .y
299 .saturating_sub(viewport.y)
300 .saturating_sub(self.gap);
301 let max_h = self.max_height.unwrap_or(space_above).min(space_above);
302 let total_h = max_h.saturating_add(border_overhead);
303 let y_pos = self
304 .anchor
305 .y
306 .saturating_sub(self.gap)
307 .saturating_sub(total_h);
308 (y_pos.max(viewport.y), total_h)
309 }
310 Placement::Below | Placement::BelowCentered => {
311 let y_start = self
312 .anchor
313 .y
314 .saturating_add(self.anchor.height)
315 .saturating_add(self.gap);
316 let bottom = viewport.y.saturating_add(viewport.height);
317 let space_below = bottom.saturating_sub(y_start);
318 let max_h = self.max_height.unwrap_or(space_below).min(space_below);
319 let total_h = max_h.saturating_add(border_overhead).min(space_below);
320 (y_start, total_h)
321 }
322 Placement::Left | Placement::Right => {
323 let y_start = self.anchor.y;
324 let bottom = viewport.y.saturating_add(viewport.height);
325 let space_below = bottom.saturating_sub(y_start);
326 let max_h = self
327 .max_height
328 .map(|h| h.saturating_add(border_overhead))
329 .unwrap_or(space_below)
330 .min(space_below);
331 (y_start, max_h)
332 }
333 };
334
335 let vp_right = viewport.x.saturating_add(viewport.width);
337 let clamped_width = total_width.min(vp_right.saturating_sub(x));
338
339 (x, y, clamped_width, available_height)
340 }
341}
342
343fn clamp_x(x: u16, width: u16, viewport: Rect) -> u16 {
345 let vp_right = viewport.x.saturating_add(viewport.width);
346 if x.saturating_add(width) > vp_right {
347 vp_right.saturating_sub(width)
348 } else {
349 x.max(viewport.x)
350 }
351}
352
353fn draw_border(buf: &mut ftui_render::buffer::Buffer, area: Rect) {
355 use ftui_render::cell::Cell;
356
357 if area.width < 2 || area.height < 2 {
358 return;
359 }
360 let x = area.x;
361 let y = area.y;
362 let w = area.width;
363 let h = area.height;
364
365 buf.set_fast(x, y, Cell::from_char('┌'));
367 buf.set_fast(x + w - 1, y, Cell::from_char('┐'));
368 buf.set_fast(x, y + h - 1, Cell::from_char('└'));
369 buf.set_fast(x + w - 1, y + h - 1, Cell::from_char('┘'));
370
371 for col in (x + 1)..(x + w - 1) {
373 buf.set_fast(col, y, Cell::from_char('─'));
374 buf.set_fast(col, y + h - 1, Cell::from_char('─'));
375 }
376
377 for row in (y + 1)..(y + h - 1) {
379 buf.set_fast(x, row, Cell::from_char('│'));
380 buf.set_fast(x + w - 1, row, Cell::from_char('│'));
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387 use ftui_render::frame::Frame;
388 use ftui_render::grapheme_pool::GraphemePool;
389
390 fn viewport() -> Rect {
391 Rect::new(0, 0, 80, 24)
392 }
393
394 #[test]
395 fn below_basic_placement() {
396 let anchor = Rect::new(10, 5, 20, 1);
397 let popover = Popover::new(anchor, Placement::Below)
398 .width(20)
399 .max_height(5);
400 let area = popover.compute_area(viewport()).unwrap();
401 assert_eq!(area.x, 10);
402 assert_eq!(area.y, 6); assert_eq!(area.width, 20);
404 assert_eq!(area.height, 5);
405 }
406
407 #[test]
408 fn above_basic_placement() {
409 let anchor = Rect::new(10, 10, 20, 1);
410 let popover = Popover::new(anchor, Placement::Above)
411 .width(20)
412 .max_height(5);
413 let area = popover.compute_area(viewport()).unwrap();
414 assert_eq!(area.x, 10);
415 assert_eq!(area.width, 20);
416 assert!(area.y + area.height <= anchor.y);
417 }
418
419 #[test]
420 fn right_basic_placement() {
421 let anchor = Rect::new(10, 5, 10, 1);
422 let popover = Popover::new(anchor, Placement::Right)
423 .width(15)
424 .max_height(3);
425 let area = popover.compute_area(viewport()).unwrap();
426 assert_eq!(area.x, 20); assert_eq!(area.y, 5);
428 assert_eq!(area.width, 15);
429 }
430
431 #[test]
432 fn left_basic_placement() {
433 let anchor = Rect::new(30, 5, 10, 1);
434 let popover = Popover::new(anchor, Placement::Left)
435 .width(15)
436 .max_height(3);
437 let area = popover.compute_area(viewport()).unwrap();
438 assert!(area.x + area.width <= 30);
439 }
440
441 #[test]
442 fn auto_flip_below_to_above() {
443 let anchor = Rect::new(10, 22, 20, 1);
445 let popover = Popover::new(anchor, Placement::Below)
446 .width(20)
447 .max_height(5);
448 let area = popover.compute_area(viewport()).unwrap();
449 assert!(area.y + area.height <= 22);
451 }
452
453 #[test]
454 fn auto_flip_above_to_below() {
455 let anchor = Rect::new(10, 1, 20, 1);
457 let popover = Popover::new(anchor, Placement::Above)
458 .width(20)
459 .max_height(5);
460 let area = popover.compute_area(viewport()).unwrap();
461 assert!(area.y >= anchor.y + anchor.height);
463 }
464
465 #[test]
466 fn auto_flip_disabled() {
467 let anchor = Rect::new(10, 22, 20, 1);
468 let popover = Popover::new(anchor, Placement::Below)
469 .width(20)
470 .max_height(5)
471 .auto_flip(false);
472 let area = popover.compute_area(viewport()).unwrap();
473 assert!(area.y >= anchor.y + anchor.height);
475 }
476
477 #[test]
478 fn width_clamped_to_viewport() {
479 let anchor = Rect::new(70, 5, 5, 1);
480 let popover = Popover::new(anchor, Placement::Below).width(20);
481 let area = popover.compute_area(viewport()).unwrap();
482 assert!(area.x + area.width <= 80);
483 }
484
485 #[test]
486 fn border_adds_overhead() {
487 let anchor = Rect::new(10, 5, 20, 1);
488 let popover = Popover::new(anchor, Placement::Below)
489 .width(20)
490 .max_height(5)
491 .with_border(true);
492 let area = popover.compute_area(viewport()).unwrap();
493 assert_eq!(area.width, 22); assert_eq!(area.height, 7); }
497
498 #[test]
499 fn gap_creates_space() {
500 let anchor = Rect::new(10, 5, 20, 1);
501 let popover = Popover::new(anchor, Placement::Below)
502 .width(20)
503 .max_height(5)
504 .gap(1);
505 let area = popover.compute_area(viewport()).unwrap();
506 assert_eq!(area.y, 7); }
508
509 #[test]
510 fn centered_placement() {
511 let anchor = Rect::new(30, 5, 20, 1);
512 let popover = Popover::new(anchor, Placement::BelowCentered)
513 .width(10)
514 .max_height(3);
515 let area = popover.compute_area(viewport()).unwrap();
516 let anchor_center = anchor.x + anchor.width / 2;
518 let popover_center = area.x + area.width / 2;
519 assert!((anchor_center as i32 - popover_center as i32).unsigned_abs() <= 1);
520 }
521
522 #[test]
523 fn zero_width_returns_none() {
524 let anchor = Rect::new(10, 5, 0, 1);
525 let popover = Popover::new(anchor, Placement::Below);
526 assert!(popover.compute_area(viewport()).is_none());
527 }
528
529 #[test]
530 fn placement_flip_roundtrip() {
531 assert_eq!(Placement::Above.flip(), Placement::Below);
532 assert_eq!(Placement::Below.flip(), Placement::Above);
533 assert_eq!(Placement::Left.flip(), Placement::Right);
534 assert_eq!(Placement::Right.flip(), Placement::Left);
535 }
536
537 #[test]
538 fn placement_is_vertical() {
539 assert!(Placement::Above.is_vertical());
540 assert!(Placement::Below.is_vertical());
541 assert!(Placement::AboveCentered.is_vertical());
542 assert!(Placement::BelowCentered.is_vertical());
543 assert!(!Placement::Left.is_vertical());
544 assert!(!Placement::Right.is_vertical());
545 }
546
547 #[test]
548 fn right_placement_with_gap() {
549 let anchor = Rect::new(10, 5, 10, 1);
550 let popover = Popover::new(anchor, Placement::Right)
551 .width(15)
552 .max_height(3)
553 .gap(2);
554 let area = popover.compute_area(viewport()).unwrap();
555 assert_eq!(area.x, 22); }
557
558 #[test]
559 fn max_height_limits_popover() {
560 let anchor = Rect::new(10, 5, 20, 1);
561 let popover = Popover::new(anchor, Placement::Below)
562 .width(20)
563 .max_height(3);
564 let area = popover.compute_area(viewport()).unwrap();
565 assert!(area.height <= 3);
566 }
567
568 #[test]
569 fn height_limited_by_viewport() {
570 let anchor = Rect::new(10, 20, 20, 1);
572 let popover = Popover::new(anchor, Placement::Below)
573 .width(20)
574 .max_height(100);
575 let area = popover.compute_area(viewport()).unwrap();
576 assert!(area.y + area.height <= 24); }
578
579 #[test]
580 fn popover_debug_impl() {
581 let popover = Popover::new(Rect::new(0, 0, 10, 1), Placement::Below);
582 let _ = format!("{popover:?}");
583 }
584
585 #[test]
586 fn bordered_render_clears_stale_inner_content() {
587 let popover = Popover::new(Rect::new(2, 1, 5, 1), Placement::Below)
588 .width(5)
589 .max_height(1)
590 .with_border(true);
591 let mut pool = GraphemePool::new();
592 let mut frame = Frame::new(20, 10, &mut pool);
593 let viewport = Rect::new(0, 0, 20, 10);
594
595 popover.render_with(viewport, &mut frame, |inner, frame| {
596 for (i, ch) in "ABCDE".chars().enumerate() {
597 frame.buffer.set(
598 inner.x + i as u16,
599 inner.y,
600 ftui_render::cell::Cell::from_char(ch),
601 );
602 }
603 });
604 popover.render_with(viewport, &mut frame, |inner, frame| {
605 for (i, ch) in "XY".chars().enumerate() {
606 frame.buffer.set(
607 inner.x + i as u16,
608 inner.y,
609 ftui_render::cell::Cell::from_char(ch),
610 );
611 }
612 });
613
614 let area = popover.compute_area(viewport).unwrap();
615 let inner_y = area.y + 1;
616 assert_eq!(
617 frame
618 .buffer
619 .get(area.x + 1, inner_y)
620 .unwrap()
621 .content
622 .as_char(),
623 Some('X')
624 );
625 assert_eq!(
626 frame
627 .buffer
628 .get(area.x + 2, inner_y)
629 .unwrap()
630 .content
631 .as_char(),
632 Some('Y')
633 );
634 assert_eq!(
635 frame
636 .buffer
637 .get(area.x + 3, inner_y)
638 .unwrap()
639 .content
640 .as_char(),
641 Some(' ')
642 );
643 assert_eq!(
644 frame
645 .buffer
646 .get(area.x + 4, inner_y)
647 .unwrap()
648 .content
649 .as_char(),
650 Some(' ')
651 );
652 assert_eq!(
653 frame
654 .buffer
655 .get(area.x + 5, inner_y)
656 .unwrap()
657 .content
658 .as_char(),
659 Some(' ')
660 );
661 }
662}