use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
use super::viewport::Window;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct Content {
pub(crate) list_total: usize,
pub(crate) details_total: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct LayoutSnapshot {
area: Rect,
top_bar: Rect,
filter_bar: Rect,
list: Rect,
details: Rect,
footer: Rect,
content: Content,
}
impl Default for LayoutSnapshot {
fn default() -> Self {
Self::compute(Rect::default(), Content::default())
}
}
impl LayoutSnapshot {
pub(crate) fn compute(area: Rect, content: Content) -> Self {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(6), Constraint::Length(1), ])
.split(area);
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
.split(chunks[2]);
Self {
area,
top_bar: chunks[0],
filter_bar: chunks[1],
list: body[0],
details: body[1],
footer: chunks[3],
content,
}
}
pub(crate) fn area(self) -> Rect {
self.area
}
pub(crate) fn top_bar(self) -> Rect {
self.top_bar
}
pub(crate) fn filter_bar(self) -> Rect {
self.filter_bar
}
pub(crate) fn list(self) -> Rect {
self.list
}
pub(crate) fn details(self) -> Rect {
self.details
}
pub(crate) fn footer(self) -> Rect {
self.footer
}
pub(crate) fn list_window(self, selected: usize) -> Window {
Window::containing(self.content.list_total, viewport_of(self.list), selected)
}
pub(crate) fn details_window(self, scroll: usize) -> Window {
Window::at(self.content.details_total, self.details_viewport(), scroll)
}
pub(crate) fn details_viewport(self) -> usize {
viewport_of(self.details)
}
pub(crate) fn details_max_scroll(self) -> usize {
Window::max_scroll(self.content.details_total, self.details_viewport())
}
pub(crate) fn list_row_at(self, position: Position, selected: usize) -> Option<usize> {
if !self.list.contains(position) {
return None;
}
let row = (position.y.checked_sub(self.list.y + 1))? as usize;
let window = self.list_window(selected);
let index = window.offset() + row;
window.range().contains(&index).then_some(index)
}
pub(crate) fn is_over_details(self, position: Position) -> bool {
self.details.contains(position)
}
}
fn viewport_of(panel: Rect) -> usize {
panel.height.saturating_sub(2) as usize
}
#[cfg(test)]
mod tests {
use super::*;
fn content(list_total: usize, details_total: usize) -> Content {
Content {
list_total,
details_total,
}
}
fn normal() -> LayoutSnapshot {
LayoutSnapshot::compute(Rect::new(0, 0, 100, 30), content(8, 40))
}
#[test]
fn the_panels_of_a_normal_terminal_tile_it_without_overlapping() {
let layout = normal();
assert_eq!(layout.top_bar(), Rect::new(0, 0, 100, 1));
assert_eq!(layout.filter_bar(), Rect::new(0, 1, 100, 1));
assert_eq!(layout.footer(), Rect::new(0, 29, 100, 1));
assert_eq!(layout.list(), Rect::new(0, 2, 58, 27));
assert_eq!(layout.details(), Rect::new(58, 2, 42, 27));
}
#[test]
fn the_details_bounds_follow_the_pane_and_its_content() {
let layout = normal();
assert_eq!(layout.details_viewport(), 25);
assert_eq!(layout.details_max_scroll(), 15);
assert_eq!(layout.details_window(0).range(), 0..25);
assert_eq!(layout.details_window(15).range(), 15..40);
assert_eq!(layout.details_window(99).range(), 15..40);
}
#[test]
fn details_that_fit_cannot_be_scrolled() {
let layout = LayoutSnapshot::compute(Rect::new(0, 0, 100, 30), content(8, 5));
assert_eq!(layout.details_max_scroll(), 0);
assert_eq!(layout.details_window(0).range(), 0..5);
assert!(!layout.details_window(0).is_clipped());
}
#[test]
fn a_click_on_a_content_row_finds_the_row_the_window_drew_there() {
let layout = normal();
assert_eq!(layout.list_row_at(Position::new(5, 3), 0), Some(0));
assert_eq!(layout.list_row_at(Position::new(5, 6), 0), Some(3));
let scrolled = LayoutSnapshot::compute(Rect::new(0, 0, 100, 9), content(20, 40));
assert_eq!(scrolled.list_window(19).offset(), 16);
assert_eq!(scrolled.list_row_at(Position::new(5, 3), 19), Some(16));
assert_eq!(scrolled.list_row_at(Position::new(5, 6), 19), Some(19));
assert_eq!(scrolled.list_row_at(Position::new(5, 7), 19), None);
}
#[test]
fn clicks_off_the_rows_select_nothing() {
let layout = normal();
assert_eq!(layout.list_row_at(Position::new(5, 2), 0), None);
assert_eq!(layout.list_row_at(Position::new(70, 6), 0), None);
assert_eq!(layout.list_row_at(Position::new(5, 0), 0), None);
assert_eq!(layout.list_row_at(Position::new(5, 11), 0), None);
assert_eq!(layout.list_row_at(Position::new(5, 29), 0), None);
}
#[test]
fn the_details_pane_answers_for_its_own_wheel_events() {
let layout = normal();
assert!(layout.is_over_details(Position::new(70, 6)));
assert!(!layout.is_over_details(Position::new(5, 6)));
assert!(!layout.is_over_details(Position::new(70, 0)));
}
#[test]
fn a_zero_size_terminal_is_safe_to_hit_test_and_scroll() {
let layout = LayoutSnapshot::compute(Rect::new(0, 0, 0, 0), content(8, 40));
assert_eq!(layout.details_viewport(), 0);
assert_eq!(layout.details_max_scroll(), 40);
assert!(layout.details_window(40).range().is_empty());
assert!(layout.details_window(0).range().is_empty());
assert!(layout.list_window(3).range().is_empty());
assert_eq!(layout.list_row_at(Position::new(0, 0), 0), None);
assert!(!layout.is_over_details(Position::new(0, 0)));
}
#[test]
fn the_default_snapshot_is_the_zero_size_one() {
let default = LayoutSnapshot::default();
assert_eq!(
default,
LayoutSnapshot::compute(Rect::default(), Content::default())
);
assert_eq!(default.list_row_at(Position::new(0, 0), 0), None);
assert_eq!(default.details_max_scroll(), 0);
}
#[test]
fn panels_shorter_than_their_borders_have_no_viewport() {
for height in 0..=6u16 {
let layout = LayoutSnapshot::compute(Rect::new(0, 0, 40, height), content(8, 40));
assert!(
layout.details_viewport() <= 4,
"height={height} viewport={}",
layout.details_viewport()
);
assert!(layout.list_window(7).range().end <= 8);
assert!(layout.details_window(99).range().end <= 40);
}
}
#[test]
fn a_resize_produces_a_snapshot_with_the_new_bounds() {
let tall = LayoutSnapshot::compute(Rect::new(0, 0, 100, 30), content(8, 40));
let short = LayoutSnapshot::compute(Rect::new(0, 0, 60, 18), content(8, 40));
assert_eq!(tall.details_max_scroll(), 15);
assert_eq!(short.details_viewport(), 13);
assert_eq!(short.details_max_scroll(), 27);
assert_eq!(short.list(), Rect::new(0, 2, 35, 15));
assert_eq!(short.details(), Rect::new(35, 2, 25, 15));
assert!(tall.is_over_details(Position::new(70, 6)));
assert!(!short.is_over_details(Position::new(70, 6)));
}
}