use serde_json::Value;
use super::super::edits::EditKey;
use super::super::field::{Bounds, FieldKind, ListItem};
use super::super::textbox::{TextBox, TextEdit};
use super::super::validation::{Refusal, item_refusal};
use super::super::viewport::Viewport;
use super::{ConfigPane, PaneEdit, PaneRow, PaneTarget};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListRow {
Item(usize),
New,
}
#[derive(Clone, PartialEq, Eq)]
pub struct ListPane {
key: String,
item: ListItem,
item_bounds: Bounds,
secret: bool,
elements: Vec<String>,
view: Viewport,
typing: Option<(Option<usize>, TextBox)>,
}
impl ListPane {
#[must_use]
pub fn new(
key: String,
item: ListItem,
item_bounds: Bounds,
secret: bool,
elements: Vec<String>,
) -> Self {
Self {
key,
item,
item_bounds,
secret,
elements,
view: Viewport::new(),
typing: None,
}
}
#[must_use]
pub fn key(&self) -> &str {
&self.key
}
#[must_use]
pub fn item(&self) -> ListItem {
self.item
}
#[must_use]
pub fn is_secret(&self) -> bool {
self.secret
}
#[must_use]
pub fn elements(&self) -> &[String] {
&self.elements
}
#[must_use]
pub fn rows(&self) -> Vec<ListRow> {
let mut rows: Vec<ListRow> = (0..self.elements.len()).map(ListRow::Item).collect();
rows.push(ListRow::New);
rows
}
#[must_use]
pub fn cursor(&self) -> Option<ListRow> {
self.rows().get(self.view.cursor()).copied()
}
#[must_use]
pub fn view(&self) -> &Viewport {
&self.view
}
#[must_use]
pub fn typing(&self) -> Option<(Option<usize>, &TextBox)> {
self.typing.as_ref().map(|(index, buffer)| (*index, buffer))
}
pub fn set_rows(&mut self, rows: usize) {
let len = self.rows().len();
self.view.set_rows(rows, len);
}
pub(in crate::lookout) fn move_by(&mut self, delta: isize) {
let len = self.rows().len();
self.view.move_by(delta, len);
}
pub(in crate::lookout) fn move_to(&mut self, index: usize) {
let len = self.rows().len();
self.view.move_to(index, len);
}
pub(in crate::lookout) fn move_to_first(&mut self) {
self.move_to(0);
}
pub(in crate::lookout) fn move_to_last(&mut self) {
let len = self.rows().len();
self.move_to(len.saturating_sub(1));
}
pub(in crate::lookout) fn adopt_view(&mut self, view: Viewport) {
self.view = view;
let len = self.rows().len();
self.view.clamp(len);
}
pub(in crate::lookout) fn set_elements(&mut self, elements: Vec<String>) {
self.elements = elements;
let len = self.rows().len();
self.view.clamp(len);
}
pub fn begin_typing(&mut self) {
self.typing = match self.cursor() {
Some(ListRow::Item(index)) => self
.elements
.get(index)
.map(|element| (Some(index), TextBox::from(element.as_str()))),
Some(ListRow::New) => Some((None, TextBox::new())),
None => None,
};
}
pub fn type_char(&mut self, typed: char) {
if let Some((_, buffer)) = self.typing.as_mut() {
buffer.insert(typed);
}
}
pub fn type_backspace(&mut self) {
if let Some((_, buffer)) = self.typing.as_mut() {
buffer.backspace();
}
}
pub fn edit_text(&mut self, edit: TextEdit) {
if let Some((_, buffer)) = self.typing.as_mut() {
buffer.edit(edit);
}
}
pub fn abandon_typing(&mut self) {
self.typing = None;
}
pub fn apply_typing(&mut self) -> Result<Option<String>, Refusal> {
let Some(buffer) = self
.typing
.as_ref()
.map(|(_, buffer)| buffer.as_str().to_owned())
else {
return Ok(None);
};
if buffer.is_empty() {
self.typing = None;
return Ok(None);
}
if let Some(refused) = item_refusal(
&self.key,
self.item,
&self.item_bounds,
&buffer,
self.secret,
) {
return Err(refused);
}
self.typing = None;
Ok(Some(buffer))
}
pub(in crate::lookout) fn with_element(&self, text: String) -> Option<Vec<String>> {
let mut elements = self.elements.clone();
match self.cursor()? {
ListRow::Item(index) => *elements.get_mut(index)? = text,
ListRow::New => elements.push(text),
}
Some(elements)
}
pub(in crate::lookout) fn without_element(&self) -> Option<Vec<String>> {
let ListRow::Item(index) = self.cursor()? else {
return None;
};
let mut elements = self.elements.clone();
(index < elements.len()).then(|| {
elements.remove(index);
elements
})
}
pub(in crate::lookout) fn reordered(&self, delta: isize) -> Option<Vec<String>> {
let ListRow::Item(index) = self.cursor()? else {
return None;
};
let target = usize::try_from(isize::try_from(index).ok()? + delta).ok()?;
if target >= self.elements.len() {
return None;
}
let mut elements = self.elements.clone();
elements.swap(index, target);
Some(elements)
}
}
fn array_elements(value: &Value) -> Vec<String> {
let Value::Array(values) = value else {
return Vec::new();
};
values
.iter()
.map(|value| match value {
Value::String(text) => text.clone(),
other => other.to_string(),
})
.collect()
}
fn list_value(item: ListItem, elements: &[String]) -> Value {
let element = |text: &String| match item {
ListItem::Text => Value::String(text.clone()),
ListItem::Integer => text
.parse::<i64>()
.map_or_else(|_| Value::String(text.clone()), Value::from),
};
Value::Array(elements.iter().map(element).collect())
}
impl core::fmt::Debug for ListPane {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"ListPane {{ key: {:?}, item: {:?}, secret: {}, elements: {}, typing: {} }}",
self.key,
self.item,
self.secret,
self.elements.len(),
if self.typing.is_some() {
"some"
} else {
"none"
}
)
}
}
impl ConfigPane {
#[must_use]
pub fn list(&self) -> Option<&ListPane> {
self.list.as_deref()
}
pub(in crate::lookout) fn list_mut(&mut self) -> Option<&mut ListPane> {
self.list.as_deref_mut()
}
pub(in crate::lookout) fn open_list(&mut self) {
let Some(PaneRow::Field(index)) = self.cursor() else {
return;
};
let Some(field) = self.fields.fields().get(index) else {
return;
};
let FieldKind::List(item) = field.kind else {
return;
};
let key = field.key.clone();
let item_bounds = field.bounds.items.as_deref().cloned().unwrap_or_default();
let secret = field.secret;
let elements = self.filed_elements_of(&key);
self.list = Some(Box::new(ListPane::new(
key,
item,
item_bounds,
secret,
elements,
)));
}
pub(in crate::lookout) fn close_list(&mut self) {
self.list = None;
}
fn elements_of(&self, key: &str) -> Vec<String> {
self.values.get(key).map(array_elements).unwrap_or_default()
}
pub(super) fn filed_elements_of(&self, key: &str) -> Vec<String> {
let filed = match self.edits.get(&EditKey::Field(key.to_owned())) {
Some(entry) => match entry.edit() {
PaneEdit::Set { value, .. } => Some(value.as_value()),
PaneEdit::SetEnv { .. } => None,
},
None => None,
};
match filed {
Some(value) => array_elements(value),
None => self.elements_of(key),
}
}
pub(in crate::lookout) fn file_list_element(&mut self, text: String) -> Result<(), Refusal> {
let Some(elements) = self.list.as_ref().and_then(|list| list.with_element(text)) else {
return Ok(());
};
self.file_list(elements)
}
pub(in crate::lookout) fn file_list_removal(&mut self) -> Result<(), Refusal> {
let Some(elements) = self.list.as_ref().and_then(|list| list.without_element()) else {
return Ok(());
};
self.file_list(elements)
}
pub(in crate::lookout) fn file_list_reorder(&mut self, delta: isize) -> Result<(), Refusal> {
let Some(elements) = self.list.as_ref().and_then(|list| list.reordered(delta)) else {
return Ok(());
};
self.file_list(elements)
}
fn file_list(&mut self, elements: Vec<String>) -> Result<(), Refusal> {
let Some(list) = self.list.as_ref() else {
return Ok(());
};
if matches!(self.target(), PaneTarget::Dog { .. })
&& list.item() == ListItem::Integer
&& let Some(odd) = elements.iter().find(|text| text.parse::<i64>().is_err())
{
let key = list.key();
let shown = if list.is_secret() {
"(hidden)"
} else {
odd.as_str()
};
return Err(Refusal {
text: format!(
"{key} holds \"{shown}\", which is not an integer; remove it before changing {key}"
),
});
}
let key = list.key().to_owned();
let value = list_value(list.item(), &elements);
self.file_field(key, value);
if let Some(list) = self.list.as_mut() {
list.set_elements(elements);
}
Ok(())
}
pub(in crate::lookout) fn adopt_list_view(&mut self, key: &str, view: Viewport) {
let Some(field) = self.fields.by_key(key) else {
return;
};
let FieldKind::List(item) = field.kind else {
return;
};
let item_bounds = field.bounds.items.as_deref().cloned().unwrap_or_default();
let secret = field.secret;
let mut list = ListPane::new(
key.to_owned(),
item,
item_bounds,
secret,
self.filed_elements_of(key),
);
list.adopt_view(view);
self.list = Some(Box::new(list));
}
}
#[cfg(test)]
mod tests {
use super::super::fixtures::{filed, web_with_args};
use super::*;
fn tags_schema() -> Value {
serde_json::json!({
"$ref": "#/$defs/Settings",
"$defs": {
"Settings": {
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": { "type": "string", "minLength": 3, "pattern": "^[a-z-]+$" },
},
},
},
},
})
}
fn tags_pane() -> ConfigPane {
ConfigPane::sheep_dog(
"web".into(),
"jobs".into(),
&tags_schema(),
serde_json::Map::new(),
)
}
#[test]
fn editing_one_element_sends_the_whole_array() {
let mut pane = ConfigPane::sheep(web_with_args(&["--port", "8080"]));
pane.move_to_key("args");
pane.open_list();
pane.list_mut().expect("open").move_to(1);
pane.file_list_element("9090".into()).expect("filed");
assert_eq!(
filed(&pane, "args"),
Some(serde_json::json!(["--port", "9090"]))
);
}
#[test]
fn a_list_panes_debug_names_no_element() {
let list = ListPane::new(
"args".into(),
ListItem::Text,
Bounds::default(),
false,
vec!["--token".into(), "hunter2".into()],
);
assert_eq!(
format!("{list:?}"),
r#"ListPane { key: "args", item: Text, secret: false, elements: 2, typing: none }"#
);
}
#[test]
fn an_integer_element_that_does_not_parse_keeps_the_editor_open() {
let mut pane = ConfigPane::sheep(web_with_args(&[]));
pane.move_to_key("stop_exit_codes");
pane.open_list();
let list = pane.list_mut().expect("open");
list.move_to_last();
list.begin_typing();
list.type_char('-');
let refused = list.apply_typing().expect_err("- is not a whole number");
assert_eq!(
refused.text,
"stop_exit_codes is \"-\", which is not a whole number shep accepts"
);
assert!(list.typing().is_some(), "the editor is still open");
list.type_char('1');
assert_eq!(
list.apply_typing().ok().flatten().as_deref(),
Some("-1"),
"and a buffer that parses is still accepted"
);
}
#[test]
fn a_list_element_under_its_item_min_length_files_nothing() {
let mut pane = tags_pane();
pane.move_to_key("tags");
pane.open_list();
let refused = {
let list = pane.list_mut().expect("the sub-screen opens");
list.move_to_last();
list.begin_typing();
for typed in "ab".chars() {
list.type_char(typed);
}
list.apply_typing()
.expect_err("two characters is under three")
};
assert_eq!(
refused.text,
"tags is \"ab\"; tags needs at least 3 characters"
);
assert!(
pane.list().expect("still open").typing().is_some(),
"the editor stays open on what was typed"
);
assert!(pane.edits().is_empty(), "nothing was filed");
let text = {
let list = pane.list_mut().expect("still open");
list.type_char('c');
list.apply_typing()
.ok()
.flatten()
.expect("three characters is the bound itself")
};
pane.file_list_element(text).expect("filed");
assert_eq!(filed(&pane, "tags"), Some(serde_json::json!(["abc"])));
}
#[test]
fn a_list_element_that_fails_its_item_pattern_files_nothing() {
let mut pane = tags_pane();
pane.move_to_key("tags");
pane.open_list();
let refused = {
let list = pane.list_mut().expect("the sub-screen opens");
list.move_to_last();
list.begin_typing();
for typed in "Ab1".chars() {
list.type_char(typed);
}
list.apply_typing()
.expect_err("uppercase is outside the pattern")
};
assert_eq!(
refused.text,
"tags is \"Ab1\", which it does not take; try it matches ^[a-z-]+$"
);
assert!(pane.edits().is_empty(), "nothing reached the table");
let text = {
let list = pane.list_mut().expect("still open");
for _ in 0..3 {
list.type_backspace();
}
for typed in "ops-team".chars() {
list.type_char(typed);
}
list.apply_typing()
.ok()
.flatten()
.expect("the pattern takes it")
};
pane.file_list_element(text).expect("filed");
assert_eq!(filed(&pane, "tags"), Some(serde_json::json!(["ops-team"])));
}
#[test]
fn a_secret_lists_refusal_prints_the_marker_and_not_the_element() {
let mut list = ListPane::new(
"tokens".into(),
ListItem::Text,
Bounds {
pattern: Some("^sk-".to_owned()),
..Default::default()
},
true,
Vec::new(),
);
list.begin_typing();
for typed in "tok_HUNTER2".chars() {
list.type_char(typed);
}
let refused = list.apply_typing().expect_err("the pattern refuses it");
assert_eq!(
refused.text,
"tokens is \"<set>\", which it does not take; try it matches ^sk-"
);
assert!(list.typing().is_some(), "the editor stays open");
}
#[test]
fn an_ordinary_list_refusal_quotes_the_element() {
let mut list = ListPane::new(
"regions".into(),
ListItem::Text,
Bounds {
pattern: Some("^eu-".to_owned()),
..Default::default()
},
false,
Vec::new(),
);
list.begin_typing();
for typed in "Europe".chars() {
list.type_char(typed);
}
let refused = list.apply_typing().expect_err("Europe does not match");
assert!(refused.text.contains("Europe"), "{}", refused.text);
}
#[test]
fn removing_and_reordering_file_the_whole_array_too() {
let mut pane = ConfigPane::sheep(web_with_args(&["a", "b", "c"]));
pane.move_to_key("args");
pane.open_list();
pane.list_mut().expect("open").move_to(1);
pane.file_list_removal().expect("filed");
assert_eq!(filed(&pane, "args"), Some(serde_json::json!(["a", "c"])));
pane.file_list_reorder(-1).expect("filed");
assert_eq!(
filed(&pane, "args"),
Some(serde_json::json!(["c", "a"])),
"the cursor is on `c` now, and moving it up files one entry, not two"
);
assert_eq!(pane.edits().len(), 1, "one field, one entry");
}
#[test]
fn a_second_list_keystroke_builds_on_the_first() {
let mut pane = ConfigPane::sheep(web_with_args(&[]));
pane.move_to_key("args");
pane.open_list();
pane.list_mut().expect("open").move_to_last();
pane.file_list_element("abc".into()).expect("filed");
pane.list_mut().expect("open").move_to_last();
pane.file_list_element("def".into()).expect("filed");
assert_eq!(
filed(&pane, "args"),
Some(serde_json::json!(["abc", "def"]))
);
assert_eq!(pane.edits().len(), 1, "one field, one entry");
}
#[test]
fn re_opening_the_sub_screen_shows_what_was_filed() {
let mut pane = ConfigPane::sheep(web_with_args(&["a", "b"]));
pane.move_to_key("args");
pane.open_list();
pane.list_mut().expect("open").move_to(0);
pane.file_list_removal().expect("filed");
assert_eq!(pane.list().expect("open").elements(), ["b"]);
pane.close_list();
pane.open_list();
assert_eq!(pane.list().expect("re-opened").elements(), ["b"]);
}
#[test]
fn moving_an_element_down_files_the_whole_array() {
let mut pane = ConfigPane::sheep(web_with_args(&["a", "b", "c"]));
pane.move_to_key("args");
pane.open_list();
pane.list_mut().expect("open").move_to(0);
pane.file_list_reorder(1).expect("filed");
assert_eq!(
filed(&pane, "args"),
Some(serde_json::json!(["b", "a", "c"]))
);
}
#[test]
fn a_removal_or_a_move_with_nothing_to_act_on_files_nothing() {
let mut pane = ConfigPane::sheep(web_with_args(&["a", "b"]));
pane.move_to_key("args");
pane.open_list();
pane.list_mut().expect("open").move_to_last();
pane.file_list_removal().expect("filed");
assert!(pane.edits().is_empty(), "the `+ new` row holds no element");
pane.list_mut().expect("open").move_to_first();
pane.file_list_reorder(-1).expect("filed");
assert!(pane.edits().is_empty(), "the first element cannot move up");
}
#[test]
fn an_integer_array_travels_as_numbers() {
let mut pane = ConfigPane::sheep(web_with_args(&[]));
pane.move_to_key("stop_exit_codes");
pane.open_list();
assert_eq!(pane.list().expect("open").elements(), ["0", "143"]);
pane.list_mut().expect("open").move_to(0);
pane.file_list_element("2".into()).expect("filed");
assert_eq!(
filed(&pane, "stop_exit_codes"),
Some(serde_json::json!([2, 143]))
);
}
}