use ratatui::layout::{Constraint, Direction as RatatuiDirection, Layout, Rect};
use crate::SkimOptions;
use crate::tui::options::TuiLayout;
use crate::tui::statusline::InfoDisplay;
use crate::tui::{Direction, Size};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PreviewPlacement {
Left,
Right,
Up,
Down,
None,
}
#[derive(Debug, Clone)]
pub struct LayoutTemplate {
show_header: bool,
preview_placement: PreviewPlacement,
work_layout_reversed: bool,
preview_layout: Option<Layout>,
work_layout: Layout,
}
impl LayoutTemplate {
pub fn from_options(options: &SkimOptions, header_height: u16) -> Self {
let has_border = options.border.is_some();
let input_rows: u16 = if has_border {
3 } else {
1 + if options.info == InfoDisplay::Default { 1 } else { 0 }
};
let show_header = options.header.is_some() || options.header_lines > 0;
let header_rows: u16 = if show_header {
if has_border { header_height + 2 } else { header_height }
} else {
0
};
let preview_visible =
(options.preview.is_some() || options.preview_fn.is_some()) && !options.preview_window.hidden;
let (preview_placement, preview_layout) = if preview_visible {
let pc = size_to_constraint(options.preview_window.size);
let placement = match options.preview_window.direction {
Direction::Left => PreviewPlacement::Left,
Direction::Right => PreviewPlacement::Right,
Direction::Up => PreviewPlacement::Up,
Direction::Down => PreviewPlacement::Down,
};
let layout = match placement {
PreviewPlacement::Left => Layout::new(RatatuiDirection::Horizontal, [pc, Constraint::Fill(1)]),
PreviewPlacement::Right => Layout::new(RatatuiDirection::Horizontal, [Constraint::Fill(1), pc]),
PreviewPlacement::Up => Layout::new(RatatuiDirection::Vertical, [pc, Constraint::Fill(1)]),
PreviewPlacement::Down => Layout::new(RatatuiDirection::Vertical, [Constraint::Fill(1), pc]),
PreviewPlacement::None => unreachable!(),
};
(placement, Some(layout))
} else {
(PreviewPlacement::None, None)
};
let non_list_rows = input_rows + header_rows;
let work_layout_reversed = options.layout == TuiLayout::Reverse;
let work_layout = if show_header {
match options.layout {
TuiLayout::Default | TuiLayout::ReverseList => Layout::vertical([
Constraint::Fill(1),
Constraint::Length(header_rows),
Constraint::Length(input_rows),
]),
TuiLayout::Reverse => Layout::vertical([
Constraint::Length(input_rows),
Constraint::Length(header_rows),
Constraint::Fill(1),
]),
}
} else {
match options.layout {
TuiLayout::Default | TuiLayout::ReverseList => Layout::vertical([
Constraint::Fill(1),
Constraint::Length(0),
Constraint::Length(non_list_rows),
]),
TuiLayout::Reverse => Layout::vertical([
Constraint::Length(non_list_rows),
Constraint::Length(0),
Constraint::Fill(1),
]),
}
};
Self {
show_header,
preview_placement,
preview_layout,
work_layout_reversed,
work_layout,
}
}
pub fn apply(&self, area: Rect) -> AppLayout {
let (work_area, preview_area): (Rect, Option<Rect>) = match &self.preview_layout {
Some(layout) => {
let [a, b]: [Rect; 2] = layout.areas(area);
match self.preview_placement {
PreviewPlacement::Left | PreviewPlacement::Up => (b, Some(a)),
_ => (a, Some(b)),
}
}
None => (area, None),
};
let [slot0, slot1, slot2]: [Rect; 3] = self.work_layout.areas(work_area);
let (list_area, header_slot, input_area) = if self.work_layout_reversed {
(slot2, slot1, slot0)
} else {
(slot0, slot1, slot2)
};
let header_area = if self.show_header { Some(header_slot) } else { None };
AppLayout {
list_area,
input_area,
header_area,
preview_area,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppLayout {
pub list_area: Rect,
pub input_area: Rect,
pub header_area: Option<Rect>,
pub preview_area: Option<Rect>,
}
impl AppLayout {
pub fn compute(area: Rect, options: &SkimOptions, header_height: u16) -> Self {
LayoutTemplate::from_options(options, header_height).apply(area)
}
}
fn size_to_constraint(size: Size) -> Constraint {
match size {
Size::Fixed(n) => Constraint::Length(n),
Size::Percent(p) => Constraint::Percentage(p),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::options::SkimOptionsBuilder;
use crate::tui::options::PreviewLayout;
fn area() -> Rect {
Rect::new(0, 0, 80, 24)
}
fn assert_full_width(rect: Rect, area: Rect, label: &str) {
assert_eq!(rect.x, area.x, "{label}: x");
assert_eq!(rect.width, area.width, "{label}: width");
}
fn assert_vertically_adjacent(a: Rect, b: Rect, label: &str) {
assert_eq!(a.y + a.height, b.y, "{label}: b should start right after a");
}
fn assert_horizontally_adjacent(a: Rect, b: Rect, label: &str) {
assert_eq!(a.x + a.width, b.x, "{label}: b should start right after a");
}
fn compute(options: &SkimOptions) -> AppLayout {
AppLayout::compute(area(), options, 0)
}
fn compute_with_header_height(options: &SkimOptions, header_height: u16) -> AppLayout {
AppLayout::compute(area(), options, header_height)
}
fn opts() -> SkimOptionsBuilder {
SkimOptionsBuilder::default()
}
#[test]
fn default_no_preview_no_header() {
let options = opts().build().unwrap();
let layout = compute(&options);
assert_eq!(layout.input_area.height, 2);
assert_eq!(layout.list_area.height, 24 - 2);
assert!(layout.header_area.is_none());
assert!(layout.preview_area.is_none());
assert_full_width(layout.list_area, area(), "list");
assert_full_width(layout.input_area, area(), "input");
assert_vertically_adjacent(layout.list_area, layout.input_area, "list→input");
}
#[test]
fn default_inline_info_no_header() {
let options = opts().inline_info(true).build().unwrap();
let layout = compute(&options);
assert_eq!(layout.input_area.height, 1);
assert_eq!(layout.list_area.height, 23);
}
#[test]
fn default_hidden_info_no_header() {
let options = opts().no_info(true).build().unwrap();
let layout = compute(&options);
assert_eq!(layout.input_area.height, 1);
assert_eq!(layout.list_area.height, 23);
}
#[test]
fn default_with_header() {
let options = opts().header("My Header").build().unwrap();
let layout = compute_with_header_height(&options, 1);
assert_eq!(layout.list_area.height, 21);
assert_eq!(layout.header_area.unwrap().height, 1);
assert_eq!(layout.input_area.height, 2);
let h = layout.header_area.unwrap();
assert_vertically_adjacent(layout.list_area, h, "list→header");
assert_vertically_adjacent(h, layout.input_area, "header→input");
}
#[test]
fn default_with_multiline_header() {
let options = opts().header("line1\nline2\nline3").build().unwrap();
let layout = compute_with_header_height(&options, 3);
assert_eq!(layout.list_area.height, 19);
assert_eq!(layout.header_area.unwrap().height, 3);
}
#[test]
fn default_with_header_lines() {
let options = opts().header_lines(2usize).build().unwrap();
let layout = compute_with_header_height(&options, 2);
assert_eq!(layout.header_area.unwrap().height, 2);
assert_eq!(layout.list_area.height, 24 - 2 - 2);
}
#[test]
fn template_apply_different_areas() {
let options = opts().build().unwrap();
let template = LayoutTemplate::from_options(&options, 0);
let small = template.apply(Rect::new(0, 0, 40, 12));
let large = template.apply(Rect::new(0, 0, 160, 48));
assert_eq!(small.input_area.height, 2);
assert_eq!(small.list_area.height, 10);
assert_eq!(large.input_area.height, 2);
assert_eq!(large.list_area.height, 46);
}
#[test]
fn reverse_no_preview_no_header() {
let options = opts().layout(TuiLayout::Reverse).build().unwrap();
let layout = compute(&options);
assert_eq!(layout.input_area.y, 0);
assert_eq!(layout.input_area.height, 2);
assert_eq!(layout.list_area.y, 2);
assert_eq!(layout.list_area.height, 22);
assert_vertically_adjacent(layout.input_area, layout.list_area, "input→list");
}
#[test]
fn reverse_with_header() {
let options = opts().layout(TuiLayout::Reverse).header("hdr").build().unwrap();
let layout = compute_with_header_height(&options, 2);
assert_eq!(layout.list_area.height, 20);
let h = layout.header_area.unwrap();
assert_eq!(h.height, 2);
assert_vertically_adjacent(layout.input_area, h, "input→header");
assert_vertically_adjacent(h, layout.list_area, "header→list");
}
#[test]
fn reverse_list_no_preview_no_header() {
let options = opts().layout(TuiLayout::ReverseList).build().unwrap();
let layout = compute(&options);
assert_eq!(layout.input_area.height, 2);
assert_eq!(layout.list_area.height, 22);
assert_vertically_adjacent(layout.list_area, layout.input_area, "list→input");
}
#[test]
fn reverse_list_with_header() {
let options = opts().layout(TuiLayout::ReverseList).header("hdr").build().unwrap();
let layout = compute_with_header_height(&options, 1);
assert_eq!(layout.list_area.height, 21);
let h = layout.header_area.unwrap();
assert_eq!(h.height, 1);
assert_vertically_adjacent(layout.list_area, h, "list→header");
assert_vertically_adjacent(h, layout.input_area, "header→input");
}
#[test]
fn default_preview_right_50_percent() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("right:50%"))
.build()
.unwrap();
let layout = compute(&options);
let preview = layout.preview_area.unwrap();
assert_eq!(preview.width, 40);
assert_eq!(preview.x, 40);
assert_eq!(layout.list_area.width, 40);
assert_eq!(layout.input_area.width, 40);
assert_horizontally_adjacent(layout.list_area, preview, "list→preview");
}
#[test]
fn default_preview_left_30_percent() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("left:30%"))
.build()
.unwrap();
let layout = compute(&options);
let preview = layout.preview_area.unwrap();
assert_eq!(preview.width, 24);
assert_eq!(preview.x, 0);
assert_eq!(layout.list_area.x, 24);
assert_horizontally_adjacent(preview, layout.list_area, "preview→list");
}
#[test]
fn default_preview_right_fixed_20() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("right:20"))
.build()
.unwrap();
let layout = compute(&options);
let preview = layout.preview_area.unwrap();
assert_eq!(preview.width, 20);
assert_eq!(layout.list_area.width, 60);
}
#[test]
fn reverse_preview_left() {
let options = opts()
.layout(TuiLayout::Reverse)
.preview("cat {}")
.preview_window(PreviewLayout::from("left:40%"))
.build()
.unwrap();
let layout = compute(&options);
let preview = layout.preview_area.unwrap();
assert_eq!(preview.width, 32);
assert_eq!(layout.input_area.y, 0);
assert_eq!(layout.input_area.x, 32);
}
#[test]
fn default_preview_up_50_percent() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("up:50%"))
.build()
.unwrap();
let layout = compute(&options);
let preview = layout.preview_area.unwrap();
assert_eq!(preview.height, 12);
assert_eq!(preview.y, 0);
assert_eq!(layout.list_area.y, 12);
assert_eq!(layout.list_area.height, 10);
assert_eq!(layout.input_area.y, 22);
}
#[test]
fn default_preview_down_50_percent() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("down:50%"))
.build()
.unwrap();
let layout = compute(&options);
let preview = layout.preview_area.unwrap();
assert_eq!(preview.height, 12);
assert_eq!(layout.list_area.y, 0);
assert_eq!(layout.list_area.height, 10);
assert_eq!(layout.input_area.y, 10);
assert_eq!(preview.y, 12);
assert_vertically_adjacent(layout.input_area, preview, "input→preview");
}
#[test]
fn default_preview_up_fixed_8() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("up:8"))
.build()
.unwrap();
let layout = compute(&options);
let preview = layout.preview_area.unwrap();
assert_eq!(preview.height, 8);
assert_eq!(preview.y, 0);
assert_eq!(layout.list_area.height, 14);
assert_full_width(preview, area(), "preview");
}
#[test]
fn preview_hidden_produces_no_preview_area() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("right:50%:hidden"))
.build()
.unwrap();
let layout = compute(&options);
assert!(layout.preview_area.is_none());
assert_eq!(layout.list_area.width, 80);
}
#[test]
fn no_preview_command_produces_no_preview_area() {
let options = opts().preview_window(PreviewLayout::from("right:50%")).build().unwrap();
let layout = compute(&options);
assert!(layout.preview_area.is_none());
assert_eq!(layout.list_area.width, 80);
}
#[test]
fn default_with_borders_no_header() {
let options = opts().border(crate::tui::BorderType::Plain).build().unwrap();
let layout = compute(&options);
assert_eq!(layout.input_area.height, 3);
assert_eq!(layout.list_area.height, 21);
assert!(layout.header_area.is_none());
}
#[test]
fn default_with_borders_and_header() {
let options = opts()
.border(crate::tui::BorderType::Plain)
.header("hdr")
.build()
.unwrap();
let layout = compute_with_header_height(&options, 2);
assert_eq!(layout.input_area.height, 3);
let h = layout.header_area.unwrap();
assert_eq!(h.height, 4);
assert_eq!(layout.list_area.height, 24 - 3 - 4);
}
#[test]
fn reverse_with_borders() {
let options = opts()
.layout(TuiLayout::Reverse)
.border(crate::tui::BorderType::Plain)
.build()
.unwrap();
let layout = compute(&options);
assert_eq!(layout.input_area.y, 0);
assert_eq!(layout.input_area.height, 3);
assert_eq!(layout.list_area.y, 3);
assert_eq!(layout.list_area.height, 21);
}
#[test]
fn all_areas_non_overlapping_default() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("right:40%"))
.header("hdr")
.build()
.unwrap();
let layout = compute_with_header_height(&options, 2);
let preview = layout.preview_area.unwrap();
let header = layout.header_area.unwrap();
assert!(
layout.list_area.x + layout.list_area.width <= preview.x || preview.x + preview.width <= layout.list_area.x,
"list and preview overlap"
);
let rects = [layout.list_area, header, layout.input_area];
for i in 0..rects.len() {
for j in (i + 1)..rects.len() {
let a = rects[i];
let b = rects[j];
let vertically_disjoint = a.y + a.height <= b.y || b.y + b.height <= a.y;
assert!(vertically_disjoint, "rects[{i}] and rects[{j}] overlap vertically");
}
}
}
#[test]
fn all_areas_non_overlapping_reverse() {
let options = opts()
.layout(TuiLayout::Reverse)
.inline_info(true)
.border(crate::tui::BorderType::Plain)
.preview("cat {}")
.preview_window(PreviewLayout::from("left:25"))
.header("hdr")
.build()
.unwrap();
let layout = compute_with_header_height(&options, 1);
let preview = layout.preview_area.unwrap();
let header = layout.header_area.unwrap();
assert_eq!(preview.x, 0);
assert_eq!(preview.width, 25);
assert_eq!(layout.list_area.x, 25);
assert_vertically_adjacent(layout.input_area, header, "input→header");
assert_vertically_adjacent(header, layout.list_area, "header→list");
}
#[test]
fn total_height_is_area_height_default() {
let options = opts().header("hdr").build().unwrap();
let layout = compute_with_header_height(&options, 3);
let total = layout.list_area.height + layout.header_area.unwrap().height + layout.input_area.height;
assert_eq!(total, area().height);
}
#[test]
fn total_width_is_area_width_with_right_preview() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("right:50%"))
.build()
.unwrap();
let layout = compute(&options);
let total = layout.list_area.width + layout.preview_area.unwrap().width;
assert_eq!(total, area().width);
}
#[test]
fn total_height_is_area_height_with_down_preview() {
let options = opts()
.inline_info(true)
.preview("cat {}")
.preview_window(PreviewLayout::from("down:6"))
.build()
.unwrap();
let layout = compute(&options);
let total = layout.preview_area.unwrap().height + layout.list_area.height + layout.input_area.height;
assert_eq!(total, area().height);
}
#[test]
fn reverse_list_with_header_and_preview_right() {
let options = opts()
.layout(TuiLayout::ReverseList)
.preview("cat {}")
.preview_window(PreviewLayout::from("right:30%"))
.header("hdr")
.build()
.unwrap();
let layout = compute_with_header_height(&options, 1);
let preview = layout.preview_area.unwrap();
let header = layout.header_area.unwrap();
assert!(preview.x > 0);
assert_vertically_adjacent(layout.list_area, header, "list→header");
assert_vertically_adjacent(header, layout.input_area, "header→input");
assert_eq!(layout.list_area.x, layout.input_area.x);
}
#[test]
fn very_small_area() {
let tiny = Rect::new(0, 0, 20, 5);
let options = opts().header("hdr").build().unwrap();
let layout = AppLayout::compute(tiny, &options, 1);
assert_eq!(layout.list_area.width, 20);
}
}