use super::flock::MIN_HEIGHT;
use ratatui::layout::Rect;
#[cfg(test)]
const CHROME_ROWS: u16 = 4;
pub(super) const ROOMY_HEIGHT: u16 = 30;
pub(super) const HOST_ROWS: u16 = 1;
pub(super) const DETAIL_ROWS: u16 = 5;
pub(super) const FEED_ROWS: u16 = 7;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Panes {
pub host: bool,
pub detail: bool,
pub feed: bool,
}
const PANE_TIERS: &[(u16, Panes)] = &[
(
24,
Panes {
host: true,
detail: true,
feed: true,
},
),
(
18,
Panes {
host: true,
detail: false,
feed: true,
},
),
(
14,
Panes {
host: true,
detail: false,
feed: false,
},
),
(MIN_HEIGHT, Panes::NONE),
];
#[must_use]
pub fn panes_for(height: u16) -> Panes {
PANE_TIERS
.iter()
.find(|(threshold, _)| height >= *threshold)
.map_or(Panes::NONE, |(_, panes)| *panes)
}
#[must_use]
pub fn body_rows(area: Rect) -> u16 {
if area.width < super::MIN_TERM_WIDTH || area.height < MIN_HEIGHT {
return 0;
}
area.height - 1 - title_gap_rows(area.height)
}
#[must_use]
pub(super) fn title_gap_rows(height: u16) -> u16 {
1 + u16::from(height >= ROOMY_HEIGHT)
}
impl Panes {
pub const NONE: Self = Self {
host: false,
detail: false,
feed: false,
};
#[cfg(test)]
#[must_use]
pub const fn rows(self) -> u16 {
let mut rows = 0;
if self.host {
rows += HOST_ROWS;
}
if self.detail {
rows += DETAIL_ROWS;
}
if self.feed {
rows += FEED_ROWS;
}
rows
}
}
#[cfg(test)]
mod tests {
use super::super::super::app::App;
use super::super::super::theme::Palette;
use std::time::Instant;
use super::super::testing::*;
use super::*;
use crate::lookout::app::{Control, KeyPress, Msg};
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
#[test]
fn a_terminal_below_the_floor_says_so_instead_of_drawing() {
let app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
Instant::now(),
);
let frame = draw_to(&app, 28, 8);
let mut lines = frame.lines();
assert_eq!(lines.next().unwrap().trim_end(), "too small");
assert_eq!(lines.next().unwrap().trim_end(), "need 33x6");
assert!(!frame.contains("STATUS"), "no header was drawn");
let cramped = draw_to(&app, 12, 8);
assert!(
cramped.lines().nth(1).unwrap().trim_end() == "need 33x6",
"the dimensions were cut off in the terminal that needed them"
);
let single = draw_to(&app, 20, 1);
assert_eq!(single.lines().next().unwrap().trim_end(), "too small");
assert_eq!(single.lines().count(), 1);
}
#[test]
fn the_marker_sits_in_the_gutter_of_the_selected_row_and_nowhere_else() {
let mut app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
Instant::now(),
);
app.update(Msg::Snapshot {
rows: (0..4)
.map(|id| {
ProcessInfo::builder(id, format!("sheep-{id}"), ProcStatus::Online).build()
})
.collect(),
at: Instant::now(),
});
app.update(Msg::Key(KeyPress::SelectDown));
let frame = draw_to(&app, 100, 12);
let rows: Vec<&str> = frame.lines().skip(3).take(5).collect();
assert!(
rows[0].starts_with(" \u{2588}\u{2588} FLOCK "),
"the section header keeps a blank gutter too: {:?}",
rows[0]
);
assert!(
rows[1].starts_with(" 0 "),
"unselected rows keep a blank gutter: {:?}",
rows[1]
);
assert!(
rows[2].starts_with("> 1 "),
"the marker is on row 1: {:?}",
rows[2]
);
assert!(
rows[3].starts_with(" 2 "),
"and on no other row: {:?}",
rows[3]
);
assert_eq!(
frame.lines().filter(|line| line.starts_with('>')).count(),
1,
"exactly one marker on the frame"
);
}
#[test]
fn drawing_never_panics_across_the_size_sweep() {
let mut app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
Instant::now(),
);
app.update(Msg::Snapshot {
rows: (0..200)
.map(|id| {
ProcessInfo::builder(id, format!("sheep-{id}"), ProcStatus::Online).build()
})
.collect(),
at: Instant::now(),
});
for (width, height) in [(1, 1), (20, 3), (31, 6), (80, 24), (250, 60), (400, 200)] {
let _ = draw_to(&app, width, height);
}
}
#[test]
fn an_armed_settings_candidate_survives_a_body_too_short_to_hold_it() {
let mut app = super::super::fixtures::app_in_settings_with_control();
let _ = app.update(Msg::Key(KeyPress::Cycle));
let text = app.settings().unwrap().pending().unwrap().text.to_string();
let rendered = draw_to(&app, 200, 10);
let last_line = rendered.lines().last().expect("at least one row");
assert!(
last_line.contains(text.trim()),
"the confirm must survive on the status bar's fixed row: {last_line:?}"
);
}
#[test]
fn every_pane_tier_fits_the_height_it_claims() {
for height in super::super::flock::MIN_HEIGHT..=200 {
let panes = panes_for(height);
let fixed = CHROME_ROWS + 1 + panes.rows();
let floor = if panes.rows() == 0 { 1 } else { 3 };
assert!(
fixed + floor <= height,
"height {height} chose {panes:?}, needing {} rows",
fixed + floor
);
}
}
#[test]
fn the_detail_pane_claims_the_rows_it_draws() {
let app =
super::super::fixtures::with_selection(super::super::fixtures::sheep_with_lambs());
assert_eq!(
super::super::detail::detail_lines(&app, 120).len(),
usize::from(DETAIL_ROWS - 1),
"one rule plus its content lines"
);
}
#[test]
fn panes_drop_in_a_fixed_order_as_the_terminal_shortens() {
assert_eq!(
panes_for(60),
Panes {
host: true,
detail: true,
feed: true
}
);
assert_eq!(
panes_for(24),
Panes {
host: true,
detail: true,
feed: true
}
);
assert_eq!(
panes_for(23),
Panes {
host: true,
detail: false,
feed: true
}
);
assert_eq!(
panes_for(18),
Panes {
host: true,
detail: false,
feed: true
}
);
assert_eq!(
panes_for(17),
Panes {
host: true,
detail: false,
feed: false
}
);
assert_eq!(
panes_for(14),
Panes {
host: true,
detail: false,
feed: false
}
);
assert_eq!(panes_for(13), Panes::NONE);
assert_eq!(
panes_for(super::super::flock::MIN_HEIGHT),
Panes::NONE,
"12a's frame, untouched"
);
}
#[test]
fn every_pane_lands_inside_its_own_rows_across_the_size_sweep() {
let live = super::super::fixtures::full_app();
let mut lost = super::super::fixtures::full_app();
lost.update(Msg::Frozen {
at_local: "2026-08-14 14:32:07".to_string(),
why: super::super::fixtures::FROZEN_WHY.to_string(),
});
for (frozen, app) in [(false, &live), (true, &lost)] {
for height in super::super::flock::MIN_HEIGHT..=60 {
for width in [super::super::MIN_TERM_WIDTH, 40, 51, 80, 120, 200] {
let frame = draw_to(app, width, height);
let lines: Vec<&str> = frame.lines().collect();
let panes = panes_for(height);
let table_body_start = 2 + if panes.host { HOST_ROWS } else { 0 } + 2;
let mut floor = height - 1;
if frozen {
if panes.feed {
floor -= super::super::link_panel::LINK_ROWS;
}
} else {
if panes.feed {
floor -= FEED_ROWS;
}
if panes.detail {
floor -= DETAIL_ROWS;
}
}
let table_body_end = floor;
for (i, line) in lines.iter().enumerate() {
let i = u16::try_from(i).unwrap_or(u16::MAX);
if i < table_body_start || i >= table_body_end {
continue;
}
assert!(
!line.starts_with("bleats "),
"the feed header sits inside the table's own rows at \
{width}x{height}, row {i}"
);
assert!(
!line.starts_with("out /home/ada/.shep/logs/"),
"the detail pane's out path sits inside the table's \
own rows at {width}x{height}, row {i}"
);
}
let last = lines.last().unwrap();
let mark = if frozen {
"\u{2588} frozen"
} else {
"read-only"
};
assert!(
last.contains(mark),
"the status bar survived at {width}x{height}: {last:?}"
);
if panes.feed || panes.detail {
let above = lines[lines.len() - 2];
assert!(
!above.trim().is_empty(),
"a blank row above the status bar at {width}x{height}"
);
}
if panes.host {
let positions: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| l.starts_with("host "))
.map(|(i, _)| i)
.collect();
assert_eq!(positions.len(), 1, "the strip at {width}x{height}");
assert!(
u16::try_from(positions[0]).unwrap_or(u16::MAX) < table_body_start,
"the strip at {width}x{height} sits at row {}, at or below the table",
positions[0]
);
}
if panes.feed {
let chip = if frozen { "THE LINK" } else { "BLEATS" };
let positions: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| l.contains(chip))
.map(|(i, _)| i)
.collect();
assert_eq!(positions.len(), 1, "the {chip} header at {width}x{height}");
assert!(
u16::try_from(positions[0]).unwrap_or(0) >= table_body_end,
"the {chip} header at {width}x{height} sits at row {}, inside or above the table",
positions[0]
);
}
if panes.detail && !frozen {
let positions: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| l.starts_with("out ") && l.contains('\u{2502}'))
.map(|(i, _)| i)
.collect();
assert_eq!(
positions.len(),
1,
"the detail pane's out path at {width}x{height}"
);
assert!(
u16::try_from(positions[0]).unwrap_or(0) >= table_body_end,
"the detail pane's out path at {width}x{height} sits at row {}, inside or above the table",
positions[0]
);
}
if frozen {
assert!(
!lines.iter().any(|l| l.contains("BLEATS")),
"the feed survived a freeze at {width}x{height}"
);
assert!(
!lines.iter().any(|l| l.starts_with("lambs ")),
"the detail band survived a freeze at {width}x{height}"
);
}
}
}
}
}
#[test]
fn a_short_terminal_spends_no_rows_on_air() {
let app = super::super::fixtures::full_app();
let short = draw_to(&app, 120, ROOMY_HEIGHT - 1);
let tall = draw_to(&app, 120, ROOMY_HEIGHT);
let blanks = |frame: &str| {
frame
.lines()
.take(6)
.filter(|line| line.trim().is_empty())
.count()
};
assert_eq!(blanks(&short), 0, "short:\n{short}");
assert_eq!(blanks(&tall), 2, "tall:\n{tall}");
}
#[test]
fn the_flock_table_keeps_the_middle_of_the_screen() {
let app = super::super::fixtures::full_app(); let frame = draw_to(&app, 120, 24);
let data_rows = frame
.lines()
.filter(|line| line.starts_with(" ") || line.starts_with("> "))
.filter(|line| line.trim_start().starts_with(|c: char| c.is_ascii_digit()))
.count();
assert!(data_rows >= 5, "the table got {data_rows} rows at 120x24");
}
}