diff --git a/src/graphics/graphics_task.rs b/src/graphics/graphics_task.rs index 39214c3..6153dae 100644 --- a/src/graphics/graphics_task.rs +++ b/src/graphics/graphics_task.rs @@ -34,7 +34,7 @@ use ratatui::{ Frame, Terminal, backend::CrosstermBackend, layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Style}, + style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, BorderType, Borders, Clear, Paragraph, block::Title}, }; @@ -380,7 +380,7 @@ impl GraphicsTask { frame: &mut Frame, command_bar_y: u16, ) { - let (autocomplete_list, pattern, input_mode, cursor, command) = { + let (autocomplete_list, pattern, input_mode, cursor, command, selected) = { let inputs_shared = inputs_shared .read() .expect("Cannot get inputs lock for read"); @@ -391,6 +391,7 @@ impl GraphicsTask { inputs_shared.mode, inputs_shared.cursor, inputs_shared.command_line.clone(), + inputs_shared.tag_list.selected(), ) }; @@ -398,16 +399,21 @@ impl GraphicsTask { return; } - let max_entries = min(frame.size().height as usize / 2, autocomplete_list.len()); - let mut entries = autocomplete_list[..max_entries].to_vec(); - if entries.len() < autocomplete_list.len() { - entries.push(Arc::new("...".to_string())); - } - - let longest_entry_len = entries + // Window the list so the highlighted entry is always on screen, keeping + // as many earlier entries visible as fit; a trailing `...` marks that + // more entries exist below the window. + let cap = min(frame.size().height as usize / 2, autocomplete_list.len()).max(1); + let start = selected + .saturating_sub(cap - 1) + .min(autocomplete_list.len() - cap); + let window = &autocomplete_list[start..start + cap]; + let has_more_below = start + cap < autocomplete_list.len(); + + let longest_entry_len = window .iter() .fold(0u16, |len, x| max(len, x.chars().count() as u16)); - let area_size = (longest_entry_len + 5, entries.len() as u16 + 2); + let row_count = window.len() as u16 + if has_more_below { 1 } else { 0 }; + let area_size = (longest_entry_len + 5, row_count + 2); let max_x = frame.size().x + frame .size() @@ -430,29 +436,40 @@ impl GraphicsTask { .borders(Borders::ALL) .border_type(BorderType::Thick) .style(Style::default().fg(Color::Cyan)); - let text = entries + let inner_width = area_size.0.saturating_sub(2) as usize; + let skip_chars = pattern.chars().count().saturating_sub(1); + let mut text = window .iter() - .map(|x| { - let is_last = - (x == entries.last().unwrap()) && (entries.len() < autocomplete_list.len()); - - Line::from(vec![ - Span::styled( - format!(" {}", if !is_last { &pattern } else { "" }), - Style::default().fg(Color::Cyan), - ), - Span::styled( - if x.as_str() == "..." { - x.to_string() - } else { - let skip_chars = pattern.chars().count().saturating_sub(1); - x.as_str().chars().skip(skip_chars).collect::() - }, - Style::default().fg(Color::DarkGray), - ), - ]) + .enumerate() + .map(|(i, x)| { + let suffix = x.as_str().chars().skip(skip_chars).collect::(); + + if start + i == selected { + // Highlighted entry: a full-width cyan bar so the current + // selection is unmistakable. + let content = format!(" {}{}", pattern, suffix); + let pad = inner_width.saturating_sub(content.chars().count()); + Line::from(Span::styled( + format!("{}{}", content, " ".repeat(pad)), + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )) + } else { + Line::from(vec![ + Span::styled(format!(" {}", pattern), Style::default().fg(Color::Cyan)), + Span::styled(suffix, Style::default().fg(Color::DarkGray)), + ]) + } }) .collect::>(); + if has_more_below { + text.push(Line::from(Span::styled( + " ...", + Style::default().fg(Color::DarkGray), + ))); + } let paragraph = Paragraph::new(text).block(block); frame.render_widget(Clear, area); diff --git a/src/infra/tags.rs b/src/infra/tags.rs index 8678620..971f416 100644 --- a/src/infra/tags.rs +++ b/src/infra/tags.rs @@ -11,6 +11,7 @@ pub struct TagList { tags: Arc>, pattern: Arc, autocomplete_list: Vec>, + selected: usize, } impl TagList { @@ -30,8 +31,32 @@ impl TagList { }) } - pub fn get_first_autocomplete_list(&self) -> Option> { - self.autocomplete_list.first().cloned() + /// The tag entry currently highlighted in the autocomplete pop-up, if any. + pub fn get_selected_autocomplete(&self) -> Option> { + self.autocomplete_list.get(self.selected).cloned() + } + + /// Index of the highlighted entry within the autocomplete list. + pub fn selected(&self) -> usize { + self.selected + } + + /// Move the highlight down one entry, stopping at the last item. + pub fn select_next(&mut self) { + if self.autocomplete_list.is_empty() { + return; + } + self.selected = (self.selected + 1).min(self.autocomplete_list.len() - 1); + } + + /// Move the highlight up one entry, stopping at the first item. + pub fn select_prev(&mut self) { + self.selected = self.selected.saturating_sub(1); + } + + /// Whether the autocomplete pop-up currently has entries to show. + pub fn has_suggestions(&self) -> bool { + !self.pattern.is_empty() && !self.autocomplete_list.is_empty() } pub fn new(file_path: PathBuf) -> Result { @@ -112,11 +137,15 @@ impl TagList { .collect(); self.autocomplete_list .sort_by_key(|a| a.to_ascii_lowercase()); + // The list is rebuilt on every keystroke, so the highlight returns to + // the top; the user drives it away with the up/down arrows. + self.selected = 0; } pub fn clear(&mut self) { self.pattern = Arc::new(String::new()); self.autocomplete_list.clear(); + self.selected = 0; } pub fn full_clear(&mut self) { @@ -226,4 +255,98 @@ mod tests { } assert_eq!(resolved, "\"hello\""); } + + // Autocomplete pop-up navigation (issue #177). + + fn tag_list_showing(tags: &[(&str, &str)], pattern: &str) -> TagList { + let mut tag_list = tag_list_with(tags); + tag_list.update_pattern(pattern, pattern.chars().count()); + tag_list.update_autocomplete_list(); + tag_list + } + + #[test] + fn test_selection_starts_at_first_entry() { + let tag_list = tag_list_showing(&[("alpha", "1"), ("beta", "2"), ("gamma", "3")], "@"); + assert_eq!(tag_list.selected(), 0); + assert_eq!( + tag_list + .get_selected_autocomplete() + .as_deref() + .map(String::as_str), + Some("alpha") + ); + } + + #[test] + fn test_select_next_and_prev_walk_the_list() { + // Sorted case-insensitively -> [alpha, beta, gamma]. + let mut tag_list = tag_list_showing(&[("beta", "2"), ("alpha", "1"), ("gamma", "3")], "@"); + + tag_list.select_next(); + assert_eq!(tag_list.selected(), 1); + assert_eq!( + tag_list + .get_selected_autocomplete() + .as_deref() + .map(String::as_str), + Some("beta") + ); + + tag_list.select_prev(); + assert_eq!(tag_list.selected(), 0); + } + + #[test] + fn test_selection_clamps_at_both_ends() { + let mut tag_list = tag_list_showing(&[("alpha", "1"), ("beta", "2")], "@"); + + // Cannot step above the first entry. + tag_list.select_prev(); + assert_eq!(tag_list.selected(), 0); + + // Cannot step past the last entry. + tag_list.select_next(); + tag_list.select_next(); + tag_list.select_next(); + assert_eq!(tag_list.selected(), 1); + } + + #[test] + fn test_selection_resets_when_list_is_rebuilt() { + let mut tag_list = tag_list_showing(&[("alpha", "1"), ("beta", "2")], "@"); + tag_list.select_next(); + assert_eq!(tag_list.selected(), 1); + + // A keystroke rebuilds the list and returns the highlight to the top. + tag_list.update_autocomplete_list(); + assert_eq!(tag_list.selected(), 0); + } + + #[test] + fn test_clear_resets_selection_and_hides_popup() { + let mut tag_list = tag_list_showing(&[("alpha", "1"), ("beta", "2")], "@"); + tag_list.select_next(); + assert!(tag_list.has_suggestions()); + + tag_list.clear(); + assert_eq!(tag_list.selected(), 0); + assert!(!tag_list.has_suggestions()); + assert!(tag_list.get_selected_autocomplete().is_none()); + } + + #[test] + fn test_has_suggestions_requires_pattern_and_matches() { + // No pattern typed yet -> no pop-up. + let empty = tag_list_with(&[("alpha", "1")]); + assert!(!empty.has_suggestions()); + + // Pattern with no matching tag -> no pop-up. + let no_match = tag_list_showing(&[("alpha", "1")], "@zzz"); + assert!(!no_match.has_suggestions()); + + // Pattern with matches -> pop-up shows. + let matching = tag_list_showing(&[("alpha", "1")], "@al"); + assert!(matching.has_suggestions()); + } } diff --git a/src/inputs/inputs_task.rs b/src/inputs/inputs_task.rs index a4c0762..8975643 100644 --- a/src/inputs/inputs_task.rs +++ b/src/inputs/inputs_task.rs @@ -504,6 +504,13 @@ impl InputsTask { KeyCode::Up => { let mut sw = shared.write().expect("Cannot get input lock for write"); + // While the tag pop-up is up, the arrows drive its selection + // instead of the command history (issue #177). + if sw.mode == InputMode::Normal && sw.tag_list.has_suggestions() { + sw.tag_list.select_prev(); + return LoopStatus::Continue; + } + match sw.mode { InputMode::Normal => { if let HistoryNavResult::Entry(entry) = @@ -525,6 +532,11 @@ impl InputsTask { KeyCode::Down => { let mut sw = shared.write().expect("Cannot get input lock for write"); + if sw.mode == InputMode::Normal && sw.tag_list.has_suggestions() { + sw.tag_list.select_next(); + return LoopStatus::Continue; + } + match sw.mode { InputMode::Normal => { if private.history.is_empty() { @@ -754,7 +766,7 @@ impl InputsTask { fn handle_tab_input(private: &mut InputsConnections, shared: Arc>) { let mut sw = shared.write().expect("Cannot get input lock for write"); - let Some(first_entry) = sw.tag_list.get_first_autocomplete_list() else { + let Some(first_entry) = sw.tag_list.get_selected_autocomplete() else { return; }; diff --git a/tests/tui_e2e.rs b/tests/tui_e2e.rs index ad2ecb8..6a518cb 100644 --- a/tests/tui_e2e.rs +++ b/tests/tui_e2e.rs @@ -265,6 +265,25 @@ fn tag_autocomplete_lists_only_matching_tags() { ); } +#[test] +fn tag_autocomplete_down_arrow_then_tab_completes_selected_tag() { + // Issue #177: the arrows move the highlight inside the pop-up and Tab + // completes the *selected* entry, not just the first one. With the list + // sorted [tag1, tag2], one Down selects tag2, so Tab must yield "@tag2" + // (which resolves to tag2's value on Enter), never "@tag1". + let mut tui = Tui::start(&[("tag1", "hello"), ("tag2", "world")]); + tui.wait_until_ready(); + + tui.type_text("@ta"); + tui.wait_for("@tag2", SETTLE); + + tui.type_text("\x1b[B"); // Down arrow: highlight tag2 + tui.type_text("\t"); // Tab: complete the highlighted entry + tui.press_enter(); + + tui.wait_for("world\\r\\n", SETTLE); +} + #[test] fn bracketed_paste_inserts_into_command_bar() { // A terminal delivers a paste wrapped in the bracketed-paste markers