use core::time::Duration;
use ratatui::text::{Line, Span};
use super::super::app::{App, Link};
use super::super::link::{RECONNECT_ATTEMPTS, RECONNECT_FIRST_WAIT, RECONNECT_MAX_WAIT};
use super::detail::chip_text;
use super::flock::fit;
use crate::output::human_duration;
use crate::output::width::text_columns;
use crate::vocabulary::Role;
pub const LINK_ROWS: u16 = 6;
#[cfg(test)]
const MIN_PANEL_WIDTH: u16 = super::MIN_TERM_WIDTH;
const LABEL_WIDTH: usize = 12;
#[must_use]
pub fn panel_lines(app: &App, width: u16) -> Vec<Line<'static>> {
let Link::Lost { at_local, why } = app.link() else {
return Vec::new();
};
let palette = app.palette();
let rungs = ladder();
let total: Duration = rungs.iter().sum();
let chip = chip_text("THE LINK");
let head = format!(
" {} redials over {}, all refused ยท lookout has stopped polling and stopped re-dialling",
rungs.len(),
seconds(total),
);
let mut ladder_spans = vec![Span::styled(label("ladder"), palette.muted())];
for rung in &rungs {
ladder_spans.push(Span::styled("\u{2588}", palette.alarm()));
ladder_spans.push(Span::styled(
format!(" {} ", rung_label(*rung)),
palette.muted(),
));
}
let since = format!(
"{at_local}, {} ago",
human_duration(millis(app.frozen_for())),
);
vec![
Line::from(vec![
Span::styled(chip, palette.band(Role::Bark)),
Span::styled(head, palette.muted()),
]),
Line::from(ladder_spans),
Line::from(vec![
Span::styled(label("refused"), palette.muted()),
Span::styled(why.clone(), palette.muted()),
]),
Line::from(vec![
Span::styled(label("since"), palette.muted()),
Span::styled(
format!(
"{since} the sheep themselves may well still be running; lookout cannot see them to say"
),
palette.muted(),
),
]),
Line::from(vec![
Span::styled(label("try"), palette.attention()),
Span::styled(
"shep muster, from another shell then reopen lookout: it does not reconnect on its own"
.to_string(),
palette.muted(),
),
]),
]
.into_iter()
.map(|line| clamp(line, width))
.collect()
}
fn label(name: &str) -> String {
let text = format!(" {name}");
let used: usize = text_columns(&text);
format!("{text}{}", " ".repeat(LABEL_WIDTH.saturating_sub(used)))
}
fn ladder() -> Vec<Duration> {
let mut wait = RECONNECT_FIRST_WAIT;
let mut out = Vec::new();
for _ in 0..RECONNECT_ATTEMPTS {
out.push(wait);
wait = (wait * 2).min(RECONNECT_MAX_WAIT);
}
out
}
fn rung_label(wait: Duration) -> String {
let ms = millis(wait);
if ms < 1_000 {
format!("{ms}ms")
} else {
format!("{}s", ms / 1_000)
}
}
fn seconds(total: Duration) -> String {
let text = format!("{:.2}", total.as_secs_f64());
let trimmed = text.trim_end_matches('0').trim_end_matches('.');
format!("{trimmed}s")
}
fn millis(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
fn clamp(line: Line<'static>, width: u16) -> Line<'static> {
let mut budget = usize::from(width);
let mut spans = Vec::with_capacity(line.spans.len());
for span in line.spans {
if budget == 0 {
break;
}
let used: usize = text_columns(&span.content);
if used <= budget {
budget -= used;
spans.push(span);
} else {
let cut = fit(&span.content, u16::try_from(budget).unwrap_or(u16::MAX));
budget = 0;
spans.push(Span::styled(cut, span.style));
}
}
Line::from(spans)
}
#[cfg(test)]
mod tests {
use super::super::super::app::Msg;
use super::super::fixtures;
use super::*;
use crate::output::width::{char_columns, visible_width};
fn frozen() -> App {
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(),
});
app
}
fn text(app: &App, width: u16) -> Vec<String> {
panel_lines(app, width)
.iter()
.map(ToString::to_string)
.collect()
}
#[test]
fn the_ladder_reads_off_the_reconnect_constants() {
assert_eq!(RECONNECT_ATTEMPTS, 5);
assert_eq!(RECONNECT_FIRST_WAIT, Duration::from_millis(250));
assert_eq!(RECONNECT_MAX_WAIT, Duration::from_secs(4));
let rungs = ladder();
assert_eq!(
rungs,
vec![
Duration::from_millis(250),
Duration::from_millis(500),
Duration::from_secs(1),
Duration::from_secs(2),
Duration::from_secs(4),
]
);
assert_eq!(seconds(rungs.iter().sum()), "7.75s");
let lines = text(&frozen(), 160);
assert!(
lines[0].contains("5 redials over 7.75s, all refused"),
"{lines:?}"
);
assert!(
lines[1]
.contains("\u{2588} 250ms \u{2588} 500ms \u{2588} 1s \u{2588} 2s \u{2588} 4s"),
"{lines:?}"
);
}
#[test]
fn every_label_starts_its_value_in_the_chip_s_own_column() {
assert_eq!(visible_width(&chip_text("THE LINK")), LABEL_WIDTH);
for name in ["ladder", "since", "try", "refused"] {
assert_eq!(visible_width(&label(name)), LABEL_WIDTH, "{name}");
}
}
#[test]
fn no_line_outruns_the_terminal_it_is_drawn_into() {
let app = frozen();
for width in [MIN_PANEL_WIDTH, 40, 80, 120, 160, 200] {
let lines = panel_lines(&app, width);
assert_eq!(lines.len(), usize::from(LINK_ROWS - 1), "at {width}");
for line in &lines {
let used: usize = line
.spans
.iter()
.flat_map(|span| span.content.chars())
.map(char_columns)
.sum();
assert!(used <= usize::from(width), "{used} cells at {width}");
}
}
}
#[test]
fn a_live_link_draws_nothing() {
assert!(panel_lines(&fixtures::full_app(), 160).is_empty());
}
#[test]
fn the_age_counts_from_the_freeze_and_says_when_it_was() {
let t0 = std::time::Instant::now();
let mut app = App::new(
super::super::super::theme::Palette::detect(None, None, None),
super::super::super::app::Control::ReadOnly,
"/home/ada/.shep".to_string(),
t0,
);
app.update(Msg::Frozen {
at_local: "2026-08-14 14:32:07".to_string(),
why: fixtures::FROZEN_WHY.to_string(),
});
let before = text(&app, 160)[3].clone();
assert!(before.contains("2026-08-14 14:32:07, 0s ago"), "{before}");
app.update(Msg::Tick {
now: t0 + Duration::from_secs(252),
});
let after = text(&app, 160)[3].clone();
assert!(after.contains("2026-08-14 14:32:07, 4m 12s ago"), "{after}");
}
}