use std::borrow::Cow;
use std::mem;
use std::num::NonZeroU32;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Rect;
use ratatui::Frame;
use toml_edit::{Array, DocumentMut, Item, Table, Value};
use crate::config;
use super::app::{AppScreen, ScreenOutcome};
use super::items_editor::{ItemsEditorPrev, ItemsEditorState, LineKey};
use super::list_screen::{
self, ListOutcome, ListRowData, ListScreenState, ListScreenView, VerbHint,
};
use super::main_menu::MainMenuState;
const VERB_LETTERS: &[char] = &['d'];
const VERBS: &[VerbHint<'static>] = &[
VerbHint {
letter: 'a',
label: "add",
},
VerbHint {
letter: 'd',
label: "delete",
},
];
#[derive(Debug, Default)]
pub(super) struct LinePickerState {
list: ListScreenState,
pub(super) prev: MainMenuState,
}
impl LinePickerState {
pub(super) fn new(prev: MainMenuState) -> Self {
Self {
list: ListScreenState::default(),
prev,
}
}
}
pub(super) fn update(
state: &mut LinePickerState,
document: &mut DocumentMut,
config: &mut config::Config,
key: KeyEvent,
) -> ScreenOutcome {
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc {
let prev = mem::take(&mut state.prev);
return ScreenOutcome::NavigateTo(AppScreen::MainMenu(prev));
}
if key.modifiers == KeyModifiers::NONE && matches!(key.code, KeyCode::Char('a')) {
let lines = numbered_lines(document);
let next = next_line_index(&lines);
match add_empty_line_outcome(document, next) {
AddLineOutcome::Added => {
refresh_config(document, config);
let new_lines = numbered_lines(document);
if let Some(idx) = new_lines.iter().position(|n| *n == next) {
state.list.set_cursor(idx, new_lines.len());
}
return ScreenOutcome::Committed;
}
AddLineOutcome::DuplicateIndex => {
linesmith_core::lsm_warn!(
"line picker: line index {} is already in use (possibly via a zero-padded duplicate)",
next.get(),
);
}
AddLineOutcome::DocumentNotEditable => {
linesmith_core::lsm_warn!(
"line picker: could not add `[line.{}]`; `[line]` is not a table",
next.get(),
);
}
}
return ScreenOutcome::Stay;
}
let lines = numbered_lines(document);
let row_count = lines.len();
let cursor = state.list.cursor();
let mut committed = false;
match list_screen::handle_key(&mut state.list, key, row_count, VERB_LETTERS, false) {
ListOutcome::Activate => {
if let Some(&n) = lines.get(cursor) {
let prev_picker = mem::take(state);
let editor = ItemsEditorState::new(
LineKey::Numbered(n),
ItemsEditorPrev::LinePicker(prev_picker),
);
return ScreenOutcome::NavigateTo(AppScreen::ItemsEditor(editor));
}
}
ListOutcome::Action('d') => {
if let Some(&n) = lines.get(cursor) {
if delete_line(document, n) {
refresh_config(document, config);
let new_count = numbered_lines(document).len();
state.list.set_cursor(cursor, new_count);
committed = true;
} else {
linesmith_core::lsm_warn!(
"line picker: could not remove `[line.{}]`; document not editable",
n.get(),
);
}
}
}
ListOutcome::Action(_)
| ListOutcome::MoveSwap { .. }
| ListOutcome::Consumed
| ListOutcome::Unhandled => {}
}
if committed {
ScreenOutcome::Committed
} else {
ScreenOutcome::Stay
}
}
pub(super) fn view(state: &LinePickerState, document: &DocumentMut, frame: &mut Frame, area: Rect) {
let lines = numbered_lines(document);
let row_data: Vec<ListRowData<'_>> = lines
.iter()
.map(|n| {
let counts = line_entry_counts(document, *n);
let label = format!("Line {}", n.get());
let description = describe_line_counts(&counts);
ListRowData {
label: Cow::Owned(label),
description: Cow::Owned(description),
}
})
.collect();
let view = ListScreenView {
title: " pick line to edit ",
rows: &row_data,
verbs: VERBS,
move_mode_supported: false,
};
list_screen::render(&state.list, &view, area, frame);
}
fn numbered_lines(document: &DocumentMut) -> Vec<NonZeroU32> {
let Some(line) = document.get("line").and_then(Item::as_table) else {
return Vec::new();
};
let mut keys: Vec<NonZeroU32> = line
.iter()
.filter_map(|(k, v)| {
if !v.is_table() {
return None;
}
k.parse::<u32>().ok().and_then(NonZeroU32::new)
})
.collect();
keys.sort();
keys.dedup();
keys
}
fn next_line_index(existing: &[NonZeroU32]) -> NonZeroU32 {
match existing.last() {
None => NonZeroU32::new(1).expect("1 is non-zero"),
Some(last) => NonZeroU32::new(last.get().saturating_add(1)).unwrap_or(*last),
}
}
#[derive(Debug, Default, PartialEq, Eq)]
struct LineEntryCounts {
segments: usize,
separators: usize,
other: usize,
}
fn line_entry_counts(document: &DocumentMut, n: NonZeroU32) -> LineEntryCounts {
let Some(line) = document.get("line").and_then(Item::as_table) else {
return LineEntryCounts::default();
};
let Some(arr) = line
.iter()
.find(|(k, v)| v.is_table() && k.parse::<u32>().ok() == Some(n.get()))
.and_then(|(_, v)| v.get("segments"))
.and_then(Item::as_array)
else {
return LineEntryCounts::default();
};
let mut counts = LineEntryCounts::default();
for entry in arr.iter() {
if entry.as_str().is_some() {
counts.segments += 1;
continue;
}
if let Some(table) = entry.as_inline_table() {
match table.get("type").and_then(|v| v.as_str()) {
Some("separator") => counts.separators += 1,
Some(_) => counts.segments += 1,
None => counts.other += 1,
}
continue;
}
counts.other += 1;
}
counts
}
fn describe_line_counts(counts: &LineEntryCounts) -> String {
let total = counts.segments + counts.separators + counts.other;
if total == 0 {
return "(empty)".to_string();
}
if counts.segments == 0 {
return "(no segments)".to_string();
}
let segments = match counts.segments {
1 => "1 segment".to_string(),
n => format!("{n} segments"),
};
if counts.separators == 0 {
segments
} else {
format!("{segments} + {} sep", counts.separators)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AddLineOutcome {
Added,
DuplicateIndex,
DocumentNotEditable,
}
fn add_empty_line_outcome(document: &mut DocumentMut, n: NonZeroU32) -> AddLineOutcome {
let line_item = document
.entry("line")
.or_insert_with(|| Item::Table(Table::new()));
let Some(table) = line_item.as_table_mut() else {
return AddLineOutcome::DocumentNotEditable;
};
if table
.iter()
.any(|(k, v)| v.is_table() && k.parse::<u32>().ok() == Some(n.get()))
{
return AddLineOutcome::DuplicateIndex;
}
let key = n.to_string();
let mut sub = Table::new();
sub["segments"] = Item::Value(Value::Array(Array::new()));
table[&key] = Item::Table(sub);
AddLineOutcome::Added
}
#[cfg(test)]
fn add_empty_line(document: &mut DocumentMut, n: NonZeroU32) -> bool {
matches!(add_empty_line_outcome(document, n), AddLineOutcome::Added,)
}
fn delete_line(document: &mut DocumentMut, n: NonZeroU32) -> bool {
let Some(table) = document.get_mut("line").and_then(Item::as_table_mut) else {
return false;
};
let Some(matched_key) = table
.iter()
.find(|(k, v)| v.is_table() && k.parse::<u32>().ok() == Some(n.get()))
.map(|(k, _)| k.to_string())
else {
return false;
};
table.remove(&matched_key).is_some()
}
fn refresh_config(document: &DocumentMut, config: &mut config::Config) {
match config::Config::from_str_validated(&document.to_string(), |_| {}) {
Ok(new_config) => *config = new_config,
Err(err) => linesmith_core::lsm_warn!(
"line picker: reparse failed, preview frozen at last-good state: {err}",
),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn document(toml: &str) -> DocumentMut {
toml.parse().expect("test toml must parse")
}
fn state() -> LinePickerState {
LinePickerState::new(MainMenuState::default())
}
#[test]
fn esc_back_navigates_to_main_menu() {
let mut s = state();
let mut doc = document("");
let mut cfg = config::Config::default();
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Esc));
assert!(matches!(
outcome,
ScreenOutcome::NavigateTo(AppScreen::MainMenu(_))
));
}
#[test]
fn numbered_lines_sorts_ascending_and_skips_non_numeric() {
let doc = document(
r#"layout = "multi-line"
[line.10]
segments = []
[line.foo]
segments = []
[line.2]
segments = []
"#,
);
let lines = numbered_lines(&doc);
let nums: Vec<u32> = lines.iter().map(|n| n.get()).collect();
assert_eq!(nums, vec![2, 10]);
}
#[test]
fn add_empty_line_creates_line_one_when_document_is_empty() {
let mut s = state();
let mut doc = document("");
let mut cfg = config::Config::default();
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('a')));
assert!(
matches!(outcome, ScreenOutcome::Committed),
"successful `a` add must signal Committed so the dispatcher auto-saves: {outcome:?}",
);
let lines = numbered_lines(&doc);
let nums: Vec<u32> = lines.iter().map(|n| n.get()).collect();
assert_eq!(nums, vec![1]);
assert_eq!(
line_entry_counts(&doc, NonZeroU32::new(1).unwrap()),
LineEntryCounts::default(),
);
}
#[test]
fn add_empty_line_picks_next_index_above_existing_max() {
let mut s = state();
let mut doc = document(
r#"layout = "multi-line"
[line.1]
segments = ["a"]
[line.3]
segments = ["b"]
"#,
);
let mut cfg = config::Config::default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('a')));
let lines = numbered_lines(&doc);
let nums: Vec<u32> = lines.iter().map(|n| n.get()).collect();
assert_eq!(
nums,
vec![1, 3, 4],
"next index appended above existing max"
);
}
#[test]
fn delete_verb_removes_highlighted_line() {
let mut s = state();
let mut doc = document(
r#"layout = "multi-line"
[line.1]
segments = ["a"]
[line.2]
segments = ["b"]
"#,
);
let mut cfg = config::Config::default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('d')));
let lines = numbered_lines(&doc);
let nums: Vec<u32> = lines.iter().map(|n| n.get()).collect();
assert_eq!(nums, vec![2], "line 1 removed");
}
#[test]
fn enter_navigates_to_items_editor_with_correct_line_key() {
let mut s = state();
let mut doc = document(
r#"layout = "multi-line"
[line.1]
segments = ["a"]
[line.2]
segments = ["b"]
"#,
);
let mut cfg = config::Config::default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Down));
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Enter));
match outcome {
ScreenOutcome::NavigateTo(AppScreen::ItemsEditor(editor)) => {
assert_eq!(
editor.line(),
LineKey::Numbered(NonZeroU32::new(2).expect("nonzero")),
);
}
other => panic!("expected NavigateTo(ItemsEditor), got {other:?}"),
}
}
#[test]
fn enter_on_empty_picker_is_inert() {
let mut s = state();
let mut doc = document("");
let mut cfg = config::Config::default();
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Enter));
assert!(matches!(outcome, ScreenOutcome::Stay));
}
#[test]
fn add_then_enter_round_trips_through_items_editor_for_new_line() {
let mut s = state();
let mut doc = document(r#"layout = "multi-line""#);
let mut cfg = config::Config::default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('a')));
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Enter));
match outcome {
ScreenOutcome::NavigateTo(AppScreen::ItemsEditor(editor)) => {
assert_eq!(
editor.line(),
LineKey::Numbered(NonZeroU32::new(1).expect("nonzero")),
);
}
other => panic!("expected NavigateTo(ItemsEditor), got {other:?}"),
}
}
#[test]
fn delete_on_empty_picker_is_inert() {
let mut s = state();
let mut doc = document("");
let mut cfg = config::Config::default();
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('d')));
assert!(matches!(outcome, ScreenOutcome::Stay));
}
#[test]
fn next_line_index_handles_saturation() {
let max = NonZeroU32::new(u32::MAX).expect("nonzero");
assert_eq!(next_line_index(&[max]), max);
}
#[test]
fn numbered_lines_dedups_keys_that_parse_to_same_index() {
let doc = document(
r#"layout = "multi-line"
[line.1]
segments = ["a"]
[line.01]
segments = ["b"]
"#,
);
let lines = numbered_lines(&doc);
let nums: Vec<u32> = lines.iter().map(|n| n.get()).collect();
assert_eq!(
nums,
vec![1],
"dedup must collapse `[line.1]` and `[line.01]` to one picker row",
);
}
#[test]
fn dedup_edit_round_trip_leaves_shadow_table_byte_identical() {
let mut doc = document(
r#"layout = "multi-line"
[line.1]
segments = ["a"]
[line.01]
segments = ["shadow_canary"]
"#,
);
assert!(delete_line(&mut doc, NonZeroU32::new(1).unwrap()));
let serialized = doc.to_string();
assert!(
!serialized.contains("[line.1]\nsegments = [\"a\"]"),
"first match removed: {serialized}",
);
assert!(
serialized.contains("[line.01]") && serialized.contains("\"shadow_canary\""),
"shadow table byte-identical after delete: {serialized}",
);
}
#[test]
fn delete_line_removes_zero_padded_key() {
let mut doc = document(
r#"layout = "multi-line"
[line]
[line.01]
segments = ["model"]
"#,
);
assert!(delete_line(&mut doc, NonZeroU32::new(1).unwrap()));
let line = doc
.get("line")
.and_then(Item::as_table)
.expect("line table present");
let serialized = doc.to_string();
assert!(
!line.contains_key("01"),
"zero-padded key must be removed: {serialized}",
);
}
#[test]
fn add_empty_line_rejects_zero_padded_duplicate() {
let mut doc = document(
r#"layout = "multi-line"
[line.01]
segments = ["model"]
"#,
);
assert!(!add_empty_line(&mut doc, NonZeroU32::new(1).unwrap()));
}
#[test]
fn line_entry_counts_splits_segments_separators_and_other() {
let doc = document(
r#"layout = "multi-line"
[line.1]
segments = ["model", { type = "separator" }, "workspace", { character = " | " }]
"#,
);
let counts = line_entry_counts(&doc, NonZeroU32::new(1).unwrap());
assert_eq!(counts.segments, 2);
assert_eq!(counts.separators, 1);
assert_eq!(counts.other, 1);
}
#[test]
fn describe_line_counts_distinguishes_no_segments_from_empty() {
let only_seps = LineEntryCounts {
segments: 0,
separators: 2,
other: 0,
};
assert_eq!(describe_line_counts(&only_seps), "(no segments)");
let empty = LineEntryCounts::default();
assert_eq!(describe_line_counts(&empty), "(empty)");
let mixed = LineEntryCounts {
segments: 2,
separators: 1,
other: 0,
};
assert_eq!(describe_line_counts(&mixed), "2 segments + 1 sep");
let segments_only = LineEntryCounts {
segments: 1,
separators: 0,
other: 0,
};
assert_eq!(describe_line_counts(&segments_only), "1 segment");
}
#[test]
fn add_then_delete_returns_to_empty() {
let mut s = state();
let mut doc = document("");
let mut cfg = config::Config::default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('a')));
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Char('d')));
assert!(numbered_lines(&doc).is_empty());
}
#[test]
fn enter_carries_picker_state_so_esc_back_nav_returns_to_picker() {
use super::super::items_editor::ItemsEditorPrev;
let mut s = state();
let mut doc = document(
r#"layout = "multi-line"
[line.1]
segments = ["a"]
[line.2]
segments = ["b"]
"#,
);
let mut cfg = config::Config::default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Down));
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Enter));
let editor = match outcome {
ScreenOutcome::NavigateTo(AppScreen::ItemsEditor(e)) => e,
other => panic!("expected NavigateTo(ItemsEditor), got {other:?}"),
};
match &editor.prev {
ItemsEditorPrev::LinePicker(picker) => {
assert_eq!(
picker.list.cursor(),
1,
"picker cursor must round-trip so Esc lands on line 2 again",
);
}
ItemsEditorPrev::MainMenu(_) => panic!(
"items editor's prev must be LinePicker, not MainMenu — Esc would skip the picker",
),
}
}
fn render_to_string(
state: &LinePickerState,
doc: &DocumentMut,
width: u16,
height: u16,
) -> String {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("backend");
terminal
.draw(|frame| view(state, doc, frame, frame.area()))
.expect("draw");
crate::tui::buffer_to_string(terminal.backend().buffer())
}
#[test]
fn snapshot_line_picker_multiple_lines() {
let s = state();
let doc = document(
r#"layout = "multi-line"
[line.1]
segments = []
[line.2]
segments = []
[line.status]
segments = []
"#,
);
insta::assert_snapshot!(
"line_picker_multiple_lines",
render_to_string(&s, &doc, 60, 16)
);
}
}