use ratatui::style::Style;
use ratatui::text::{Line, Span};
use super::super::app::{
ActionState, App, Control, InputMode, Link, RowKey, Settings, SettingsPrompt, retrying_sentence,
};
use super::super::pane::{ConfigPane, PanePending};
use super::cell;
use super::flock::fit;
use super::settings::field_label;
#[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(
retrying_sentence(*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(prompt) = app.settings().and_then(Settings::pending) {
let text = if prompt.sent {
format!("{} sent, waiting for the shepherd", prompt.text)
} else {
format!("{} enter confirms, any other key cancels", prompt.text)
};
(text, palette.attention())
} else if let Some(prompt) = app.config_pane().and_then(pane_prompt) {
let text = if prompt.sent {
format!("{} sent, waiting for the shepherd", prompt.text)
} else {
format!("{} enter confirms, any other key cancels", prompt.text)
};
(text, palette.attention())
} else if let Some((label, buffer)) = app.config_pane().and_then(pane_editor) {
(
format!("{label} {buffer}\u{258f} enter applies esc cancels"),
palette.attention(),
)
} else if let Some(action) = app.action().filter(|a| !a.sent) {
(confirm_prompt(&action), palette.attention())
} else if let Some((field, buffer)) = app.settings().and_then(Settings::typing) {
(
format!(
"editing {} {buffer}\u{258f} enter applies esc cancels",
field_label(*field)
),
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 let Some(pane) = app.config_pane() {
(
pane_hint(app.control(), pane_screen(pane)).to_string(),
palette.attention(),
)
} else if app.settings().is_none() && !app.filter().is_empty() {
(
format!("filter \"{}\" / edit esc clear", app.filter()),
palette.muted(),
)
} else {
(
hint_for(app.control(), app.settings().is_some()),
palette.attention(),
)
};
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);
let ground = palette.ground();
Line::from(vec![
Span::styled(fit(&left, left_width), left_style.patch(ground)),
Span::styled(format!(" {right}"), palette.muted().patch(ground)),
])
}
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()
)
}
RowKey::Section(_) => unreachable!("a header is never an action target"),
}
}
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
),
RowKey::Section(_) => unreachable!("a header is never an action target"),
}
}
fn pane_prompt(pane: &ConfigPane) -> Option<SettingsPrompt<'_>> {
match pane.pending_edit()? {
PanePending::Armed { text, .. } => Some(SettingsPrompt { text, sent: false }),
PanePending::Sent { text, .. } => Some(SettingsPrompt { text, sent: true }),
PanePending::Typing { .. } => None,
}
}
fn pane_editor(pane: &ConfigPane) -> Option<(String, &str)> {
if let Some(list) = pane.list() {
return match list.typing()? {
(Some(index), buffer) => Some((format!("{} {index} =", list.key()), buffer)),
(None, buffer) => Some((format!("new {} element", list.key()), buffer)),
};
}
if let Some(env) = pane.env() {
return match env.typing()? {
(Some(key), buffer) => Some((format!("env {key} ="), buffer)),
(None, buffer) => Some(("new env KEY=value".to_owned(), buffer)),
};
}
match pane.pending_edit()? {
PanePending::Typing { key, buffer } => Some((format!("editing {key}"), buffer.as_str())),
PanePending::Armed { .. } | PanePending::Sent { .. } => None,
}
}
const fn pane_hint(control: Control, screen: PaneScreen) -> &'static str {
match (control, screen) {
(Control::ReadOnly, PaneScreen::Fields) => {
"esc close j/k select g/G first/last r refresh h help * yours ! parked q quit"
}
(Control::Allowed, PaneScreen::Fields) => {
"esc close j/k select g/G first/last r refresh space cycle e edit h help * yours ! parked q quit"
}
(Control::ReadOnly, PaneScreen::Env | PaneScreen::List) => {
"esc back j/k select g/G first/last r refresh q quit"
}
(Control::Allowed, PaneScreen::Env) => {
"esc back j/k select g/G first/last r refresh e set q quit"
}
(Control::Allowed, PaneScreen::List) => {
"esc back j/k select g/G first/last r refresh e edit d remove K/J move q quit"
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PaneScreen {
Fields,
Env,
List,
}
fn pane_screen(pane: &ConfigPane) -> PaneScreen {
if pane.list().is_some() {
PaneScreen::List
} else if pane.env().is_some() {
PaneScreen::Env
} else {
PaneScreen::Fields
}
}
fn hint_for(control: Control, settings_open: bool) -> String {
if settings_open {
return match control {
Control::ReadOnly => "esc/s close j/k select g/G first/last r refresh q quit",
Control::Allowed => {
"esc/s close j/k select g/G first/last r refresh space cycle enter apply q quit"
}
}
.to_string();
}
match control {
Control::ReadOnly => {
"q quit j/k select g/G first/last r refresh / filter s settings e edit * yours ! parked"
}
Control::Allowed => {
"q quit j/k select / filter x stop R restart L reload s settings e edit * yours ! parked"
}
}
.to_string()
}
#[must_use]
pub fn rule_line(style: Style, width: u16) -> Line<'static> {
Line::from(Span::styled(cell::rule(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, app_in_settings, app_in_settings_on, app_in_settings_with_control,
armed_app, armed_app_with_a_filter_and_a_notice, editing_app, filtered_app, rendered,
};
use super::*;
use crate::commands::settings::SettingField;
use crate::lookout::app::{ActionVerb, App, KeyPress, Msg};
use crate::lookout::theme::Palette;
#[test]
fn the_legend_fits_wherever_the_cfg_column_is_drawn() {
let widest = [
hint_for(Control::ReadOnly, false),
hint_for(Control::Allowed, false),
]
.into_iter()
.map(|hint| hint.chars().count())
.max()
.expect("two hints");
let cfg_tier = usize::from(super::super::flock::cfg_tier_width());
assert!(
widest <= cfg_tier,
"the hint is {widest} wide and CFG draws from {cfg_tier}, so the \
legend truncates where the glyph still shows"
);
}
#[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_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 the_bar_shows_the_settings_editor_rather_than_the_filter_box() {
let mut app = app_in_settings_on(SettingField::Socket);
let _ = app.update(Msg::Key(KeyPress::Confirm));
let bar = rendered(&status_line(&app, 120));
assert!(
bar.contains("editing socket "),
"names the field being typed: {bar:?}"
);
assert!(
bar.contains("/home/ada/.shep/run/shep.sock\u{258f}"),
"shows the buffer and the cursor, not the dashboard's own filter: {bar:?}"
);
assert!(
!bar.contains("filter "),
"must not read as the filter box: {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::TextApply));
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 the_dashboard_hint_says_what_the_cfg_glyphs_mean() {
for control in [Control::ReadOnly, Control::Allowed] {
let hint = hint_for(control, false);
assert!(hint.contains("* yours"), "{hint}");
assert!(hint.contains("! parked"), "{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"
);
}
#[test]
fn the_settings_edit_keys_are_advertised_only_when_the_gate_is_open() {
let closed = rendered(&status_line(&app_in_settings(), 200));
for key in ["space cycle", "enter apply"] {
assert!(
!closed.contains(key),
"{key} advertised read-only: {closed:?}"
);
}
let open = rendered(&status_line(&app_in_settings_with_control(), 200));
for key in ["space cycle", "enter apply"] {
assert!(
open.contains(key),
"{key} missing when the gate is open: {open:?}"
);
}
for both in [&closed, &open] {
assert!(both.contains("esc/s close"), "got {both:?}");
assert!(both.contains("r refresh"), "got {both:?}");
}
}
#[test]
fn q_quit_is_named_on_the_settings_screen_in_both_control_states() {
let closed = rendered(&status_line(&app_in_settings(), 200));
let open = rendered(&status_line(&app_in_settings_with_control(), 200));
for hint in [&closed, &open] {
assert!(hint.contains("q quit"), "got {hint:?}");
}
}
fn pane_to(app: &mut App, key: &str) {
let index = app
.config_pane()
.expect("the pane is open")
.fields()
.fields()
.iter()
.position(|field| field.key == key)
.unwrap_or_else(|| panic!("no field named {key}"));
app.update(Msg::Key(KeyPress::SelectFirst));
for _ in 0..index {
app.update(Msg::Key(KeyPress::SelectDown));
}
}
#[test]
fn the_panes_edit_keys_are_advertised_only_when_the_gate_is_open() {
let closed = rendered(&status_line(
&super::super::fixtures::app_in_sheep_pane(),
200,
));
let open = rendered(&status_line(
&super::super::fixtures::app_in_sheep_pane_with_control(),
200,
));
for key in ["space cycle", "e edit"] {
assert!(
!closed.contains(key),
"{key} advertised read-only: {closed:?}"
);
assert!(
open.contains(key),
"{key} missing with the gate open: {open:?}"
);
}
for both in [&closed, &open] {
assert!(both.contains("esc close"), "got {both:?}");
assert!(both.contains("h help"), "got {both:?}");
assert!(both.contains("* yours"), "got {both:?}");
assert!(both.contains("! parked"), "got {both:?}");
assert!(both.contains("q quit"), "got {both:?}");
assert!(!both.contains("x stop"), "got {both:?}");
}
}
#[test]
fn the_env_sub_screen_says_esc_backs_out_rather_than_closes() {
let mut app = super::super::fixtures::app_in_sheep_pane_with_control();
pane_to(&mut app, "env");
app.update(Msg::Key(KeyPress::Confirm));
let bar = rendered(&status_line(&app, 200));
assert!(bar.contains("esc back"), "got {bar:?}");
assert!(bar.contains("e set"), "got {bar:?}");
assert!(
!bar.contains("e close"),
"e no longer closes anything: {bar:?}"
);
assert!(!bar.contains("enter set"), "got {bar:?}");
}
#[test]
fn every_pane_hint_names_the_movement_and_refresh_keys_it_binds() {
for screen in [PaneScreen::Fields, PaneScreen::Env, PaneScreen::List] {
for control in [Control::ReadOnly, Control::Allowed] {
let hint = pane_hint(control, screen);
for key in ["j/k select", "g/G first/last", "r refresh", "q quit"] {
assert!(hint.contains(key), "{control:?} {screen:?}: {hint:?}");
}
}
}
}
#[test]
fn the_field_lists_hint_carries_a_legend_for_its_own_flag_glyphs() {
for control in [Control::ReadOnly, Control::Allowed] {
let hint = pane_hint(control, PaneScreen::Fields);
assert!(hint.contains("* yours"), "{control:?}: {hint:?}");
assert!(hint.contains("! parked"), "{control:?}: {hint:?}");
for screen in [PaneScreen::Env, PaneScreen::List] {
let sub = pane_hint(control, screen);
assert!(!sub.contains('*'), "{control:?} {screen:?}: {sub:?}");
assert!(!sub.contains('!'), "{control:?} {screen:?}: {sub:?}");
}
}
}
#[test]
fn the_env_sub_screens_movement_and_refresh_keys_do_what_the_hint_says() {
let mut app = super::super::fixtures::app_in_sheep_pane_with_control();
pane_to(&mut app, "env");
app.update(Msg::Key(KeyPress::Confirm));
app.update(Msg::Key(KeyPress::SelectLast));
assert_eq!(app.config_pane().unwrap().env().unwrap().view().cursor(), 2);
app.update(Msg::Key(KeyPress::SelectFirst));
assert_eq!(app.config_pane().unwrap().env().unwrap().view().cursor(), 0);
assert!(matches!(
app.update(Msg::Key(KeyPress::Refresh)),
crate::lookout::app::Effect::Send(_)
));
}
#[test]
fn an_armed_pane_edit_reaches_the_status_bar_and_says_so_once_sent() {
let mut app = super::super::fixtures::app_in_sheep_pane_with_control();
pane_to(&mut app, "autorestart");
app.update(Msg::Key(KeyPress::Cycle));
let armed = rendered(&status_line(&app, 200));
assert!(armed.contains("set autorestart = false"), "got {armed:?}");
assert!(armed.contains("enter confirms"), "got {armed:?}");
app.update(Msg::Key(KeyPress::Confirm));
let sent = rendered(&status_line(&app, 200));
assert!(sent.contains("set autorestart = false"), "got {sent:?}");
assert!(
sent.contains("sent, waiting for the shepherd"),
"got {sent:?}"
);
}
#[test]
fn the_panes_editors_get_their_own_status_line_rather_than_the_filters() {
let mut app = super::super::fixtures::app_in_sheep_pane_with_control();
pane_to(&mut app, "cwd");
app.update(Msg::Key(KeyPress::Confirm));
app.update(Msg::Key(KeyPress::TextChar('x')));
let field = rendered(&status_line(&app, 200));
assert!(field.contains("editing cwd"), "got {field:?}");
assert!(!field.contains("filter"), "got {field:?}");
app.update(Msg::Key(KeyPress::TextAbandon));
pane_to(&mut app, "env");
app.update(Msg::Key(KeyPress::Confirm));
app.update(Msg::Key(KeyPress::Confirm));
app.update(Msg::Key(KeyPress::TextChar('y')));
let env = rendered(&status_line(&app, 200));
assert!(env.contains("env DB_HOST ="), "got {env:?}");
assert!(env.contains('y'), "got {env:?}");
assert!(!env.contains("filter"), "got {env:?}");
}
#[test]
fn the_config_pane_gets_its_own_key_hint() {
let app = super::super::fixtures::app_in_sheep_pane();
let bar = status_line(&app, 120).to_string();
assert!(bar.contains("esc close"), "got {bar:?}");
assert!(bar.contains("r refresh"), "got {bar:?}");
assert!(!bar.contains("x stop"), "got {bar:?}");
assert!(!bar.contains("s settings"), "got {bar:?}");
}
#[test]
fn the_list_sub_screens_own_keys_do_what_its_hint_says() {
let mut app = super::super::fixtures::app_in_sheep_pane_with_control();
pane_to(&mut app, "args");
app.update(Msg::Key(KeyPress::Confirm));
let bar = rendered(&status_line(&app, 200));
assert!(bar.contains("esc back"), "got {bar:?}");
assert!(bar.contains("d remove"), "got {bar:?}");
assert!(bar.contains("K/J move"), "got {bar:?}");
app.update(Msg::Key(KeyPress::ListRemove));
assert!(app.config_pane().unwrap().is_armed(), "d arms a removal");
app.update(Msg::Key(KeyPress::Escape));
app.update(Msg::Key(KeyPress::ListMoveDown));
assert!(app.config_pane().unwrap().is_armed(), "J arms a move");
}
}