#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::widgets::{Block, Borders};
use crate::widget::{RenderCtx, Renderable, hash_combine, hash_str};
#[derive(Debug, Clone)]
pub struct Overlay {
title: Option<String>,
width_pct: f32,
height_pct: f32,
}
impl Overlay {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn titled(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
#[must_use]
pub fn sized(mut self, width: f32, height: f32) -> Self {
self.width_pct = width.clamp(0.0, 1.0);
self.height_pct = height.clamp(0.0, 1.0);
self
}
#[must_use]
pub fn computed_rect(&self, area: Rect) -> Option<Rect> {
if area.width == 0 || area.height == 0 {
return None;
}
let w_target = f32::from(area.width) * self.width_pct;
let h_target = f32::from(area.height) * self.height_pct;
let popup_w = w_target.round().max(1.0) as u16;
let popup_h = h_target.round().max(1.0) as u16;
let popup_w = popup_w.min(area.width);
let popup_h = popup_h.min(area.height);
let extra_x = area.width - popup_w;
let extra_y = area.height - popup_h;
let x = area.x + extra_x / 2;
let y = area.y + extra_y / 2;
Some(Rect {
x,
y,
width: popup_w,
height: popup_h,
})
}
#[must_use]
pub fn computed_inner_rect(&self, area: Rect) -> Option<Rect> {
let outer = self.computed_rect(area)?;
if outer.width <= 2 || outer.height <= 2 {
return None;
}
Some(Rect {
x: outer.x + 1,
y: outer.y + 1,
width: outer.width - 2,
height: outer.height - 2,
})
}
}
impl Default for Overlay {
fn default() -> Self {
Self {
title: None,
width_pct: 0.6,
height_pct: 0.4,
}
}
}
impl Renderable for Overlay {
fn content_hash(&self) -> u64 {
let title_hash = self.title.as_deref().map_or(0, hash_str);
hash_combine(
hash_combine(title_hash, u64::from(self.width_pct.to_bits())),
u64::from(self.height_pct.to_bits()),
)
}
fn height_for(&self, _width: u16, _ctx: &RenderCtx) -> u16 {
0
}
fn render(&mut self, area: Rect, ctx: &mut RenderCtx) {
let Some(outer) = self.computed_rect(area) else {
return;
};
let bg_style = ctx.theme().styles.panel_bg;
let border_style = ctx.theme().styles.border;
let buf = ctx.buffer_mut();
for dy in 0..outer.height {
let y = outer.y + dy;
for dx in 0..outer.width {
let x = outer.x + dx;
let cell = &mut buf[(x, y)];
cell.set_symbol(" ");
cell.set_style(bg_style);
}
}
let mut block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.style(bg_style);
if let Some(title) = self.title.as_deref() {
block = block.title(title);
}
block.render(outer, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::{TerminalCaps, Theme};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
fn render_overlay(overlay: &mut Overlay, width: u16, height: u16) -> Buffer {
let backend = TestBackend::new(width, height);
let mut term = Terminal::new(backend).unwrap();
term.draw(|frame| {
let theme = Theme::dark();
let caps = TerminalCaps::default();
let mut ctx = RenderCtx::new(frame, &theme, &caps);
let area = Rect {
x: 0,
y: 0,
width,
height,
};
overlay.render(area, &mut ctx);
})
.unwrap();
term.backend().buffer().clone()
}
#[test]
fn new_equals_default() {
let a = Overlay::new();
let b = Overlay::default();
assert_eq!(a.content_hash(), b.content_hash());
}
#[test]
fn overlay_renders_centered_with_title() {
let mut overlay = Overlay::new().titled("Confirm").sized(0.5, 0.5);
let buf = render_overlay(&mut overlay, 20, 10);
assert_eq!(buf[(5, 2)].symbol(), "┌", "top-left corner");
assert_eq!(buf[(14, 2)].symbol(), "┐", "top-right corner");
assert_eq!(buf[(13, 2)].symbol(), "─", "top edge mid cell past title");
let top_row: String = (5..=14).map(|x| buf[(x, 2)].symbol().to_string()).collect();
assert!(
top_row.contains("Confirm"),
"title should appear in top border row, got: {top_row:?}",
);
}
#[test]
fn overlay_clamps_to_area() {
let overlay = Overlay::new().sized(2.0, 5.0);
let rect = overlay
.computed_rect(Rect {
x: 0,
y: 0,
width: 10,
height: 6,
})
.unwrap();
assert_eq!(rect.width, 10, "pct > 1.0 should clamp to area.width");
assert_eq!(rect.height, 6, "pct > 1.0 should clamp to area.height");
assert_eq!(rect.x, 0);
assert_eq!(rect.y, 0);
}
#[test]
fn overlay_zero_pct_gives_minimal_box() {
let overlay = Overlay::new().sized(0.0, 0.0);
let rect = overlay
.computed_rect(Rect {
x: 0,
y: 0,
width: 80,
height: 24,
})
.unwrap();
assert_eq!(rect.width, 1, "0% width rounds up to 1 cell");
assert_eq!(rect.height, 1, "0% height rounds up to 1 cell");
}
#[test]
fn overlay_centered_in_asymmetric_parent() {
let overlay = Overlay::new().sized(0.5, 0.5);
let rect = overlay
.computed_rect(Rect {
x: 3,
y: 1,
width: 7,
height: 5,
})
.unwrap();
assert_eq!(rect.x, 4);
assert_eq!(rect.y, 2);
assert_eq!(rect.width, 4);
assert_eq!(rect.height, 3);
}
#[test]
fn overlay_inner_rect_excludes_borders() {
let overlay = Overlay::new().titled("X").sized(0.5, 0.5);
let inner = overlay
.computed_inner_rect(Rect {
x: 0,
y: 0,
width: 20,
height: 10,
})
.unwrap();
assert_eq!(inner.width, 8);
assert_eq!(inner.height, 3);
}
#[test]
fn overlay_inner_rect_none_for_too_small_outer() {
let overlay = Overlay::new();
let inner = overlay.computed_inner_rect(Rect {
x: 0,
y: 0,
width: 2,
height: 2,
});
assert!(inner.is_none(), "border takes 2 cells; no inner room");
}
#[test]
fn overlay_hash_changes_with_title() {
let a = Overlay::new().titled("A");
let b = Overlay::new().titled("B");
assert_ne!(a.content_hash(), b.content_hash());
}
#[test]
fn overlay_hash_changes_with_size() {
let a = Overlay::new().sized(0.4, 0.3);
let b = Overlay::new().sized(0.4, 0.5);
assert_ne!(a.content_hash(), b.content_hash());
}
#[test]
fn overlay_hash_stable_on_same_state() {
let a = Overlay::new().titled("X").sized(0.5, 0.5);
let b = Overlay::new().titled("X").sized(0.5, 0.5);
assert_eq!(a.content_hash(), b.content_hash());
}
#[test]
fn overlay_handles_zero_area() {
let mut overlay = Overlay::new();
let buf = render_overlay(&mut overlay, 0, 0);
assert_eq!(*buf.area(), Rect::new(0, 0, 0, 0));
}
#[test]
fn overlay_size_negatives_clamped_to_zero() {
let overlay = Overlay::new().sized(-1.0, -2.0);
let rect = overlay
.computed_rect(Rect {
x: 0,
y: 0,
width: 10,
height: 6,
})
.unwrap();
assert_eq!(rect.width, 1, "negative pct clamps to 0, rounds to 1");
assert_eq!(rect.height, 1);
}
#[test]
fn overlay_height_for_returns_zero() {
let overlay = Overlay::new();
let backend = TestBackend::new(1, 1);
let mut term = Terminal::new(backend).unwrap();
term.draw(|frame| {
let theme = Theme::dark();
let caps = TerminalCaps::default();
let ctx = RenderCtx::new(frame, &theme, &caps);
assert_eq!(overlay.height_for(80, &ctx), 0);
})
.unwrap();
}
#[test]
fn overlay_renders_without_title() {
let mut overlay = Overlay::new().sized(0.4, 0.4);
let buf = render_overlay(&mut overlay, 20, 10);
let rect = overlay
.computed_rect(Rect {
x: 0,
y: 0,
width: 20,
height: 10,
})
.unwrap();
assert_eq!(buf[(rect.x, rect.y)].symbol(), "┌");
}
}