use ratatui::style::Style;
use ratatui::text::{Line, Span};
use shep_core::config::ApplyGroup;
use super::super::super::field::{Field, FieldKind, ValueKind};
use super::super::super::pane::{ConfigPane, Lock, PaneRow};
use super::super::super::theme::Palette;
use super::super::flock::{fit, mark};
use super::layout::{BLURB_WRAP, body_width, cost_label, mask_secret, panel_width, widths, wrap};
pub(super) fn section_header(label: &str, palette: Palette) -> Line<'static> {
Line::from(Span::styled(format!(" {label}"), palette.muted()))
}
pub(super) 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) => '~',
Some(Lock::SubScreen) => '\u{203a}',
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)
}
pub(super) 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;
}
}
pub(super) 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()
}
pub(super) 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",
}
}
pub(super) 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")
}
}
}
#[cfg(test)]
mod tests {
use super::super::fixtures::{
nested_secret_dog_pane, secret_dog_pane_with_an_edit, text_of, web_pane,
};
use super::super::pane_lines;
use super::*;
use crate::lookout::view::MIN_TERM_WIDTH;
use crate::lookout::view::fixtures;
use crate::output::width::char_columns;
use shep_core::config::GROUP_ORDER;
#[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(),
42,
"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('\u{203a}'), ["dogs"]);
assert_eq!(glyphed(' ').len(), 42 - 2 - 3 - 1);
}
#[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 = 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(' '));
}
#[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"
);
}
#[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 a_table_holding_a_secret_draws_masked_in_the_row_and_the_panel() {
let pane = nested_secret_dog_pane();
let text = text_of(&pane_lines(&pane, fixtures::plain(), 160, 0));
let row = text
.iter()
.find(|line| line.contains(" db "))
.expect("db is drawn at 160 columns");
assert!(row.contains("<set>"), "{row:?}");
assert!(
text.iter()
.any(|line| line.contains("now") && line.contains("<set>")),
"the panel's now line: {text:?}"
);
assert!(
!text.join("\n").contains("ab12cd34"),
"the password leaked: {text:?}"
);
}
#[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);
}
}