use ratatui::style::Style;
use ratatui::text::{Line, Span};
use super::super::app::{ActionState, App, Control, InputMode, Link, RowKey};
use super::flock::fit;
#[must_use]
pub fn title_line(app: &App, home: &str, width: u16) -> Line<'static> {
let palette = app.palette();
let left = format!("shep lookout {home}");
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")
};
Line::from(vec![
Span::raw(fit(
&left,
width.saturating_sub(u16::try_from(right.chars().count()).unwrap_or(0)),
)),
Span::styled(right, palette.muted()),
])
}
#[must_use]
pub fn banner_line(app: &App) -> Option<Line<'static>> {
let palette = app.palette();
match app.link() {
Link::Live => None,
Link::Retrying { attempt } => Some(Line::from(Span::styled(
format!("the shepherd stopped answering — reconnecting (attempt {attempt})"),
palette.attention(),
))),
Link::Lost { at_local } => Some(Line::from(Span::styled(
format!("the shepherd has died — these values are frozen as of {at_local}"),
palette.alarm(),
))),
}
}
#[must_use]
pub fn status_line(app: &App, width: u16) -> Line<'static> {
let palette = app.palette();
let (left, left_style) = if let Some(action) = app.action().filter(|a| !a.sent) {
(confirm_prompt(&action), palette.attention())
} else if app.mode() == InputMode::Text {
(
format!(
"filter {}\u{258f} enter applies esc cancels ctrl-c quits",
app.filter()
),
palette.attention(),
)
} else if let Some(notice) = app.notice() {
(
notice.to_string(),
if notice.is_grave() {
palette.refusal()
} else {
palette.attention()
},
)
} else if let Some(action) = app.action() {
let text = in_flight_text(&action);
(text, palette.attention())
} else if !app.filter().is_empty() {
(
format!("filter \"{}\" / edit esc clear", app.filter()),
palette.muted(),
)
} else {
(hint_for(app.control()), palette.muted())
};
let right = match app.control() {
Control::ReadOnly => "read-only",
Control::Allowed => "control enabled",
};
let right_len = u16::try_from(right.chars().count()).unwrap_or(0);
let left_width = width.saturating_sub(right_len).saturating_sub(1);
Line::from(vec![
Span::styled(fit(&left, left_width), left_style),
Span::styled(format!(" {right}"), palette.muted()),
])
}
fn confirm_prompt(action: &ActionState<'_>) -> String {
match action.target {
RowKey::Sheep(id) => format!(
"{} {} (id {id})? enter confirms, any other key cancels",
action.verb.label(),
action.name
),
RowKey::Group(name) => {
let count = action.count;
format!(
"{} all {count} instances of {name}? enter confirms, any other key cancels",
action.verb.label()
)
}
}
}
fn in_flight_text(action: &ActionState<'_>) -> String {
match action.target {
RowKey::Sheep(id) => format!(
"{} {} (id {id}): sent, waiting for the shepherd",
action.verb.label(),
action.name
),
RowKey::Group(name) => format!(
"{} all {} instances of {name}: sent, waiting for the shepherd",
action.verb.label(),
action.count
),
}
}
fn hint_for(control: Control) -> String {
match control {
Control::ReadOnly => "q quit j/k select g/G first/last r refresh / filter",
Control::Allowed => "q quit j/k select / filter x stop R restart L reload",
}
.to_string()
}
#[must_use]
pub fn rule_line(style: Style, width: u16) -> Line<'static> {
Line::from(Span::styled("─".repeat(usize::from(width)), style))
}
#[cfg(test)]
mod tests {
use std::ffi::OsStr;
use std::time::Instant;
use shep_core::protocol::BusEvent;
use super::super::fixtures::{
acting_app, allowed_app, armed_app, armed_app_with_a_filter_and_a_notice, editing_app,
filtered_app, rendered,
};
use super::*;
use crate::lookout::app::{ActionVerb, App, KeyPress, Msg};
use crate::lookout::theme::Palette;
#[test]
fn a_truncated_hint_still_leaves_a_gap_before_the_control_label() {
let palette = Palette::detect(None, Some(OsStr::new("xterm-256color")), None);
let app = App::new(
palette,
Control::ReadOnly,
"/home/ada/.shep".to_string(),
Instant::now(),
);
let line = status_line(&app, 49);
let rendered: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert_eq!(rendered.chars().count(), 49, "must fill the full width");
assert!(
rendered.ends_with(" read-only"),
"expected a space before the label, got: {rendered:?}"
);
assert!(
!rendered.contains("…read-only"),
"the ellipsis must not butt straight against the label: {rendered:?}"
);
}
#[test]
fn the_key_hint_says_what_the_keys_now_do() {
let app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
Instant::now(),
);
let hint: String = status_line(&app, 200)
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert!(hint.contains("j/k select"), "got {hint:?}");
assert!(hint.contains("g/G first/last"), "got {hint:?}");
assert!(
!hint.contains("scroll"),
"the pane no longer scrolls: {hint:?}"
);
}
#[test]
fn a_wide_status_line_still_pads_out_to_the_full_width() {
let palette = Palette::detect(None, Some(OsStr::new("xterm-256color")), None);
let app = App::new(
palette,
Control::Allowed,
"/home/ada/.shep".to_string(),
Instant::now(),
);
let line = status_line(&app, 120);
let rendered: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert_eq!(rendered.chars().count(), 120);
assert!(rendered.ends_with(" control enabled"));
}
#[test]
fn the_title_counts_both_numbers_while_a_filter_is_on() {
let app = filtered_app("web");
let title = rendered(&title_line(&app, "/home/ada/.shep", 120));
assert!(title.contains("2 of 4 in the flock"), "got {title:?}");
}
#[test]
fn the_unfiltered_title_is_unchanged() {
let app = filtered_app("");
let title = rendered(&title_line(&app, "/home/ada/.shep", 120));
assert!(title.contains("4 in the flock"), "got {title:?}");
assert!(
!title.contains(" of "),
"no second number when nothing is hidden"
);
}
#[test]
fn the_bar_names_the_filter_keys_while_a_filter_is_applied() {
let app = filtered_app("web");
let bar = rendered(&status_line(&app, 120));
assert!(bar.contains("filter \"web\""), "the query, quoted: {bar:?}");
assert!(bar.contains("/ edit"), "got {bar:?}");
assert!(bar.contains("esc clear"), "got {bar:?}");
}
#[test]
fn the_bar_carries_the_query_and_a_cursor_while_editing() {
let app = editing_app("we");
let bar = rendered(&status_line(&app, 120));
assert!(
bar.contains("filter we\u{258f}"),
"query then cursor: {bar:?}"
);
assert!(bar.contains("enter applies"), "got {bar:?}");
assert!(bar.contains("esc cancels"), "got {bar:?}");
assert!(bar.contains("ctrl-c quits"), "got {bar:?}");
}
#[test]
fn a_notice_raised_while_typing_does_not_cover_the_box() {
let mut app = editing_app("we");
app.update(Msg::Event(BusEvent::Dropped { count: 3 }));
let bar = rendered(&status_line(&app, 120));
assert!(
bar.contains("filter we\u{258f}"),
"the box is still there: {bar:?}"
);
assert!(!bar.contains("dropped 3 events"), "got {bar:?}");
}
#[test]
fn closing_the_box_shows_the_notice_that_was_waiting() {
let mut app = editing_app("we");
app.update(Msg::Event(BusEvent::Dropped { count: 3 }));
app.update(Msg::Key(KeyPress::FilterApply));
let bar = rendered(&status_line(&app, 120));
assert!(bar.contains("dropped 3 events"), "got {bar:?}");
}
#[test]
fn the_read_only_hint_advertises_the_filter_key() {
let app = filtered_app("");
let hint = rendered(&status_line(&app, 200));
assert!(hint.contains("/ filter"), "got {hint:?}");
}
#[test]
fn an_armed_confirm_names_the_verb_the_sheep_and_the_answer() {
let app = armed_app(ActionVerb::Restart);
let bar = rendered(&status_line(&app, 120));
assert!(bar.contains("restart api (id 2)?"), "got {bar:?}");
assert!(
bar.contains("enter confirms, any other key cancels"),
"got {bar:?}"
);
}
#[test]
fn an_in_flight_action_says_it_is_waiting() {
let app = acting_app(ActionVerb::Stop);
let bar = rendered(&status_line(&app, 120));
assert!(
bar.contains("stop api (id 2): sent, waiting for the shepherd"),
"got {bar:?}"
);
}
#[test]
fn the_confirm_outranks_a_notice_and_the_filter_line() {
let app = armed_app_with_a_filter_and_a_notice();
let bar = rendered(&status_line(&app, 120));
assert!(bar.contains("stop api (id 2)?"), "got {bar:?}");
assert!(
!bar.contains("filter \""),
"the filter line is below it: {bar:?}"
);
}
#[test]
fn a_refusal_while_an_action_is_in_flight_reaches_the_bar() {
let mut app = acting_app(ActionVerb::Stop);
app.update(Msg::Key(KeyPress::Action(ActionVerb::Restart)));
let bar = rendered(&status_line(&app, 120));
assert!(
bar.contains("one action is already in flight"),
"the refusal is on the bar, not only in the reducer: {bar:?}"
);
let mut app = acting_app(ActionVerb::Stop);
app.update(Msg::Event(BusEvent::DaemonShutdown));
let bar = rendered(&status_line(&app, 120));
assert!(bar.contains("the shepherd is shutting down"), "got {bar:?}");
}
#[test]
fn the_in_flight_line_comes_back_when_the_notice_clears() {
let mut app = acting_app(ActionVerb::Stop);
app.update(Msg::Key(KeyPress::Action(ActionVerb::Restart)));
app.update(Msg::Key(KeyPress::SelectDown));
let bar = rendered(&status_line(&app, 120));
assert!(
bar.contains("stop api (id 2): sent, waiting for the shepherd"),
"got {bar:?}"
);
}
#[test]
fn the_action_keys_are_advertised_only_when_the_gate_is_open() {
let closed = rendered(&status_line(&filtered_app(""), 200));
for key in ["x stop", "R restart", "L reload"] {
assert!(
!closed.contains(key),
"{key} advertised read-only: {closed:?}"
);
}
let open = rendered(&status_line(&allowed_app(), 200));
for key in ["x stop", "R restart", "L reload"] {
assert!(
open.contains(key),
"{key} missing when the gate is open: {open:?}"
);
}
assert!(
open.contains("/ filter"),
"and the filter key survives both forms"
);
}
}