Skip to main content

ftui_widgets/
popover.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Popover widget for anchored floating content.
3//!
4//! [`Popover`] renders a lightweight floating panel positioned relative to an
5//! anchor rectangle. It automatically flips placement when there isn't enough
6//! space, making it suitable for tooltips, dropdowns, and context menus.
7//!
8//! # Migration rationale
9//!
10//! Web frameworks use portals, popovers, and floating-ui for content that
11//! renders outside the normal document flow. This widget provides an explicit,
12//! terminal-native equivalent that the migration code emitter can target.
13//!
14//! # Differences from Modal
15//!
16//! - **No backdrop**: Popover renders only the content, not a full-screen overlay
17//! - **Anchor-relative positioning**: Content floats near a reference element
18//! - **Auto-flip**: Placement adjusts to stay within viewport bounds
19//! - **Lightweight**: No focus trapping or animation built in (compose with FocusTrap if needed)
20//!
21//! # Example
22//!
23//! ```ignore
24//! use ftui_widgets::popover::{Popover, Placement};
25//! use ftui_layout::Rect;
26//!
27//! let anchor = Rect::new(10, 5, 20, 1); // The button/element to anchor to
28//! let popover = Popover::new(anchor, Placement::Below)
29//!     .width(30)
30//!     .max_height(10)
31//!     .with_border(true);
32//!
33//! // Render content inside the popover
34//! popover.render_with(area, frame, |content_area, frame| {
35//!     // Draw your dropdown/tooltip content here
36//! });
37//! ```
38
39#![forbid(unsafe_code)]
40
41use ftui_layout::Rect;
42use ftui_render::frame::Frame;
43
44/// Where to place the popover relative to the anchor.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Placement {
47    /// Above the anchor, horizontally aligned to its left edge.
48    Above,
49    /// Below the anchor, horizontally aligned to its left edge.
50    Below,
51    /// To the left of the anchor, vertically aligned to its top edge.
52    Left,
53    /// To the right of the anchor, vertically aligned to its top edge.
54    Right,
55    /// Above the anchor, horizontally centered.
56    AboveCentered,
57    /// Below the anchor, horizontally centered.
58    BelowCentered,
59}
60
61impl Placement {
62    /// Return the opposite placement for flip logic.
63    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    /// Whether this is a vertical (above/below) placement.
73    fn is_vertical(self) -> bool {
74        matches!(
75            self,
76            Self::Above | Self::Below | Self::AboveCentered | Self::BelowCentered
77        )
78    }
79}
80
81/// Configuration for a popover widget.
82#[derive(Debug, Clone)]
83pub struct Popover {
84    /// The anchor rectangle to position relative to.
85    pub anchor: Rect,
86    /// Preferred placement direction.
87    pub placement: Placement,
88    /// Desired width of the popover content area. If `None`, uses anchor width.
89    pub width: Option<u16>,
90    /// Maximum height of the popover. If `None`, fills available space.
91    pub max_height: Option<u16>,
92    /// Whether to draw a border around the popover.
93    pub bordered: bool,
94    /// Gap between anchor and popover (in cells).
95    pub gap: u16,
96    /// Whether to auto-flip when there isn't enough space.
97    pub auto_flip: bool,
98}
99
100impl Popover {
101    /// Create a popover anchored to the given rectangle.
102    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    /// Set the desired width.
115    #[must_use]
116    pub fn width(mut self, w: u16) -> Self {
117        self.width = Some(w);
118        self
119    }
120
121    /// Set the maximum height.
122    #[must_use]
123    pub fn max_height(mut self, h: u16) -> Self {
124        self.max_height = Some(h);
125        self
126    }
127
128    /// Enable or disable the border.
129    #[must_use]
130    pub fn with_border(mut self, bordered: bool) -> Self {
131        self.bordered = bordered;
132        self
133    }
134
135    /// Set the gap between anchor and popover.
136    #[must_use]
137    pub fn gap(mut self, gap: u16) -> Self {
138        self.gap = gap;
139        self
140    }
141
142    /// Enable or disable auto-flip.
143    #[must_use]
144    pub fn auto_flip(mut self, flip: bool) -> Self {
145        self.auto_flip = flip;
146        self
147    }
148
149    /// Compute the content area for this popover within the given viewport.
150    ///
151    /// Returns the [`Rect`] where content should be rendered, accounting for
152    /// placement, flip logic, and viewport bounds. Returns `None` if the
153    /// popover cannot fit at all.
154    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    /// Render the popover border (if enabled) and invoke the callback for content.
174    ///
175    /// The callback receives the inner content area (inside the border if any).
176    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            // Draw a simple box border
186            let buf = &mut frame.buffer;
187            draw_border(buf, area);
188
189            // Content area is inset by 1 on each side
190            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    /// Resolve placement with flip logic.
205    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        // Try the opposite direction
215        let flipped = primary.flip();
216        let flipped_available = self.available_space(flipped, viewport);
217        if flipped_available >= needed {
218            return flipped;
219        }
220
221        // Fall back to whichever has more space
222        if flipped_available > available {
223            flipped
224        } else {
225            primary
226        }
227    }
228
229    /// How much space is available in the given direction.
230    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    /// Minimum space needed for the popover in the given direction.
248    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            // Need max_height (or at least 1 line) + border + gap
252            let height = self.max_height.unwrap_or(1);
253            height
254                .saturating_add(border_overhead)
255                .saturating_add(self.gap)
256        } else {
257            // Need at least content_width + border + gap
258            content_width
259                .saturating_add(border_overhead)
260                .saturating_add(self.gap)
261        }
262    }
263
264    /// Compute the actual layout rect for the given placement.
265    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        // Compute x position
275        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        // Compute y position and available height
294        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        // Clamp width to viewport
336        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
343/// Clamp x position so the popover doesn't overflow the viewport.
344fn 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
353/// Draw a simple single-line border around a rect.
354fn 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    // Corners
366    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    // Top and bottom edges
372    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    // Left and right edges
378    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); // anchor.y + anchor.height
403        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); // anchor.x + anchor.width
427        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        // Anchor near the bottom, should flip to above
444        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        // Should be above the anchor since there's no room below
450        assert!(area.y + area.height <= 22);
451    }
452
453    #[test]
454    fn auto_flip_above_to_below() {
455        // Anchor near the top, should flip to below
456        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        // Should be below the anchor since there's no room above
462        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        // Should stay below even with limited space
474        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        // Total area includes border overhead
494        assert_eq!(area.width, 22); // 20 content + 2 border
495        assert_eq!(area.height, 7); // 5 content + 2 border
496    }
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); // anchor.y + anchor.height + gap
507    }
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        // Center of anchor is at 40, popover width 10, so x should be ~35
517        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); // anchor.x + anchor.width + gap
556    }
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        // Anchor near bottom, even max_height 100 should be clamped
571        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); // viewport height
577    }
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}