use crate::style::fit_visible;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextOverlayPosition {
StartAt(usize),
EndAt(usize),
Top,
Bottom,
AboveBottom { rows_below: usize },
}
#[derive(Debug, Clone)]
pub struct TextOverlay {
rows: Vec<String>,
position: TextOverlayPosition,
width: Option<usize>,
}
impl TextOverlay {
pub fn new(rows: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
rows: rows.into_iter().map(Into::into).collect(),
position: TextOverlayPosition::Bottom,
width: None,
}
}
pub fn start_at(mut self, row: usize) -> Self {
self.position = TextOverlayPosition::StartAt(row);
self
}
pub fn end_at(mut self, row: usize) -> Self {
self.position = TextOverlayPosition::EndAt(row);
self
}
pub fn top(mut self) -> Self {
self.position = TextOverlayPosition::Top;
self
}
pub fn bottom(mut self) -> Self {
self.position = TextOverlayPosition::Bottom;
self
}
pub fn above_bottom(mut self, rows_below: usize) -> Self {
self.position = TextOverlayPosition::AboveBottom { rows_below };
self
}
pub fn width(mut self, width: usize) -> Self {
self.width = Some(width);
self
}
pub fn apply(&self, frame: &str) -> String {
if self.rows.is_empty() {
return frame.to_string();
}
let mut frame_rows: Vec<String> = frame.split('\n').map(str::to_string).collect();
if frame_rows.is_empty() {
return frame.to_string();
}
let start = self.start_row(frame_rows.len());
for (idx, row) in self.rows.iter().enumerate() {
let target = start + idx;
if target >= frame_rows.len() {
break;
}
frame_rows[target] = self.render_row(row);
}
frame_rows.join("\n")
}
fn start_row(&self, frame_height: usize) -> usize {
match self.position {
TextOverlayPosition::StartAt(row) => row,
TextOverlayPosition::EndAt(row) => {
row.saturating_sub(self.rows.len().saturating_sub(1))
}
TextOverlayPosition::Top => 0,
TextOverlayPosition::Bottom => frame_height.saturating_sub(self.rows.len()),
TextOverlayPosition::AboveBottom { rows_below } => {
let end_row = frame_height.saturating_sub(1).saturating_sub(rows_below);
end_row.saturating_sub(self.rows.len().saturating_sub(1))
}
}
}
fn render_row(&self, row: &str) -> String {
self.width
.map(|width| fit_visible(row, width))
.unwrap_or_else(|| row.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::{strip_ansi, visible_len, Color, Style};
fn frame(rows: usize) -> String {
(0..rows)
.map(|idx| format!("row {idx}"))
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn overlays_rows_ending_at_requested_row() {
let rendered = TextOverlay::new(["one", "two"]).end_at(3).apply(&frame(5));
let rows = rendered.lines().collect::<Vec<_>>();
assert_eq!(rows, vec!["row 0", "row 1", "one", "two", "row 4"]);
}
#[test]
fn overlays_rows_above_reserved_bottom_area() {
let rendered = TextOverlay::new(["menu a", "menu b", "menu c"])
.above_bottom(5)
.apply(&frame(10));
let rows = rendered.lines().collect::<Vec<_>>();
assert_eq!(rows[2], "menu a");
assert_eq!(rows[3], "menu b");
assert_eq!(rows[4], "menu c");
assert_eq!(rows[5], "row 5");
}
#[test]
fn overlays_from_top_or_start_row() {
let top = TextOverlay::new(["title"]).top().apply(&frame(3));
assert_eq!(top.lines().next(), Some("title"));
let start = TextOverlay::new(["panel"]).start_at(1).apply(&frame(3));
assert_eq!(start.lines().nth(1), Some("panel"));
}
#[test]
fn bottom_overlay_preserves_frame_height() {
let rendered = TextOverlay::new(["a", "b", "c"]).bottom().apply(&frame(2));
let rows = rendered.lines().collect::<Vec<_>>();
assert_eq!(rows, vec!["a", "b"]);
}
#[test]
fn empty_overlay_is_noop() {
let base = frame(3);
let rows: Vec<String> = Vec::new();
assert_eq!(TextOverlay::new(rows).apply(&base), base);
}
#[test]
fn width_fits_styled_and_wide_rows() {
let styled = Style::new().fg(Color::Cyan).render("中文abcdef");
let rendered = TextOverlay::new([styled]).width(8).top().apply(&frame(2));
let first = rendered.lines().next().unwrap();
assert_eq!(visible_len(first), 8);
assert!(strip_ansi(first).contains("中文"));
assert!(first.contains('\u{1b}'));
}
}