use ratatui::text::{Line, Span};
use shep_core::config::GROUP_ORDER;
use super::super::super::pane::{ConfigPane, PaneEdit, PaneTarget, Tab};
use super::super::super::theme::Palette;
use super::super::cell;
use super::super::flock::fit;
use super::layout::{body_width, mask_secret, widths};
use crate::output::width::text_columns;
use crate::vocabulary::Role;
const TAB_HINT: &str = "tab \u{21c6}";
pub(super) fn title_line(pane: &ConfigPane, palette: Palette, width: u16) -> Line<'static> {
Line::from(Span::styled(
format!(" {}", fit(&heading(pane), body_width(width))),
palette.muted(),
))
}
pub(super) fn tab_strip_line(
pane: &ConfigPane,
palette: Palette,
width: u16,
) -> Option<Line<'static>> {
let open = pane.status()?.tab();
let tabs = match open {
Tab::Status => "[status] settings",
Tab::Settings => "status [settings]",
};
let room = usize::from(body_width(width));
let left = format!(" {tabs}");
let used = text_columns(&left);
let mut spans = vec![Span::styled(
fit(&left, body_width(width)).trim_end().to_owned(),
palette.muted(),
)];
let hint = text_columns(TAB_HINT);
if used + 1 + hint <= room {
spans.push(Span::raw(" ".repeat(room - used - hint)));
spans.push(Span::styled(TAB_HINT, palette.muted()));
}
Some(Line::from(spans))
}
fn heading(pane: &ConfigPane) -> String {
match pane.target() {
PaneTarget::Sheep { name } => format!("{name} (sheep config)"),
PaneTarget::Dog { name, .. } => format!("{name} (dog config)"),
PaneTarget::SheepDog { sheep, dog, .. } => format!("{sheep} \u{203a} {dog} (dog table)"),
}
}
pub(super) fn has_groups(pane: &ConfigPane) -> bool {
pane.fields()
.fields()
.iter()
.any(|field| field.group.is_some())
}
pub(super) fn title_band_line(pane: &ConfigPane, palette: Palette, width: u16) -> Line<'static> {
let mut text = heading(pane);
let count = pane.edits().len();
match count {
0 => {}
1 => text.push_str(" 1 edit"),
n => text.push_str(&format!(" {n} edits")),
}
Line::from(Span::styled(
cell::band(&text, usize::from(width)),
palette.band(Role::Butter),
))
}
pub(super) fn tab_row_line(pane: &ConfigPane, palette: Palette, width: u16) -> Line<'static> {
let active = pane.group();
let chip = |group: &str| format!(" {group} ");
let chips = GROUP_ORDER
.iter()
.map(|group| chip(group))
.collect::<Vec<_>>()
.join(" ");
let full = format!("{chips} tab/S-tab next / prev group 1\u{2026}9 jump");
let text = fit(&full, body_width(width));
let marker = chip(active);
let mut spans = vec![Span::raw(" ")];
if let Some(start) = text.find(&marker) {
let end = start + marker.len();
if start > 0 {
spans.push(Span::styled(text[..start].to_owned(), palette.muted()));
}
spans.push(Span::styled(text[start..end].to_owned(), palette.ground()));
if end < text.len() {
spans.push(Span::styled(text[end..].to_owned(), palette.muted()));
}
} else {
spans.push(Span::styled(text, palette.muted()));
}
Line::from(spans)
}
pub(super) fn hairline_line(palette: Palette, width: u16) -> Line<'static> {
Line::from(Span::styled(
format!(" {}", cell::rule(usize::from(body_width(width)))),
palette.line(),
))
}
pub(super) fn column_header_line(palette: Palette, width: u16, show_lands: bool) -> Line<'static> {
let (key_w, value_w, cost_w) = widths(body_width(width), show_lands);
let mut text = String::from(" ");
text.push_str(&fit("FIELD", key_w));
if value_w > 0 {
text.push_str(" ");
text.push_str(&fit("VALUE", value_w));
}
if cost_w > 0 {
text.push_str(" ");
text.push_str(&fit("LANDS", cost_w));
}
Line::from(Span::styled(text, palette.muted()))
}
pub(super) fn legend_line(palette: Palette, width: u16) -> Line<'static> {
Line::from(Span::styled(
format!(
" {}",
fit(
"= read-only, set it in the Flockfile ~ no widget for this shape \
! parked, awaits a respawn * yours -> changed by you, not yet written",
body_width(width)
)
),
palette.muted(),
))
}
pub(super) fn pending_edit_line(
pane: &ConfigPane,
edit: &PaneEdit,
width: u16,
palette: Palette,
) -> Line<'static> {
let text = match edit {
PaneEdit::Set { key, .. } => {
let secret = pane.field_of(key).is_some_and(|field| field.secret);
let old = pane.display_value(key);
let new = pane.edited_value(key).unwrap_or_default();
let old = mask_secret(secret, old);
let new = mask_secret(secret, new);
format!("{key} {old} -> {new}")
}
PaneEdit::SetEnv { key, value } => match value {
Some(_) => format!("env.{key} -> <set>"),
None => format!("env.{key} removed"),
},
};
Line::from(Span::styled(
format!(" {}", fit(&text, body_width(width).saturating_sub(4))),
palette.attention(),
))
}
#[cfg(test)]
mod tests {
use super::super::fixtures::{secret_dog_pane_with_an_edit, text_of, web_pane};
use super::super::pane_lines;
use super::*;
use crate::lookout::app::{KeyPress, Msg};
use crate::lookout::view::fixtures;
#[test]
fn the_legend_explains_every_glyph_a_field_row_draws() {
let text = text_of(&[legend_line(fixtures::plain(), 160)]);
assert!(
text[0]
.trim_end()
.ends_with("-> changed by you, not yet written"),
"{:?}",
text[0]
);
for entry in [
"= read-only",
"~ no widget",
"! parked",
"* yours",
"-> changed by you",
] {
assert!(text[0].contains(entry), "{entry:?} in {:?}", text[0]);
}
}
#[test]
fn the_title_names_the_target_and_no_longer_calls_it_read_only() {
let text = text_of(&pane_lines(&web_pane(), fixtures::plain(), 120, 0));
assert!(text[0].contains("web"), "{:?}", text[0]);
assert!(text[0].contains("(sheep config)"), "{:?}", text[0]);
assert!(!text[0].contains("read-only"), "{:?}", text[0]);
}
#[test]
fn the_pending_section_masks_a_secret_fields_old_and_new_value() {
let pane = secret_dog_pane_with_an_edit();
let (_, entry) = pane.edits().iter().next().expect("one edit was filed");
let line = pending_edit_line(&pane, entry.edit(), 120, fixtures::plain());
let text: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert!(!text.contains("ab12cd34"), "the old value leaked: {text}");
assert!(!text.contains("ef56gh78"), "the new value leaked: {text}");
assert!(text.contains("<set> -> <set>"), "{text}");
}
#[test]
fn tab_walks_every_group_and_each_one_shows_its_own_fields() {
let mut app = fixtures::app_in_sheep_pane();
let mut seen = Vec::new();
for _ in 0..GROUP_ORDER.len() {
let group = app.config_pane().unwrap().group().to_owned();
let tab_row = fixtures::config_pane_tab_row_for_tests(&app, 160);
for name in GROUP_ORDER {
assert!(
tab_row.contains(name),
"the tab row does not name {name}: {tab_row:?}"
);
}
seen.push(group);
app.update(Msg::Key(KeyPress::NextGroup));
}
assert_eq!(seen, GROUP_ORDER.to_vec());
}
#[test]
fn tab_draws_the_active_group_as_its_own_chip() {
for active in GROUP_ORDER {
let mut pane = web_pane();
let digit =
u8::try_from(GROUP_ORDER.iter().position(|g| g == active).unwrap() + 1).unwrap();
pane.set_group(digit);
let line = tab_row_line(&pane, fixtures::coloured(), 160);
let painted: Vec<&str> = line
.spans
.iter()
.filter(|span| span.style.bg.is_some())
.map(|span| span.content.as_ref().trim())
.collect();
assert_eq!(
painted,
vec![*active],
"exactly one chip should carry a ground, the active group's own"
);
}
}
#[test]
fn tab_wraps_from_the_last_group_to_the_first() {
let mut app = fixtures::app_in_sheep_pane();
for _ in 0..GROUP_ORDER.len() {
app.update(Msg::Key(KeyPress::NextGroup));
}
assert_eq!(app.config_pane().unwrap().group(), GROUP_ORDER[0]);
}
#[test]
fn shift_tab_wraps_from_the_first_group_to_the_last() {
let mut app = fixtures::app_in_sheep_pane();
app.update(Msg::Key(KeyPress::PrevGroup));
assert_eq!(
app.config_pane().unwrap().group(),
GROUP_ORDER[GROUP_ORDER.len() - 1]
);
}
#[test]
fn shift_tab_walks_the_groups_tab_walked_in_reverse() {
let mut app = fixtures::app_in_sheep_pane();
app.update(Msg::Key(KeyPress::NextGroup));
app.update(Msg::Key(KeyPress::NextGroup));
app.update(Msg::Key(KeyPress::PrevGroup));
assert_eq!(app.config_pane().unwrap().group(), GROUP_ORDER[1]);
}
#[test]
fn shift_tab_puts_the_cursor_on_the_new_groups_first_row() {
let mut app = fixtures::app_in_sheep_pane();
app.update(Msg::Key(KeyPress::NextGroup));
app.update(Msg::Key(KeyPress::SelectDown));
app.update(Msg::Key(KeyPress::PrevGroup));
let pane = app.config_pane().unwrap();
assert_eq!(pane.cursor(), pane.rows().first().copied());
assert_ne!(pane.cursor(), None, "the first group has a row to land on");
}
#[test]
fn the_digits_reach_the_same_groups_tab_does() {
for (index, wanted) in GROUP_ORDER.iter().enumerate() {
let mut app = fixtures::app_in_sheep_pane();
let digit = u8::try_from(index + 1).unwrap();
app.update(Msg::Key(KeyPress::Group(digit)));
assert_eq!(&app.config_pane().unwrap().group(), wanted);
}
}
#[test]
fn the_list_shows_only_the_active_groups_fields() {
let mut app = fixtures::app_in_sheep_pane();
app.update(Msg::Key(KeyPress::Group(1)));
let rows = fixtures::config_pane_field_rows_for_tests(&app);
assert!(rows.iter().any(|row| row.contains("cwd")), "{rows:?}");
assert!(
!rows.iter().any(|row| row.contains("kill_timeout")),
"a shutdown field is showing under process: {rows:?}"
);
}
#[test]
fn the_pending_section_lists_an_edit_from_another_group() {
let mut app = fixtures::app_in_sheep_pane_with_two_edits();
app.update(Msg::Key(KeyPress::Group(1)));
let rows = fixtures::config_pane_pending_rows_for_tests(&app);
assert!(
rows.iter().any(|row| row.contains("max_memory")),
"{rows:?}"
);
}
#[test]
fn the_title_band_counts_edits_without_calling_them_pending() {
let app = fixtures::app_in_sheep_pane_with_two_edits();
let band = fixtures::config_pane_title_band_for_tests(&app, 160);
assert!(band.contains("2 edits"), "{band}");
assert!(!band.contains("pending"), "{band}");
}
fn strip_text(app: &crate::lookout::app::App, width: u16) -> Option<String> {
let pane = app.config_pane().expect("the pane is open");
tab_strip_line(pane, fixtures::plain(), width).map(|line| text_of(&[line]).remove(0))
}
#[test]
fn the_tab_strip_brackets_the_open_tab_and_keeps_its_hint_at_the_right() {
let mut app = fixtures::app_in_pane_dog(None);
let strip = strip_text(&app, 60).expect("a pane dog has tabs");
assert!(strip.starts_with(" [status] settings "), "{strip:?}");
assert!(strip.ends_with("tab \u{21c6}"), "{strip:?}");
assert_eq!(crate::output::width::visible_width(&strip), 58);
app.update(Msg::Key(KeyPress::NextGroup));
let strip = strip_text(&app, 60).expect("and still on settings");
assert!(strip.starts_with(" status [settings] "), "{strip:?}");
}
#[test]
fn the_tab_strip_drops_its_hint_before_it_cuts_a_tab() {
let app = fixtures::app_in_pane_dog(None);
let strip = strip_text(&app, 24).expect("a pane dog has tabs");
assert_eq!(strip, " [status] settings");
}
#[test]
fn a_dog_without_a_pane_shows_no_tab_strip() {
let app = fixtures::app_in_dog_pane();
assert_eq!(strip_text(&app, 120), None);
let lines = super::super::fixtures::config_pane_lines_for_tests(&app, 120, 0);
assert!(
!text_of(&lines)
.iter()
.any(|line| line.contains("tab \u{21c6}")),
"the settings pane is today's"
);
}
}