use std::mem;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use toml_edit::DocumentMut;
use crate::config;
use super::app::{AppScreen, ScreenOutcome};
use super::items_editor::{self, InsertTarget, ItemsEditorState};
#[derive(Debug)]
pub(super) struct TypePickerState {
candidates: &'static [&'static str],
cursor: usize,
target: InsertTarget,
prev: ItemsEditorState,
}
impl TypePickerState {
pub(super) fn new(target: InsertTarget, prev: ItemsEditorState) -> Self {
Self {
candidates: linesmith_core::segments::BUILT_IN_SEGMENT_IDS,
cursor: 0,
target,
prev,
}
}
}
const VISIBLE_WINDOW: usize = 5;
fn visible_window(state: &TypePickerState) -> (usize, &'static [&'static str]) {
let len = state.candidates.len();
if len == 0 {
return (0, &[]);
}
let n = VISIBLE_WINDOW.min(len);
let half = n / 2;
let start = state.cursor.saturating_sub(half).min(len - n);
(start, &state.candidates[start..start + n])
}
pub(super) fn update(
state: &mut TypePickerState,
document: &mut DocumentMut,
config: &mut config::Config,
key: KeyEvent,
) -> ScreenOutcome {
if key.modifiers != KeyModifiers::NONE {
return ScreenOutcome::Stay;
}
match key.code {
KeyCode::Esc => {
let prev = mem::take(&mut state.prev);
ScreenOutcome::NavigateTo(AppScreen::ItemsEditor(prev))
}
KeyCode::Left => {
navigate(state, Step::Left);
ScreenOutcome::Stay
}
KeyCode::Right => {
navigate(state, Step::Right);
ScreenOutcome::Stay
}
KeyCode::Enter => {
if state.candidates.is_empty() {
let prev = mem::take(&mut state.prev);
return ScreenOutcome::NavigateTo(AppScreen::ItemsEditor(prev));
}
let segment = state.candidates[state.cursor];
let target = state.target;
let prev = mem::take(&mut state.prev);
items_editor::apply_insert(prev, document, config, target, segment)
}
_ => ScreenOutcome::Stay,
}
}
#[derive(Debug, Clone, Copy)]
enum Step {
Left,
Right,
}
fn navigate(state: &mut TypePickerState, step: Step) {
let len = state.candidates.len();
if len == 0 {
return;
}
state.cursor = match step {
Step::Left if state.cursor == 0 => len - 1,
Step::Left => state.cursor - 1,
Step::Right if state.cursor + 1 >= len => 0,
Step::Right => state.cursor + 1,
};
}
pub(super) fn view(state: &TypePickerState, frame: &mut Frame, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), ])
.split(area);
let title = Paragraph::new(Line::from(Span::styled(
" add segment ",
Style::default().add_modifier(Modifier::BOLD),
)))
.alignment(Alignment::Center);
frame.render_widget(title, chunks[0]);
let help = Paragraph::new(Line::from(vec![
Span::styled("←→", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" navigate · "),
Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" select · "),
Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" cancel"),
]))
.alignment(Alignment::Center);
frame.render_widget(help, chunks[1]);
let (start, visible) = visible_window(state);
let len = state.candidates.len();
let dim_style = Style::default().add_modifier(Modifier::DIM);
let mut spans: Vec<Span<'_>> = Vec::with_capacity(visible.len() * 2 + 2);
if start > 0 {
spans.push(Span::styled("… ", dim_style));
}
for (offset, candidate) in visible.iter().enumerate() {
if offset > 0 {
spans.push(Span::raw(" "));
}
let original_idx = start + offset;
let style = if original_idx == state.cursor {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
spans.push(Span::styled(*candidate, style));
}
if start + visible.len() < len {
spans.push(Span::styled(" …", dim_style));
}
let candidates = Paragraph::new(Line::from(spans)).alignment(Alignment::Center);
frame.render_widget(candidates, chunks[3]);
}
#[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 picker_state(target: InsertTarget) -> TypePickerState {
TypePickerState::new(target, ItemsEditorState::default())
}
#[test]
fn left_wraps_from_first_to_last_candidate() {
let mut s = picker_state(InsertTarget::After(0));
let mut doc = document("");
let mut cfg = config::Config::default();
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Left));
assert!(matches!(outcome, ScreenOutcome::Stay));
assert_eq!(s.cursor, s.candidates.len() - 1);
}
#[test]
fn right_wraps_from_last_to_first_candidate() {
let mut s = picker_state(InsertTarget::After(0));
s.cursor = s.candidates.len() - 1;
let mut doc = document("");
let mut cfg = config::Config::default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Right));
assert_eq!(s.cursor, 0);
}
#[test]
fn esc_returns_to_items_editor_without_mutating_document() {
let raw = "[line]\nsegments = [\"a\"]\n";
let mut s = picker_state(InsertTarget::After(0));
let mut doc = document(raw);
let mut cfg = config::Config::default();
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Esc));
assert!(matches!(
outcome,
ScreenOutcome::NavigateTo(AppScreen::ItemsEditor(_))
));
assert_eq!(doc.to_string(), raw);
}
#[test]
fn esc_round_trips_prev_editor_cursor_through_mem_take() {
let mut prev = ItemsEditorState::default();
prev.set_cursor(2, 3);
let mut s = TypePickerState::new(InsertTarget::After(2), prev);
let mut doc = document("[line]\nsegments = [\"a\", \"b\", \"c\"]\n");
let mut cfg = config::Config::default();
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Esc));
match outcome {
ScreenOutcome::NavigateTo(AppScreen::ItemsEditor(restored)) => {
assert_eq!(restored.cursor(), 2);
}
other => panic!("expected NavigateTo(ItemsEditor), got {other:?}"),
}
}
#[test]
fn candidates_include_non_default_built_in_segments() {
let s = picker_state(InsertTarget::After(0));
assert!(
s.candidates.contains(&"context_bar"),
"picker should expose all built-ins, not just defaults: {:?}",
s.candidates,
);
assert!(
s.candidates.len() >= linesmith_core::segments::BUILT_IN_SEGMENT_IDS.len(),
"candidate count should match BUILT_IN_SEGMENT_IDS",
);
}
#[test]
fn visible_window_keeps_cursor_in_view_at_each_position() {
let mut s = picker_state(InsertTarget::After(0));
for cursor in 0..s.candidates.len() {
s.cursor = cursor;
let (start, visible) = visible_window(&s);
assert!(
cursor >= start && cursor < start + visible.len(),
"cursor={cursor} not in window [{start}, {})",
start + visible.len(),
);
assert!(visible.len() <= VISIBLE_WINDOW);
}
}
#[test]
fn navigate_advances_cursor_by_one_in_the_middle() {
let mut s = picker_state(InsertTarget::After(0));
s.cursor = 2;
let mut doc = document("");
let mut cfg = config::Config::default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Right));
assert_eq!(s.cursor, 3);
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Left));
assert_eq!(s.cursor, 2);
}
#[test]
fn chord_modifier_keys_are_inert() {
let mut s = picker_state(InsertTarget::After(0));
let mut doc = document("");
let mut cfg = config::Config::default();
let chord_left = KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT);
update(&mut s, &mut doc, &mut cfg, chord_left);
assert_eq!(s.cursor, 0, "chord arrow must not advance cursor");
let chord_esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::CONTROL);
let outcome = update(&mut s, &mut doc, &mut cfg, chord_esc);
assert!(
matches!(outcome, ScreenOutcome::Stay),
"chord Esc must not cancel: {outcome:?}"
);
}
#[test]
fn enter_inserts_after_cursor_and_returns_to_editor() {
let mut prev = ItemsEditorState::default();
prev.set_cursor(1, 2);
let mut s = TypePickerState::new(InsertTarget::After(1), prev);
s.cursor = 0;
let pick = s.candidates[0];
let mut doc = document(
r#"[line]
segments = ["a", "b"]
"#,
);
let mut cfg = config::Config::default();
let outcome = update(&mut s, &mut doc, &mut cfg, key(KeyCode::Enter));
match outcome {
ScreenOutcome::CommitAndNavigate(AppScreen::ItemsEditor(_)) => {}
other => panic!("expected CommitAndNavigate(ItemsEditor), got {other:?}"),
}
let written = doc.to_string();
assert!(
written.contains(&format!("\"{pick}\"")),
"picked segment must be in document: {written}",
);
}
#[test]
fn enter_inserts_before_cursor_at_index_zero() {
let mut s = TypePickerState::new(InsertTarget::Before(0), ItemsEditorState::default());
s.cursor = 0;
let pick = s.candidates[0];
let mut doc = document(
r#"[line]
segments = ["a", "b"]
"#,
);
let mut cfg = config::Config::default();
update(&mut s, &mut doc, &mut cfg, key(KeyCode::Enter));
let segments = cfg.line.expect("line reparsed").segments;
assert_eq!(segments[0].segment_id(), Some(pick));
assert_eq!(segments[1].segment_id(), Some("a"));
assert_eq!(segments[2].segment_id(), Some("b"));
}
fn render_to_string(state: &TypePickerState, 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, frame, frame.area()))
.expect("draw");
crate::tui::buffer_to_string(terminal.backend().buffer())
}
#[test]
fn snapshot_type_picker_after_first_segment() {
let s = picker_state(InsertTarget::After(0));
insta::assert_snapshot!("type_picker_after_first", render_to_string(&s, 60, 18));
}
}