use std::time::Instant;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use shep_core::config::{ApplyGroup, GROUP_ORDER};
use super::super::app::{App, CONFIRM_EXPIRY, CloseDialog};
use super::super::field::{Field, FieldKind, ValueKind};
use super::super::pane::{
ConfigPane, EnvTyping, ListPane, ListRow, Lock, PaneEdit, PaneRow, PaneTarget, ReloadKind,
};
use super::super::theme::Palette;
use super::super::validation;
use super::cell;
use super::flock::{fit, mark};
use super::overlay;
use super::scroll::Attempt;
use crate::output::width::char_columns;
use crate::vocabulary::Role;
const GUTTER: u16 = 2;
const BOX_WIDTH: u16 = 86;
const KEY_W: u16 = 26;
const KEY_MIN: u16 = 8;
const VALUE_MIN: u16 = 8;
const POSITION_W: u16 = 3;
const COST_W: u16 = 10;
const FULL_WIDTH: u16 = KEY_W + 2 + VALUE_MIN + 2 + COST_W;
const VALUE_WIDTH: u16 = KEY_MIN + 2 + VALUE_MIN;
const BLURB_WRAP: u16 = 66;
const LEFT_MIN: u16 = 40;
const PANEL_MIN: u16 = 50;
const PANEL_MAX: u16 = 72;
fn panel_width(width: u16) -> Option<u16> {
let wanted = (u32::from(width) * 45 / 100) as u16;
let panel = wanted.clamp(PANEL_MIN, PANEL_MAX);
(width.saturating_sub(panel) >= LEFT_MIN).then_some(panel)
}
const LANDS_WITH_PANEL_MIN: u16 = 160;
const fn lands_fits_beside_panel(width: u16, has_panel: bool) -> bool {
!has_panel || width >= LANDS_WITH_PANEL_MIN
}
const fn body_width(width: u16) -> u16 {
width.saturating_sub(GUTTER)
}
const fn cost_label(group: ApplyGroup) -> &'static str {
match group {
ApplyGroup::Live => "now",
ApplyGroup::NextSpawn => "next start",
ApplyGroup::Structural => "read-only",
ApplyGroup::NeedsRespawn | _ => "respawn",
}
}
fn widths(width: u16, show_lands: bool) -> (u16, u16, u16) {
if show_lands && width >= FULL_WIDTH {
let rest = width - COST_W - 2;
let key = KEY_W.min(rest - VALUE_MIN - 2);
(key, rest - key - 2, COST_W)
} else if width >= VALUE_WIDTH {
let key = KEY_W.min(width - VALUE_MIN - 2);
(key, width - key - 2, 0)
} else {
(width, 0, 0)
}
}
fn section_header(label: &str, palette: Palette) -> Line<'static> {
Line::from(Span::styled(format!(" {label}"), palette.muted()))
}
fn title_line(pane: &ConfigPane, palette: Palette, width: u16) -> Line<'static> {
let kind = match pane.target() {
PaneTarget::Sheep { .. } => "sheep config",
PaneTarget::Dog { .. } => "dog config",
};
Line::from(Span::styled(
format!(
" {}",
fit(
&format!("{} ({kind})", pane.target().name()),
body_width(width)
)
),
palette.muted(),
))
}
fn mask_secret(secret: bool, raw: String) -> String {
if secret && raw != "(unset)" {
"<set>".to_owned()
} else {
raw
}
}
fn field_line(
pane: &ConfigPane,
index: usize,
selected: bool,
width: u16,
palette: Palette,
show_lands: bool,
) -> Line<'static> {
let Some(field) = pane.fields().fields().get(index) else {
return Line::default();
};
let (key_w, value_w, cost_w) = widths(body_width(width), show_lands);
let flag = match (pane.is_pending(&field.key), pane.is_overridden(&field.key)) {
(true, _) => '!',
(false, true) => '*',
(false, false) => ' ',
};
let raw = pane.display_value(&field.key);
let typing = pane
.typing()
.filter(|typing| typing.key == field.key)
.map(|typing| &typing.buffer);
let value = match typing {
Some(buffer) => format!("{buffer}\u{258f}"),
None => mask_secret(field.secret, raw),
};
let lock = match pane.lock(&field.key) {
Some(Lock::Refused) => '=',
Some(Lock::NoWidget) => '~',
None => ' ',
};
let mut rest = String::from(lock);
rest.push_str(&fit(&format!("{flag}{}", field.key), key_w));
let cost_cell = (cost_w > 0).then(|| fit(pane.cost(&field.key).map_or("", cost_label), cost_w));
let ground = if selected {
palette.ground()
} else {
Style::default()
};
let mark_style = if selected {
palette.attention().patch(ground)
} else {
Style::default()
};
let mark_span = Span::styled(mark(selected), mark_style);
let edited = typing
.is_none()
.then(|| pane.edited_value(&field.key))
.flatten();
let Some(new_value) = edited else {
let mut text = rest;
if value_w > 0 {
text.push_str(" ");
text.push_str(&fit(&value, value_w));
}
if let Some(cost) = &cost_cell {
text.push_str(" ");
text.push_str(cost);
}
let rest_style = if field.editable {
Style::default()
} else {
palette.muted()
};
return Line::from(vec![
mark_span,
Span::styled(text, rest_style.patch(ground)),
]);
};
let new_value = mask_secret(field.secret, new_value);
let mut spans = vec![mark_span, Span::styled(rest, ground)];
if value_w > 0 {
spans.push(Span::styled(" ", ground));
spans.push(Span::styled(
fit(&format!("{value} -> {new_value}"), value_w),
palette.attention().patch(ground),
));
}
if let Some(cost) = cost_cell {
spans.push(Span::styled(" ", ground));
spans.push(Span::styled(cost, ground));
}
Line::from(spans)
}
fn push_wrapped_blurb(
lines: &mut Vec<Line<'static>>,
budget: &mut usize,
pane: &ConfigPane,
palette: Palette,
width: u16,
) {
for (text, style) in top_lines(pane, palette, width) {
if *budget <= 1 {
break;
}
lines.push(Line::from(Span::styled(
format!(" {}", fit(&text, body_width(width))),
style,
)));
*budget -= 1;
}
}
fn top_lines(pane: &ConfigPane, palette: Palette, width: u16) -> Vec<(String, Style)> {
if panel_width(width).is_some() {
return Vec::new();
}
let Some(PaneRow::Field(index)) = pane.cursor() else {
return Vec::new();
};
let Some(field) = pane.fields().fields().get(index) else {
return Vec::new();
};
wrap(
&field.help,
usize::from(BLURB_WRAP.min(width.saturating_sub(2))),
)
.into_iter()
.map(|row| (row, palette.muted()))
.collect()
}
const fn plural(count: usize) -> &'static str {
if count == 1 { "" } else { "S" }
}
const fn needs_or_need(count: usize) -> &'static str {
if count == 1 { "NEEDS" } else { "NEED" }
}
const HEADING_SHEEP_COLUMN: usize = 36;
fn close_dialog_heading_row(dialog: &CloseDialog, body: u16) -> String {
let left = close_dialog_heading(dialog);
let right = close_dialog_sheep_clause(dialog);
let left_w = columns(&left);
let gap = HEADING_SHEEP_COLUMN.saturating_sub(left_w).max(1);
if left_w + gap + columns(&right) > usize::from(body) {
return left;
}
format!("{left}{}{right}", " ".repeat(gap))
}
fn close_dialog_sheep_clause(dialog: &CloseDialog) -> String {
let state = format!("{} is {}", dialog.target_name(), dialog.status());
match dialog.pid() {
Some(pid) => format!("{state}, pid {pid}"),
None => state,
}
}
fn columns(text: &str) -> usize {
text.chars().map(char_columns).sum()
}
fn close_dialog_heading(dialog: &CloseDialog) -> String {
match (dialog.unsent(), dialog.parked()) {
(0, parked) => format!("{parked} FIELD{} ALREADY WAITING", plural(parked)),
(unsent, 0) => format!(
"{unsent} EDIT{} {} A RESPAWN",
plural(unsent),
needs_or_need(unsent)
),
(unsent, parked) => format!(
"{unsent} EDIT{} {} A RESPAWN, {parked} FIELD{} ALREADY DID",
plural(unsent),
needs_or_need(unsent),
plural(parked)
),
}
}
fn close_dialog_naming_sentence(fields: &[String]) -> String {
let list = match fields {
[] => String::new(),
[one] => one.clone(),
[first, second] => format!("{first} and {second}"),
[first, second, third] => format!("{first}, {second} and {third}"),
[first, second, rest @ ..] => {
format!("{first}, {second} and {} more", rest.len())
}
};
let verb = if fields.len() == 1 { "takes" } else { "take" };
format!("{list} {verb} hold when the process starts again.")
}
fn close_dialog_reload_sentence(dialog: &CloseDialog) -> String {
let graceful = dialog.graceful_timeout();
match (dialog.reload(), dialog.instances()) {
(ReloadKind::Overlap, 1) => {
"the replacement starts alongside and takes over. No gap, if the app sets \
SO_REUSEPORT itself."
.to_owned()
}
(ReloadKind::Overlap, _) => {
"one instance at a time, each replacement alongside the one it replaces. No gap, \
if the app sets SO_REUSEPORT itself."
.to_owned()
}
(ReloadKind::Serial, 1) => format!(
"drains it, then starts the replacement. Up to {graceful}, so slower than a \
restart for the same gap."
),
(ReloadKind::Serial, n) => format!(
"one instance at a time, each drained before its replacement starts. Up to \
{graceful} each, 1 of {n} down at a time."
),
}
}
fn close_dialog_option_line(text: String, palette: Palette, width: u16) -> Line<'static> {
Line::from(Span::styled(
format!(" {}", fit(&text, width)),
palette.ground(),
))
}
const RELOAD_LABEL: &str = "L reload ";
fn close_dialog_reload_lines(
dialog: &CloseDialog,
palette: Palette,
body: u16,
) -> Vec<Line<'static>> {
let label_w = u16::try_from(RELOAD_LABEL.chars().count()).unwrap_or(0);
let sentence = close_dialog_reload_sentence(dialog);
let available = usize::from(body.saturating_sub(label_w));
let indent = " ".repeat(usize::from(label_w));
wrap(&sentence, available)
.into_iter()
.enumerate()
.map(|(index, chunk)| {
let text = if index == 0 {
format!("{RELOAD_LABEL}{chunk}")
} else {
format!("{indent}{chunk}")
};
close_dialog_option_line(text, palette, body)
})
.collect()
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum DialogRow {
Key,
Heading,
Continuation,
Naming,
Live,
Blank,
}
#[must_use]
pub(super) fn close_dialog_lines(
dialog: &CloseDialog,
palette: Palette,
width: u16,
now: Instant,
) -> Vec<Line<'static>> {
close_dialog_rows(dialog, palette, width, now)
.into_iter()
.map(|(_, line)| line)
.collect()
}
fn close_dialog_rows(
dialog: &CloseDialog,
palette: Palette,
width: u16,
now: Instant,
) -> Vec<(DialogRow, Line<'static>)> {
let body = body_width(width);
let mut lines = vec![(
DialogRow::Heading,
Line::from(Span::styled(
format!(" {}", fit(&close_dialog_heading_row(dialog, body), body)),
palette.band(Role::Butter),
)),
)];
if dialog.unsent() > 0 {
let sentence = close_dialog_naming_sentence(dialog.unsent_fields());
lines.push((
DialogRow::Naming,
close_dialog_option_line(sentence, palette, body),
));
if dialog.live() > 0 {
lines.push((
DialogRow::Live,
close_dialog_option_line(
"Everything else you changed is already live.".to_owned(),
palette,
body,
),
));
}
}
lines.push((DialogRow::Blank, Line::from(Span::raw(""))));
lines.push((
DialogRow::Key,
close_dialog_option_line(
format!(
"R restart now stop, then start. The stop takes up to {}.",
dialog.kill_timeout()
),
palette,
body,
),
));
lines.extend(
close_dialog_reload_lines(dialog, palette, body)
.into_iter()
.enumerate()
.map(|(index, line)| {
let kind = if index == 0 {
DialogRow::Key
} else {
DialogRow::Continuation
};
(kind, line)
}),
);
lines.push((
DialogRow::Key,
close_dialog_option_line(
"c continue write them and leave it running. They wait for a respawn."
.to_owned(),
palette,
body,
),
));
lines.push((DialogRow::Blank, Line::from(Span::raw(""))));
let elapsed = now.saturating_duration_since(dialog.at());
let remaining = CONFIRM_EXPIRY.saturating_sub(elapsed);
lines.push((
DialogRow::Key,
close_dialog_option_line(
format!(
"esc keep editing, write nothing \u{b7} this prompt expires in {}s {}",
remaining.as_secs(),
cell::gauge(
u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX),
Some(u64::try_from(CONFIRM_EXPIRY.as_millis()).unwrap_or(u64::MAX)),
10
)
),
palette,
body,
),
));
lines
}
fn shed_dialog_rows(rows: &mut Vec<(DialogRow, Line<'static>)>, height: u16, width: u16) {
let height = usize::from(height);
while rows.len() > height {
let sheddable = rows
.iter()
.enumerate()
.filter(|(_, (kind, _))| *kind != DialogRow::Key)
.max_by_key(|(index, (kind, _))| (*kind, *index))
.map(|(index, _)| index);
let Some(index) = sheddable else { break };
let cut = rows.remove(index).0 == DialogRow::Continuation;
if cut && let Some((_, line)) = index.checked_sub(1).and_then(|above| rows.get_mut(above)) {
mark_cut(line, width);
}
}
rows.truncate(height);
}
fn mark_cut(line: &mut Line<'static>, width: u16) {
debug_assert!(width > 0, "mark_cut needs a column to put the marker in");
let Some(span) = line.spans.last_mut() else {
return;
};
let mut text = span.content.trim_end().to_owned();
if text.ends_with('\u{2026}') {
return;
}
while columns(&text) + 1 > usize::from(width) {
text.pop();
}
text.push('\u{2026}');
span.content = text.into();
}
fn list_lines(
pane: &ConfigPane,
list: &ListPane,
palette: Palette,
width: u16,
budget: usize,
) -> Vec<Line<'static>> {
let mut lines = vec![Line::from(Span::styled(
format!(
" {}",
fit(
&format!("{} {} (list)", pane.target().name(), list.key()),
body_width(width)
)
),
palette.muted(),
))];
let body_budget = budget - 1;
if body_budget == 0 {
return lines;
}
let secret = pane.fields().by_key(list.key()).is_some_and(|f| f.secret);
let rows = list.rows();
let cursor_row = list.view().cursor().min(rows.len().saturating_sub(1));
lines.extend(super::scroll::to_cursor(
cursor_row,
list.view().offset(),
|offset| list_body_from(list, palette, width, body_budget, offset, secret),
|| vec![list_line(list, cursor_row, true, width, palette, secret)],
));
lines
}
fn list_line(
list: &ListPane,
index: usize,
selected: bool,
width: u16,
palette: Palette,
secret: bool,
) -> Line<'static> {
let body = body_width(width);
let position_w = POSITION_W.min(body);
let value_w = body.saturating_sub(position_w + 2);
let typed = selected
.then(|| list.typing())
.flatten()
.map(|(_, buffer)| buffer);
let (position, value) = match list.rows().get(index).copied() {
Some(ListRow::Item(item)) => (
format!("{item}"),
typed.map_or_else(
|| {
let raw = list
.elements()
.get(item)
.cloned()
.unwrap_or_else(|| "(unset)".to_owned());
mask_secret(secret, raw)
},
|buffer| format!("{buffer}\u{258f}"),
),
),
Some(ListRow::New) => match typed {
Some(buffer) => (String::new(), format!("{buffer}\u{258f}")),
None => (String::new(), "+ new".to_owned()),
},
None => return Line::default(),
};
let mut text = format!("{} ", mark(selected));
text.push_str(&fit(&position, position_w));
if value_w > 0 {
text.push_str(" ");
text.push_str(&fit(&value, value_w));
}
if matches!(list.rows().get(index), Some(ListRow::New)) {
return Line::from(Span::styled(text, palette.muted()));
}
Line::from(Span::raw(text))
}
fn list_body_from(
list: &ListPane,
palette: Palette,
width: u16,
budget: usize,
offset: usize,
secret: bool,
) -> Attempt {
let rows = list.rows();
let total = rows.len();
let cursor_row = list.view().cursor().min(total.saturating_sub(1));
let above = usize::from(offset > 0);
let mut lines: Vec<Line<'static>> = Vec::new();
let mut drawn = 0usize;
for index in offset..total {
if lines.len() + 1 + above + usize::from(index + 1 < total) > budget {
break;
}
lines.push(list_line(
list,
index,
index == cursor_row,
width,
palette,
secret,
));
drawn += 1;
}
let hidden_below = total.saturating_sub(offset + drawn);
if hidden_below > 0 {
lines.push(Line::from(Span::styled(
format!(" ... {hidden_below} below"),
palette.muted(),
)));
}
if offset > 0 {
lines.insert(
0,
Line::from(Span::styled(
format!(" ... {offset} above"),
palette.muted(),
)),
);
}
Attempt {
cursor_drawn: drawn > 0 && (offset..offset + drawn).contains(&cursor_row),
lines,
}
}
fn has_groups(pane: &ConfigPane) -> bool {
pane.fields()
.fields()
.iter()
.any(|field| field.group.is_some())
}
fn title_band_line(pane: &ConfigPane, palette: Palette, width: u16) -> Line<'static> {
let kind = match pane.target() {
PaneTarget::Sheep { .. } => "sheep config",
PaneTarget::Dog { .. } => "dog config",
};
let mut text = format!("{} ({kind})", pane.target().name());
let count = pane.edits().len();
match count {
0 => {}
1 => text.push_str(" 1 edit"),
n => text.push_str(&format!(" {n} edits")),
}
Line::from(Span::styled(
cell::band(&text, usize::from(width)),
palette.band(Role::Butter),
))
}
fn tab_row_line(pane: &ConfigPane, palette: Palette, width: u16) -> Line<'static> {
let active = pane.group();
let chip = |group: &str| format!(" {group} ");
let chips = GROUP_ORDER
.iter()
.map(|group| chip(group))
.collect::<Vec<_>>()
.join(" ");
let full = format!("{chips} tab next group 1\u{2026}8 jump");
let text = fit(&full, body_width(width));
let marker = chip(active);
let mut spans = vec![Span::raw(" ")];
if let Some(start) = text.find(&marker) {
let end = start + marker.len();
if start > 0 {
spans.push(Span::styled(text[..start].to_owned(), palette.muted()));
}
spans.push(Span::styled(text[start..end].to_owned(), palette.ground()));
if end < text.len() {
spans.push(Span::styled(text[end..].to_owned(), palette.muted()));
}
} else {
spans.push(Span::styled(text, palette.muted()));
}
Line::from(spans)
}
fn hairline_line(palette: Palette, width: u16) -> Line<'static> {
Line::from(Span::styled(
format!(" {}", cell::rule(usize::from(body_width(width)))),
palette.line(),
))
}
fn column_header_line(palette: Palette, width: u16, show_lands: bool) -> Line<'static> {
let (key_w, value_w, cost_w) = widths(body_width(width), show_lands);
let mut text = String::from(" ");
text.push_str(&fit("FIELD", key_w));
if value_w > 0 {
text.push_str(" ");
text.push_str(&fit("VALUE", value_w));
}
if cost_w > 0 {
text.push_str(" ");
text.push_str(&fit("LANDS", cost_w));
}
Line::from(Span::styled(text, palette.muted()))
}
fn legend_line(palette: Palette, width: u16) -> Line<'static> {
Line::from(Span::styled(
format!(
" {}",
fit(
"= read-only, set it in the Flockfile ~ no widget for this shape \
! parked, awaits a respawn * yours -> changed by you, not yet written",
body_width(width)
)
),
palette.muted(),
))
}
fn pending_edit_line(
pane: &ConfigPane,
edit: &PaneEdit,
width: u16,
palette: Palette,
) -> Line<'static> {
let text = match edit {
PaneEdit::Set { key, .. } => {
let secret = pane.fields().by_key(key).is_some_and(|field| field.secret);
let old = pane.display_value(key);
let new = pane.edited_value(key).unwrap_or_default();
let old = mask_secret(secret, old);
let new = mask_secret(secret, new);
format!("{key} {old} -> {new}")
}
PaneEdit::SetEnv { key, value } => match value {
Some(_) => format!("env.{key} -> <set>"),
None => format!("env.{key} removed"),
},
};
Line::from(Span::styled(
format!(" {}", fit(&text, body_width(width).saturating_sub(4))),
palette.attention(),
))
}
fn pending_and_env_lines(
pane: &ConfigPane,
palette: Palette,
width: u16,
budget: usize,
show_lands: bool,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
if budget == 0 {
return lines;
}
let pending_len = if pane.edits().is_empty() {
0
} else {
2 + pane.edits().len()
};
let env_len = 2 + pane.env_key_names().len() + 1;
if pending_len + env_len > budget
&& let Some(PaneRow::Env(_) | PaneRow::AddEnv) = pane.cursor()
{
return vec![cursor_env_row_line(pane, width, palette, show_lands)];
}
let mut remaining = budget;
if !pane.edits().is_empty() && remaining > 0 {
lines.push(Line::default());
remaining -= 1;
if remaining > 0 {
lines.push(section_header("pending edits", palette));
remaining -= 1;
}
for (_, entry) in pane.edits().iter() {
if remaining == 0 {
break;
}
lines.push(pending_edit_line(pane, entry.edit(), width, palette));
remaining -= 1;
}
}
if remaining > 0 {
lines.push(Line::default());
remaining -= 1;
}
if remaining > 0 {
lines.push(section_header("env", palette));
remaining -= 1;
}
for (index, name) in pane.env_key_names().iter().enumerate() {
if remaining == 0 {
break;
}
let selected = pane.cursor() == Some(PaneRow::Env(index));
lines.push(env_row_line(
pane, name, selected, width, palette, show_lands,
));
remaining -= 1;
}
if remaining > 0 {
let selected = pane.cursor() == Some(PaneRow::AddEnv);
lines.push(add_env_row_line(pane, selected, width, palette, show_lands));
}
lines
}
fn cursor_env_row_line(
pane: &ConfigPane,
width: u16,
palette: Palette,
show_lands: bool,
) -> Line<'static> {
match pane.cursor() {
Some(PaneRow::Env(index)) => {
let name = pane
.env_key_names()
.get(index)
.expect("the cursor names a real env row");
env_row_line(pane, name, true, width, palette, show_lands)
}
Some(PaneRow::AddEnv) => add_env_row_line(pane, true, width, palette, show_lands),
Some(PaneRow::Field(_)) | None => {
unreachable!("callers check the cursor is on an env row first")
}
}
}
fn env_row_line(
pane: &ConfigPane,
name: &str,
selected: bool,
width: u16,
palette: Palette,
show_lands: bool,
) -> Line<'static> {
let (key_w, value_w, _) = widths(body_width(width), show_lands);
let typing = selected
.then(|| pane.env_typing())
.flatten()
.filter(|typing| typing.key() == Some(name))
.map(EnvTyping::buffer);
let value = typing.map_or_else(|| "(set)".to_owned(), |buffer| format!("{buffer}\u{258f}"));
let ground = if selected {
palette.ground()
} else {
Style::default()
};
let mark_style = if selected {
palette.attention().patch(ground)
} else {
Style::default()
};
let mark_span = Span::styled(mark(selected), mark_style);
let mut text = String::from(" ");
text.push_str(&fit(&format!(" {name}"), key_w));
if value_w > 0 {
text.push_str(" ");
text.push_str(&fit(&value, value_w));
}
Line::from(vec![mark_span, Span::styled(text, ground)])
}
fn add_env_row_line(
pane: &ConfigPane,
selected: bool,
width: u16,
palette: Palette,
show_lands: bool,
) -> Line<'static> {
let (key_w, _, _) = widths(body_width(width), show_lands);
let typing = selected
.then(|| pane.env_typing())
.flatten()
.filter(|typing| typing.key().is_none())
.map(EnvTyping::buffer);
let ground = if selected {
palette.ground()
} else {
Style::default()
};
let mark_style = if selected {
palette.attention().patch(ground)
} else {
Style::default()
};
let mark_span = Span::styled(mark(selected), mark_style);
let (caption, muted) = match typing {
Some(buffer) => (format!("{buffer}\u{258f}"), false),
None => ("+ add a key".to_owned(), true),
};
let mut text = String::from(" ");
text.push_str(&fit(&format!(" {caption}"), key_w));
let style = if muted {
palette.muted()
} else {
Style::default()
};
Line::from(vec![mark_span, Span::styled(text, style.patch(ground))])
}
fn grouped_body_lines(
pane: &ConfigPane,
palette: Palette,
width: u16,
budget: usize,
show_lands: bool,
) -> Vec<Line<'static>> {
if matches!(pane.cursor(), Some(PaneRow::Env(_) | PaneRow::AddEnv)) {
let tail = pending_and_env_lines(pane, palette, width, budget, show_lands);
let remaining = budget.saturating_sub(tail.len());
let mut lines = if !pane.field_rows().is_empty() && remaining > 0 {
body_from(pane, palette, width, remaining, 0, false, show_lands).lines
} else {
Vec::new()
};
lines.extend(tail);
return lines;
}
let mut lines = if !pane.fields().is_empty() && budget > 0 {
let field_rows = pane.field_rows();
let cursor_row = field_rows
.iter()
.position(|row| Some(*row) == pane.cursor())
.unwrap_or(0);
super::scroll::to_cursor(
cursor_row,
pane.view().offset(),
|offset| body_from(pane, palette, width, budget, offset, false, show_lands),
|| cursor_only(pane, palette, width, budget, cursor_row, show_lands),
)
} else {
Vec::new()
};
let remaining = budget.saturating_sub(lines.len());
lines.extend(pending_and_env_lines(
pane, palette, width, remaining, show_lands,
));
lines
}
fn grouped_pane_lines(
pane: &ConfigPane,
palette: Palette,
width: u16,
budget: usize,
) -> Vec<Line<'static>> {
grouped_pane_lines_with_panel(pane, palette, width, budget, None)
}
fn grouped_pane_with_panel_lines(
pane: &ConfigPane,
palette: Palette,
width: u16,
panel_w: u16,
budget: usize,
) -> Vec<Line<'static>> {
let panel = panel_lines(pane, palette, panel_w);
grouped_pane_lines_with_panel(pane, palette, width, budget, Some((panel, panel_w)))
}
fn grouped_pane_lines_with_panel(
pane: &ConfigPane,
palette: Palette,
width: u16,
budget: usize,
panel: Option<(Vec<Line<'static>>, u16)>,
) -> Vec<Line<'static>> {
let has_panel = panel.is_some();
let show_lands = lands_fits_beside_panel(width, has_panel);
let left_width = panel
.as_ref()
.map_or(width, |(_, panel_width)| width.saturating_sub(*panel_width));
let mut lines = vec![title_band_line(pane, palette, width)];
let mut remaining = budget - 1;
if remaining == 0 {
return lines;
}
if remaining > 1 {
lines.push(tab_row_line(pane, palette, width));
remaining -= 1;
}
if remaining > 1 {
lines.push(hairline_line(palette, width));
remaining -= 1;
}
if remaining > 1 {
lines.push(column_header_line(palette, left_width, show_lands));
remaining -= 1;
}
push_wrapped_blurb(&mut lines, &mut remaining, pane, palette, width);
if remaining == 0 {
return lines;
}
let footer_lines = if remaining > 1 {
2.min(remaining - 1)
} else {
0
};
let body_budget = remaining - footer_lines;
if body_budget > 0 {
let body = grouped_body_lines(pane, palette, left_width, body_budget, show_lands);
lines.extend(match panel {
Some((panel, _)) => merge_beside_panel(body, panel, left_width, body_budget),
None => body,
});
}
if footer_lines >= 1 {
lines.push(hairline_line(palette, width));
}
if footer_lines >= 2 {
lines.push(legend_line(palette, width));
}
lines
}
fn line_columns(line: &Line<'_>) -> usize {
line.spans
.iter()
.flat_map(|span| span.content.chars())
.map(char_columns)
.sum()
}
fn merge_beside_panel(
left: Vec<Line<'static>>,
panel: Vec<Line<'static>>,
left_width: u16,
budget: usize,
) -> Vec<Line<'static>> {
let left_width = usize::from(left_width);
let rows = left.len().max(panel.len()).min(budget);
let mut left = left.into_iter();
let mut panel = panel.into_iter();
(0..rows)
.map(|_| {
let line = left.next().unwrap_or_default();
let used = line_columns(&line);
let mut spans = line.spans;
if used < left_width {
spans.push(Span::raw(" ".repeat(left_width - used)));
}
if let Some(panel_line) = panel.next() {
spans.extend(panel_line.spans);
}
Line::from(spans)
})
.collect()
}
fn dog_footer_text(pane: &ConfigPane, body_budget: usize) -> Option<String> {
let PaneTarget::Dog { name, .. } = pane.target() else {
return None;
};
(body_budget > 0).then(|| format!("shep publishes the change; {name} decides what to reload"))
}
fn push_footer_line(
lines: &mut Vec<Line<'static>>,
footer: Option<String>,
width: u16,
palette: Palette,
) {
if let Some(text) = footer {
lines.push(Line::from(Span::styled(
format!(" {}", fit(&text, body_width(width))),
palette.muted(),
)));
}
}
#[must_use]
pub fn pane_lines(
pane: &ConfigPane,
palette: Palette,
width: u16,
height: u16,
) -> Vec<Line<'static>> {
let budget = if height == 0 {
usize::MAX
} else {
usize::from(height)
};
if budget == 0 {
return Vec::new();
}
if let Some(list) = pane.list() {
return list_lines(pane, list, palette, width, budget);
}
if has_groups(pane) {
if let Some(panel_w) = panel_width(width) {
return grouped_pane_with_panel_lines(pane, palette, width, panel_w, budget);
}
return grouped_pane_lines(pane, palette, width, budget);
}
let panel = panel_width(width).map(|panel_w| (panel_lines(pane, palette, panel_w), panel_w));
ungrouped_pane_lines_with_panel(pane, palette, width, budget, panel)
}
fn ungrouped_pane_lines_with_panel(
pane: &ConfigPane,
palette: Palette,
width: u16,
budget: usize,
panel: Option<(Vec<Line<'static>>, u16)>,
) -> Vec<Line<'static>> {
let show_lands = lands_fits_beside_panel(width, panel.is_some());
let left_width = panel
.as_ref()
.map_or(width, |(_, panel_width)| width.saturating_sub(*panel_width));
let mut lines = vec![title_line(pane, palette, width)];
let mut body_budget = budget - 1;
let footer = dog_footer_text(pane, body_budget);
if footer.is_some() {
body_budget -= 1;
}
push_wrapped_blurb(&mut lines, &mut body_budget, pane, palette, width);
if !pane.fields().is_empty() && body_budget > 0 {
let total = pane.rows().len();
let cursor_row = pane.view().cursor().min(total - 1);
let body = super::scroll::to_cursor(
cursor_row,
pane.view().offset(),
|offset| {
body_from(
pane,
palette,
left_width,
body_budget,
offset,
true,
show_lands,
)
},
|| {
cursor_only(
pane,
palette,
left_width,
body_budget,
cursor_row,
show_lands,
)
},
);
lines.extend(match panel {
Some((panel, _)) => merge_beside_panel(body, panel, left_width, body_budget),
None => body,
});
}
push_footer_line(&mut lines, footer, width, palette);
lines
}
fn body_from(
pane: &ConfigPane,
palette: Palette,
width: u16,
budget: usize,
offset: usize,
show_group_headers: bool,
show_lands: bool,
) -> Attempt {
let rows = pane.field_rows();
let total = rows.len();
let cursor_row = rows
.iter()
.position(|row| Some(*row) == pane.cursor())
.unwrap_or(0)
.min(total.saturating_sub(1));
let above = usize::from(offset > 0);
let room = |taken: usize, need: usize, index: usize| {
taken + need + above + usize::from(index + 1 < total) <= budget
};
let mut lines: Vec<Line<'static>> = Vec::new();
let mut current_group: Option<&str> = None;
let mut pending_header: Vec<Line<'static>> = Vec::new();
let mut drawn = 0usize;
for (index, row) in rows.iter().enumerate() {
let PaneRow::Field(field_index) = *row else {
continue;
};
let group = pane
.fields()
.fields()
.get(field_index)
.and_then(|field| field.group.as_deref());
if show_group_headers && current_group != group {
let mut header = Vec::new();
if current_group.is_some() {
header.push(Line::default());
}
if let Some(group) = group {
header.push(section_header(group, palette));
}
pending_header = header;
current_group = group;
}
if index < offset {
continue;
}
if !room(lines.len(), pending_header.len() + 1, index) {
break;
}
lines.append(&mut pending_header);
lines.push(field_line(
pane,
field_index,
pane.cursor() == Some(*row),
width,
palette,
show_lands,
));
drawn += 1;
}
let hidden_below = total.saturating_sub(offset + drawn);
if hidden_below > 0 {
lines.push(Line::from(Span::styled(
format!(" ... {hidden_below} below"),
palette.muted(),
)));
}
if offset > 0 {
lines.insert(
0,
Line::from(Span::styled(
format!(" ... {offset} above"),
palette.muted(),
)),
);
}
Attempt {
cursor_drawn: drawn > 0 && (offset..offset + drawn).contains(&cursor_row),
lines,
}
}
fn cursor_only(
pane: &ConfigPane,
palette: Palette,
width: u16,
budget: usize,
cursor_row: usize,
show_lands: bool,
) -> Vec<Line<'static>> {
let rows = pane.field_rows();
let mut lines = Vec::new();
if let Some(PaneRow::Field(index)) = rows.get(cursor_row).copied() {
lines.push(field_line(pane, index, true, width, palette, show_lands));
}
let hidden_below = rows.len().saturating_sub(cursor_row + 1);
if cursor_row > 0 && lines.len() < budget {
lines.insert(
0,
Line::from(Span::styled(
format!(" ... {cursor_row} above"),
palette.muted(),
)),
);
}
if hidden_below > 0 && lines.len() < budget {
lines.push(Line::from(Span::styled(
format!(" ... {hidden_below} below"),
palette.muted(),
)));
}
lines
}
fn type_label(field: &Field) -> &'static str {
match field.value_kind {
Some(ValueKind::UpDuration) => return "duration",
Some(ValueKind::MemSize) => return "memory size",
None => {}
}
match &field.kind {
FieldKind::Bool => "bool",
FieldKind::Integer => "integer",
FieldKind::Text | FieldKind::Suggested(_) => "text",
FieldKind::Choice(_) => "choice",
FieldKind::Map => "map",
FieldKind::List(_) => "list",
FieldKind::Opaque => "opaque",
}
}
fn impact_tag(group: ApplyGroup) -> (char, &'static str, &'static str) {
match group {
ApplyGroup::Live => (
'\u{25cf}',
"now",
"the running sheep picks it up without stopping",
),
ApplyGroup::NextSpawn => (
'\u{25b2}',
"next start",
"takes effect the next time the sheep starts",
),
ApplyGroup::Structural => (
'\u{25b2}',
"read-only",
"shep never writes this; set it in the Flockfile instead",
),
ApplyGroup::NeedsRespawn | _ => {
('\u{25b2}', "respawn", "the sheep must stop and start again")
}
}
}
fn wrap(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![text.to_owned()];
}
let mut lines = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
let word_w: usize = word.chars().map(char_columns).sum();
let current_w: usize = current.chars().map(char_columns).sum();
if current.is_empty() {
current.push_str(word);
} else if current_w + 1 + word_w <= width {
current.push(' ');
current.push_str(word);
} else {
lines.push(std::mem::take(&mut current));
current.push_str(word);
}
}
if !current.is_empty() || lines.is_empty() {
lines.push(current);
}
lines
}
fn clipped(text: &str, width: u16) -> String {
let width = usize::from(width);
let columns: usize = text.chars().map(char_columns).sum();
if columns <= width {
return text.to_owned();
}
if width == 0 {
return String::new();
}
let budget = width - 1;
let mut out = String::new();
let mut used = 0;
for c in text.chars() {
let w = char_columns(c);
if used + w > budget {
break;
}
out.push(c);
used += w;
}
out.push('…');
out
}
fn bounded_row(
prefix: Span<'static>,
prefix_cols: u16,
text: &str,
text_style: Style,
width: u16,
) -> Line<'static> {
let budget = width.saturating_sub(prefix_cols);
Line::from(vec![
prefix,
Span::styled(clipped(text, budget), text_style),
])
}
pub(super) fn panel_for_field(
field: &Field,
pane: &ConfigPane,
palette: Palette,
width: u16,
) -> Vec<Line<'static>> {
let width = width.min(PANEL_MAX);
let mut lines = Vec::new();
lines.push(Line::from(vec![
Span::styled(" FOCUSED ", palette.band(Role::Butter)),
Span::raw(format!(" {}", field.key)),
Span::styled(format!(" {}", type_label(field)), palette.muted()),
]));
for row in wrap(
&field.help,
usize::from(BLURB_WRAP.min(width.saturating_sub(2))),
) {
lines.push(Line::from(Span::raw(format!(" {row}"))));
}
lines.push(Line::default());
let now = mask_secret(field.secret, pane.display_value(&field.key));
let default = field.default.clone().unwrap_or_else(|| "(none)".to_owned());
let example = field.example.clone().unwrap_or_else(|| "(none)".to_owned());
for (label, value) in [("now", now), ("default", default), ("example", example)] {
let prefix = format!(" {label:<8}");
let prefix_cols = u16::try_from(prefix.chars().count()).unwrap_or(u16::MAX);
lines.push(bounded_row(
Span::styled(prefix, palette.muted()),
prefix_cols,
&value,
Style::default(),
width,
));
}
if let Some(group) = pane.cost(&field.key) {
let (glyph, word, sentence) = impact_tag(group);
lines.push(Line::default());
let prefix = format!(" {glyph} {word} ");
let prefix_cols = u16::try_from(prefix.chars().count()).unwrap_or(u16::MAX);
lines.push(bounded_row(
Span::raw(prefix),
prefix_cols,
sentence,
Style::default(),
width,
));
let hint = "pick the timing when you close the pane";
let hint_cols = u16::try_from(hint.chars().count()).unwrap_or(width);
let pad = width.saturating_sub(hint_cols);
lines.push(Line::from(Span::raw(format!(
"{}{hint}",
" ".repeat(usize::from(pad))
))));
}
let bullets = validation::bullets(field);
if !bullets.is_empty() {
lines.push(Line::default());
lines.push(hairline_line(palette, width));
lines.push(Line::from(Span::styled(" VALIDATION", palette.muted())));
for text in &bullets.accepts {
lines.push(bounded_row(
Span::styled(" \u{2588} ", palette.band(Role::Meadow)),
4,
text,
Style::default(),
width,
));
}
for text in &bullets.refuses {
lines.push(bounded_row(
Span::styled(" \u{2588} ", palette.band(Role::Bark)),
4,
&format!("refused: {text}"),
Style::default(),
width,
));
}
}
if !field.neighbours.is_empty() {
lines.push(Line::default());
lines.push(Line::from(Span::styled(" NEIGHBOURS", palette.muted())));
for neighbour in &field.neighbours {
let prefix = format!(" {} ", neighbour.field);
let prefix_cols = u16::try_from(prefix.chars().count()).unwrap_or(u16::MAX);
lines.push(bounded_row(
Span::raw(prefix),
prefix_cols,
&neighbour.note,
Style::default(),
width,
));
}
}
lines
}
#[must_use]
pub(super) fn panel_lines(pane: &ConfigPane, palette: Palette, width: u16) -> Vec<Line<'static>> {
let Some(PaneRow::Field(index)) = pane.cursor() else {
return Vec::new();
};
let Some(field) = pane.fields().fields().get(index) else {
return Vec::new();
};
panel_for_field(field, pane, palette, width)
}
pub fn draw_pane(app: &App, pane: &ConfigPane, area: Rect, buffer: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let lines = pane_lines(pane, app.palette(), area.width, area.height);
for (offset, line) in lines.iter().enumerate().take(usize::from(area.height)) {
let offset = u16::try_from(offset).unwrap_or(0);
buffer.set_line(area.x, area.y + offset, line, area.width);
}
if let Some(dialog) = app.close_dialog() {
overlay::mute(buffer, area, app.palette());
draw_close_dialog(dialog, app.palette(), app.now(), area, buffer);
}
}
fn draw_close_dialog(
dialog: &CloseDialog,
palette: Palette,
now: Instant,
area: Rect,
buffer: &mut Buffer,
) {
if overlay::is_boxed(area.width, BOX_WIDTH) {
let lines = close_dialog_lines(dialog, palette, BOX_WIDTH, now);
if overlay::boxed_height(&lines) <= area.height {
overlay::draw_boxed(&lines, BOX_WIDTH, palette, Style::reset(), area, buffer);
return;
}
}
draw_borderless_close_dialog(dialog, palette, now, area, buffer);
}
fn draw_borderless_close_dialog(
dialog: &CloseDialog,
palette: Palette,
now: Instant,
area: Rect,
buffer: &mut Buffer,
) {
let mut rows = close_dialog_rows(dialog, palette, area.width, now);
shed_dialog_rows(&mut rows, area.height, area.width);
let top = area.y
+ area
.height
.saturating_sub(u16::try_from(rows.len()).unwrap_or(0));
let blank = overlay::blank_of(area.width);
for (offset, (_, line)) in rows.iter().enumerate() {
let offset = u16::try_from(offset).unwrap_or(0);
overlay::blank_row(buffer, area.x, top + offset, &blank, Style::reset());
buffer.set_line(area.x, top + offset, line, area.width);
}
}
#[cfg(test)]
mod tests {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use super::super::MIN_TERM_WIDTH;
use super::super::fixtures;
use super::super::flock::MIN_HEIGHT;
use super::*;
use crate::lookout::app::{Effect, KeyPress, Msg};
use crate::lookout::frames::render_text;
use crate::output::width::visible_width;
fn web_pane() -> ConfigPane {
ConfigPane::sheep(fixtures::sheep_config_view())
}
fn text_of(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
})
.collect()
}
#[test]
fn the_dialog_names_both_halves_in_its_heading() {
let dialog = fixtures::close_dialog_with(2, 1);
let lines = close_dialog_lines(&dialog, fixtures::plain(), 120, dialog.at());
let heading = text_of(&lines)[0].trim().to_string();
assert!(
heading.starts_with("2 EDITS NEED A RESPAWN, 1 FIELD ALREADY DID"),
"{heading:?}"
);
}
#[test]
fn the_heading_names_the_sheep_its_state_and_its_pid() {
let dialog = fixtures::close_dialog_with(1, 0);
let lines = close_dialog_lines(&dialog, fixtures::plain(), 120, dialog.at());
let heading = text_of(&lines)[0].trim().to_string();
assert!(heading.ends_with("web is online, pid 71578"), "{heading:?}");
}
#[test]
fn a_sheep_with_no_single_pid_gets_a_heading_that_names_none() {
let dialog = fixtures::close_dialog_without_a_pid();
let lines = close_dialog_lines(&dialog, fixtures::plain(), 120, dialog.at());
let heading = text_of(&lines)[0].trim().to_string();
assert!(heading.ends_with("web is online"), "{heading:?}");
assert!(!heading.contains("pid"), "{heading:?}");
}
#[test]
fn a_narrow_heading_drops_the_sheep_and_keeps_the_question() {
for width in [MIN_TERM_WIDTH, 40, 51, 60, 69, 70, BOX_WIDTH, 89, 120, 160] {
let dialog = fixtures::close_dialog_with(2, 1);
let lines = close_dialog_lines(&dialog, fixtures::plain(), width, dialog.at());
let heading = text_of(&lines)[0].clone();
assert!(
visible_width(&heading) <= usize::from(width),
"{width}: {heading:?}"
);
let trimmed = heading.trim();
assert!(
trimmed.starts_with("2 EDIT"),
"the question survives at {width}: {heading:?}"
);
let named = heading.contains("web is online, pid 71578");
assert_eq!(named, width >= 70, "{width}: {heading:?}");
}
}
#[test]
fn a_single_edit_gets_a_singular_verb() {
let dialog = fixtures::close_dialog_with(1, 0);
let lines = close_dialog_lines(&dialog, fixtures::plain(), 120, dialog.at());
assert!(
text_of(&lines)[0]
.trim()
.starts_with("1 EDIT NEEDS A RESPAWN"),
"{:?}",
text_of(&lines)[0]
);
}
#[test]
fn a_serial_reload_does_not_promise_no_gap() {
let dialog = fixtures::close_dialog_reloading(ReloadKind::Serial, 1);
let lines = close_dialog_lines(&dialog, fixtures::plain(), 120, dialog.at());
let reload = fixtures::row_starting_with(&lines, "L");
assert!(reload.contains("slower than a restart"), "{reload}");
assert!(!reload.contains("No gap"), "{reload}");
}
#[test]
fn an_overlapping_reload_carries_the_reuse_port_caveat() {
let dialog = fixtures::close_dialog_reloading(ReloadKind::Overlap, 1);
let lines = close_dialog_lines(&dialog, fixtures::plain(), 120, dialog.at());
let reload = fixtures::row_starting_with(&lines, "L");
assert!(
reload.contains("if the app sets SO_REUSEPORT itself"),
"{reload}"
);
}
#[test]
fn the_reload_sentence_wraps_rather_than_truncates_at_every_width() {
for width in [
BOX_WIDTH,
overlay::floor_for(BOX_WIDTH) - 1,
MIN_TERM_WIDTH,
160,
] {
for kind in [ReloadKind::Overlap, ReloadKind::Serial] {
for instances in [1, 3] {
let dialog = fixtures::close_dialog_reloading(kind, instances);
let sentence = close_dialog_reload_sentence(&dialog);
let last_word = sentence.split_whitespace().next_back().unwrap();
let lines = close_dialog_lines(&dialog, fixtures::plain(), width, dialog.at());
let joined = text_of(&lines).join(" ");
assert!(
joined.contains(last_word),
"width {width}, {kind:?}, {instances} instances: {joined}"
);
}
}
}
}
#[test]
fn no_line_calls_an_instance_a_lamb() {
for kind in [ReloadKind::Overlap, ReloadKind::Serial] {
for instances in [1, 3] {
let dialog = fixtures::close_dialog_reloading(kind, instances);
let lines = close_dialog_lines(&dialog, fixtures::plain(), 120, dialog.at());
for row in text_of(&lines) {
assert!(!row.contains("lamb"), "{row}");
}
}
}
}
#[test]
fn the_restart_row_states_the_sheeps_own_kill_timeout() {
let dialog = fixtures::close_dialog_with(1, 0);
let lines = close_dialog_lines(&dialog, fixtures::plain(), 120, dialog.at());
let restart = fixtures::row_starting_with(&lines, "R");
assert!(restart.contains("5s"), "{restart}");
}
#[test]
fn everything_else_is_already_live_draws_beside_a_live_edit() {
let dialog = fixtures::close_dialog_with_live_edit(true);
let lines = close_dialog_lines(&dialog, fixtures::plain(), 120, dialog.at());
assert!(
text_of(&lines)
.iter()
.any(|line| line.contains("Everything else you changed is already live")),
"{:?}",
text_of(&lines)
);
}
#[test]
fn everything_else_is_already_live_does_not_draw_with_nothing_else_filed() {
let dialog = fixtures::close_dialog_with_live_edit(false);
let lines = close_dialog_lines(&dialog, fixtures::plain(), 120, dialog.at());
assert!(
!text_of(&lines)
.iter()
.any(|line| line.contains("Everything else you changed is already live")),
"{:?}",
text_of(&lines)
);
}
#[test]
fn the_gauge_shortens_as_now_advances() {
let dialog = fixtures::close_dialog_with(1, 0);
let fresh = close_dialog_lines(&dialog, fixtures::plain(), 120, dialog.at());
let fresh_row = fixtures::row_starting_with(&fresh, "esc");
let fresh_filled = fresh_row.matches('\u{2588}').count();
let halfway = dialog.at() + CONFIRM_EXPIRY / 2;
let later = close_dialog_lines(&dialog, fixtures::plain(), 120, halfway);
let later_row = fixtures::row_starting_with(&later, "esc");
let later_filled = later_row.matches('\u{2588}').count();
assert_eq!(fresh_filled, 10, "{fresh_row}");
assert_eq!(later_filled, 5, "{later_row}");
}
#[test]
fn the_box_draws_at_its_floor_and_not_one_column_below() {
let floor = overlay::floor_for(BOX_WIDTH);
assert!(overlay::is_boxed(floor, BOX_WIDTH));
assert!(!overlay::is_boxed(floor - 1, BOX_WIDTH));
}
#[test]
fn the_borderless_form_spans_the_whole_width_and_never_clips() {
let rendered = fixtures::render_dialog(89, 48);
let heading = fixtures::row_containing(&rendered, "NEEDS A RESPAWN");
assert!(
!heading.contains('▐'),
"no border below the floor: {heading}"
);
assert!(
visible_width(&heading) <= 89,
"clipped or overran: {heading}"
);
}
#[test]
fn the_dialog_fits_every_size_the_dashboard_supports_and_stays_answerable() {
for width in [MIN_TERM_WIDTH, 40, 51, 89, 90, 120, 160] {
for height in MIN_HEIGHT..=24 {
let rendered = render_text(&fixtures::render_dialog(width, height));
for needle in ["restart now", "reload", "continue", "keep editing"] {
assert!(
rendered.contains(needle),
"{width}x{height} lost {needle:?}:\n{rendered}"
);
}
let _ = fixtures::draw_pane_with_dialog(width, height);
}
}
}
#[test]
fn the_reload_caveat_outlives_the_naming_sentence() {
let app = fixtures::app_with_close_dialog();
let dialog = app.close_dialog().expect("the dialog is up");
let whole = close_dialog_reload_sentence(dialog);
let naming = close_dialog_naming_sentence(dialog.unsent_fields());
let rendered = render_text(&fixtures::render_dialog(90, 8));
let rows = reload_rows(&rendered);
let last = rows.last().expect("the reload row draws at every size");
assert!(
rows.join(" ").contains(&whole),
"the caveat is what survives: {rows:?}"
);
assert!(
!last.ends_with('\u{2026}'),
"nothing was cut, so nothing is marked: {rows:?}"
);
assert!(
!rendered.contains(&naming),
"the naming sentence is what went instead:\n{rendered}"
);
}
#[test]
fn a_continuation_that_cannot_survive_leaves_the_cut_marked() {
for (width, height) in [(90u16, 7u16), (MIN_TERM_WIDTH, MIN_HEIGHT)] {
let rendered = render_text(&fixtures::render_dialog(width, height));
let rows = reload_rows(&rendered);
assert_eq!(rows.len(), 1, "{width}x{height}: {rows:?}");
let last = rows.last().expect("the reload row draws at every size");
assert!(
last.ends_with('\u{2026}'),
"{width}x{height}: the cut is unmarked: {rows:?}"
);
}
}
#[test]
fn a_shed_reload_continuation_leaves_the_cut_marked() {
let app = fixtures::app_with_close_dialog();
let dialog = app.close_dialog().expect("the dialog is up");
let whole = close_dialog_reload_sentence(dialog);
for width in [MIN_TERM_WIDTH, 40, 51, 89, 90, 120, 160] {
for height in MIN_HEIGHT..=24 {
let rendered = render_text(&fixtures::render_dialog(width, height));
let rows = reload_rows(&rendered);
let joined = rows.join(" ");
assert!(
joined.contains(&whole) || rows.iter().any(|row| row.ends_with('\u{2026}')),
"{width}x{height}: the caveat went missing unmarked: {rows:?}"
);
}
}
}
#[track_caller]
fn reload_rows(frame: &str) -> Vec<String> {
let mut rows = Vec::new();
for line in frame.lines() {
let text = dialog_interior(line);
if rows.is_empty() {
if text.starts_with("L reload") {
rows.push(text);
}
continue;
}
if text.starts_with("c continue") || text.is_empty() {
break;
}
rows.push(text);
}
assert!(!rows.is_empty(), "no reload row in:\n{frame}");
rows
}
fn dialog_interior(line: &str) -> String {
let inside = match (line.find('▐'), line.rfind('▌')) {
(Some(left), Some(right)) if left < right => &line[left + '▐'.len_utf8()..right],
_ => line,
};
inside.trim().to_owned()
}
#[test]
fn the_pane_behind_the_dialog_is_muted() {
let buffer = fixtures::draw_pane_with_dialog(160, 48);
let behind = buffer[(2, 4)].style();
assert_eq!(behind.fg, fixtures::plain_dimmed().fg);
}
#[test]
fn no_color_flattens_the_pane_behind_instead_of_leaving_it_lit() {
let buffer = fixtures::draw_pane_with_dialog_and_palette(160, 48, fixtures::no_color());
let title_band = buffer[(2, 0)].style();
assert_eq!(title_band.add_modifier, ratatui::style::Modifier::empty());
}
#[track_caller]
fn cell_in_row_containing(buffer: &Buffer, needle: &str, offset: u16) -> ratatui::style::Style {
let text = render_text(buffer);
let (y, line) = text
.lines()
.enumerate()
.find(|(_, line)| line.contains(needle))
.unwrap_or_else(|| panic!("no row contains {needle:?}"));
let x = line.find(needle).unwrap_or(0);
let x = u16::try_from(x).unwrap_or(0) + offset;
let y = u16::try_from(y).unwrap_or(0);
buffer[(x, y)].style()
}
#[test]
fn the_heading_stays_a_reversed_band_under_no_color() {
let buffer = fixtures::draw_pane_with_dialog_and_palette(160, 48, fixtures::no_color());
let heading = cell_in_row_containing(&buffer, "NEEDS A RESPAWN", 0);
assert!(
heading
.add_modifier
.contains(ratatui::style::Modifier::REVERSED)
);
}
#[test]
fn a_sheep_pane_at_a_comfortable_width() {
let lines = pane_lines(&web_pane(), fixtures::plain(), 120, 0);
insta::assert_snapshot!("sheep_pane_wide", text_of(&lines).join("\n"));
}
#[test]
fn a_sheep_pane_scrolled_to_the_last_field_of_a_group_shows_it() {
let mut pane = web_pane();
pane.set_rows(8);
pane.move_to_key("user");
let text = text_of(&pane_lines(&pane, fixtures::plain(), 120, 9));
assert!(text.len() <= 9, "{text:?}");
assert!(text.iter().any(|line| line.contains("above")), "{text:?}");
assert!(
text.iter().any(|line| line.contains("user")),
"process's last field is visible: {text:?}"
);
assert!(!text.iter().any(|line| line.contains("below")), "{text:?}");
}
#[test]
fn every_pane_line_fits_the_width_it_was_drawn_for() {
let pane = web_pane();
for width in super::super::MIN_TERM_WIDTH..=200 {
for line in text_of(&pane_lines(&pane, fixtures::plain(), width, 0)) {
assert!(
visible_width(&line) <= usize::from(width),
"width {width} drew {}: {line:?}",
visible_width(&line)
);
}
}
}
#[test]
fn a_structural_field_renders_muted_and_the_cost_column_says_why() {
let lines = all_group_lines(fixtures::coloured());
let instances = lines
.iter()
.find(|line| text_of(core::slice::from_ref(line))[0].contains("instances"))
.expect("every field is drawn at 89 columns");
assert!(
text_of(core::slice::from_ref(instances))[0].contains("read-only"),
"{instances:?}"
);
assert_eq!(
instances.spans[1].style,
fixtures::coloured().muted(),
"a refused row is muted"
);
let probe = lines
.iter()
.find(|line| text_of(core::slice::from_ref(line))[0].contains("~ liveness_probe"))
.expect("a field with no widget is drawn too");
let rendered = text_of(core::slice::from_ref(probe))[0].clone();
assert!(
rendered.contains("now") && !rendered.contains("read-only"),
"shep writes `liveness_probe`, so its cost is a real cost: {rendered:?}"
);
assert_eq!(
probe.spans[1].style,
fixtures::coloured().muted(),
"muting says `not from here`, which is true of both kinds"
);
}
fn all_group_lines(palette: Palette) -> Vec<Line<'static>> {
let mut pane = web_pane();
let mut lines = Vec::new();
for _ in 0..GROUP_ORDER.len() {
lines.extend(pane_lines(&pane, palette, 89, 0));
pane.next_group();
}
lines
}
fn parts(line: &str) -> Option<(char, char, char, String)> {
let mut chars = line.chars();
let mark = chars.next()?;
let lock = chars.next()?;
let flag = chars.next()?;
let key = chars.as_str().split_whitespace().next()?.to_string();
(mark == '>' || mark == ' ').then_some((mark, lock, flag, key))
}
fn rows_of(text: &[String]) -> Vec<(char, char, char, String)> {
let keys: Vec<String> = web_pane()
.fields()
.fields()
.iter()
.map(|field| field.key.clone())
.collect();
text.iter()
.filter_map(|line| parts(line))
.filter(|(_, _, _, key)| keys.contains(key))
.collect()
}
#[test]
fn the_flags_mark_exactly_the_overridden_and_pending_fields() {
let text = text_of(&all_group_lines(fixtures::plain()));
let flagged = |wanted: char| -> Vec<String> {
rows_of(&text)
.into_iter()
.filter(|(_, _, flag, _)| *flag == wanted)
.map(|(_, _, _, key)| key)
.collect()
};
assert_eq!(flagged('*'), ["reuse_port", "max_restarts"]);
assert_eq!(flagged('!'), ["kill_signal"]);
assert_eq!(
rows_of(&text).len(),
41,
"every field but env is drawn at 89"
);
}
#[test]
fn a_refused_field_and_one_the_pane_has_no_widget_for_get_different_glyphs() {
let text = text_of(&all_group_lines(fixtures::plain()));
let glyphed = |wanted: char| -> Vec<String> {
rows_of(&text)
.into_iter()
.filter(|(_, lock, _, _)| *lock == wanted)
.map(|(_, _, _, key)| key)
.collect()
};
assert_eq!(glyphed('='), ["instances", "name"]);
assert_eq!(
glyphed('~'),
["level_rules", "liveness_probe", "readiness_probe"]
);
assert_eq!(glyphed(' ').len(), 41 - 2 - 3);
}
#[test]
fn a_bare_duration_or_mem_size_shows_its_resolved_unit_in_the_row() {
let text = text_of(&all_group_lines(fixtures::plain()));
let row = |key: &str| {
text.iter()
.find(|line| line.contains(key))
.unwrap_or_else(|| panic!("{key} is drawn at 89 columns: {text:?}"))
};
assert!(
row("kill_timeout").contains("1600ms"),
"{:?}",
row("kill_timeout")
);
assert!(
row("exp_backoff_restart_delay").contains("100ms"),
"{:?}",
row("exp_backoff_restart_delay")
);
assert!(
row("listen_timeout").contains("3s"),
"{:?}",
row("listen_timeout")
);
}
#[test]
fn the_two_glyphs_survive_the_narrowest_width_and_a_palette_with_no_colour() {
let width = super::super::MIN_TERM_WIDTH;
let mut pane = web_pane();
let mut rows = Vec::new();
let mut all_text = Vec::new();
for _ in 0..GROUP_ORDER.len() {
pane.move_to_last();
let text = text_of(&pane_lines(&pane, fixtures::plain(), width, 0));
rows.extend(rows_of(&text));
all_text.extend(text);
pane.next_group();
}
let keys: Vec<String> = web_pane()
.fields()
.fields()
.iter()
.map(|field| field.key.clone())
.collect();
assert!(
!all_text.iter().any(|line| {
parts(line).is_some_and(|(_, _, _, key)| keys.contains(&key))
&& line.contains("read-only")
}),
"no field row can afford the cost cell at {width}: {all_text:?}"
);
let glyph = |key: &str| rows.iter().find(|(_, _, _, k)| k == key).map(|r| r.1);
assert_eq!(glyph("instances"), Some('='));
assert_eq!(glyph("liveness_probe"), Some('~'));
assert_eq!(glyph("watch"), Some(' '));
}
fn screen_at(app: &mut crate::lookout::app::App, height: u16) -> String {
screen_of(app, 120, height)
}
fn screen_of(app: &mut crate::lookout::app::App, width: u16, height: u16) -> String {
let area = Rect::new(0, 0, width, height);
app.note_body_rows(super::super::body_rows(area));
app.note_body_width(width);
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| super::super::draw(app, frame))
.unwrap();
render_text(terminal.backend().buffer())
}
fn marked(text: &str) -> usize {
text.lines().filter(|line| line.starts_with('>')).count()
}
#[test]
fn the_cursor_survives_every_step_of_a_walk_down_and_back_up() {
for height in [6u16, 7, 8, 10, 14, 20, 45] {
let mut app = fixtures::app_in_sheep_pane();
let total = app.config_pane().unwrap().rows().len();
for step in 0..=total {
let text = screen_at(&mut app, height);
assert_eq!(marked(&text), 1, "{height} rows, {step} down:\n{text}");
app.update(Msg::Key(KeyPress::SelectDown));
}
for step in 0..=total {
let text = screen_at(&mut app, height);
assert_eq!(marked(&text), 1, "{height} rows, {step} up:\n{text}");
app.update(Msg::Key(KeyPress::SelectUp));
}
}
}
#[test]
fn the_cursor_survives_a_jump_to_the_last_field_at_a_roomy_height() {
for height in [24u16, 29, 30, 31, 45] {
let mut app = fixtures::app_in_sheep_pane();
app.update(Msg::Key(KeyPress::SelectLast));
let text = screen_at(&mut app, height);
assert_eq!(marked(&text), 1, "{height} rows:\n{text}");
}
}
#[test]
fn the_body_never_outgrows_the_height_it_was_given() {
let mut pane = web_pane();
for width in [120u16, 89] {
for height in 1..=60u16 {
pane.set_rows(usize::from(height.saturating_sub(1)));
for cursor in [0usize, 7, 20, 38] {
pane.move_to_first();
pane.move_by(isize::try_from(cursor).unwrap());
let text = text_of(&pane_lines(&pane, fixtures::plain(), width, height));
assert!(
text.len() <= usize::from(height),
"width {width}, height {height}, cursor {cursor}: {text:?}"
);
}
}
}
}
#[test]
fn the_blurb_never_spends_the_row_the_cursor_needs() {
for (which, mut pane, key) in [
("grouped", web_pane(), "autorestart"),
("ungrouped", bark_pane(), "history_bytes"),
] {
pane.move_to_key(key);
let anchor = blurb_anchor(&pane);
let mut checked = 0;
for height in 1..=20u16 {
let rows = text_of(&pane_lines(&pane, fixtures::plain(), 89, height));
if !rows.iter().any(|row| row.contains(&anchor)) {
continue;
}
checked += 1;
assert!(
rows.iter()
.any(|row| row.contains(key) && !row.contains(&anchor)),
"{which} at height {height}: the blurb drew and the cursor's row did not: {rows:?}"
);
}
assert!(
checked > 0,
"{which}: the blurb never drew, so nothing was checked"
);
}
}
#[test]
fn a_filed_edit_draws_no_question_under_the_title() {
let mut pane = web_pane();
pane.move_to_key("autorestart");
pane.cycle();
let text = text_of(&pane_lines(&pane, fixtures::plain(), 120, 0));
assert!(
!text.iter().any(|line| line.contains("enter confirms")),
"{text:?}"
);
assert!(
!text.iter().any(|line| line.contains("set autorestart")),
"{text:?}"
);
}
#[test]
fn the_blurb_shares_the_panes_own_indent() {
let pane = web_pane();
let lines = pane_lines(&pane, fixtures::plain(), 89, 40);
let rows = text_of(&lines);
let indent = |row: &str| row.len() - row.trim_start().len();
let header = rows
.iter()
.find(|row| row.contains("FIELD") && row.contains("VALUE"))
.expect("no column header");
let anchor = blurb_anchor(&pane);
let blurb = rows
.iter()
.find(|row| row.contains(&anchor))
.expect("no blurb row");
assert_eq!(
indent(blurb),
indent(header),
"blurb {:?} against header {:?}",
blurb.get(..12),
header.get(..12)
);
}
#[test]
fn the_blurb_draws_at_a_width_with_no_panel() {
let pane = web_pane();
assert!(panel_width(89).is_none(), "89 must have no panel");
let lines = pane_lines(&pane, fixtures::plain(), 89, 40);
let anchor = blurb_anchor(&pane);
let rows = text_of(&lines);
assert!(
rows.iter().any(|row| row.contains(&anchor)),
"no blurb at 89 columns: {rows:?}"
);
}
#[test]
fn the_blurb_follows_the_cursor_with_no_panel() {
let mut pane = web_pane();
let first = blurb_anchor(&pane);
pane.move_by(1);
let second = blurb_anchor(&pane);
assert!(
!first.contains(&second) && !second.contains(&first),
"the fixture needs two fields whose longest help words are not substrings of each other: {first:?} / {second:?}"
);
let lines = pane_lines(&pane, fixtures::plain(), 89, 40);
let rows = text_of(&lines);
let blurb_row = rows
.iter()
.find(|row| row.contains(&second))
.unwrap_or_else(|| panic!("the cursor moved and the blurb did not: {rows:?}"));
assert!(
!blurb_row.contains(&first),
"the previous field's blurb is still on screen: {rows:?}"
);
}
#[test]
fn the_panel_is_the_only_blurb_where_it_draws() {
let pane = web_pane();
assert!(panel_width(160).is_some(), "160 must have a panel");
assert!(
top_lines(&pane, fixtures::plain(), 160).is_empty(),
"the inline blurb drew beside the panel, so the help is on \
screen twice"
);
assert!(
!top_lines(&pane, fixtures::plain(), 89).is_empty(),
"and it must still draw where the panel cannot"
);
}
fn blurb_anchor(pane: &ConfigPane) -> String {
let help = field_help_under_cursor(pane);
help.split_whitespace()
.max_by_key(|word| word.len())
.expect("the field help is empty")
.to_owned()
}
fn field_help_under_cursor(pane: &ConfigPane) -> String {
let Some(PaneRow::Field(index)) = pane.cursor() else {
panic!("the cursor is not on a field");
};
pane.fields().fields()[index].help.clone()
}
#[test]
fn help_text_still_respects_the_width_and_height_budgets() {
let mut pane = web_pane();
pane.move_to_key("max_memory");
for width in super::super::MIN_TERM_WIDTH..=200 {
for line in text_of(&pane_lines(&pane, fixtures::plain(), width, 0)) {
assert!(
visible_width(&line) <= usize::from(width),
"width {width}: {line:?}"
);
}
}
for height in 1..=30u16 {
let text = text_of(&pane_lines(&pane, fixtures::plain(), 120, height));
assert!(
text.len() <= usize::from(height),
"height {height}: {text:?}"
);
}
}
fn pane_to(app: &mut crate::lookout::app::App, key: &str) {
fixtures::select_field(app, key);
}
#[test]
fn the_cost_column_predicts_and_the_status_bar_reports() {
for (key, column, pending, sentence) in [
("watch", "now", true, "waits for `shep reload web`"),
("autostart", "next start", false, "set to false"),
] {
let mut app = fixtures::app_in_sheep_pane_with_control();
pane_to(&mut app, key);
let text = text_of(&pane_lines(
app.config_pane().unwrap(),
fixtures::plain(),
89,
0,
));
let row = text
.iter()
.find(|line| {
let mut words = line.split_whitespace();
let mut rest = line.get(3..).map(str::split_whitespace);
(words.next() == Some(key) && words.next().is_some())
|| rest
.as_mut()
.is_some_and(|w| w.next() == Some(key) && w.next().is_some())
})
.unwrap_or_else(|| panic!("{key} is drawn at 89 columns: {text:?}"));
assert!(row.contains(column), "{key}: {row:?}");
app.update(Msg::Key(KeyPress::Cycle));
let _ = app.update(Msg::Key(KeyPress::Escape));
let effect = if app.close_dialog().is_some() {
app.update(Msg::Key(KeyPress::Continue))
} else {
Effect::None
};
let Effect::SendAll(mut batch) = effect else {
panic!("{key}: closing the pane sends");
};
app.update(Msg::Replied {
sent: batch.remove(0),
result: Ok(shep_core::protocol::Response::SheepFieldSet {
name: "web".to_string(),
key: key.to_string(),
pending,
warning: None,
}),
});
let bar = crate::lookout::view::status::status_line(&app, 200).to_string();
assert!(bar.contains(sentence), "{key}: {bar:?}");
}
}
#[test]
fn an_open_editor_draws_its_buffer_in_the_fields_own_row() {
let mut pane = web_pane();
pane.move_to_key("cwd");
pane.begin_typing();
for typed in "/srv".chars() {
pane.type_char(typed);
}
let text = text_of(&pane_lines(&pane, fixtures::plain(), 120, 0));
let row = text
.iter()
.find(|line| line.contains(" cwd"))
.expect("cwd is drawn at 120 columns");
assert!(row.contains("/srv\u{258f}"), "{row:?}");
assert!(
!text.iter().any(|line| line.contains("set cwd")),
"an editor is not a confirm: {text:?}"
);
}
fn bark_pane() -> ConfigPane {
let schema = crate::dog::builtin_schema("bark").expect("bark is a built-in");
ConfigPane::dog(
"bark".into(),
None,
schema,
"poll = \"60s\"\nhistory_bytes = 4096\n\n[sinks.ops]\nkind = \"slack\"\nurl = \"https://hooks.example/x\"\n"
.into(),
)
}
#[test]
fn a_dog_pane_at_a_comfortable_width() {
let text = text_of(&pane_lines(&bark_pane(), fixtures::plain(), 120, 0));
assert!(
!text.iter().any(|line| line.contains("hooks.example")),
"a secret is never rendered: {text:?}"
);
assert!(text.iter().any(|line| line.contains("<set>")), "{text:?}");
assert!(
text.iter()
.any(|line| line.contains("decides what to reload")),
"{text:?}"
);
insta::assert_snapshot!("dog_pane_wide", text.join("\n"));
}
#[test]
fn a_dog_panes_footer_is_paid_for_out_of_the_height_it_was_given() {
let mut pane = bark_pane();
for height in 1..=20u16 {
pane.set_rows(usize::from(height.saturating_sub(1)));
for cursor in [0usize, 2, 4] {
pane.move_to_first();
pane.move_by(isize::try_from(cursor).unwrap());
let text = text_of(&pane_lines(&pane, fixtures::plain(), 120, height));
assert!(
text.len() <= usize::from(height),
"height {height}, cursor {cursor}: {text:?}"
);
}
}
}
#[test]
fn every_dog_pane_line_fits_the_width_it_was_drawn_for() {
let pane = bark_pane();
for width in super::super::MIN_TERM_WIDTH..=200 {
for line in text_of(&pane_lines(&pane, fixtures::plain(), width, 0)) {
assert!(
visible_width(&line) <= usize::from(width),
"width {width} drew {}: {line:?}",
visible_width(&line)
);
}
}
}
#[test]
fn the_env_rows_draw_at_a_comfortable_width() {
let pane = web_pane();
let rows = fixtures::config_pane_env_rows_for_tests(&pane);
assert!(rows.iter().any(|row| row.contains("DB_HOST")), "{rows:?}");
assert!(rows.iter().any(|row| row.contains("LOG_LEVEL")), "{rows:?}");
assert!(
rows.iter()
.all(|row| row.contains("(set)") || row.contains("+ add a key")),
"{rows:?}"
);
assert!(
rows.iter().any(|row| row.contains("+ add a key")),
"{rows:?}"
);
}
#[test]
fn a_filed_env_write_never_reaches_the_screen() {
let mut pane = web_pane();
pane.move_to_last();
assert_eq!(pane.cursor(), Some(PaneRow::AddEnv));
pane.begin_env_typing();
for typed in "NEW_KEY=hunter2".chars() {
pane.type_env_char(typed);
}
pane.apply_env_typing();
let text = text_of(&pane_lines(&pane, fixtures::plain(), 120, 0));
assert!(!text.join("\n").contains("hunter2"), "{text:?}");
}
#[test]
fn every_env_value_renders_as_set_and_never_as_itself() {
let pane = ConfigPane::sheep({
let mut config = shep_core::config::AppConfig {
name: "web".to_string(),
..Default::default()
};
config
.env
.insert("DB_PASSWORD".to_string(), "hunter2".to_string());
shep_core::protocol::SheepConfigView::new(config, Vec::new(), Vec::new())
});
let rows = fixtures::config_pane_env_rows_for_tests(&pane);
assert!(
rows.iter().any(|row| row.contains("DB_PASSWORD")),
"{rows:?}"
);
assert!(
rows.iter().all(|row| !row.contains("hunter2")),
"an env value reached the pane: {rows:?}"
);
assert!(rows.iter().any(|row| row.contains("(set)")), "{rows:?}");
}
#[test]
fn every_env_line_fits_the_width_it_was_drawn_for() {
let mut pane = web_pane();
pane.move_to_last();
assert_eq!(pane.cursor(), Some(PaneRow::AddEnv));
pane.begin_env_typing();
for typed in "A_VERY_LONG_ENV_KEY_NAME=and a value longer still".chars() {
pane.type_env_char(typed);
}
for width in super::super::MIN_TERM_WIDTH..=200 {
for line in text_of(&pane_lines(&pane, fixtures::plain(), width, 0)) {
assert!(
visible_width(&line) <= usize::from(width),
"width {width} drew {}: {line:?}",
visible_width(&line)
);
}
}
}
#[test]
fn the_title_names_the_target_and_no_longer_calls_it_read_only() {
let text = text_of(&pane_lines(&web_pane(), fixtures::plain(), 120, 0));
assert!(text[0].contains("web"), "{:?}", text[0]);
assert!(text[0].contains("(sheep config)"), "{:?}", text[0]);
assert!(!text[0].contains("read-only"), "{:?}", text[0]);
}
fn web_with_args(args: &[&str]) -> shep_core::protocol::SheepConfigView {
let config = shep_core::config::AppConfig {
name: "web".to_string(),
args: args.iter().map(|arg| (*arg).to_string()).collect(),
..Default::default()
};
shep_core::protocol::SheepConfigView::new(config, Vec::new(), Vec::new())
}
fn rendered_list(pane: &ConfigPane, width: u16, height: u16) -> Vec<String> {
let mut pane = pane.clone();
pane.move_to_key("args");
pane.open_list();
text_of(&pane_lines(&pane, fixtures::plain(), width, height))
}
fn app_on_the_list_screen(args: &[&str]) -> crate::lookout::app::App {
let mut app = fixtures::with_selection(
shep_core::protocol::ProcessInfo::builder(
9,
"web",
shep_core::status::ProcStatus::Online,
)
.pid(Some(48_000))
.build(),
);
app.set_control_for_tests(crate::lookout::app::Control::Allowed);
app.update(Msg::Key(KeyPress::Edit));
app.update(Msg::Replied {
sent: crate::lookout::app::Sent::SheepConfig {
name: "web".to_string(),
},
result: Ok(shep_core::protocol::Response::SheepConfig(Box::new(
web_with_args(args),
))),
});
pane_to(&mut app, "args");
let _ = app.update(Msg::Key(KeyPress::Confirm));
app
}
#[test]
fn the_list_screen_shows_its_values_unlike_env() {
let pane = ConfigPane::sheep(web_with_args(&["--port"]));
let lines = rendered_list(&pane, 120, 20);
assert!(lines.iter().any(|l| l.contains("--port")), "{lines:?}");
assert!(
!lines.iter().any(|l| l.contains(r#"["--port"]"#)),
"the element is a row of its own, not the field list's JSON cell: {lines:?}"
);
}
fn secret_list_dog_pane() -> ConfigPane {
let schema = serde_json::json!({
"properties": {
"tokens": {
"type": "array",
"items": { "type": "string" },
"x-shep-secret": true,
}
}
});
let mut pane = ConfigPane::dog(
"watch".into(),
None,
schema,
"tokens = [\"ab12cd34\", \"ef56gh78\"]\n".into(),
);
pane.move_to_key("tokens");
pane.open_list();
pane
}
fn secret_dog_pane_with_an_edit() -> ConfigPane {
let schema = serde_json::json!({
"properties": {
"token": {
"type": "string",
"x-shep-secret": true,
}
}
});
let mut pane = ConfigPane::dog(
"watch".into(),
None,
schema,
"token = \"ab12cd34\"\n".into(),
);
pane.move_to_key("token");
pane.begin_typing();
for c in "ef56gh78".chars() {
pane.type_char(c);
}
pane.apply_typing();
pane
}
#[test]
fn the_pending_section_masks_a_secret_fields_old_and_new_value() {
let pane = secret_dog_pane_with_an_edit();
let (_, entry) = pane.edits().iter().next().expect("one edit was filed");
let line = pending_edit_line(&pane, entry.edit(), 120, fixtures::plain());
let text: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert!(!text.contains("ab12cd34"), "the old value leaked: {text}");
assert!(!text.contains("ef56gh78"), "the new value leaked: {text}");
assert!(text.contains("<set> -> <set>"), "{text}");
}
#[test]
fn the_field_row_masks_both_halves_of_a_secrets_edited_value() {
let pane = secret_dog_pane_with_an_edit();
let text = text_of(&pane_lines(&pane, fixtures::plain(), 120, 0));
let row = text
.iter()
.find(|line| line.contains(" token"))
.expect("token is drawn at 120 columns");
assert!(row.contains("<set> -> <set>"), "{row:?}");
assert!(
!text.join("\n").contains("ab12cd34"),
"the old value leaked: {text:?}"
);
assert!(
!text.join("\n").contains("ef56gh78"),
"the new value leaked: {text:?}"
);
}
#[test]
fn the_explanation_panel_masks_a_secret_fields_value() {
let pane = secret_dog_pane_with_an_edit();
let field = pane.fields().by_key("token").expect("token is a field");
let lines = panel_for_field(field, &pane, fixtures::plain(), 120);
let text: String = lines
.iter()
.flat_map(|line| line.spans.iter())
.map(|span| span.content.as_ref())
.collect();
assert!(
!text.contains("ab12cd34"),
"the current value leaked: {text}"
);
assert!(text.contains("<set>"), "{text}");
}
#[test]
fn the_list_screen_masks_a_secret_arrays_elements() {
let text = text_of(&pane_lines(
&secret_list_dog_pane(),
fixtures::plain(),
120,
0,
));
assert!(
!text
.iter()
.any(|line| line.contains("ab12cd34") || line.contains("ef56gh78")),
"a secret element is never rendered: {text:?}"
);
assert!(
text.iter().filter(|line| line.contains("<set>")).count() == 2,
"both elements mask to <set>: {text:?}"
);
}
#[test]
fn the_list_sub_screen_at_a_comfortable_width() {
let lines = rendered_list(&web_pane(), 120, 0);
insta::assert_snapshot!("list_sub_screen", lines.join("\n"));
}
#[test]
fn the_list_sub_screen_never_outgrows_the_height_or_the_width_it_was_given() {
let mut pane = ConfigPane::sheep(web_with_args(&[
"--port",
"8080",
"--host",
"0.0.0.0",
"--verbose",
"--log",
"debug",
]));
pane.move_to_key("args");
pane.open_list();
pane.file_list_removal();
for height in 1..=20u16 {
pane.list_mut().unwrap().move_to_first();
let total = pane.list().unwrap().rows().len();
pane.list_mut()
.unwrap()
.set_rows(usize::from(height.saturating_sub(1)));
for step in 0..=total {
let text = text_of(&pane_lines(&pane, fixtures::plain(), 120, height));
assert!(
text.len() <= usize::from(height),
"height {height}, step {step}: {text:?}"
);
pane.list_mut().unwrap().move_by(1);
}
}
for width in super::super::MIN_TERM_WIDTH..=200 {
for line in text_of(&pane_lines(&pane, fixtures::plain(), width, 0)) {
assert!(
visible_width(&line) <= usize::from(width),
"width {width} drew {}: {line:?}",
visible_width(&line)
);
}
}
}
#[test]
fn the_list_cursor_survives_every_step_at_the_minimum_height() {
let args = [
"--port",
"8080",
"--host",
"0.0.0.0",
"--verbose",
"--log",
"debug",
"--quiet",
];
for height in [super::super::flock::MIN_HEIGHT, 7, 8, 12] {
let mut app = app_on_the_list_screen(&args);
let total = app.config_pane().unwrap().list().unwrap().rows().len();
for step in 0..=total {
let text = screen_at(&mut app, height);
assert_eq!(marked(&text), 1, "height {height}, step {step}:\n{text}");
app.update(Msg::Key(KeyPress::SelectDown));
}
}
}
#[test]
fn tab_walks_every_group_and_each_one_shows_its_own_fields() {
let mut app = fixtures::app_in_sheep_pane();
let mut seen = Vec::new();
for _ in 0..GROUP_ORDER.len() {
let group = app.config_pane().unwrap().group().to_owned();
let tab_row = fixtures::config_pane_tab_row_for_tests(&app, 160);
for name in GROUP_ORDER {
assert!(
tab_row.contains(name),
"the tab row does not name {name}: {tab_row:?}"
);
}
seen.push(group);
app.update(Msg::Key(KeyPress::NextGroup));
}
assert_eq!(seen, GROUP_ORDER.to_vec());
}
#[test]
fn tab_draws_the_active_group_as_its_own_chip() {
for active in GROUP_ORDER {
let mut pane = web_pane();
let digit =
u8::try_from(GROUP_ORDER.iter().position(|g| g == active).unwrap() + 1).unwrap();
pane.set_group(digit);
let line = tab_row_line(&pane, fixtures::coloured(), 160);
let painted: Vec<&str> = line
.spans
.iter()
.filter(|span| span.style.bg.is_some())
.map(|span| span.content.as_ref().trim())
.collect();
assert_eq!(
painted,
vec![*active],
"exactly one chip should carry a ground, the active group's own"
);
}
}
#[test]
fn tab_wraps_from_the_last_group_to_the_first() {
let mut app = fixtures::app_in_sheep_pane();
for _ in 0..GROUP_ORDER.len() {
app.update(Msg::Key(KeyPress::NextGroup));
}
assert_eq!(app.config_pane().unwrap().group(), GROUP_ORDER[0]);
}
#[test]
fn the_digits_reach_the_same_groups_tab_does() {
for (index, wanted) in GROUP_ORDER.iter().enumerate() {
let mut app = fixtures::app_in_sheep_pane();
let digit = u8::try_from(index + 1).unwrap();
app.update(Msg::Key(KeyPress::Group(digit)));
assert_eq!(&app.config_pane().unwrap().group(), wanted);
}
}
#[test]
fn the_list_shows_only_the_active_groups_fields() {
let mut app = fixtures::app_in_sheep_pane();
app.update(Msg::Key(KeyPress::Group(1)));
let rows = fixtures::config_pane_field_rows_for_tests(&app);
assert!(rows.iter().any(|row| row.contains("cwd")), "{rows:?}");
assert!(
!rows.iter().any(|row| row.contains("kill_timeout")),
"a shutdown field is showing under process: {rows:?}"
);
}
#[test]
fn the_pending_section_lists_an_edit_from_another_group() {
let mut app = fixtures::app_in_sheep_pane_with_two_edits();
app.update(Msg::Key(KeyPress::Group(1)));
let rows = fixtures::config_pane_pending_rows_for_tests(&app);
assert!(
rows.iter().any(|row| row.contains("max_memory")),
"{rows:?}"
);
}
#[test]
fn the_title_band_counts_edits_without_calling_them_pending() {
let app = fixtures::app_in_sheep_pane_with_two_edits();
let band = fixtures::config_pane_title_band_for_tests(&app, 160);
assert!(band.contains("2 edits"), "{band}");
assert!(!band.contains("pending"), "{band}");
}
#[test]
fn an_edited_row_shows_old_then_new_and_takes_no_marker() {
let app = fixtures::app_in_sheep_pane_with_two_edits();
let row = fixtures::config_pane_row_for_tests(&app, "max_memory");
assert!(row.contains("->"), "{row}");
assert!(!row.trim_start().starts_with('!'), "{row}");
}
#[test]
fn the_impact_glyphs_are_one_column_wide() {
assert_eq!(char_columns('\u{25b2}'), 1);
assert_eq!(char_columns('\u{25cf}'), 1);
}
#[test]
fn the_panel_describes_the_focused_field_only() {
let app = fixtures::app_in_sheep_pane();
let panel = fixtures::config_pane_panel_for_tests(&app, 160);
assert!(panel.iter().any(|row| row.contains("cwd")), "{panel:?}");
assert!(
!panel.iter().any(|row| row.contains("kill_timeout")),
"the panel is showing a field that is not focused: {panel:?}"
);
}
#[test]
fn the_panel_names_now_default_and_example() {
let app = fixtures::app_in_sheep_pane();
let panel = fixtures::config_pane_panel_for_tests(&app, 160);
for label in ["now", "default", "example"] {
assert!(
panel.iter().any(|row| row.trim_start().starts_with(label)),
"no {label} row: {panel:?}"
);
}
}
#[test]
fn the_panel_follows_the_selection() {
let mut app = fixtures::app_in_sheep_pane();
let first = fixtures::config_pane_panel_for_tests(&app, 160);
app.update(Msg::Key(KeyPress::SelectDown));
let second = fixtures::config_pane_panel_for_tests(&app, 160);
assert_ne!(first, second);
}
#[test]
fn a_duration_field_takes_its_bullets_from_the_type_table() {
let mut app = fixtures::app_in_sheep_pane();
app.update(Msg::Key(KeyPress::Group(4)));
let panel = fixtures::config_pane_panel_focused_on(&app, "min_uptime", 160);
assert!(
panel.iter().any(|row| row.contains("milliseconds")),
"{panel:?}"
);
}
#[test]
fn a_field_with_nothing_to_say_renders_no_headings() {
let app = fixtures::app_in_sheep_pane();
let panel = fixtures::config_pane_panel_focused_on(&app, "fold", 160);
assert!(
!panel.iter().any(|row| row.contains("VALIDATION")),
"{panel:?}"
);
assert!(
!panel.iter().any(|row| row.contains("NEIGHBOURS")),
"{panel:?}"
);
}
#[test]
fn a_refused_form_reads_as_refused_without_colour() {
let mut app = fixtures::app_with_plain_palette_in_sheep_pane();
fixtures::select_field(&mut app, "name");
let panel = fixtures::config_pane_panel_for_tests(&app, 160);
let refusal = panel
.iter()
.find(|row| row.contains("path separator"))
.expect("name states a refusal");
assert!(refusal.contains("refused"), "{refusal}");
}
#[test]
fn the_blurb_wraps_rather_than_truncating() {
let app = fixtures::app_in_sheep_pane();
let panel = fixtures::config_pane_panel_for_tests(&app, 160);
assert!(
panel.iter().all(|row| row.chars().count() <= 72),
"a panel row overflows its column: {panel:?}"
);
assert!(panel.iter().filter(|row| !row.trim().is_empty()).count() > 3);
}
#[test]
fn the_panel_draws_beside_the_field_list_at_the_design_target() {
let app = fixtures::app_in_sheep_pane();
let pane = app.config_pane().expect("the pane is open");
let at_target = text_of(&pane_lines(pane, fixtures::plain(), 160, 48));
assert!(
at_target.iter().any(|line| line.contains("FOCUSED")),
"the panel never drew at the design target: {at_target:?}"
);
let below_floor = text_of(&pane_lines(pane, fixtures::plain(), 89, 48));
assert!(
!below_floor.iter().any(|line| line.contains("FOCUSED")),
"the panel must not draw below its own floor: {below_floor:?}"
);
}
#[test]
fn no_row_spills_across_the_panel_boundary_or_past_its_own_width() {
let app = fixtures::app_in_sheep_pane();
let pane = app.config_pane().expect("the pane is open");
for width in [160, 200] {
for line in pane_lines(pane, fixtures::plain(), width, 48) {
let cols = line_columns(&line);
assert!(
cols <= usize::from(width),
"a row is {cols} columns wide at terminal width {width}: {line:?}"
);
}
}
}
#[test]
fn a_dogs_panel_draws_beside_its_field_list_at_the_design_target() {
let app = fixtures::app_in_dog_pane();
assert!(
fixtures::config_pane_draws_a_panel(&app, 160),
"the panel never drew for a dog at the design target"
);
assert!(
!fixtures::config_pane_draws_a_panel(&app, 89),
"the panel must not draw below its own floor"
);
}
#[test]
fn a_dogs_panel_has_no_impact_region() {
let app = fixtures::app_in_dog_pane();
let panel = fixtures::config_pane_panel_for_tests(&app, 160);
assert!(
panel.iter().any(|line| line.contains("FOCUSED")),
"the panel must actually be drawn for its own absence to mean anything: {panel:?}"
);
assert!(
!panel
.iter()
.any(|line| line.contains("pick the timing when you close the pane")),
"a dog's cost is always None, so no impact sentence should render: {panel:?}"
);
}
#[test]
fn a_dogs_panel_layout_draws_no_tab_row() {
let app = fixtures::app_in_dog_pane();
assert!(
fixtures::config_pane_draws_a_panel(&app, 160),
"the panel must actually be drawn for its own absence to mean anything"
);
assert!(
!fixtures::config_pane_draws_a_tab_row(&app, 160),
"a dog has no groups to tab through"
);
}
#[test]
fn the_design_target_splits_eighty_eight_and_seventy_two() {
assert_eq!(panel_width(160), Some(72));
}
fn config_pane_lines_for_tests(app: &App, width: u16, height: u16) -> Vec<Line<'static>> {
let pane = app.config_pane().expect("the pane is open");
pane_lines(pane, fixtures::plain(), width, height)
}
#[test]
fn lands_gives_way_to_the_panel_below_the_design_target_and_joins_it_above() {
let app = fixtures::app_in_sheep_pane();
for width in MIN_TERM_WIDTH..=200 {
let rows = fixtures::render_all(&config_pane_lines_for_tests(&app, width, 48));
let panel = rows.contains("FOCUSED");
let lands = rows.contains("LANDS");
let has_panel = panel_width(width).is_some();
match (has_panel, width >= LANDS_WITH_PANEL_MIN) {
(true, true) => assert!(
panel && lands,
"the panel and LANDS must both draw at {width} columns"
),
(true, false) => assert!(
panel && !lands,
"LANDS must give way to the panel at {width} columns"
),
(false, _) => {
assert!(!panel, "the panel must not draw at {width} columns");
let expected_lands = body_width(width) >= FULL_WIDTH;
assert_eq!(
lands, expected_lands,
"LANDS mismatch at {width} columns, no panel"
);
}
}
}
}
#[test]
fn the_panel_goes_below_ninety_columns() {
assert!(panel_width(90).is_some());
assert_eq!(panel_width(89), None);
}
#[test]
fn the_panel_never_drops_below_its_own_floor() {
for width in 90..=200 {
let panel = panel_width(width).expect("the panel is drawn above 89");
assert!((50..=72).contains(&panel), "{panel} at {width}");
}
}
#[test]
fn the_left_column_never_falls_below_its_own_floor() {
for width in 90..=200 {
let panel = panel_width(width).expect("the panel is drawn above 89");
assert!(width - panel >= 40, "{} left at {width}", width - panel);
}
}
#[test]
fn every_row_fits_the_width_it_was_drawn_for() {
for width in MIN_TERM_WIDTH..=200 {
let app = fixtures::app_in_sheep_pane();
for row in config_pane_lines_for_tests(&app, width, 48) {
assert!(
fixtures::render_all(std::slice::from_ref(&row))
.chars()
.count()
<= usize::from(width),
"a row overflows at {width}"
);
}
}
}
#[test]
fn a_short_body_sheds_the_legend_before_the_tab_row() {
let app = fixtures::app_in_sheep_pane();
let rows = fixtures::render_all(&config_pane_lines_for_tests(&app, 160, 6));
assert!(rows.contains("tab next group"), "the tab row must survive");
assert!(!rows.contains("changed by you"), "the legend must go first");
}
}