pub mod bleats;
pub mod bleats_full;
pub mod cell;
pub mod detail;
pub mod flock;
pub mod host;
mod keymap;
pub mod link_panel;
mod overlay;
pub mod pane;
pub mod scroll;
pub mod secrets;
pub mod settings;
pub mod sheep;
pub mod status;
#[cfg(test)]
pub mod fixtures;
use ratatui::Frame;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use self::flock::MIN_HEIGHT;
use super::app::{App, Body, Grouping, Link, RowKey};
use super::theme::Palette;
use crate::vocabulary::Role;
pub const MIN_TERM_WIDTH: u16 = flock::MIN_WIDTH + flock::GUTTER;
#[cfg(test)]
const CHROME_ROWS: u16 = 4;
const ROOMY_HEIGHT: u16 = 30;
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)
}
#[must_use]
pub fn body_rows(area: Rect) -> u16 {
if area.width < MIN_TERM_WIDTH || area.height < MIN_HEIGHT {
return 0;
}
area.height - 1 - title_gap_rows(area.height)
}
#[must_use]
fn title_gap_rows(height: u16) -> u16 {
1 + u16::from(height >= ROOMY_HEIGHT)
}
fn draw_too_small(frame: &mut Frame<'_>, area: Rect) {
if area.width == 0 || area.height == 0 {
return;
}
let first = Line::from(Span::raw("too small"));
frame
.buffer_mut()
.set_line(area.x, area.y, &first, area.width);
if area.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, area.width);
}
}
pub fn draw(app: &App, frame: &mut Frame<'_>) {
let area = frame.area();
let (width, height) = (area.width, area.height);
if width < MIN_TERM_WIDTH || height < MIN_HEIGHT {
draw_too_small(frame, area);
return;
}
draw_body(app, frame);
draw_keymap_overlay(app, area, frame.buffer_mut(), app.palette());
}
fn draw_body(app: &App, frame: &mut Frame<'_>) {
let area = frame.area();
let (width, height) = (area.width, area.height);
let palette = app.palette();
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, &title_band(app, width), width);
if let Body::Sheep(pane) = app.body() {
let top = y + 1;
let body = Rect {
x: area.x,
y: top,
width,
height: bottom.saturating_sub(top),
};
sheep::draw(app, pane, body, buffer);
buffer.set_line(area.x, bottom, &status::status_line(app, width), width);
return;
}
y += title_gap_rows(height);
let roomy = height >= ROOMY_HEIGHT;
let frozen = matches!(app.link(), Link::Lost { .. });
match app.body() {
Body::ConfigPane(pane) => {
let body = Rect {
x: area.x,
y,
width,
height: body_rows(area),
};
pane::draw_pane(app, pane, body, buffer);
buffer.set_line(area.x, bottom, &status::status_line(app, width), width);
return;
}
Body::Settings(settings) => {
let body = Rect {
x: area.x,
y,
width,
height: body_rows(area),
};
settings::draw_settings(app, settings, body, buffer);
buffer.set_line(area.x, bottom, &status::status_line(app, width), width);
return;
}
Body::Bleats(pane) => {
let body = Rect {
x: area.x,
y,
width,
height: body_rows(area),
};
bleats_full::draw(app, pane, body, buffer);
buffer.set_line(area.x, bottom, &status::status_line(app, width), width);
return;
}
Body::Secrets(pane) => {
let body = Rect {
x: area.x,
y,
width,
height: body_rows(area),
};
secrets::draw(app, pane, body, buffer);
buffer.set_line(area.x, bottom, &status::status_line(app, width), width);
return;
}
Body::Sheep(_) => unreachable!("handled above, ahead of the roomy blank row"),
Body::FlockTable => {}
}
if let Some(banner) = status::banner_line(app, width) {
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 flat_columns = flock::columns_for(table_width);
let fold_columns = flock::fold_columns_for(table_width);
buffer.set_line(area.x, y, &status::rule_line(palette.line(), width), width);
y += 1;
if roomy {
y += 1;
}
let header = match app.grouping() {
Grouping::Flat => flock::header_line(flat_columns, table_width, palette.muted(), frozen),
Grouping::ByFold => {
flock::fold_columns_header_line(fold_columns, table_width, palette.muted())
}
};
buffer.set_line(area.x + flock::GUTTER, y, &header, table_width);
y += 1;
let mut floor = bottom;
let link_at = (frozen && panes.feed).then(|| {
floor -= link_panel::LINK_ROWS;
floor
});
let feed_at = (!frozen && panes.feed).then(|| {
floor -= FEED_ROWS;
floor
});
let detail_at = (!frozen && 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);
let (gutter_text, gutter_style) = flock::gutter(is_selected, palette);
buffer.set_line(
area.x,
y + slot,
&Line::from(Span::styled(gutter_text, gutter_style)),
1,
);
let line = if let RowKey::Section(label) = key {
let role = if *label == "Dogs" {
Role::Sky
} else {
Role::Meadow
};
section_band(
&label.to_ascii_uppercase(),
role,
&app.data_palette(),
table_width,
)
} else {
match app.grouping() {
Grouping::Flat => {
flock::key_line(app, key, flat_columns, table_width, is_selected)
}
Grouping::ByFold => {
flock::fold_key_line(app, key, fold_columns, table_width, is_selected)
}
}
};
buffer.set_line(area.x + flock::GUTTER, y + slot, &line, table_width);
}
}
if let Some(top) = detail_at {
buffer.set_line(
area.x,
top,
&status::rule_line(palette.line(), 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.line(), 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);
}
}
if let Some(top) = link_at {
buffer.set_line(
area.x,
top,
&status::rule_line(palette.line(), width),
width,
);
for (offset, line) in link_panel::panel_lines(app, width).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);
}
fn draw_keymap_overlay(app: &App, area: Rect, buffer: &mut Buffer, palette: Palette) {
if app.keymap_open() {
overlay::mute(buffer, area, palette);
keymap::draw(app, area, buffer);
}
}
fn title_band(app: &App, width: u16) -> Line<'static> {
let (left, right, role) = match app.link() {
Link::Lost { at_local, .. } => (
format!(" THE SHEPHERD HAS DIED \u{2596} these values are frozen as of {at_local}"),
" nothing here is live \u{2596} q to quit ".to_string(),
Role::Bark,
),
Link::Live | Link::Retrying { .. } => {
let visible = app.rows().len();
let total = app.flock_len();
let right = if app.filter().is_empty() {
format!(" {total} in the flock")
} else {
format!(" {visible} of {total} in the flock")
};
(
format!("shep lookout {}", app.home()),
right,
Role::Meadow,
)
}
};
let budget = width.saturating_sub(u16::try_from(right.chars().count()).unwrap_or(0));
let text = format!("{}{right}", flock::fit(&left, budget));
band_line(text, width, app.palette().band(role))
}
fn section_band(label: &str, role: Role, palette: &Palette, width: u16) -> Line<'static> {
Line::from(Span::styled(
cell::band(label, usize::from(width)),
palette.band(role),
))
}
fn band_line(text: String, width: u16, style: Style) -> Line<'static> {
let drawn = text
.chars()
.map(crate::output::width::char_columns)
.sum::<usize>();
let mut padded = text;
if drawn < usize::from(width) {
padded.extend(std::iter::repeat_n(' ', usize::from(width) - drawn));
}
Line::from(Span::styled(padded, style))
}
#[cfg(test)]
mod tests {
use std::time::Instant;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::style::{Color, Modifier};
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;
#[test]
fn the_title_band_is_reverse_video_across_the_whole_width() {
let app = fixtures::app_with(Vec::new(), fixtures::coloured());
let line = title_band(&app, 80);
assert_eq!(
line.spans
.iter()
.map(|s| s.content.chars().count())
.sum::<usize>(),
80,
"a band that stops where its text stops leaves unpainted cells"
);
assert!(
line.spans[0]
.style
.add_modifier
.contains(Modifier::REVERSED)
);
}
#[test]
fn a_frozen_link_turns_the_title_band_bark() {
let mut app = fixtures::app_with(Vec::new(), fixtures::coloured());
app.update(Msg::Frozen {
at_local: "2026-08-14 14:32:07".to_string(),
why: fixtures::FROZEN_WHY.to_string(),
});
let line = title_band(&app, 80);
assert_eq!(line.spans[0].style.fg, Some(Color::Indexed(166)));
}
#[test]
fn the_title_band_counts_both_numbers_while_a_filter_is_on() {
let app = fixtures::filtered_app("web");
let title = fixtures::rendered(&title_band(&app, 120));
assert!(title.contains("2 of 4 in the flock"), "got {title:?}");
}
#[test]
fn the_unfiltered_title_band_is_unchanged() {
let app = fixtures::filtered_app("");
let title = fixtures::rendered(&title_band(&app, 120));
assert!(title.contains("4 in the flock"), "got {title:?}");
assert!(
!title.contains(" of "),
"no second number when nothing is hidden"
);
}
#[test]
fn the_flock_and_dogs_bands_carry_different_roles() {
let flock = vec![
ProcessInfo::builder(1, "web", ProcStatus::Online).build(),
ProcessInfo::builder(90, "otel", ProcStatus::Online)
.pid(Some(90_000))
.dog(Some(shep_core::protocol::DogSource::BuiltIn))
.build(),
];
let app = fixtures::app_with(flock, fixtures::coloured());
let width = 60;
let mut terminal = Terminal::new(TestBackend::new(width, 12)).unwrap();
terminal.draw(|frame| draw(&app, frame)).unwrap();
let buffer = terminal.backend().buffer();
let text = crate::lookout::frames::render_text(buffer);
let lines: Vec<&str> = text.lines().collect();
let flock_y = u16::try_from(
lines
.iter()
.position(|l| l.contains("FLOCK"))
.expect("a FLOCK band is drawn"),
)
.unwrap();
let dogs_y = u16::try_from(
lines
.iter()
.position(|l| l.contains("DOGS"))
.expect("a DOGS band is drawn"),
)
.unwrap();
let flock_fg = buffer.cell((flock::GUTTER, flock_y)).unwrap().fg;
let dogs_fg = buffer.cell((flock::GUTTER, dogs_y)).unwrap().fg;
assert_ne!(
flock_fg, dogs_fg,
"the flock and dogs bands must carry different roles"
);
}
#[test]
fn the_section_bands_name_their_section_in_words() {
let flock = section_band(
"FLOCK",
crate::vocabulary::Role::Meadow,
&fixtures::coloured(),
40,
);
assert!(flock.spans.iter().any(|s| s.content.contains("FLOCK")));
}
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 the_refusal_gets_no_keymap_overlay_over_it() {
let mut app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
Instant::now(),
);
let _ = app.update(Msg::Key(KeyPress::Help));
assert!(app.keymap_open(), "the overlay did not open");
let frame = draw_to(&app, 130, 3);
assert_eq!(frame.lines().next().unwrap().trim_end(), "too small");
for group in crate::lookout::keymap::Group::DRAWN {
assert!(
!frame.contains(group.heading()),
"the overlay drew {} over the refusal: {frame}",
group.heading()
);
}
}
#[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(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 the_selected_rows_ground_paints_every_column_of_the_table_not_just_its_text() {
let mut app = App::new(
Palette::detect(None, None, Some(std::ffi::OsStr::new("truecolor"))),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
Instant::now(),
);
app.update(Msg::Snapshot {
rows: vec![
ProcessInfo::builder(0, "web", ProcStatus::Online).build(),
ProcessInfo::builder(1, "worker", ProcStatus::Online).build(),
],
at: Instant::now(),
});
let width = 100;
let mut terminal = Terminal::new(TestBackend::new(width, 12)).unwrap();
terminal.draw(|frame| draw(&app, frame)).unwrap();
let buffer = terminal.backend().buffer();
let text = crate::lookout::frames::render_text(buffer);
let lines: Vec<&str> = text.lines().collect();
let selected_y = 4;
let unselected_y = 5;
assert!(
lines[selected_y].contains("web"),
"row 0 is the selected one: {:?}",
lines[selected_y]
);
assert!(
lines[unselected_y].contains("worker"),
"row 1 stays unselected: {:?}",
lines[unselected_y]
);
let palette = app.palette();
let ground = palette.ground().bg;
assert!(
ground.is_some(),
"truecolor gets a real ground to paint with"
);
let table_width = width - flock::GUTTER;
let painted = (flock::GUTTER..width)
.filter(|&x| {
buffer
.cell((x, u16::try_from(selected_y).unwrap()))
.is_some_and(|cell| Some(cell.bg) == ground)
})
.count();
assert_eq!(
painted,
usize::from(table_width),
"the ground must reach every column of the table, not just the text"
);
let unpainted = (flock::GUTTER..width)
.filter(|&x| {
buffer
.cell((x, u16::try_from(unselected_y).unwrap()))
.is_some_and(|cell| Some(cell.bg) == ground)
})
.count();
assert_eq!(unpainted, 0, "an unselected row carries no ground at all");
}
#[test]
fn the_row_under_the_band_marks_its_own_truncation() {
let mut app = fixtures::full_app();
app.update(Msg::Frozen {
at_local: "2026-08-14 14:32:07".to_string(),
why: fixtures::FROZEN_WHY.to_string(),
});
let under = |width| {
draw_to(&app, width, 24)
.lines()
.nth(1)
.expect("a second line")
.to_string()
};
assert!(under(90).trim_end().ends_with('…'), "{:?}", under(90));
assert!(
under(160).contains("it will not exit on its own"),
"and the whole sentence survives where it fits: {:?}",
under(160)
);
}
#[test]
fn a_freeze_clears_the_notice_that_would_sit_on_the_key_hint() {
let mut app = fixtures::full_app();
app.update(Msg::BusLagged { count: 4 });
assert!(app.notice().is_some(), "a notice to be cleared");
app.update(Msg::Frozen {
at_local: "2026-08-14 14:32:07".to_string(),
why: fixtures::FROZEN_WHY.to_string(),
});
assert!(app.notice().is_none());
let bar = draw_to(&app, 160, 30)
.lines()
.next_back()
.expect("a status bar")
.to_string();
assert!(bar.contains("j/k g/G move"), "{bar:?}");
assert!(
!bar.contains(" r "),
"the bar must not offer a key a freeze has already refused: {bar:?}"
);
assert!(bar.contains("\u{2588} frozen"), "{bar:?}");
}
#[test]
fn a_frozen_link_says_so_in_the_band_and_keeps_the_home_path_below_it() {
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(),
why: fixtures::FROZEN_WHY.to_string(),
});
let frame = draw_to(&app, 160, 12);
let mut lines = frame.lines();
let band = lines.next().expect("a title band");
assert!(band.contains("THE SHEPHERD HAS DIED"), "{band:?}");
assert!(band.contains("2026-08-14 14:32:07"), "{band:?}");
assert!(
!band.contains("in the flock"),
"the flock count is one more frozen number: {band:?}"
);
let under = lines.next().expect("a second line");
assert!(under.contains("/home/ada/.shep"), "{under:?}");
assert!(under.contains("it will not exit on its own"), "{under:?}");
}
#[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 drawing_the_settings_screen_never_panics_across_the_size_sweep() {
let app = fixtures::app_in_settings();
for (width, height) in [(1, 1), (20, 3), (33, 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 = 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 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 live = fixtures::full_app();
let mut lost = fixtures::full_app();
lost.update(Msg::Frozen {
at_local: "2026-08-14 14:32:07".to_string(),
why: fixtures::FROZEN_WHY.to_string(),
});
for (frozen, app) in [(false, &live), (true, &lost)] {
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 frozen {
if panes.feed {
floor -= 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 = 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 = 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");
}
#[test]
fn the_fold_views_header_and_its_rows_share_one_column_set() {
let mut app = fixtures::app_with(
vec![fixtures::sheep_in_fold(1, "api", Some("edge"))],
fixtures::plain(),
);
let _ = app.update(Msg::Key(KeyPress::FoldView));
let text = draw_to(&app, 120, 16);
let lines: Vec<&str> = text.lines().collect();
let header = lines
.iter()
.find(|line| line.contains("STATUS"))
.expect("the column header row is drawn");
let member_row = lines
.iter()
.find(|line| line.contains("api"))
.expect("the fold's one member row is drawn");
fn char_position(line: &str, needle: &str) -> Option<usize> {
let byte = line.find(needle)?;
Some(line[..byte].chars().count())
}
let header_status_at = char_position(header, "STATUS").expect("checked above");
let row_status_at =
char_position(member_row, "online").expect("the fold's one member is online");
assert_eq!(
header_status_at, row_status_at,
"header:\n{header}\nrow:\n{member_row}"
);
}
#[test]
fn a_fold_header_reads_as_one_without_any_colour() {
let mut app = fixtures::app_with(
vec![
fixtures::sheep_in_fold(1, "api", Some("edge")),
fixtures::sheep_in_fold(2, "cdn", Some("edge")),
],
fixtures::plain(),
);
let _ = app.update(Msg::Key(KeyPress::FoldView));
let expanded = draw_to(&app, 120, 16);
assert!(
expanded.contains("\u{25be} edge"),
"an expanded fold points down: {expanded}"
);
assert!(
!expanded.contains("\u{25be} api"),
"a member row carries no triangle: {expanded}"
);
app.select_fold_for_tests("edge");
let _ = app.update(Msg::Key(KeyPress::Collapse));
let collapsed = draw_to(&app, 120, 16);
assert!(
collapsed.contains("\u{25b8} edge"),
"a collapsed fold points right: {collapsed}"
);
assert!(
!collapsed.contains("\u{25be} edge"),
"and never both ways at once: {collapsed}"
);
}
}