Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 47 additions & 30 deletions src/graphics/graphics_task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};
Expand Down Expand Up @@ -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");
Expand All @@ -391,23 +391,29 @@ impl GraphicsTask {
inputs_shared.mode,
inputs_shared.cursor,
inputs_shared.command_line.clone(),
inputs_shared.tag_list.selected(),
)
};

if autocomplete_list.is_empty() || pattern.is_empty() || input_mode != InputMode::Normal {
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()
Expand All @@ -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::<String>()
},
Style::default().fg(Color::DarkGray),
),
])
.enumerate()
.map(|(i, x)| {
let suffix = x.as_str().chars().skip(skip_chars).collect::<String>();

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::<Vec<_>>();
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);
Expand Down
127 changes: 125 additions & 2 deletions src/infra/tags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub struct TagList {
tags: Arc<HashMap<String, String>>,
pattern: Arc<String>,
autocomplete_list: Vec<Arc<String>>,
selected: usize,
}

impl TagList {
Expand All @@ -30,8 +31,32 @@ impl TagList {
})
}

pub fn get_first_autocomplete_list(&self) -> Option<Arc<String>> {
self.autocomplete_list.first().cloned()
/// The tag entry currently highlighted in the autocomplete pop-up, if any.
pub fn get_selected_autocomplete(&self) -> Option<Arc<String>> {
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<Self, String> {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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());
}
}
14 changes: 13 additions & 1 deletion src/inputs/inputs_task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) =
Expand All @@ -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() {
Expand Down Expand Up @@ -754,7 +766,7 @@ impl InputsTask {
fn handle_tab_input(private: &mut InputsConnections, shared: Arc<RwLock<InputsShared>>) {
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;
};

Expand Down
19 changes: 19 additions & 0 deletions tests/tui_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading