pub mod bleats;
pub mod detail;
pub mod flock;
pub mod host;
pub mod status;
#[cfg(test)]
pub mod fixtures;
use ratatui::Frame;
use ratatui::text::{Line, Span};
use self::flock::MIN_HEIGHT;
use super::app::App;
pub const MIN_TERM_WIDTH: u16 = flock::MIN_WIDTH + flock::GUTTER;
#[cfg(test)]
const CHROME_ROWS: u16 = 4;
const HOST_ROWS: u16 = 1;
const DETAIL_ROWS: u16 = 5;
const FEED_ROWS: u16 = 7;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Panes {
pub host: bool,
pub detail: bool,
pub feed: bool,
}
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
}
}
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)
}
pub fn draw(app: &App, frame: &mut Frame<'_>) {
let area = frame.area();
let (width, height) = (area.width, area.height);
let palette = app.palette();
if width < MIN_TERM_WIDTH || height < MIN_HEIGHT {
if width == 0 || height == 0 {
return;
}
let first = Line::from(Span::raw("too small"));
frame.buffer_mut().set_line(area.x, area.y, &first, width);
if height >= 2 {
let second = Line::from(Span::raw(format!("need {MIN_TERM_WIDTH}x{MIN_HEIGHT}")));
frame
.buffer_mut()
.set_line(area.x, area.y + 1, &second, width);
}
return;
}
let panes = panes_for(height);
let mut y = area.y;
let bottom = area.y + height - 1;
let buffer = frame.buffer_mut();
buffer.set_line(
area.x,
y,
&status::title_line(app, app.home(), width),
width,
);
y += 1;
if let Some(banner) = status::banner_line(app) {
buffer.set_line(area.x, y, &banner, width);
y += 1;
}
if panes.host {
buffer.set_line(area.x, y, &host::strip_line(app, width), width);
y += HOST_ROWS;
}
let table_width = width - flock::GUTTER;
let columns = flock::columns_for(table_width);
buffer.set_line(
area.x + flock::GUTTER,
y,
&flock::header_line(columns, table_width, palette.muted()),
table_width,
);
y += 1;
buffer.set_line(area.x, y, &status::rule_line(palette.muted(), width), width);
y += 1;
let mut floor = bottom;
let feed_at = panes.feed.then(|| {
floor -= FEED_ROWS;
floor
});
let detail_at = panes.detail.then(|| {
floor -= DETAIL_ROWS;
floor
});
let viewport = usize::from(floor - y);
let keys = app.visible_rows();
if keys.is_empty() {
let text = if app.flock_len() == 0 {
"the flock is empty".to_string()
} else {
format!("no sheep's name contains \"{}\"", app.filter())
};
let line = Line::from(Span::styled(text, palette.muted()));
buffer.set_line(area.x, y, &line, width);
} else {
let offset = flock::scroll_offset(app.selected_index().unwrap_or(0), viewport, keys.len());
let selected = app.selected();
for (slot, key) in keys.iter().skip(offset).take(viewport).enumerate() {
let slot = u16::try_from(slot).unwrap_or(0);
let is_selected = selected.as_ref() == Some(key);
buffer.set_line(
area.x,
y + slot,
&Line::from(Span::raw(flock::mark(is_selected))),
1,
);
buffer.set_line(
area.x + flock::GUTTER,
y + slot,
&flock::key_line(app, key, columns, table_width),
table_width,
);
}
}
if let Some(top) = detail_at {
buffer.set_line(
area.x,
top,
&status::rule_line(palette.muted(), width),
width,
);
for (offset, line) in detail::detail_lines(app, width).iter().enumerate() {
let offset = u16::try_from(offset).unwrap_or(0);
buffer.set_line(area.x, top + 1 + offset, line, width);
}
}
if let Some(top) = feed_at {
buffer.set_line(
area.x,
top,
&status::rule_line(palette.muted(), width),
width,
);
let rows = usize::from(FEED_ROWS - 1);
for (offset, line) in bleats::feed_lines(app, width, rows).iter().enumerate() {
let offset = u16::try_from(offset).unwrap_or(0);
buffer.set_line(area.x, top + 1 + offset, line, width);
}
}
buffer.set_line(area.x, bottom, &status::status_line(app, width), width);
}
#[cfg(test)]
mod tests {
use std::time::Instant;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
use super::*;
use crate::lookout::app::{App, Control, KeyPress, Msg};
use crate::lookout::theme::Palette;
fn draw_to(app: &App, width: u16, height: u16) -> String {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal.draw(|frame| draw(app, frame)).unwrap();
crate::lookout::frames::render_text(terminal.backend().buffer())
}
#[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 an_empty_flock_still_prints_the_header_and_says_it_is_empty() {
let app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
Instant::now(),
);
let frame = draw_to(&app, 100, 12);
assert!(frame.contains("STATUS"));
assert!(frame.contains("the flock is empty"));
}
#[test]
fn a_filter_matching_nothing_does_not_say_the_flock_is_empty() {
let app = fixtures::filtered_app("zzz");
let frame = draw_to(&app, 120, 30);
assert!(
frame.contains("no sheep's name contains \"zzz\""),
"the table body names the query: {frame:?}"
);
assert!(
!frame.contains("the flock is empty"),
"and does not claim the flock is: {frame:?}"
);
assert!(
frame.contains("no sheep selected: no name contains \"zzz\""),
"the detail pane says its own reason: {frame:?}"
);
assert!(
frame.contains("bleats no sheep is selected"),
"the feed's sentence is already true and is unchanged: {frame:?}"
);
}
#[test]
fn an_empty_flock_still_says_the_flock_is_empty() {
let app = fixtures::filtered_app_of(Vec::new(), "");
let frame = draw_to(&app, 120, 30);
assert!(frame.contains("the flock is empty"), "got {frame:?}");
assert!(!frame.contains("no sheep's name contains"), "got {frame:?}");
}
#[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(4).collect();
assert!(
rows[0].starts_with(" 0 "),
"unselected rows keep a blank gutter: {:?}",
rows[0]
);
assert!(
rows[1].starts_with("> 1 "),
"the marker is on row 1: {:?}",
rows[1]
);
assert!(
rows[2].starts_with(" 2 "),
"and on no other row: {:?}",
rows[2]
);
assert_eq!(
frame.lines().filter(|line| line.starts_with('>')).count(),
1,
"exactly one marker on the frame"
);
}
#[test]
fn a_frozen_link_puts_the_banner_under_the_title() {
let mut app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
Instant::now(),
);
app.update(Msg::Frozen {
at_local: "2026-08-14 14:32:07".to_string(),
});
let frame = draw_to(&app, 100, 12);
let banner = frame.lines().nth(1).expect("a second line").to_string();
assert!(banner.contains("the shepherd has died"));
assert!(banner.contains("2026-08-14 14:32:07"));
}
#[test]
fn the_status_bar_always_says_which_control_state_is_in_force() {
let now = Instant::now();
let read_only = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
now,
);
assert!(draw_to(&read_only, 100, 12).contains("read-only"));
let allowed = App::new(
Palette::detect(None, None, None),
Control::Allowed,
"/home/ada/.shep".to_string(),
now,
);
let frame = draw_to(&allowed, 100, 12);
assert!(frame.contains("control enabled"));
assert!(!frame.contains("read-only"));
}
#[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 every_pane_tier_fits_the_height_it_claims() {
for height in 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 = fixtures::with_selection(fixtures::sheep_with_lambs());
assert_eq!(
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(flock::MIN_HEIGHT),
Panes::NONE,
"12a's frame, untouched"
);
}
#[test]
fn every_pane_lands_inside_its_own_rows_across_the_size_sweep() {
let mut app = fixtures::full_app();
app.update(Msg::Frozen {
at_local: "2026-08-14 14:32:07".to_string(),
});
for height in flock::MIN_HEIGHT..=60 {
for width in [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 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();
assert!(
last.contains("read-only"),
"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 positions: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| l.starts_with("bleats "))
.map(|(i, _)| i)
.collect();
assert_eq!(positions.len(), 1, "the feed header at {width}x{height}");
assert!(
u16::try_from(positions[0]).unwrap_or(0) >= table_body_end,
"the feed header at {width}x{height} sits at row {}, inside or above the table",
positions[0]
);
}
if panes.detail {
let positions: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| l.starts_with("out /home/ada/.shep/logs/"))
.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]
);
}
}
}
}
#[test]
fn the_flock_table_keeps_the_middle_of_the_screen() {
let app = 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");
}
}