use ratatui::style::Style;
use ratatui::text::{Line, Span};
use super::super::super::field::Field;
use super::super::super::pane::{ConfigPane, EntryBody, PaneRow};
use super::super::super::theme::Palette;
use super::super::super::validation;
use super::chrome::hairline_line;
use super::entry_row::ENTRY_LABEL;
use super::field_row::{TABLE_LABEL, impact_tag, type_label};
use super::layout::{BLURB_WRAP, PANEL_MAX, clipped, mask_secret, wrap};
use crate::vocabulary::Role;
pub(super) 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(in crate::lookout::view) 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(in crate::lookout::view) fn panel_lines(
pane: &ConfigPane,
palette: Palette,
width: u16,
) -> Vec<Line<'static>> {
let index = match pane.cursor() {
Some(PaneRow::Field(index)) => index,
Some(PaneRow::Table(index)) => return panel_for_table(pane, index, palette, width),
Some(PaneRow::Entry(index)) => return panel_for_entry(pane, index, palette, width),
Some(PaneRow::Env(_) | PaneRow::AddEnv) | None => return Vec::new(),
};
let Some(field) = pane.fields().fields().get(index) else {
return Vec::new();
};
panel_for_field(field, pane, palette, width)
}
fn panel_for_table(
pane: &ConfigPane,
index: usize,
palette: Palette,
width: u16,
) -> Vec<Line<'static>> {
let Some(path) = pane.table_path(index) else {
return Vec::new();
};
chip_line(path, TABLE_LABEL, palette, width)
}
fn panel_for_entry(
pane: &ConfigPane,
index: usize,
palette: Palette,
width: u16,
) -> Vec<Line<'static>> {
let Some(row) = pane.entry_row(index) else {
return Vec::new();
};
if let EntryBody::Leaf { field, .. } = &row.body {
return panel_for_field(field, pane, palette, width);
}
let label = match &row.body {
EntryBody::Node { entry: true, .. } => ENTRY_LABEL,
EntryBody::Node { .. } => TABLE_LABEL,
EntryBody::Leaf { .. } => return Vec::new(),
};
chip_line(&row.label, label, palette, width)
}
fn chip_line(text: &str, type_label: &str, palette: Palette, width: u16) -> Vec<Line<'static>> {
let chip = " FOCUSED ";
let label = format!(" {type_label}");
let taken = chip.chars().count() + 1 + label.chars().count();
let budget = width
.min(PANEL_MAX)
.saturating_sub(u16::try_from(taken).unwrap_or(u16::MAX));
vec![Line::from(vec![
Span::styled(chip, palette.band(Role::Butter)),
Span::raw(format!(" {}", clipped(text, budget))),
Span::styled(label, palette.muted()),
])]
}
#[cfg(test)]
mod tests {
use super::super::fixtures::{secret_dog_pane_with_an_edit, text_of};
use super::super::pane_lines;
use super::*;
use crate::lookout::app::{KeyPress, Msg};
use crate::lookout::view::fixtures;
#[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 a_tables_panel_clips_a_long_path_and_keeps_its_label() {
let mut pane = super::super::fixtures::nested_table_dog_pane();
pane.move_to_key("models.worker.token");
pane.close_table();
assert!(pane.cursor_on_table());
let text = text_of(&panel_lines(&pane, fixtures::plain(), 24));
assert_eq!(text.len(), 1, "{text:?}");
assert!(text[0].chars().count() <= 24, "{text:?}");
assert!(text[0].ends_with("table"), "{text:?}");
assert!(text[0].contains('\u{2026}'), "{text:?}");
}
#[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 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"
);
}
}