use crate::config::{CatalogItem, CustomVarDef, ItemKind, VarKind};
use crate::tui::util::{centered_rect, is_ctrl_c, next_key_press};
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
backend::Backend,
widgets::{Block, Borders, Clear, Paragraph},
Terminal,
};
enum DialogAction<T> {
Continue,
Cancel,
Save(T),
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum VarField {
Name,
Kind,
Separator,
}
struct CustomVarDialogState {
name: String,
kind: VarKind,
separator: String,
field: VarField,
hint: Option<String>,
}
impl CustomVarDialogState {
fn new() -> Self {
Self {
name: String::new(),
kind: VarKind::List,
separator: ":".to_string(),
field: VarField::Name,
hint: None,
}
}
fn toggle_kind(&mut self) {
self.kind = match self.kind {
VarKind::Scalar => VarKind::List,
VarKind::List => VarKind::Scalar,
};
if self.kind == VarKind::List && self.separator.is_empty() {
self.separator = ":".to_string();
}
}
fn handle_key(&mut self, key: KeyEvent) -> DialogAction<CustomVarDef> {
self.hint = None;
if is_ctrl_c(&key) {
return DialogAction::Cancel;
}
match key.code {
KeyCode::Esc => DialogAction::Cancel,
KeyCode::Enter => {
let trimmed = self.name.trim();
if trimmed.is_empty() {
return DialogAction::Cancel;
}
if self.kind == VarKind::List && self.separator.is_empty() {
self.hint = Some("separator must not be empty".to_string());
return DialogAction::Continue;
}
DialogAction::Save(CustomVarDef {
name: trimmed.to_string(),
kind: self.kind.clone(),
separator: if self.kind == VarKind::List {
self.separator.clone()
} else {
String::new()
},
})
}
KeyCode::Tab => {
self.field = match self.field {
VarField::Name => VarField::Kind,
VarField::Kind => VarField::Separator,
VarField::Separator => VarField::Name,
};
DialogAction::Continue
}
KeyCode::Char('t') | KeyCode::Char('T')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
self.toggle_kind();
DialogAction::Continue
}
KeyCode::Backspace => {
match self.field {
VarField::Name => {
self.name.pop();
}
VarField::Kind => {}
VarField::Separator => {
if self.kind == VarKind::List {
self.separator.pop();
}
}
}
DialogAction::Continue
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
match self.field {
VarField::Name => self.name.push(c),
VarField::Kind => {}
VarField::Separator => {
if self.kind == VarKind::List {
self.separator.push(c);
}
}
}
DialogAction::Continue
}
_ => DialogAction::Continue,
}
}
}
pub fn create_custom_var_dialog<B: Backend>(
terminal: &mut Terminal<B>,
) -> Result<Option<CustomVarDef>> {
let mut st = CustomVarDialogState::new();
loop {
terminal.draw(|f| {
let area = centered_rect(70, 35, f.size());
let title =
"Create custom env var (Tab: next, Ctrl+T: toggle kind, Enter: save, Esc: cancel)";
let block = Block::default().borders(Borders::ALL).title(title);
let kind_s = match st.kind {
VarKind::Scalar => "Scalar",
VarKind::List => "List",
};
let name_prefix = if st.field == VarField::Name { "> " } else { " " };
let kind_prefix = if st.field == VarField::Kind { "> " } else { " " };
let sep_prefix = if st.field == VarField::Separator {
"> "
} else {
" "
};
let sep_line = if st.kind == VarKind::List {
format!("{sep_prefix}Separator: {}", st.separator)
} else {
format!("{sep_prefix}Separator: (n/a)")
};
let mut text = format!(
"{name_prefix}Name: {}\n{kind_prefix}Kind: {kind_s}\n{sep_line}\n\nNote: list vars are edited as parts; export joins parts using Separator.",
st.name
);
if let Some(h) = &st.hint {
text.push_str(&format!("\n\n{h}"));
}
let p = Paragraph::new(text).block(block);
f.render_widget(p, area);
})?;
if let Some(key) = next_key_press(std::time::Duration::from_millis(100))? {
match st.handle_key(key) {
DialogAction::Continue => {}
DialogAction::Cancel => return Ok(None),
DialogAction::Save(def) => return Ok(Some(def)),
}
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ItemField {
Kind,
Value,
Program,
Version,
Tags,
}
struct ItemDialogState {
id: Option<i64>,
kind: ItemKind,
value: String,
program: String,
version: String,
tags: String,
field: ItemField,
}
impl ItemDialogState {
fn new(initial: Option<&CatalogItem>) -> Self {
Self {
id: initial.and_then(|i| i.id),
kind: initial.map(|i| i.kind.clone()).unwrap_or(ItemKind::Text),
value: initial.map(|i| i.value.clone()).unwrap_or_default(),
program: initial.and_then(|i| i.program.clone()).unwrap_or_default(),
version: initial.and_then(|i| i.version.clone()).unwrap_or_default(),
tags: initial.map(|i| i.tags.join(",")).unwrap_or_default(),
field: ItemField::Value,
}
}
fn handle_key(&mut self, key: KeyEvent) -> DialogAction<CatalogItem> {
if is_ctrl_c(&key) {
return DialogAction::Cancel;
}
match key.code {
KeyCode::Esc => DialogAction::Cancel,
KeyCode::Enter => {
let trimmed = self.value.trim();
if trimmed.is_empty() {
return DialogAction::Cancel;
}
let tags_vec = self
.tags
.split(',')
.map(|t| t.trim())
.filter(|t| !t.is_empty())
.map(|t| t.to_string())
.collect::<Vec<_>>();
DialogAction::Save(CatalogItem {
id: self.id,
kind: self.kind.clone(),
value: trimmed.to_string(),
program: if self.kind == ItemKind::Path && !self.program.trim().is_empty() {
Some(self.program.trim().to_string())
} else {
None
},
version: if self.kind == ItemKind::Path && !self.version.trim().is_empty() {
Some(self.version.trim().to_string())
} else {
None
},
tags: tags_vec,
})
}
KeyCode::Tab => {
self.field = match self.field {
ItemField::Kind => ItemField::Value,
ItemField::Value => ItemField::Program,
ItemField::Program => ItemField::Version,
ItemField::Version => ItemField::Tags,
ItemField::Tags => ItemField::Kind,
};
if self.kind == ItemKind::Text
&& matches!(self.field, ItemField::Program | ItemField::Version)
{
self.field = ItemField::Tags;
}
DialogAction::Continue
}
KeyCode::Char('t') | KeyCode::Char('T')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
self.kind = match self.kind {
ItemKind::Text => ItemKind::Path,
ItemKind::Path => ItemKind::Text,
};
if self.kind == ItemKind::Text
&& matches!(self.field, ItemField::Program | ItemField::Version)
{
self.field = ItemField::Tags;
}
DialogAction::Continue
}
KeyCode::Backspace => {
match self.field {
ItemField::Kind => {}
ItemField::Value => {
self.value.pop();
}
ItemField::Program => {
if self.kind == ItemKind::Path {
self.program.pop();
}
}
ItemField::Version => {
if self.kind == ItemKind::Path {
self.version.pop();
}
}
ItemField::Tags => {
self.tags.pop();
}
}
DialogAction::Continue
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
match self.field {
ItemField::Kind => {}
ItemField::Value => self.value.push(c),
ItemField::Program => {
if self.kind == ItemKind::Path {
self.program.push(c);
}
}
ItemField::Version => {
if self.kind == ItemKind::Path {
self.version.push(c);
}
}
ItemField::Tags => self.tags.push(c),
}
DialogAction::Continue
}
_ => DialogAction::Continue,
}
}
}
pub fn create_or_edit_item_dialog<B: Backend>(
terminal: &mut Terminal<B>,
initial: Option<&CatalogItem>,
) -> Result<Option<CatalogItem>> {
let mut st = ItemDialogState::new(initial);
loop {
terminal.draw(|f| {
let area = centered_rect(80, 45, f.size());
f.render_widget(Clear, area);
let title = "🗃️ Item (Tab: next, Ctrl+T: toggle kind, Enter: save, Esc: cancel)";
let block = Block::default().borders(Borders::ALL).title(title);
let kind_s = match st.kind {
ItemKind::Text => "Text",
ItemKind::Path => "Path",
};
let prefix = |want: ItemField| if st.field == want { "> " } else { " " };
let program_line = if st.kind == ItemKind::Path {
format!("{}Program: {}", prefix(ItemField::Program), st.program)
} else {
format!("{}Program: (n/a)", prefix(ItemField::Program))
};
let version_line = if st.kind == ItemKind::Path {
format!("{}Version: {}", prefix(ItemField::Version), st.version)
} else {
format!("{}Version: (n/a)", prefix(ItemField::Version))
};
let text = format!(
"{}Kind: {kind_s}\n{}Value: {}\n{program_line}\n{version_line}\n{}Tags: {}\n\nTip: Use Tags to filter; drop items only works for list-like vars.",
prefix(ItemField::Kind),
prefix(ItemField::Value),
st.value,
prefix(ItemField::Tags),
st.tags,
);
let p = Paragraph::new(text).block(block);
f.render_widget(p, area);
})?;
if let Some(key) = next_key_press(std::time::Duration::from_millis(100))? {
match st.handle_key(key) {
DialogAction::Continue => {}
DialogAction::Cancel => return Ok(None),
DialogAction::Save(item) => return Ok(Some(item)),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
fn type_str(mut feed: impl FnMut(KeyEvent), s: &str) {
for c in s.chars() {
feed(press(KeyCode::Char(c)));
}
}
#[test]
fn var_dialog_types_t_into_name_instead_of_toggling_kind() {
let mut st = CustomVarDialogState::new();
type_str(|k| drop(st.handle_key(k)), "PYTHONPATH");
assert_eq!(st.name, "PYTHONPATH");
assert_eq!(st.kind, VarKind::List);
}
#[test]
fn var_dialog_ctrl_c_cancels() {
let mut st = CustomVarDialogState::new();
assert!(matches!(st.handle_key(ctrl('c')), DialogAction::Cancel));
let mut st = CustomVarDialogState::new();
assert!(matches!(st.handle_key(ctrl('C')), DialogAction::Cancel));
}
#[test]
fn item_dialog_ctrl_c_cancels() {
let mut st = ItemDialogState::new(None);
assert!(matches!(st.handle_key(ctrl('c')), DialogAction::Cancel));
let mut st = ItemDialogState::new(None);
assert!(matches!(st.handle_key(ctrl('C')), DialogAction::Cancel));
}
#[test]
fn var_dialog_ctrl_t_toggles_kind() {
let mut st = CustomVarDialogState::new();
assert!(matches!(st.handle_key(ctrl('t')), DialogAction::Continue));
assert_eq!(st.kind, VarKind::Scalar);
assert!(matches!(st.handle_key(ctrl('T')), DialogAction::Continue));
assert_eq!(st.kind, VarKind::List);
}
#[test]
fn var_dialog_rejects_empty_list_separator() {
let mut st = CustomVarDialogState::new();
type_str(|k| drop(st.handle_key(k)), "MYLIST");
st.separator.clear();
assert!(matches!(
st.handle_key(press(KeyCode::Enter)),
DialogAction::Continue
));
assert!(st.hint.is_some());
}
#[test]
fn var_dialog_saves_valid_list_var() {
let mut st = CustomVarDialogState::new();
type_str(|k| drop(st.handle_key(k)), "MYLIST");
match st.handle_key(press(KeyCode::Enter)) {
DialogAction::Save(def) => {
assert_eq!(def.name, "MYLIST");
assert_eq!(def.kind, VarKind::List);
assert_eq!(def.separator, ":");
}
_ => panic!("expected save"),
}
}
fn path_item() -> CatalogItem {
CatalogItem {
id: None,
kind: ItemKind::Path,
value: String::new(),
program: Some("python".to_string()),
version: Some("3.12".to_string()),
tags: vec![],
}
}
#[test]
fn item_dialog_types_t_into_value_without_toggling_or_wiping() {
let initial = path_item();
let mut st = ItemDialogState::new(Some(&initial));
type_str(|k| drop(st.handle_key(k)), "/opt/tools");
assert_eq!(st.value, "/opt/tools");
assert_eq!(st.kind, ItemKind::Path);
assert_eq!(st.program, "python");
assert_eq!(st.version, "3.12");
}
#[test]
fn item_dialog_ctrl_t_toggle_keeps_program_and_version() {
let initial = path_item();
let mut st = ItemDialogState::new(Some(&initial));
st.handle_key(ctrl('t'));
assert_eq!(st.kind, ItemKind::Text);
st.handle_key(ctrl('t'));
assert_eq!(st.kind, ItemKind::Path);
assert_eq!(st.program, "python");
assert_eq!(st.version, "3.12");
}
#[test]
fn item_dialog_save_omits_program_and_version_for_text_kind() {
let initial = path_item();
let mut st = ItemDialogState::new(Some(&initial));
type_str(|k| drop(st.handle_key(k)), "hello");
st.handle_key(ctrl('t'));
assert_eq!(st.kind, ItemKind::Text);
match st.handle_key(press(KeyCode::Enter)) {
DialogAction::Save(item) => {
assert_eq!(item.value, "hello");
assert_eq!(item.program, None);
assert_eq!(item.version, None);
}
_ => panic!("expected save"),
}
}
}