use ratatui::layout::Rect;
use crate::widget::{RenderCtx, Renderable, hash_combine};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StickyPosition {
Top,
Bottom,
}
#[derive(Debug, Clone, Copy)]
pub struct Sticky {
position: StickyPosition,
height: u16,
content_hash_val: u64,
}
impl Sticky {
#[must_use]
pub fn new(position: StickyPosition, height: u16) -> Self {
Self {
position,
height,
content_hash_val: 0,
}
}
pub const fn set_content_hash(&mut self, hash: u64) {
self.content_hash_val = hash;
}
#[must_use]
pub fn computed_rect(&self, area: Rect) -> Rect {
let h = self.height.min(area.height);
if h == 0 {
return Rect {
x: area.x,
y: area.y,
width: area.width,
height: 0,
};
}
match self.position {
StickyPosition::Top => Rect {
x: area.x,
y: area.y,
width: area.width,
height: h,
},
StickyPosition::Bottom => Rect {
x: area.x,
y: area.y.saturating_add(area.height).saturating_sub(h),
width: area.width,
height: h,
},
}
}
}
impl Renderable for Sticky {
fn content_hash(&self) -> u64 {
hash_combine(
hash_combine(self.content_hash_val, u64::from(self.height)),
match self.position {
StickyPosition::Top => 1,
StickyPosition::Bottom => 2,
},
)
}
fn height_for(&self, _width: u16, _ctx: &RenderCtx) -> u16 {
self.height
}
fn render(&mut self, area: Rect, ctx: &mut RenderCtx) {
let rect = self.computed_rect(area);
if rect.width == 0 || rect.height == 0 {
return;
}
let style = ctx.theme().styles.surface_bg;
let buf = ctx.buffer_mut();
for dy in 0..rect.height {
let y = rect.y + dy;
for dx in 0..rect.width {
let x = rect.x + dx;
let cell = &mut buf[(x, y)];
cell.set_symbol(" ");
cell.set_style(style);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::{TerminalCaps, Theme};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
fn render_sticky(sticky: &mut Sticky, 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,
};
sticky.render(area, &mut ctx);
})
.unwrap();
term.backend().buffer().clone()
}
#[test]
fn sticky_renders_at_top() {
let mut sticky = Sticky::new(StickyPosition::Top, 2);
let buf = render_sticky(&mut sticky, 10, 6);
for x in 0..10 {
assert_eq!(buf[(x, 0)].symbol(), " ", "row 0 col {x} should be space");
assert_eq!(buf[(x, 1)].symbol(), " ", "row 1 col {x} should be space");
}
let _ = (2..=5).map(|y| assert_ne!(y, 0)).count(); }
#[test]
fn sticky_renders_at_bottom() {
let mut sticky = Sticky::new(StickyPosition::Bottom, 2);
let buf = render_sticky(&mut sticky, 10, 6);
for x in 0..10 {
assert_eq!(buf[(x, 4)].symbol(), " ", "row 4 col {x} should be space");
assert_eq!(buf[(x, 5)].symbol(), " ", "row 5 col {x} should be space");
}
}
#[test]
fn sticky_computed_rect_clamps_to_area_height() {
let sticky = Sticky::new(StickyPosition::Bottom, 100);
let rect = sticky.computed_rect(Rect {
x: 0,
y: 0,
width: 80,
height: 6,
});
assert_eq!(rect.height, 6, "height should be clamped to area.height");
assert_eq!(rect.y, 0);
}
#[test]
fn sticky_computed_rect_top_uses_zero_offset() {
let sticky = Sticky::new(StickyPosition::Top, 3);
let rect = sticky.computed_rect(Rect {
x: 0,
y: 5,
width: 80,
height: 10,
});
assert_eq!(rect.y, 5, "top sticky y should equal parent y");
assert_eq!(rect.height, 3);
}
#[test]
fn sticky_computed_rect_bottom_subtracts_height() {
let sticky = Sticky::new(StickyPosition::Bottom, 2);
let rect = sticky.computed_rect(Rect {
x: 0,
y: 0,
width: 80,
height: 6,
});
assert_eq!(rect.y, 4);
assert_eq!(rect.height, 2);
}
#[test]
fn sticky_height_isolates_position() {
let top = Sticky::new(StickyPosition::Top, 2);
let bottom = Sticky::new(StickyPosition::Bottom, 2);
assert_ne!(top.content_hash(), bottom.content_hash());
}
#[test]
fn sticky_height_difference_isolates() {
let a = Sticky::new(StickyPosition::Top, 2);
let b = Sticky::new(StickyPosition::Top, 3);
assert_ne!(a.content_hash(), b.content_hash());
}
#[test]
fn sticky_set_content_hash_changes_self_hash() {
let mut sticky = Sticky::new(StickyPosition::Top, 1);
let h0 = sticky.content_hash();
sticky.set_content_hash(0xdead_beef);
let h1 = sticky.content_hash();
assert_ne!(h0, h1);
}
#[test]
fn sticky_zero_height_paints_nothing() {
let mut sticky = Sticky::new(StickyPosition::Top, 0);
let buf = render_sticky(&mut sticky, 10, 6);
assert_eq!(*buf.area(), Rect::new(0, 0, 10, 6));
}
#[test]
fn sticky_height_for_reports_declared_height() {
let sticky = Sticky::new(StickyPosition::Bottom, 4);
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!(sticky.height_for(80, &ctx), 4);
})
.unwrap();
}
#[test]
fn sticky_default_hash_is_zero_content() {
let sticky = Sticky::new(StickyPosition::Top, 1);
let sticky2 = Sticky::new(StickyPosition::Top, 1);
assert_eq!(sticky.content_hash(), sticky2.content_hash());
}
}