#![forbid(unsafe_code)]
use ftui_layout::Rect;
use ftui_render::frame::Frame;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Placement {
Above,
Below,
Left,
Right,
AboveCentered,
BelowCentered,
}
impl Placement {
fn flip(self) -> Self {
match self {
Self::Above | Self::AboveCentered => Self::Below,
Self::Below | Self::BelowCentered => Self::Above,
Self::Left => Self::Right,
Self::Right => Self::Left,
}
}
fn is_vertical(self) -> bool {
matches!(
self,
Self::Above | Self::Below | Self::AboveCentered | Self::BelowCentered
)
}
}
#[derive(Debug, Clone)]
pub struct Popover {
pub anchor: Rect,
pub placement: Placement,
pub width: Option<u16>,
pub max_height: Option<u16>,
pub bordered: bool,
pub gap: u16,
pub auto_flip: bool,
}
impl Popover {
pub fn new(anchor: Rect, placement: Placement) -> Self {
Self {
anchor,
placement,
width: None,
max_height: None,
bordered: false,
gap: 0,
auto_flip: true,
}
}
#[must_use]
pub fn width(mut self, w: u16) -> Self {
self.width = Some(w);
self
}
#[must_use]
pub fn max_height(mut self, h: u16) -> Self {
self.max_height = Some(h);
self
}
#[must_use]
pub fn with_border(mut self, bordered: bool) -> Self {
self.bordered = bordered;
self
}
#[must_use]
pub fn gap(mut self, gap: u16) -> Self {
self.gap = gap;
self
}
#[must_use]
pub fn auto_flip(mut self, flip: bool) -> Self {
self.auto_flip = flip;
self
}
pub fn compute_area(&self, viewport: Rect) -> Option<Rect> {
let content_width = self.width.unwrap_or(self.anchor.width);
if content_width == 0 {
return None;
}
let placement = if self.auto_flip {
self.resolve_placement(viewport, content_width)
} else {
self.placement
};
let (x, y, w, h) = self.layout(placement, viewport, content_width);
if w == 0 || h == 0 {
return None;
}
Some(Rect::new(x, y, w, h))
}
pub fn render_with<F>(&self, viewport: Rect, frame: &mut Frame, render_content: F)
where
F: FnOnce(Rect, &mut Frame),
{
let Some(area) = self.compute_area(viewport) else {
return;
};
if self.bordered {
let buf = &mut frame.buffer;
draw_border(buf, area);
let inner = if area.width >= 2 && area.height >= 2 {
Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2)
} else {
area
};
if !inner.is_empty() {
buf.fill(inner, ftui_render::cell::Cell::from_char(' '));
}
render_content(inner, frame);
} else {
render_content(area, frame);
}
}
fn resolve_placement(&self, viewport: Rect, content_width: u16) -> Placement {
let primary = self.placement;
let available = self.available_space(primary, viewport);
let needed = self.needed_space(primary, content_width);
if available >= needed {
return primary;
}
let flipped = primary.flip();
let flipped_available = self.available_space(flipped, viewport);
if flipped_available >= needed {
return flipped;
}
if flipped_available > available {
flipped
} else {
primary
}
}
fn available_space(&self, placement: Placement, viewport: Rect) -> u16 {
match placement {
Placement::Above | Placement::AboveCentered => self.anchor.y.saturating_sub(viewport.y),
Placement::Below | Placement::BelowCentered => {
let bottom = viewport.y.saturating_add(viewport.height);
let anchor_bottom = self.anchor.y.saturating_add(self.anchor.height);
bottom.saturating_sub(anchor_bottom)
}
Placement::Left => self.anchor.x.saturating_sub(viewport.x),
Placement::Right => {
let right = viewport.x.saturating_add(viewport.width);
let anchor_right = self.anchor.x.saturating_add(self.anchor.width);
right.saturating_sub(anchor_right)
}
}
}
fn needed_space(&self, placement: Placement, content_width: u16) -> u16 {
let border_overhead = if self.bordered { 2 } else { 0 };
if placement.is_vertical() {
let height = self.max_height.unwrap_or(1);
height
.saturating_add(border_overhead)
.saturating_add(self.gap)
} else {
content_width
.saturating_add(border_overhead)
.saturating_add(self.gap)
}
}
fn layout(
&self,
placement: Placement,
viewport: Rect,
content_width: u16,
) -> (u16, u16, u16, u16) {
let border_overhead = if self.bordered { 2 } else { 0 };
let total_width = content_width.saturating_add(border_overhead);
let x = match placement {
Placement::Above | Placement::Below => clamp_x(self.anchor.x, total_width, viewport),
Placement::AboveCentered | Placement::BelowCentered => {
let center = self.anchor.x.saturating_add(self.anchor.width / 2);
let start = center.saturating_sub(total_width / 2);
clamp_x(start, total_width, viewport)
}
Placement::Left => {
let end = self.anchor.x.saturating_sub(self.gap);
end.saturating_sub(total_width)
}
Placement::Right => self
.anchor
.x
.saturating_add(self.anchor.width)
.saturating_add(self.gap),
};
let (y, available_height) = match placement {
Placement::Above | Placement::AboveCentered => {
let space_above = self
.anchor
.y
.saturating_sub(viewport.y)
.saturating_sub(self.gap);
let max_h = self.max_height.unwrap_or(space_above).min(space_above);
let total_h = max_h.saturating_add(border_overhead);
let y_pos = self
.anchor
.y
.saturating_sub(self.gap)
.saturating_sub(total_h);
(y_pos.max(viewport.y), total_h)
}
Placement::Below | Placement::BelowCentered => {
let y_start = self
.anchor
.y
.saturating_add(self.anchor.height)
.saturating_add(self.gap);
let bottom = viewport.y.saturating_add(viewport.height);
let space_below = bottom.saturating_sub(y_start);
let max_h = self.max_height.unwrap_or(space_below).min(space_below);
let total_h = max_h.saturating_add(border_overhead).min(space_below);
(y_start, total_h)
}
Placement::Left | Placement::Right => {
let y_start = self.anchor.y;
let bottom = viewport.y.saturating_add(viewport.height);
let space_below = bottom.saturating_sub(y_start);
let max_h = self
.max_height
.map(|h| h.saturating_add(border_overhead))
.unwrap_or(space_below)
.min(space_below);
(y_start, max_h)
}
};
let vp_right = viewport.x.saturating_add(viewport.width);
let clamped_width = total_width.min(vp_right.saturating_sub(x));
(x, y, clamped_width, available_height)
}
}
fn clamp_x(x: u16, width: u16, viewport: Rect) -> u16 {
let vp_right = viewport.x.saturating_add(viewport.width);
if x.saturating_add(width) > vp_right {
vp_right.saturating_sub(width)
} else {
x.max(viewport.x)
}
}
fn draw_border(buf: &mut ftui_render::buffer::Buffer, area: Rect) {
use ftui_render::cell::Cell;
if area.width < 2 || area.height < 2 {
return;
}
let x = area.x;
let y = area.y;
let w = area.width;
let h = area.height;
buf.set_fast(x, y, Cell::from_char('┌'));
buf.set_fast(x + w - 1, y, Cell::from_char('┐'));
buf.set_fast(x, y + h - 1, Cell::from_char('└'));
buf.set_fast(x + w - 1, y + h - 1, Cell::from_char('┘'));
for col in (x + 1)..(x + w - 1) {
buf.set_fast(col, y, Cell::from_char('─'));
buf.set_fast(col, y + h - 1, Cell::from_char('─'));
}
for row in (y + 1)..(y + h - 1) {
buf.set_fast(x, row, Cell::from_char('│'));
buf.set_fast(x + w - 1, row, Cell::from_char('│'));
}
}
#[cfg(test)]
mod tests {
use super::*;
use ftui_render::frame::Frame;
use ftui_render::grapheme_pool::GraphemePool;
fn viewport() -> Rect {
Rect::new(0, 0, 80, 24)
}
#[test]
fn below_basic_placement() {
let anchor = Rect::new(10, 5, 20, 1);
let popover = Popover::new(anchor, Placement::Below)
.width(20)
.max_height(5);
let area = popover.compute_area(viewport()).unwrap();
assert_eq!(area.x, 10);
assert_eq!(area.y, 6); assert_eq!(area.width, 20);
assert_eq!(area.height, 5);
}
#[test]
fn above_basic_placement() {
let anchor = Rect::new(10, 10, 20, 1);
let popover = Popover::new(anchor, Placement::Above)
.width(20)
.max_height(5);
let area = popover.compute_area(viewport()).unwrap();
assert_eq!(area.x, 10);
assert_eq!(area.width, 20);
assert!(area.y + area.height <= anchor.y);
}
#[test]
fn right_basic_placement() {
let anchor = Rect::new(10, 5, 10, 1);
let popover = Popover::new(anchor, Placement::Right)
.width(15)
.max_height(3);
let area = popover.compute_area(viewport()).unwrap();
assert_eq!(area.x, 20); assert_eq!(area.y, 5);
assert_eq!(area.width, 15);
}
#[test]
fn left_basic_placement() {
let anchor = Rect::new(30, 5, 10, 1);
let popover = Popover::new(anchor, Placement::Left)
.width(15)
.max_height(3);
let area = popover.compute_area(viewport()).unwrap();
assert!(area.x + area.width <= 30);
}
#[test]
fn auto_flip_below_to_above() {
let anchor = Rect::new(10, 22, 20, 1);
let popover = Popover::new(anchor, Placement::Below)
.width(20)
.max_height(5);
let area = popover.compute_area(viewport()).unwrap();
assert!(area.y + area.height <= 22);
}
#[test]
fn auto_flip_above_to_below() {
let anchor = Rect::new(10, 1, 20, 1);
let popover = Popover::new(anchor, Placement::Above)
.width(20)
.max_height(5);
let area = popover.compute_area(viewport()).unwrap();
assert!(area.y >= anchor.y + anchor.height);
}
#[test]
fn auto_flip_disabled() {
let anchor = Rect::new(10, 22, 20, 1);
let popover = Popover::new(anchor, Placement::Below)
.width(20)
.max_height(5)
.auto_flip(false);
let area = popover.compute_area(viewport()).unwrap();
assert!(area.y >= anchor.y + anchor.height);
}
#[test]
fn width_clamped_to_viewport() {
let anchor = Rect::new(70, 5, 5, 1);
let popover = Popover::new(anchor, Placement::Below).width(20);
let area = popover.compute_area(viewport()).unwrap();
assert!(area.x + area.width <= 80);
}
#[test]
fn border_adds_overhead() {
let anchor = Rect::new(10, 5, 20, 1);
let popover = Popover::new(anchor, Placement::Below)
.width(20)
.max_height(5)
.with_border(true);
let area = popover.compute_area(viewport()).unwrap();
assert_eq!(area.width, 22); assert_eq!(area.height, 7); }
#[test]
fn gap_creates_space() {
let anchor = Rect::new(10, 5, 20, 1);
let popover = Popover::new(anchor, Placement::Below)
.width(20)
.max_height(5)
.gap(1);
let area = popover.compute_area(viewport()).unwrap();
assert_eq!(area.y, 7); }
#[test]
fn centered_placement() {
let anchor = Rect::new(30, 5, 20, 1);
let popover = Popover::new(anchor, Placement::BelowCentered)
.width(10)
.max_height(3);
let area = popover.compute_area(viewport()).unwrap();
let anchor_center = anchor.x + anchor.width / 2;
let popover_center = area.x + area.width / 2;
assert!((anchor_center as i32 - popover_center as i32).unsigned_abs() <= 1);
}
#[test]
fn zero_width_returns_none() {
let anchor = Rect::new(10, 5, 0, 1);
let popover = Popover::new(anchor, Placement::Below);
assert!(popover.compute_area(viewport()).is_none());
}
#[test]
fn placement_flip_roundtrip() {
assert_eq!(Placement::Above.flip(), Placement::Below);
assert_eq!(Placement::Below.flip(), Placement::Above);
assert_eq!(Placement::Left.flip(), Placement::Right);
assert_eq!(Placement::Right.flip(), Placement::Left);
}
#[test]
fn placement_is_vertical() {
assert!(Placement::Above.is_vertical());
assert!(Placement::Below.is_vertical());
assert!(Placement::AboveCentered.is_vertical());
assert!(Placement::BelowCentered.is_vertical());
assert!(!Placement::Left.is_vertical());
assert!(!Placement::Right.is_vertical());
}
#[test]
fn right_placement_with_gap() {
let anchor = Rect::new(10, 5, 10, 1);
let popover = Popover::new(anchor, Placement::Right)
.width(15)
.max_height(3)
.gap(2);
let area = popover.compute_area(viewport()).unwrap();
assert_eq!(area.x, 22); }
#[test]
fn max_height_limits_popover() {
let anchor = Rect::new(10, 5, 20, 1);
let popover = Popover::new(anchor, Placement::Below)
.width(20)
.max_height(3);
let area = popover.compute_area(viewport()).unwrap();
assert!(area.height <= 3);
}
#[test]
fn height_limited_by_viewport() {
let anchor = Rect::new(10, 20, 20, 1);
let popover = Popover::new(anchor, Placement::Below)
.width(20)
.max_height(100);
let area = popover.compute_area(viewport()).unwrap();
assert!(area.y + area.height <= 24); }
#[test]
fn popover_debug_impl() {
let popover = Popover::new(Rect::new(0, 0, 10, 1), Placement::Below);
let _ = format!("{popover:?}");
}
#[test]
fn bordered_render_clears_stale_inner_content() {
let popover = Popover::new(Rect::new(2, 1, 5, 1), Placement::Below)
.width(5)
.max_height(1)
.with_border(true);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 10, &mut pool);
let viewport = Rect::new(0, 0, 20, 10);
popover.render_with(viewport, &mut frame, |inner, frame| {
for (i, ch) in "ABCDE".chars().enumerate() {
frame.buffer.set(
inner.x + i as u16,
inner.y,
ftui_render::cell::Cell::from_char(ch),
);
}
});
popover.render_with(viewport, &mut frame, |inner, frame| {
for (i, ch) in "XY".chars().enumerate() {
frame.buffer.set(
inner.x + i as u16,
inner.y,
ftui_render::cell::Cell::from_char(ch),
);
}
});
let area = popover.compute_area(viewport).unwrap();
let inner_y = area.y + 1;
assert_eq!(
frame
.buffer
.get(area.x + 1, inner_y)
.unwrap()
.content
.as_char(),
Some('X')
);
assert_eq!(
frame
.buffer
.get(area.x + 2, inner_y)
.unwrap()
.content
.as_char(),
Some('Y')
);
assert_eq!(
frame
.buffer
.get(area.x + 3, inner_y)
.unwrap()
.content
.as_char(),
Some(' ')
);
assert_eq!(
frame
.buffer
.get(area.x + 4, inner_y)
.unwrap()
.content
.as_char(),
Some(' ')
);
assert_eq!(
frame
.buffer
.get(area.x + 5, inner_y)
.unwrap()
.content
.as_char(),
Some(' ')
);
}
}