use ratatui::style::Style;
use ratatui::text::{Line, Span};
use super::super::super::keyvalue::{Field, KeyValues};
use super::super::super::pane::{ConfigPane, PaneRow};
use super::super::super::textbox::TextBox;
use super::super::super::theme::Palette;
use super::super::flock::{fit, mark};
use super::chrome::pending_edit_line;
use super::field_row::section_header;
use super::layout::{body_width, widths};
pub(super) fn pending_and_env_lines(
pane: &ConfigPane,
palette: Palette,
width: u16,
budget: usize,
show_lands: bool,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
if budget == 0 {
return lines;
}
let pending_len = if pane.edits().is_empty() {
0
} else {
2 + pane.edits().len()
};
let shows_env = pane.shows_env();
let env_len = if shows_env {
2 + pane.env_key_names().len() + 1
} else {
0
};
if pending_len + env_len > budget
&& let Some(PaneRow::Env(_) | PaneRow::AddEnv) = pane.cursor()
{
return vec![cursor_env_row_line(pane, width, palette, show_lands)];
}
let mut remaining = budget;
if !pane.edits().is_empty() && remaining > 0 {
lines.push(Line::default());
remaining -= 1;
if remaining > 0 {
lines.push(section_header("pending edits", palette));
remaining -= 1;
}
for (_, entry) in pane.edits().iter() {
if remaining == 0 {
break;
}
lines.push(pending_edit_line(pane, entry.edit(), width, palette));
remaining -= 1;
}
}
if !shows_env {
return lines;
}
if remaining > 0 {
lines.push(Line::default());
remaining -= 1;
}
if remaining > 0 {
lines.push(section_header("env", palette));
remaining -= 1;
}
for (index, name) in pane.env_key_names().iter().enumerate() {
if remaining == 0 {
break;
}
let selected = pane.cursor() == Some(PaneRow::Env(index));
lines.push(env_row_line(
pane, name, selected, width, palette, show_lands,
));
remaining -= 1;
}
if remaining > 0 {
let selected = pane.cursor() == Some(PaneRow::AddEnv);
lines.push(add_env_row_line(pane, selected, width, palette, show_lands));
}
lines
}
pub(super) fn cursor_env_row_line(
pane: &ConfigPane,
width: u16,
palette: Palette,
show_lands: bool,
) -> Line<'static> {
match pane.cursor() {
Some(PaneRow::Env(index)) => {
let name = pane
.env_key_names()
.get(index)
.expect("the cursor names a real env row");
env_row_line(pane, name, true, width, palette, show_lands)
}
Some(PaneRow::AddEnv) => add_env_row_line(pane, true, width, palette, show_lands),
Some(
PaneRow::Field(_)
| PaneRow::Table(_)
| PaneRow::Entry(_)
| PaneRow::Choice { .. }
| PaneRow::Arm { .. }
| PaneRow::Dog(_)
| PaneRow::Under { .. },
)
| None => {
unreachable!("callers check the cursor is on an env row first")
}
}
}
fn editor_on<'a>(pane: &'a ConfigPane, under: &str) -> Option<&'a KeyValues> {
pane.env_typing()
.filter(|editor| editor.opened_under() == Some(under))
}
fn field_cell(buffer: &TextBox, focused: bool, secret: bool, width: u16) -> String {
if focused {
buffer.drawn_for(secret, width.saturating_sub(1))
} else {
buffer.shown(secret)
}
}
pub(super) fn env_row_line(
pane: &ConfigPane,
name: &str,
selected: bool,
width: u16,
palette: Palette,
show_lands: bool,
) -> Line<'static> {
let (key_w, value_w, _) = widths(body_width(width), show_lands);
let editor = selected.then(|| editor_on(pane, name)).flatten();
let (key_cell, value_cell) = match editor {
None => (name.to_owned(), "(set)".to_owned()),
Some(editor) => (
field_cell(editor.key(), editor.focus() == Field::Key, false, key_w),
field_cell(
editor.value(),
editor.focus() == Field::Value,
true,
value_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 mut text = String::from(" ");
text.push_str(&fit(&format!(" {key_cell}"), key_w));
if value_w > 0 {
text.push_str(" ");
text.push_str(&fit(&value_cell, value_w));
}
Line::from(vec![mark_span, Span::styled(text, ground)])
}
pub(super) fn add_env_row_line(
pane: &ConfigPane,
selected: bool,
width: u16,
palette: Palette,
show_lands: bool,
) -> Line<'static> {
let (key_w, value_w, _) = widths(body_width(width), show_lands);
let editor = selected.then(|| pane.env_typing()).flatten();
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 (caption, muted, value) = match editor {
None => ("+ add a key".to_owned(), true, String::new()),
Some(editor) => (
field_cell(editor.key(), editor.focus() == Field::Key, false, key_w),
false,
field_cell(
editor.value(),
editor.focus() == Field::Value,
true,
value_w,
),
),
};
let mut text = String::from(" ");
text.push_str(&fit(&format!(" {caption}"), key_w));
if value_w > 0 {
text.push_str(" ");
text.push_str(&fit(&value, value_w));
}
let style = if muted {
palette.muted()
} else {
Style::default()
};
Line::from(vec![mark_span, Span::styled(text, style.patch(ground))])
}
#[cfg(test)]
mod tests {
use super::super::fixtures::{text_of, web_pane};
use super::super::pane_lines;
use super::*;
use crate::lookout::app::KeyPress;
use crate::lookout::view::MIN_TERM_WIDTH;
use crate::lookout::view::fixtures;
use crate::output::width::visible_width;
fn type_into(pane: &mut ConfigPane, text: &str) {
for typed in text.chars() {
pane.type_env_text(KeyPress::TextChar(typed));
}
}
#[test]
fn the_env_rows_draw_at_a_comfortable_width() {
let pane = web_pane();
let rows = fixtures::config_pane_env_rows_for_tests(&pane);
assert!(rows.iter().any(|row| row.contains("DB_HOST")), "{rows:?}");
assert!(rows.iter().any(|row| row.contains("LOG_LEVEL")), "{rows:?}");
assert!(
rows.iter()
.all(|row| row.contains("(set)") || row.contains("+ add a key")),
"{rows:?}"
);
assert!(
rows.iter().any(|row| row.contains("+ add a key")),
"{rows:?}"
);
}
#[test]
fn a_filed_env_write_never_reaches_the_screen() {
let mut pane = web_pane();
pane.move_to_last();
assert_eq!(pane.cursor(), Some(PaneRow::AddEnv));
pane.begin_env_typing();
type_into(&mut pane, "NEW_KEY");
pane.step_env_field(false);
type_into(&mut pane, "hunter2");
pane.submit_env_typing();
let text = text_of(&pane_lines(&pane, fixtures::plain(), 120, 0));
assert!(!text.join("\n").contains("hunter2"), "{text:?}");
}
#[test]
fn every_env_value_renders_as_set_and_never_as_itself() {
let pane = ConfigPane::sheep({
let mut config = shep_core::config::AppConfig {
name: "web".to_string(),
..Default::default()
};
config
.env
.insert("DB_PASSWORD".to_string(), "hunter2".to_string());
shep_core::protocol::SheepConfigView::new(config, Vec::new(), Vec::new())
});
let rows = fixtures::config_pane_env_rows_for_tests(&pane);
assert!(
rows.iter().any(|row| row.contains("DB_PASSWORD")),
"{rows:?}"
);
assert!(
rows.iter().all(|row| !row.contains("hunter2")),
"an env value reached the pane: {rows:?}"
);
assert!(rows.iter().any(|row| row.contains("(set)")), "{rows:?}");
}
#[test]
fn every_env_line_fits_the_width_it_was_drawn_for() {
let mut pane = web_pane();
pane.move_to_last();
assert_eq!(pane.cursor(), Some(PaneRow::AddEnv));
pane.begin_env_typing();
type_into(&mut pane, "A_VERY_LONG_ENV_KEY_NAME");
pane.step_env_field(false);
type_into(&mut pane, "and a value longer still");
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} drew {}: {line:?}",
visible_width(&line)
);
}
}
}
fn rows_of(pane: &ConfigPane) -> Vec<String> {
text_of(&pane_lines(pane, fixtures::plain(), 160, 0))
}
#[track_caller]
fn add_row_at(pane: &ConfigPane) -> usize {
rows_of(pane)
.iter()
.position(|row| row.contains("add a key"))
.expect("the pane draws a + add a key row")
}
#[test]
fn the_open_editor_draws_key_and_value_side_by_side_on_the_add_row() {
let mut pane = web_pane();
pane.move_to_last();
assert_eq!(pane.cursor(), Some(PaneRow::AddEnv));
let at = add_row_at(&pane);
pane.begin_env_typing();
type_into(&mut pane, "NEW_KEY");
let rows = rows_of(&pane);
let row = &rows[at];
assert!(row.contains("NEW_KEY"), "KEY is drawn: {row:?}");
assert!(row.contains('\u{258f}'), "the cursor is on KEY: {row:?}");
assert!(
!row.contains("add a key"),
"the caption gives way to what is being typed: {row:?}"
);
pane.step_env_field(false);
type_into(&mut pane, "hunter2");
let rows = rows_of(&pane);
let row = &rows[at];
assert!(row.contains("NEW_KEY"), "KEY is still drawn: {row:?}");
let blocks = "\u{2588}".repeat(7);
assert!(!row.contains("hunter2"), "VALUE leaked: {row:?}");
assert!(row.contains(&blocks), "VALUE draws beside it: {row:?}");
assert!(
row.find(&blocks).expect("value") > row.find("NEW_KEY").expect("key"),
"VALUE draws in its own column, after the key column: {row:?}"
);
assert!(
row.find('\u{258f}').expect("cursor") > row.find(&blocks).expect("value"),
"and the cursor moved with the field: {row:?}"
);
}
#[test]
fn an_env_value_being_typed_is_masked() {
let mut pane = web_pane();
pane.move_to_last();
let at = add_row_at(&pane);
pane.begin_env_typing();
type_into(&mut pane, "NEW_KEY");
pane.step_env_field(false);
type_into(&mut pane, "hunter2");
let row = |pane: &ConfigPane| rows_of(pane)[at].clone();
assert!(!row(&pane).contains("hunter2"), "{:?}", row(&pane));
assert!(row(&pane).contains("NEW_KEY"), "the key is not a secret");
pane.step_env_field(true);
assert!(!row(&pane).contains("hunter2"), "{:?}", row(&pane));
assert!(
row(&pane).contains(&"\u{2588}".repeat(7)),
"{:?}",
row(&pane)
);
pane.step_env_field(false);
pane.type_env_text(KeyPress::TextEdit(
crate::lookout::textbox::TextEdit::Reveal,
));
assert!(row(&pane).contains("hunter2\u{258f}"), "{:?}", row(&pane));
}
#[test]
fn a_rename_keeps_drawing_the_row_it_was_opened_on() {
let mut pane = web_pane();
pane.move_to_last();
pane.move_by(-1);
assert_eq!(pane.cursor(), Some(PaneRow::Env(1)));
let before = rows_of(&pane);
let at = before
.iter()
.position(|row| row.contains("LOG_LEVEL"))
.expect("LOG_LEVEL is drawn on its own row");
pane.begin_env_typing();
pane.step_env_field(true);
pane.type_env_text(KeyPress::TextEdit(
crate::lookout::textbox::TextEdit::DeleteToStart,
));
type_into(&mut pane, "LOG_VERBOSITY");
let after = rows_of(&pane);
assert_eq!(after.len(), before.len(), "no row appeared or went away");
assert!(
after[at].contains("LOG_VERBOSITY"),
"the rename draws on the row it started on: before {:?}, after {:?}",
before[at],
after[at]
);
assert_eq!(
pane.env_typing().expect("open").opened_under(),
Some("LOG_LEVEL")
);
}
}