use ratatui::text::{Line, Span};
use super::super::super::pane::{ConfigPane, PaneRow};
use super::super::super::theme::Palette;
use super::super::scroll::{self, Attempt};
use super::entry_row::entry_line;
use super::env::pending_and_env_lines;
use super::field_row::{field_line, section_header, table_line};
pub(super) fn grouped_body_lines(
pane: &ConfigPane,
palette: Palette,
width: u16,
budget: usize,
show_lands: bool,
) -> Vec<Line<'static>> {
if matches!(pane.cursor(), Some(PaneRow::Env(_) | PaneRow::AddEnv)) {
let tail = pending_and_env_lines(pane, palette, width, budget, show_lands);
let remaining = budget.saturating_sub(tail.len());
let mut lines = if !pane.field_rows().is_empty() && remaining > 0 {
body_from(pane, palette, width, remaining, 0, false, show_lands).lines
} else {
Vec::new()
};
lines.extend(tail);
return lines;
}
let mut lines = if !pane.field_rows().is_empty() && budget > 0 {
let field_rows = pane.field_rows();
let cursor_row = field_rows
.iter()
.position(|row| Some(*row) == pane.cursor())
.unwrap_or(0);
scroll::to_cursor(
cursor_row,
pane.view().offset(),
|offset| body_from(pane, palette, width, budget, offset, false, show_lands),
|| cursor_only(pane, palette, width, budget, cursor_row, show_lands),
)
} else {
Vec::new()
};
let remaining = budget.saturating_sub(lines.len());
lines.extend(pending_and_env_lines(
pane, palette, width, remaining, show_lands,
));
lines
}
pub(super) fn body_from(
pane: &ConfigPane,
palette: Palette,
width: u16,
budget: usize,
offset: usize,
show_group_headers: bool,
show_lands: bool,
) -> Attempt {
let rows = pane.field_rows();
let total = rows.len();
let cursor_row = rows
.iter()
.position(|row| Some(*row) == pane.cursor())
.unwrap_or(0)
.min(total.saturating_sub(1));
let above = usize::from(offset > 0);
let room = |taken: usize, need: usize, index: usize| {
taken + need + above + usize::from(index + 1 < total) <= budget
};
let mut lines: Vec<Line<'static>> = Vec::new();
let mut current_group: Option<&str> = None;
let mut pending_header: Vec<Line<'static>> = Vec::new();
let mut drawn = 0usize;
for (index, row) in rows.iter().enumerate() {
let field_index = match *row {
PaneRow::Field(field_index) => field_index,
PaneRow::Table(table_index) => {
if index < offset {
continue;
}
if !room(lines.len(), 1, index) {
break;
}
lines.push(table_line(
pane,
table_index,
pane.cursor() == Some(*row),
width,
palette,
show_lands,
));
drawn += 1;
continue;
}
PaneRow::Entry(entry_index) => {
if index < offset {
continue;
}
if !room(lines.len(), 1, index) {
break;
}
lines.push(entry_line(
pane,
entry_index,
pane.cursor() == Some(*row),
width,
palette,
show_lands,
));
drawn += 1;
continue;
}
PaneRow::Env(_) | PaneRow::AddEnv => continue,
};
let group = pane
.fields()
.fields()
.get(field_index)
.and_then(|field| field.group.as_deref());
if show_group_headers && current_group != group {
let mut header = Vec::new();
if current_group.is_some() {
header.push(Line::default());
}
if let Some(group) = group {
header.push(section_header(group, palette));
}
pending_header = header;
current_group = group;
}
if index < offset {
continue;
}
if !room(lines.len(), pending_header.len() + 1, index) {
break;
}
lines.append(&mut pending_header);
lines.push(field_line(
pane,
field_index,
pane.cursor() == Some(*row),
width,
palette,
show_lands,
));
drawn += 1;
}
let hidden_below = total.saturating_sub(offset + drawn);
if hidden_below > 0 {
lines.push(Line::from(Span::styled(
format!(" ... {hidden_below} below"),
palette.muted(),
)));
}
if offset > 0 {
lines.insert(
0,
Line::from(Span::styled(
format!(" ... {offset} above"),
palette.muted(),
)),
);
}
Attempt {
cursor_drawn: drawn > 0 && (offset..offset + drawn).contains(&cursor_row),
lines,
}
}
pub(super) fn cursor_only(
pane: &ConfigPane,
palette: Palette,
width: u16,
budget: usize,
cursor_row: usize,
show_lands: bool,
) -> Vec<Line<'static>> {
let rows = pane.field_rows();
let mut lines = Vec::new();
match rows.get(cursor_row).copied() {
Some(PaneRow::Field(index)) => {
lines.push(field_line(pane, index, true, width, palette, show_lands));
}
Some(PaneRow::Table(index)) => {
lines.push(table_line(pane, index, true, width, palette, show_lands));
}
Some(PaneRow::Entry(index)) => {
lines.push(entry_line(pane, index, true, width, palette, show_lands));
}
Some(PaneRow::Env(_) | PaneRow::AddEnv) | None => {}
}
let hidden_below = rows.len().saturating_sub(cursor_row + 1);
if cursor_row > 0 && lines.len() < budget {
lines.insert(
0,
Line::from(Span::styled(
format!(" ... {cursor_row} above"),
palette.muted(),
)),
);
}
if hidden_below > 0 && lines.len() < budget {
lines.push(Line::from(Span::styled(
format!(" ... {hidden_below} below"),
palette.muted(),
)));
}
lines
}
#[cfg(test)]
mod tests {
use super::super::draw::pane_lines;
use super::super::fixtures::{
bark_pane, blurb_anchor, config_pane_lines_for_tests, marked, screen_at, text_of, web_pane,
};
use super::super::layout::panel_width;
use crate::lookout::app::{KeyPress, Msg};
use crate::lookout::view::MIN_TERM_WIDTH;
use crate::lookout::view::fixtures;
use crate::output::width::visible_width;
#[test]
fn a_sheep_pane_scrolled_to_the_last_field_of_a_group_shows_it() {
let mut pane = web_pane();
pane.set_rows(8);
pane.move_to_key("user");
let text = text_of(&pane_lines(&pane, fixtures::plain(), 120, 9));
assert!(text.len() <= 9, "{text:?}");
assert!(text.iter().any(|line| line.contains("above")), "{text:?}");
assert!(
text.iter().any(|line| line.contains("user")),
"process's last field is visible: {text:?}"
);
assert!(!text.iter().any(|line| line.contains("below")), "{text:?}");
}
#[test]
fn the_cursor_survives_every_step_of_a_walk_down_and_back_up() {
for height in [6u16, 7, 8, 10, 14, 20, 45] {
let mut app = fixtures::app_in_sheep_pane();
let total = app.config_pane().unwrap().rows().len();
for step in 0..=total {
let text = screen_at(&mut app, height);
assert_eq!(marked(&text), 1, "{height} rows, {step} down:\n{text}");
app.update(Msg::Key(KeyPress::SelectDown));
}
for step in 0..=total {
let text = screen_at(&mut app, height);
assert_eq!(marked(&text), 1, "{height} rows, {step} up:\n{text}");
app.update(Msg::Key(KeyPress::SelectUp));
}
}
}
#[test]
fn the_cursor_survives_a_jump_to_the_last_field_at_a_roomy_height() {
for height in [24u16, 29, 30, 31, 45] {
let mut app = fixtures::app_in_sheep_pane();
app.update(Msg::Key(KeyPress::SelectLast));
let text = screen_at(&mut app, height);
assert_eq!(marked(&text), 1, "{height} rows:\n{text}");
}
}
#[test]
fn the_body_never_outgrows_the_height_it_was_given() {
let mut pane = web_pane();
for width in [120u16, 89] {
for height in 1..=60u16 {
pane.set_rows(usize::from(height.saturating_sub(1)));
for cursor in [0usize, 7, 20, 38] {
pane.move_to_first();
pane.move_by(isize::try_from(cursor).unwrap());
let text = text_of(&pane_lines(&pane, fixtures::plain(), width, height));
assert!(
text.len() <= usize::from(height),
"width {width}, height {height}, cursor {cursor}: {text:?}"
);
}
}
}
}
#[test]
fn the_blurb_never_spends_the_row_the_cursor_needs() {
for (which, mut pane, key) in [
("grouped", web_pane(), "autorestart"),
("ungrouped", bark_pane(), "history_bytes"),
] {
pane.move_to_key(key);
let anchor = blurb_anchor(&pane);
let mut checked = 0;
for height in 1..=20u16 {
let rows = text_of(&pane_lines(&pane, fixtures::plain(), 89, height));
if !rows.iter().any(|row| row.contains(&anchor)) {
continue;
}
checked += 1;
assert!(
rows.iter()
.any(|row| row.contains(key) && !row.contains(&anchor)),
"{which} at height {height}: the blurb drew and the cursor's row did not: {rows:?}"
);
}
assert!(
checked > 0,
"{which}: the blurb never drew, so nothing was checked"
);
}
}
#[test]
fn a_filed_edit_draws_no_question_under_the_title() {
let mut pane = web_pane();
pane.move_to_key("autorestart");
pane.cycle();
let text = text_of(&pane_lines(&pane, fixtures::plain(), 120, 0));
assert!(
!text.iter().any(|line| line.contains("enter confirms")),
"{text:?}"
);
assert!(
!text.iter().any(|line| line.contains("set autorestart")),
"{text:?}"
);
}
#[test]
fn the_blurb_shares_the_panes_own_indent() {
let pane = web_pane();
let lines = pane_lines(&pane, fixtures::plain(), 89, 40);
let rows = text_of(&lines);
let indent = |row: &str| row.len() - row.trim_start().len();
let header = rows
.iter()
.find(|row| row.contains("FIELD") && row.contains("VALUE"))
.expect("no column header");
let anchor = blurb_anchor(&pane);
let blurb = rows
.iter()
.find(|row| row.contains(&anchor))
.expect("no blurb row");
assert_eq!(
indent(blurb),
indent(header),
"blurb {:?} against header {:?}",
blurb.get(..12),
header.get(..12)
);
}
#[test]
fn the_blurb_draws_at_a_width_with_no_panel() {
let pane = web_pane();
assert!(panel_width(89).is_none(), "89 must have no panel");
let lines = pane_lines(&pane, fixtures::plain(), 89, 40);
let anchor = blurb_anchor(&pane);
let rows = text_of(&lines);
assert!(
rows.iter().any(|row| row.contains(&anchor)),
"no blurb at 89 columns: {rows:?}"
);
}
#[test]
fn the_blurb_follows_the_cursor_with_no_panel() {
let mut pane = web_pane();
let first = blurb_anchor(&pane);
pane.move_by(1);
let second = blurb_anchor(&pane);
assert!(
!first.contains(&second) && !second.contains(&first),
"the fixture needs two fields whose longest help words are not substrings of each other: {first:?} / {second:?}"
);
let lines = pane_lines(&pane, fixtures::plain(), 89, 40);
let rows = text_of(&lines);
let blurb_row = rows
.iter()
.find(|row| row.contains(&second))
.unwrap_or_else(|| panic!("the cursor moved and the blurb did not: {rows:?}"));
assert!(
!blurb_row.contains(&first),
"the previous field's blurb is still on screen: {rows:?}"
);
}
#[test]
fn help_text_still_respects_the_width_and_height_budgets() {
let mut pane = web_pane();
pane.move_to_key("max_memory");
for width in MIN_TERM_WIDTH..=200 {
for line in text_of(&pane_lines(&pane, fixtures::plain(), width, 0)) {
assert!(
visible_width(&line) <= usize::from(width),
"width {width}: {line:?}"
);
}
}
for height in 1..=30u16 {
let text = text_of(&pane_lines(&pane, fixtures::plain(), 120, height));
assert!(
text.len() <= usize::from(height),
"height {height}: {text:?}"
);
}
}
#[test]
fn an_open_editor_draws_its_buffer_in_the_fields_own_row() {
let mut pane = web_pane();
pane.move_to_key("cwd");
pane.begin_typing();
for typed in "/srv".chars() {
pane.type_char(typed);
}
let text = text_of(&pane_lines(&pane, fixtures::plain(), 120, 0));
let row = text
.iter()
.find(|line| line.contains(" cwd"))
.expect("cwd is drawn at 120 columns");
assert!(row.contains("/srv\u{258f}"), "{row:?}");
assert!(
!text.iter().any(|line| line.contains("set cwd")),
"an editor is not a confirm: {text:?}"
);
}
#[test]
fn a_short_body_sheds_the_legend_before_the_tab_row() {
let app = fixtures::app_in_sheep_pane();
let rows = fixtures::render_all(&config_pane_lines_for_tests(&app, 160, 6));
assert!(rows.contains("tab next group"), "the tab row must survive");
assert!(!rows.contains("changed by you"), "the legend must go first");
}
}