diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 120000 index 0000000000..c475374bb3 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1 @@ +/nix/store/5jr06kaid73ilblgxp0njd5k35nxynnx-pre-commit-config.json \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 0faf072c28..8bf1c4beef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -614,6 +614,15 @@ dependencies = [ "libc", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -681,6 +690,7 @@ dependencies = [ "crossterm", "etcetera", "figment", + "fuzzy-matcher", "git-version", "git2", "imara-diff", @@ -1798,6 +1808,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tinystr" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 0a3eafeb54..368a8ff68c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,4 +76,5 @@ strum = { version = "0.26.3", features = ["strum_macros"] } tinyvec = "1.10.0" smashquote = "0.1.2" imara-diff = { version = "0.2.0", default-features = false } +fuzzy-matcher = "0.3.7" url = "2.5.7" diff --git a/README.md b/README.md index 3664b302c5..8328b0f7ae 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,19 @@ Configuration is also loaded from: - Windows: `%USERPROFILE%\AppData\Roaming\gitu\config.toml` , refer to the [default configuration](src/default_config.toml). + +#### Picker Style Customization + +You can customize the appearance of the interactive picker by adding the following to your config: + +```toml +[style.picker] +prompt = { fg = "cyan" } # Prompt text color +info = { mods = "DIM" } # Status line style (e.g., "3/10 matches") +selection_line = { mods = "BOLD" } # Selected item style +matched = { fg = "yellow", mods = "BOLD" } # Fuzzy-matched characters highlight +``` + ### Installing Gitu Follow the install instructions: [Installing Gitu](docs/installing.md)\ Or install from your package manager: diff --git a/src/app.rs b/src/app.rs index b34a02d731..8a6cdc7704 100644 --- a/src/app.rs +++ b/src/app.rs @@ -33,6 +33,8 @@ use crate::item_data::RefKind; use crate::menu::Menu; use crate::menu::PendingMenu; use crate::ops::Op; +use crate::picker::PickerData; +use crate::picker::PickerState; use crate::prompt; use crate::screen; use crate::screen::Screen; @@ -52,6 +54,7 @@ pub(crate) struct State { enable_async_cmds: bool, pub current_cmd_log: CmdLog, pub prompt: prompt::Prompt, + pub picker: Option, pub clipboard: Option, needs_redraw: bool, file_watcher: Option, @@ -103,6 +106,7 @@ impl App { pending_menu, current_cmd_log: CmdLog::new(), prompt: prompt::Prompt::new(), + picker: None, clipboard, file_watcher: None, needs_redraw: true, @@ -213,7 +217,9 @@ impl App { self.state.current_cmd_log.clear(); } - if self.state.prompt.state.is_focused() { + if self.state.picker.is_some() { + self.handle_picker_input(key); + } else if self.state.prompt.state.is_focused() { self.state.prompt.state.handle_key_event(key); } else { self.handle_key_input(term, key)?; @@ -618,6 +624,95 @@ impl App { self.redraw_now(term)?; } } + + /// Show a picker and wait for user to select an item or cancel. + /// + /// Returns: + /// - `Ok(Some(data))` - User selected an item + /// - `Ok(None)` - User cancelled (Esc or Ctrl-C) + /// - `Err(e)` - An error occurred + /// + /// # Example + /// ```ignore + /// let items = vec![ + /// PickerItem::new("main", PickerData::Revision("main".to_string())), + /// PickerItem::new("develop", PickerData::Revision("develop".to_string())), + /// ]; + /// let picker = PickerState::new("Select branch", items, false); + /// + /// match app.picker(term, picker)? { + /// Some(PickerData::Revision(name)) => { + /// // User selected a branch + /// println!("Selected: {}", name); + /// } + /// Some(PickerData::CustomInput(_)) => { + /// // Should not happen when allow_custom_input is false + /// } + /// None => { + /// // User cancelled + /// println!("Cancelled"); + /// } + /// } + /// ``` + #[allow(dead_code)] + pub fn picker( + &mut self, + term: &mut Term, + picker_state: PickerState, + ) -> Res> { + self.state.picker = Some(picker_state); + let result = self.handle_picker(term); + + self.state.picker = None; + + result + } + + fn handle_picker(&mut self, term: &mut Term) -> Res> { + self.redraw_now(term)?; + + loop { + let event = term.backend_mut().read_event()?; + self.handle_event(term, event)?; + + if let Some(ref picker) = self.state.picker { + if picker.is_done() { + // User selected an item + return Ok(picker.selected().map(|item| item.data.clone())); + } else if picker.is_cancelled() { + // User cancelled - this is not an error + return Ok(None); + } + } + + self.redraw_now(term)?; + } + } + + fn handle_picker_input(&mut self, key: event::KeyEvent) { + if let Some(ref mut picker) = self.state.picker { + // The character received in the KeyEvent changes as shift is pressed, + // e.g. '/' becomes '?' on a US keyboard. So just ignore SHIFT. + let mods_without_shift = key.modifiers.difference(KeyModifiers::SHIFT); + let key_combo = vec![(mods_without_shift, key.code)]; + + let bindings = &self.state.config.picker_bindings; + + if bindings.next.iter().any(|b| b == &key_combo) { + picker.next(); + } else if bindings.previous.iter().any(|b| b == &key_combo) { + picker.previous(); + } else if bindings.done.iter().any(|b| b == &key_combo) { + picker.done(); + } else if bindings.cancel.iter().any(|b| b == &key_combo) { + picker.cancel(); + } else { + // Text input - delegate to text state + picker.input_state.handle_key_event(key); + picker.update_filter(); + } + } + } } fn get_prompt_result(params: &PromptParams, app: &mut App) -> Res { diff --git a/src/config.rs b/src/config.rs index c07df9f1e4..99d7afc8b5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,7 @@ use std::{collections::BTreeMap, path::PathBuf}; -use crate::{Bindings, Res, error::Error, menu::Menu, ops::Op}; +use crate::{Bindings, Res, error::Error, key_parser, menu::Menu, ops::Op}; +use crossterm::event::{KeyCode, KeyModifiers}; use etcetera::{BaseStrategy, choose_base_strategy}; use figment::{ Figment, @@ -15,6 +16,27 @@ pub struct Config { pub general: GeneralConfig, pub style: StyleConfig, pub bindings: Bindings, + pub picker_bindings: PickerBindings, +} + +#[derive(Default, Deserialize)] +pub(crate) struct PickerBindingsConfig { + #[serde(default)] + pub next: Vec, + #[serde(default)] + pub previous: Vec, + #[serde(default)] + pub done: Vec, + #[serde(default)] + pub cancel: Vec, +} + +#[derive(Default, Deserialize)] +pub(crate) struct BindingsConfig { + #[serde(flatten)] + pub menus: BTreeMap>>, + #[serde(default)] + pub picker: PickerBindingsConfig, } #[derive(Default, Deserialize)] @@ -23,7 +45,7 @@ pub struct Config { pub(crate) struct FigmentConfig { pub general: GeneralConfig, pub style: StyleConfig, - pub bindings: BTreeMap>>, + pub bindings: BindingsConfig, } #[derive(Default, Debug, Deserialize)] @@ -78,6 +100,9 @@ pub struct StyleConfig { #[serde(default)] pub syntax_highlight: SyntaxHighlightConfig, + #[serde(default)] + pub picker: PickerStyleConfig, + pub cursor: SymbolStyleConfigEntry, pub selection_bar: SymbolStyleConfigEntry, pub selection_line: StyleConfigEntry, @@ -170,6 +195,18 @@ pub struct SyntaxHighlightConfig { pub variable_parameter: StyleConfigEntry, } +#[derive(Default, Debug, Deserialize)] +pub struct PickerStyleConfig { + #[serde(default)] + pub prompt: StyleConfigEntry, + #[serde(default)] + pub info: StyleConfigEntry, + #[serde(default)] + pub selection_line: StyleConfigEntry, + #[serde(default)] + pub matched: StyleConfigEntry, +} + #[derive(Default, Debug, Deserialize)] pub struct StyleConfigEntry { #[serde(default)] @@ -216,6 +253,55 @@ impl From<&SymbolStyleConfigEntry> for Style { } } +pub struct PickerBindings { + pub next: Vec>, + pub previous: Vec>, + pub done: Vec>, + pub cancel: Vec>, +} + +impl TryFrom for PickerBindings { + type Error = crate::error::Error; + + fn try_from(config: PickerBindingsConfig) -> Result { + let mut bad_bindings = Vec::new(); + + let next = parse_picker_keys(&config.next, "picker.next", &mut bad_bindings); + let previous = parse_picker_keys(&config.previous, "picker.previous", &mut bad_bindings); + let done = parse_picker_keys(&config.done, "picker.done", &mut bad_bindings); + let cancel = parse_picker_keys(&config.cancel, "picker.cancel", &mut bad_bindings); + + if !bad_bindings.is_empty() { + return Err(Error::Bindings { bad_key_bindings: bad_bindings }); + } + + Ok(Self { + next, + previous, + done, + cancel, + }) + } +} + +fn parse_picker_keys( + raw_keys: &[String], + action_name: &str, + bad_bindings: &mut Vec, +) -> Vec> { + raw_keys + .iter() + .filter_map(|keys| { + if let Ok(("", parsed)) = key_parser::parse_config_keys(keys) { + Some(parsed) + } else { + bad_bindings.push(format!("- {} = {}", action_name, keys)); + None + } + }) + .collect() +} + pub fn init_config(path: Option) -> Res { let config_path = path.unwrap_or_else(config_path); @@ -228,19 +314,21 @@ pub fn init_config(path: Option) -> Res { let FigmentConfig { general, style, - bindings: raw_bindings, + bindings: bindings_config, } = Figment::new() .merge(Toml::string(DEFAULT_CONFIG)) .merge(Toml::file(config_path)) .extract() .map_err(Box::new) .map_err(Error::Config)?; - let bindings = Bindings::try_from(raw_bindings)?; + let bindings = Bindings::try_from(bindings_config.menus)?; + let picker_bindings = PickerBindings::try_from(bindings_config.picker)?; Ok(Config { general, style, bindings, + picker_bindings, }) } @@ -256,7 +344,7 @@ pub(crate) fn init_test_config() -> Res { let FigmentConfig { mut general, style, - bindings: raw_bindings, + bindings: bindings_config, } = Figment::new() .merge(Toml::string(DEFAULT_CONFIG)) .extract() @@ -269,7 +357,8 @@ pub(crate) fn init_test_config() -> Res { Ok(Config { general, style, - bindings: Bindings::try_from(raw_bindings).unwrap(), + bindings: Bindings::try_from(bindings_config.menus).unwrap(), + picker_bindings: PickerBindings::try_from(bindings_config.picker).unwrap(), }) } diff --git a/src/default_config.toml b/src/default_config.toml index 7e1c4a8d10..880a403633 100644 --- a/src/default_config.toml +++ b/src/default_config.toml @@ -83,6 +83,11 @@ syntax_highlight.type_builtin = { fg = "yellow" } syntax_highlight.variable_builtin = {} syntax_highlight.variable_parameter = {} +picker.prompt = { fg = "cyan" } +picker.info = { mods = "DIM" } +picker.selection_line = { mods = "BOLD" } +picker.matched = { fg = "yellow", mods = "BOLD" } + cursor = { symbol = "▌", fg = "blue" } selection_bar = { symbol = "▌", fg = "blue", mods = "DIM" } selection_line = { mods = "BOLD" } @@ -227,3 +232,8 @@ stash_menu.stash_pop = ["p"] stash_menu.stash_apply = ["a"] stash_menu.stash_drop = ["k"] stash_menu.quit = ["q", "esc"] + +picker.next = ["down", "ctrl+n"] +picker.previous = ["up", "ctrl+p"] +picker.done = ["enter"] +picker.cancel = ["esc", "ctrl+c"] diff --git a/src/lib.rs b/src/lib.rs index 2f7e720171..0f8ca83ff4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ mod items; mod key_parser; mod menu; mod ops; +pub mod picker; mod prompt; mod screen; mod syntax_parser; diff --git a/src/picker.rs b/src/picker.rs new file mode 100644 index 0000000000..ec70302ffa --- /dev/null +++ b/src/picker.rs @@ -0,0 +1,1005 @@ +use fuzzy_matcher::FuzzyMatcher; +use fuzzy_matcher::skim::SkimMatcherV2; +use std::borrow::Cow; +use tui_prompts::State as _; +use tui_prompts::TextState; + +/// Data that can be selected in a picker +#[derive(Debug, Clone, PartialEq)] +pub enum PickerData { + /// Revision (branch, commit, reference, etc.) + Revision(String), + /// Remote name + Remote(String), + /// Custom user input (literal text from input field) + CustomInput(String), +} + +impl PickerData { + /// Get the display string for this data + pub fn display(&self) -> &str { + match self { + PickerData::Revision(s) => s, + PickerData::Remote(s) => s, + PickerData::CustomInput(s) => s, + } + } +} + +/// An item in the picker list +#[derive(Debug, Clone, PartialEq)] +pub struct PickerItem { + /// The text to display and match against + pub display: Cow<'static, str>, + /// Associated data + pub data: PickerData, +} + +impl PickerItem { + pub fn new(display: impl Into>, data: PickerData) -> Self { + Self { + display: display.into(), + data, + } + } +} + +/// Result of a fuzzy match with score +#[derive(Debug, Clone)] +struct MatchResult { + index: usize, + score: i64, +} + +/// Picker status +#[derive(Debug, Clone, PartialEq)] +pub enum PickerStatus { + /// Picker is active + Active, + /// User selected an item + Done, + /// User cancelled + Cancelled, +} + +/// State of the picker component +pub struct PickerState { + /// All available items (excluding custom input) + items: Vec, + /// Filtered and sorted indices based on current pattern + filtered_indices: Vec, + /// Current cursor position in filtered results + cursor: usize, + /// Current input pattern + pub input_state: TextState<'static>, + /// Fuzzy matcher + matcher: SkimMatcherV2, + /// Prompt text to display + pub prompt_text: Cow<'static, str>, + /// Current status + status: PickerStatus, + /// Allow user to input custom value not in the list + allow_custom_input: bool, + /// Custom input item (separate from items list) + custom_input_item: Option, +} + +impl PickerState { + /// Create a new picker with items + pub fn new( + prompt: impl Into>, + items: Vec, + allow_custom_input: bool, + ) -> Self { + let mut state = Self { + items: items.clone(), + filtered_indices: Vec::new(), + cursor: 0, + input_state: TextState::default(), + matcher: SkimMatcherV2::default(), + prompt_text: prompt.into(), + status: PickerStatus::Active, + allow_custom_input, + custom_input_item: None, + }; + state.update_filter(); + state + } + + /// Get current input pattern + pub fn pattern(&self) -> &str { + self.input_state.value() + } + + /// Update the filter based on current input pattern + pub fn update_filter(&mut self) { + let pattern = self.pattern().to_string(); + + if pattern.is_empty() { + // Show all items when no pattern + self.filtered_indices = (0..self.items.len()).collect(); + self.custom_input_item = None; + } else { + // Fuzzy match and sort by score + let mut matches: Vec = self + .items + .iter() + .enumerate() + .filter_map(|(i, item)| { + self.matcher + .fuzzy_match(&item.display, &pattern) + .map(|score| MatchResult { index: i, score }) + }) + .collect(); + + // Sort by score (higher is better) + matches.sort_by(|a, b| b.score.cmp(&a.score)); + + self.filtered_indices = matches.into_iter().map(|m| m.index).collect(); + + // Create custom input item if enabled + if self.allow_custom_input { + self.custom_input_item = Some(PickerItem::new( + pattern.clone(), + PickerData::CustomInput(pattern), + )); + } else { + self.custom_input_item = None; + } + } + + // Reset cursor if out of bounds + let total_count = self.filtered_indices.len() + + if self.custom_input_item.is_some() { + 1 + } else { + 0 + }; + if self.cursor >= total_count { + self.cursor = 0; + } + } + + /// Get the currently selected item, if any + pub fn selected(&self) -> Option<&PickerItem> { + // Check if cursor is on custom input item (always at the end) + if self.cursor == self.filtered_indices.len() { + return self.custom_input_item.as_ref(); + } + + self.filtered_indices + .get(self.cursor) + .and_then(|&i| self.items.get(i)) + } + + /// Get all filtered items with their original indices + /// Custom input item (if present) is always last with index usize::MAX + pub fn filtered_items(&self) -> impl Iterator { + self.filtered_indices + .iter() + .filter_map(|&i| self.items.get(i).map(|item| (i, item))) + .chain( + self.custom_input_item + .as_ref() + .map(|item| (usize::MAX, item)), + ) + } + + /// Move cursor to next item + pub fn next(&mut self) { + let total_count = self.filtered_indices.len() + + if self.custom_input_item.is_some() { + 1 + } else { + 0 + }; + if total_count > 0 { + self.cursor = (self.cursor + 1) % total_count; + } + } + + /// Move cursor to previous item + pub fn previous(&mut self) { + let total_count = self.filtered_indices.len() + + if self.custom_input_item.is_some() { + 1 + } else { + 0 + }; + if total_count > 0 { + self.cursor = if self.cursor == 0 { + total_count - 1 + } else { + self.cursor - 1 + }; + } + } + + /// Get current cursor position + pub fn cursor(&self) -> usize { + self.cursor + } + + /// Get total number of items + pub fn total_items(&self) -> usize { + self.items.len() + } + + /// Get number of filtered items + pub fn filtered_count(&self) -> usize { + self.filtered_indices.len() + } + + /// Get the fuzzy match positions for a given item (for highlighting) + pub fn match_indices(&self, item_index: usize) -> Option> { + let pattern = self.pattern(); + if pattern.is_empty() { + return None; + } + + // Don't highlight custom input items (marked with usize::MAX) + if item_index == usize::MAX { + return None; + } + + self.items + .get(item_index) + .and_then(|item| self.matcher.fuzzy_indices(&item.display, pattern)) + .map(|(_, indices)| indices) + } + + /// Get current status + pub fn status(&self) -> &PickerStatus { + &self.status + } + + /// Mark picker as done (user selected an item) + pub fn done(&mut self) { + self.status = PickerStatus::Done; + } + + /// Mark picker as cancelled (user cancelled) + pub fn cancel(&mut self) { + self.status = PickerStatus::Cancelled; + } + + /// Check if picker is done + pub fn is_done(&self) -> bool { + self.status == PickerStatus::Done + } + + /// Check if picker is cancelled + pub fn is_cancelled(&self) -> bool { + self.status == PickerStatus::Cancelled + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + fn create_test_items() -> Vec { + vec![ + PickerItem::new("main", PickerData::Revision("main".to_string())), + PickerItem::new("develop", PickerData::Revision("develop".to_string())), + PickerItem::new( + "feature/test", + PickerData::Revision("feature/test".to_string()), + ), + PickerItem::new( + "feature/new", + PickerData::Revision("feature/new".to_string()), + ), + PickerItem::new("bugfix/123", PickerData::Revision("bugfix/123".to_string())), + ] + } + + #[test] + fn test_picker_data_display() { + let revision = PickerData::Revision("main".to_string()); + assert_eq!(revision.display(), "main"); + + let custom = PickerData::CustomInput("custom".to_string()); + assert_eq!(custom.display(), "custom"); + } + + #[test] + fn test_picker_item_new() { + let item = PickerItem::new("main", PickerData::Revision("main".to_string())); + assert_eq!(item.display.as_ref(), "main"); + assert_eq!(item.data.display(), "main"); + } + + #[test] + fn test_picker_state_new_without_custom_input() { + let items = create_test_items(); + let state = PickerState::new("Select branch", items.clone(), false); + + assert_eq!(state.prompt_text.as_ref(), "Select branch"); + assert_eq!(state.total_items(), 5); + assert_eq!(state.filtered_count(), 5); + assert_eq!(state.cursor(), 0); + assert_eq!(state.pattern(), ""); + assert_eq!(state.status(), &PickerStatus::Active); + assert!(!state.allow_custom_input); + } + + #[test] + fn test_picker_state_new_with_custom_input() { + let items = create_test_items(); + let state = PickerState::new("Select branch", items, true); + + assert!(state.allow_custom_input); + assert_eq!(state.custom_input_item, None); // No custom item when pattern is empty + } + + #[test] + fn test_empty_pattern_shows_all_items() { + let items = create_test_items(); + let state = PickerState::new("Select", items, false); + + assert_eq!(state.filtered_count(), 5); + assert_eq!(state.filtered_indices, vec![0, 1, 2, 3, 4]); + } + + #[test] + fn test_fuzzy_filtering() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state.update_filter(); + + // Should match "feature/test", "feature/new", and "bugfix/123" (fuzzy match) + assert_eq!(state.filtered_count(), 3); + + let filtered: Vec<_> = state.filtered_items().collect(); + assert!( + filtered + .iter() + .any(|(_, item)| item.display == "feature/test") + ); + assert!( + filtered + .iter() + .any(|(_, item)| item.display == "feature/new") + ); + assert!( + filtered + .iter() + .any(|(_, item)| item.display == "bugfix/123") + ); + } + + #[test] + fn test_fuzzy_filtering_sorts_by_score() { + // Create items with varying match quality for pattern "feat" + let items = vec![ + PickerItem::new("feat", PickerData::Revision("feat".to_string())), // Exact match + PickerItem::new("feature", PickerData::Revision("feature".to_string())), // Prefix match + PickerItem::new( + "feature/test", + PickerData::Revision("feature/test".to_string()), + ), // Prefix with more chars + PickerItem::new( + "fix-eat-bug", + PickerData::Revision("fix-eat-bug".to_string()), + ), // Scattered match + ]; + let mut state = PickerState::new("Select", items, false); + + // Type "feat" pattern + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::empty())); + state.update_filter(); + + // All should match + assert_eq!(state.filtered_count(), 4); + + // Verify sorted by score: exact match > prefix match > scattered match + let filtered: Vec<_> = state + .filtered_items() + .map(|(_, item)| item.display.as_ref()) + .collect(); + + // "feat" (exact) should be first, "fix-eat-bug" (scattered) should be last + assert_eq!(filtered[0], "feat"); + assert_eq!(filtered[filtered.len() - 1], "fix-eat-bug"); + } + + #[test] + fn test_case_insensitive_matching() { + let items = vec![ + PickerItem::new("Feature", PickerData::Revision("Feature".to_string())), + PickerItem::new("feature", PickerData::Revision("feature".to_string())), + PickerItem::new("FEATURE", PickerData::Revision("FEATURE".to_string())), + ]; + + let mut state = PickerState::new("Select item", items, false); + + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + state.update_filter(); + + // fuzzy-matcher is case-insensitive by default + assert_eq!(state.filtered_count(), 3); + } + + #[test] + fn test_no_matches() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::empty())); + state.update_filter(); + + assert_eq!(state.filtered_count(), 0); + assert!(state.selected().is_none()); + } + + #[test] + fn test_custom_input_creation() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, true); + + // Use pattern "fea" which matches feature/test and feature/new + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + state.update_filter(); + + // Custom input item should be created + assert!(state.custom_input_item.is_some()); + + // Should have multiple regular matches + custom input + let filtered: Vec<_> = state.filtered_items().collect(); + assert!(filtered.len() >= 3); // At least 2 feature items + bugfix + custom input + + // Custom input should be last in filtered items + let last = filtered.last().unwrap(); + assert_eq!(last.0, usize::MAX); + assert_eq!(last.1.display.as_ref(), "fea"); + } + + #[test] + fn test_custom_input_not_created_when_disabled() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty())); + state.update_filter(); + + assert!(state.custom_input_item.is_none()); + } + + #[test] + fn test_custom_input_not_created_on_empty_pattern() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, true); + + // Start with no input + assert!(state.custom_input_item.is_none()); + + // Add then remove input + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + state.update_filter(); + assert!(state.custom_input_item.is_some()); + + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty())); + state.update_filter(); + assert!(state.custom_input_item.is_none()); + } + + #[test] + fn test_cursor_next() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + assert_eq!(state.cursor(), 0); + + state.next(); + assert_eq!(state.cursor(), 1); + + state.next(); + assert_eq!(state.cursor(), 2); + } + + #[test] + fn test_cursor_next_wraps_around() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + // Move to end + for _ in 0..5 { + state.next(); + } + + // Should wrap to 0 + assert_eq!(state.cursor(), 0); + } + + #[test] + fn test_cursor_previous() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + state.next(); + state.next(); + assert_eq!(state.cursor(), 2); + + state.previous(); + assert_eq!(state.cursor(), 1); + + state.previous(); + assert_eq!(state.cursor(), 0); + } + + #[test] + fn test_cursor_previous_wraps_around() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + assert_eq!(state.cursor(), 0); + + state.previous(); + // Should wrap to last item + assert_eq!(state.cursor(), 4); + } + + #[test] + fn test_cursor_with_custom_input() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, true); + + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state.update_filter(); + + // 3 matched items (feature/test, feature/new, bugfix/123) + 1 custom input = 4 total + assert_eq!(state.cursor(), 0); + + state.next(); + assert_eq!(state.cursor(), 1); + + state.next(); + assert_eq!(state.cursor(), 2); + + state.next(); + assert_eq!(state.cursor(), 3); // Custom input position + + state.next(); + assert_eq!(state.cursor(), 0); // Wrapped around forward + + state.previous(); + assert_eq!(state.cursor(), 3); // Wrapped around backward to custom input + } + + #[test] + fn test_navigation_with_custom_input_at_end() { + let items = vec![ + PickerItem::new("feature/a", PickerData::Revision("feature/a".to_string())), + PickerItem::new("feature/b", PickerData::Revision("feature/b".to_string())), + ]; + + let mut state = PickerState::new("Select", items, true); + + // Type "feat" to get matches + custom input + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::empty())); + state.update_filter(); + + // Should have 2 matches + custom input = 3 total + assert_eq!(state.filtered_count(), 2); + assert_eq!(state.cursor(), 0); + + // Navigate to first match + let selected = state.selected().unwrap(); + match &selected.data { + PickerData::Revision(_) => {} + _ => panic!("Expected first item to be a revision"), + } + + // Navigate to second match + state.next(); + assert_eq!(state.cursor(), 1); + + // Navigate to custom input (last item) + state.next(); + assert_eq!(state.cursor(), 2); + let selected = state.selected().unwrap(); + match &selected.data { + PickerData::CustomInput(s) => assert_eq!(s, "feat"), + _ => panic!("Expected custom input at end"), + } + + // Wrap around to first + state.next(); + assert_eq!(state.cursor(), 0); + } + + #[test] + fn test_cursor_resets_when_filter_reduces_items() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + // Move cursor to position 4 + for _ in 0..4 { + state.next(); + } + assert_eq!(state.cursor(), 4); + + // Filter to only 2 items + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state.update_filter(); + + // Cursor should reset to 0 + assert_eq!(state.cursor(), 0); + } + + #[test] + fn test_scroll_through_many_items() { + // Create 20 items to test scrolling behavior + let items: Vec<_> = (0..20) + .map(|i| { + PickerItem::new( + format!("branch-{:02}", i), + PickerData::Revision(format!("branch-{:02}", i)), + ) + }) + .collect(); + + let mut state = PickerState::new("Select", items, false); + + // Start at first item + assert_eq!(state.cursor(), 0); + + // Navigate to middle item + for _ in 0..10 { + state.next(); + } + assert_eq!(state.cursor(), 10); + + // Navigate to last item + for _ in 0..9 { + state.next(); + } + assert_eq!(state.cursor(), 19); + + // Wrap around to first + state.next(); + assert_eq!(state.cursor(), 0); + + // Navigate backwards + state.previous(); + assert_eq!(state.cursor(), 19); + } + + #[test] + fn test_navigation_after_filtering() { + let items = vec![ + PickerItem::new("feature/a", PickerData::Revision("feature/a".to_string())), + PickerItem::new("feature/b", PickerData::Revision("feature/b".to_string())), + PickerItem::new("main", PickerData::Revision("main".to_string())), + PickerItem::new("develop", PickerData::Revision("develop".to_string())), + ]; + + let mut state = PickerState::new("Select", items, false); + + // Filter to get only feature/* branches + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + state.update_filter(); + + // Should have 2 filtered items + assert_eq!(state.filtered_count(), 2); + assert_eq!(state.cursor(), 0); + + // Navigate through filtered items + state.next(); + assert_eq!(state.cursor(), 1); + + // Wrap around + state.next(); + assert_eq!(state.cursor(), 0); + + // Go backwards + state.previous(); + assert_eq!(state.cursor(), 1); + } + + #[test] + fn test_selected_returns_correct_item() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + let selected = state.selected().unwrap(); + assert_eq!(selected.display.as_ref(), "main"); + + state.next(); + state.next(); + let selected = state.selected().unwrap(); + assert_eq!(selected.display.as_ref(), "feature/test"); + } + + #[test] + fn test_selected_with_filter() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::empty())); + state.update_filter(); + + // Should match "bugfix/123" + let selected = state.selected().unwrap(); + assert_eq!(selected.display.as_ref(), "bugfix/123"); + } + + #[test] + fn test_selected_returns_custom_input() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, true); + + // Use a pattern that doesn't match any items + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty())); + state.update_filter(); + + // No matches, only custom input at cursor 0 + assert_eq!(state.cursor(), 0); + assert_eq!(state.selected().unwrap().display.as_ref(), "qq"); + } + + #[test] + fn test_select_custom_input() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, true); + + // Type a pattern that doesn't match anything + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::empty())); + state.update_filter(); + + // Should have no matches but custom input + assert_eq!(state.filtered_count(), 0); + assert!(state.custom_input_item.is_some()); + + // Cursor should be at custom input (position 0 since no other items) + assert_eq!(state.cursor(), 0); + + // Selected item should be the custom input + let selected = state.selected().unwrap(); + assert_eq!(selected.display, "xyz"); + match &selected.data { + PickerData::CustomInput(s) => assert_eq!(s, "xyz"), + _ => panic!("Expected CustomInput"), + } + + // Mark as done + state.done(); + assert!(state.is_done()); + assert_eq!(state.status(), &PickerStatus::Done); + } + + #[test] + fn test_filtered_items_order() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, true); + + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state.update_filter(); + + let indices: Vec<_> = state.filtered_items().map(|(idx, _)| idx).collect(); + + // Verify normal item indices followed by custom input + assert_eq!(indices[0], 2); // feature/test + assert_eq!(indices[1], 3); // feature/new + assert_eq!(indices[2], 4); // bugfix/123 + assert_eq!(indices[3], usize::MAX); // custom input + } + + #[test] + fn test_match_indices_with_pattern() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::empty())); + state.update_filter(); + + // Get the index of "main" in original items + let indices = state.match_indices(0); + assert!(indices.is_some()); + + let indices = indices.unwrap(); + assert_eq!(indices.len(), 3); // 'm', 'a', 'i' should match + } + + #[test] + fn test_match_indices_empty_pattern() { + let items = create_test_items(); + let state = PickerState::new("Select", items, false); + + let indices = state.match_indices(0); + assert!(indices.is_none()); + } + + #[test] + fn test_match_indices_custom_input_returns_none() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, true); + + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::empty())); + state.update_filter(); + + // usize::MAX is used for custom input items + let indices = state.match_indices(usize::MAX); + assert!(indices.is_none()); + } + + #[test] + fn test_status_transitions() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + assert_eq!(state.status(), &PickerStatus::Active); + assert!(!state.is_done()); + assert!(!state.is_cancelled()); + + state.done(); + assert_eq!(state.status(), &PickerStatus::Done); + assert!(state.is_done()); + assert!(!state.is_cancelled()); + } + + #[test] + fn test_status_cancelled() { + let items = create_test_items(); + let mut state = PickerState::new("Select", items, false); + + state.cancel(); + assert_eq!(state.status(), &PickerStatus::Cancelled); + assert!(!state.is_done()); + assert!(state.is_cancelled()); + } + + #[test] + fn test_empty_items_list() { + let state = PickerState::new("Select", vec![], false); + + assert_eq!(state.total_items(), 0); + assert_eq!(state.filtered_count(), 0); + assert_eq!(state.cursor(), 0); + assert!(state.selected().is_none()); + } + + #[test] + fn test_empty_items_with_custom_input() { + let mut state = PickerState::new("Select", vec![], true); + + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + state.update_filter(); + + assert_eq!(state.total_items(), 0); + assert_eq!(state.filtered_count(), 0); + assert!(state.custom_input_item.is_some()); + + assert_eq!(state.selected().unwrap().display.as_ref(), "a"); + } + + #[test] + fn test_cursor_navigation_empty_list() { + let mut state = PickerState::new("Select", vec![], false); + + state.next(); + assert_eq!(state.cursor(), 0); + + state.previous(); + assert_eq!(state.cursor(), 0); + } + + #[test] + fn test_single_item_navigation() { + let items = vec![PickerItem::new( + "only", + PickerData::Revision("only".to_string()), + )]; + let mut state = PickerState::new("Select", items, false); + + assert_eq!(state.cursor(), 0); + + state.next(); + assert_eq!(state.cursor(), 0); // Wraps to same item + + state.previous(); + assert_eq!(state.cursor(), 0); // Wraps to same item + } +} diff --git a/src/screen/mod.rs b/src/screen/mod.rs index cdc3ed9579..f797e83453 100644 --- a/src/screen/mod.rs +++ b/src/screen/mod.rs @@ -374,7 +374,12 @@ struct LineView<'a> { const SPACES: &str = " "; -pub(crate) fn layout_screen<'a>(layout: &mut UiTree<'a>, size: Size, screen: &'a Screen) { +pub(crate) fn layout_screen<'a>( + layout: &mut UiTree<'a>, + size: Size, + screen: &'a Screen, + hide_cursor: bool, +) { let style = &screen.config.style; layout.vertical(None, OPTS, |layout| { @@ -386,7 +391,7 @@ pub(crate) fn layout_screen<'a>(layout: &mut UiTree<'a>, size: Size, screen: &'a let bg = area_sel.patch(line_sel); let mut line_end = 1; - let gutter_char = if line.highlighted { + let gutter_char = if !hide_cursor && line.highlighted { gutter_char(style, is_line_sel, bg) } else { (" ".into(), Style::new()) diff --git a/src/ui.rs b/src/ui.rs index 88eb3b0540..b08050539e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -13,6 +13,7 @@ use unicode_segmentation::UnicodeSegmentation; pub(crate) mod layout; mod menu; +pub mod picker; const CARET: &str = "\u{2588}"; const DASHES: &str = "────────────────────────────────────────────────────────────────"; @@ -25,13 +26,15 @@ pub(crate) fn ui(frame: &mut Frame, state: &mut State) { layout.vertical(None, OPTS, |layout| { layout.vertical(None, OPTS.grow(), |layout| { - screen::layout_screen(layout, size, state.screens.last().unwrap()); + let hide_cursor = state.picker.is_some(); + screen::layout_screen(layout, size, state.screens.last().unwrap(), hide_cursor); }); layout.vertical(None, OPTS, |layout| { menu::layout_menu(layout, state, size.width as usize); layout_command_log(layout, state, size.width as usize); layout_prompt(layout, state, size.width as usize); + layout_picker(layout, state, size.width as usize); }); }); @@ -89,6 +92,12 @@ fn layout_prompt<'a>(layout: &mut UiTree<'a>, state: &'a State, width: usize) { }); } +fn layout_picker<'a>(layout: &mut UiTree<'a>, state: &'a State, width: usize) { + if let Some(ref picker_state) = state.picker { + picker::layout_picker(layout, picker_state, &state.config, width); + } +} + pub(crate) fn layout_text<'a>(layout: &mut UiTree<'a>, text: Text<'a>) { layout.vertical(None, OPTS, |layout| { for line in text { diff --git a/src/ui/menu.rs b/src/ui/menu.rs index 78488d5c1d..c3a7f2d091 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -18,6 +18,10 @@ pub(crate) fn layout_menu<'a>(layout: &mut UiTree<'a>, state: &'a State, width: return; } + if state.picker.is_some() { + return; + } + let config = Arc::clone(&state.config); let item = state.screens.last().unwrap().get_selected_item(); let style = &config.style; diff --git a/src/ui/picker.rs b/src/ui/picker.rs new file mode 100644 index 0000000000..8e937d203b --- /dev/null +++ b/src/ui/picker.rs @@ -0,0 +1,419 @@ +use std::borrow::Cow; + +use ratatui::prelude::*; +use tui_prompts::State as _; +use unicode_segmentation::UnicodeSegmentation; + +use crate::config::Config; +use crate::picker::PickerState; +use crate::ui::layout::OPTS; +use crate::ui::{CARET, DASHES, UiTree, layout_span, repeat_chars}; + +const MAX_ITEMS_DISPLAY: usize = 10; + +/// Layout the picker UI +pub(crate) fn layout_picker<'a>( + layout: &mut UiTree<'a>, + state: &'a PickerState, + config: &Config, + width: usize, +) { + // Separator line + let separator_style = Style::from(&config.style.separator); + repeat_chars(layout, width, DASHES, separator_style); + + let prompt_style: Style = (&config.style.picker.prompt).into(); + let info_style: Style = (&config.style.picker.info).into(); + layout.horizontal(None, OPTS, |layout| { + let status_text = format!(" {}/{} ", state.filtered_count(), state.total_items()); + layout_span(layout, (status_text.into(), info_style)); + + // Prompt with separator (like regular prompt) + layout_span(layout, (state.prompt_text.as_ref().into(), prompt_style)); + layout_span(layout, (" › ".into(), prompt_style)); + layout_span(layout, (state.input_state.value().into(), Style::new())); + layout_span(layout, (CARET.into(), Style::new())); + }); + + // Calculate visible items range (scroll window) + let cursor = state.cursor(); + let total_items = state.filtered_items().count(); + let visible_count = MAX_ITEMS_DISPLAY.min(total_items); + let start = calculate_visible_range(cursor, total_items, MAX_ITEMS_DISPLAY); + + // Render items - always show MAX_ITEMS_DISPLAY rows for fixed height + let mut rendered_count = 0; + for (display_idx, (original_idx, item)) in state + .filtered_items() + .enumerate() + .skip(start) + .take(visible_count) + { + let is_selected = display_idx == cursor; + let style = if is_selected { + (&config.style.picker.selection_line).into() + } else { + Style::new() + }; + + layout.horizontal(None, OPTS, |layout| { + // Selection indicator (cursor bar like status screen) + if is_selected { + let cursor_style: Style = (&config.style.cursor).into(); + let indicator = format!("{}", config.style.cursor.symbol); + layout_span(layout, (indicator.into(), cursor_style)); + } else { + layout_span(layout, (" ".into(), Style::new())); + } + + // Render item text with fuzzy match highlighting + if let Some(match_indices) = state.match_indices(original_idx) { + render_highlighted_text(layout, &item.display, &match_indices, style, config); + } else { + layout_span(layout, (item.display.as_ref().into(), style)); + } + }); + rendered_count += 1; + } + + // Fill remaining rows with empty lines to maintain fixed height + for _ in rendered_count..MAX_ITEMS_DISPLAY { + layout.horizontal(None, OPTS, |layout| { + layout_span(layout, (" ".into(), Style::new())); + }); + } +} + +/// Calculate the visible range start for scrolling based on cursor position +fn calculate_visible_range(cursor: usize, total: usize, max_items: usize) -> usize { + if total <= max_items { + return 0; + } + + // Center the cursor in the visible window + let half = max_items / 2; + if cursor < half { + 0 + } else if cursor >= total - half { + total - max_items + } else { + cursor - half + } +} + +/// Render text with specific characters highlighted (for fuzzy match visualization) +fn render_highlighted_text<'a>( + layout: &mut UiTree<'a>, + text: &'a str, + highlight_indices: &[usize], + base_style: Style, + config: &Config, +) { + let graphemes: Vec<&str> = text.graphemes(true).collect(); + let highlight_style: Style = (&config.style.picker.matched).into(); + + let mut buffer = String::new(); + + for (idx, &grapheme) in graphemes.iter().enumerate() { + let should_highlight = highlight_indices.contains(&idx); + + if should_highlight { + // Flush non-highlighted buffer + if !buffer.is_empty() { + layout_span(layout, (Cow::Owned(buffer.clone()), base_style)); + buffer.clear(); + } + // Render highlighted character + layout_span(layout, (Cow::Owned(grapheme.to_string()), highlight_style)); + } else { + buffer.push_str(grapheme); + } + } + + // Flush remaining buffer + if !buffer.is_empty() { + layout_span(layout, (Cow::Owned(buffer), base_style)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::picker::{PickerData, PickerItem, PickerState}; + use crate::ui::layout::LayoutTree; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use itertools::Itertools; + use std::collections::BTreeMap; + + /// Create a default test config for picker tests + fn test_config() -> Config { + use crate::config::{GeneralConfig, PickerBindingsConfig, StyleConfig}; + + Config { + general: GeneralConfig::default(), + style: StyleConfig::default(), + bindings: BTreeMap::new().try_into().unwrap(), + picker_bindings: PickerBindingsConfig::default().try_into().unwrap(), + } + } + + fn create_test_items() -> Vec { + vec![ + PickerItem::new("main", PickerData::Revision("main".to_string())), + PickerItem::new("develop", PickerData::Revision("develop".to_string())), + PickerItem::new( + "feature/test", + PickerData::Revision("feature/test".to_string()), + ), + PickerItem::new( + "feature/new", + PickerData::Revision("feature/new".to_string()), + ), + PickerItem::new("bugfix/123", PickerData::Revision("bugfix/123".to_string())), + ] + } + + /// Render the picker layout to a string for testing purposes. + /// Note: ASCII only — does not support Unicode beyond single-byte chars. + fn render_to_string(layout: UiTree, width: usize, height: usize) -> String { + let mut grid = vec![' '; height * width]; + + for item in layout.iter() { + let x0 = item.pos[0] as usize; + let y0 = item.pos[1] as usize; + let item_width = item.size[0] as usize; + let text = &item.data.0; + + for (i, c) in text.chars().take(item_width).enumerate() { + if y0 < height && x0 + i < width { + grid[y0 * width + (x0 + i)] = c; + } + } + } + + grid.chunks(width) + .map(|row| row.iter().collect::().trim_end().to_string()) + .join("\n") + } + + #[test] + fn test_picker_empty_input() { + let items = create_test_items(); + let state = PickerState::new("Select branch", items, false); + let config = test_config(); + + let mut layout = LayoutTree::new(); + layout.vertical(None, crate::ui::layout::OPTS, |layout| { + layout_picker(layout, &state, &config, 40); + }); + layout.compute([40, 15]); + + insta::assert_snapshot!(render_to_string(layout, 40, 15)); + } + + #[test] + fn test_picker_with_filter() { + let items = create_test_items(); + let mut state = PickerState::new("Select branch", items, false); + + // Type "fea" to filter + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + state.update_filter(); + + let config = test_config(); + + let mut layout = LayoutTree::new(); + layout.vertical(None, crate::ui::layout::OPTS, |layout| { + layout_picker(layout, &state, &config, 40); + }); + layout.compute([40, 15]); + + insta::assert_snapshot!(render_to_string(layout, 40, 15)); + } + + #[test] + fn test_picker_cursor_movement() { + let items = create_test_items(); + let mut state = PickerState::new("Select branch", items, false); + + // Move cursor to third item + state.next(); + state.next(); + + let config = test_config(); + + let mut layout = LayoutTree::new(); + layout.vertical(None, crate::ui::layout::OPTS, |layout| { + layout_picker(layout, &state, &config, 40); + }); + layout.compute([40, 15]); + + insta::assert_snapshot!(render_to_string(layout, 40, 15)); + } + + #[test] + fn test_picker_scroll_many_items() { + // Create 20 items to test scrolling + let items: Vec<_> = (0..20) + .map(|i| { + PickerItem::new( + format!("branch-{:02}", i), + PickerData::Revision(format!("branch-{:02}", i)), + ) + }) + .collect(); + + let mut state = PickerState::new("Select branch", items, false); + let config = test_config(); + + // Move cursor to middle (position 10) + for _ in 0..10 { + state.next(); + } + + let mut layout = LayoutTree::new(); + layout.vertical(None, crate::ui::layout::OPTS, |layout| { + layout_picker(layout, &state, &config, 40); + }); + layout.compute([40, 15]); + + insta::assert_snapshot!(render_to_string(layout, 40, 15)); + } + + #[test] + fn test_picker_scroll_near_end() { + // Create 20 items to test scrolling near the end + let items: Vec<_> = (0..20) + .map(|i| { + PickerItem::new( + format!("branch-{:02}", i), + PickerData::Revision(format!("branch-{:02}", i)), + ) + }) + .collect(); + + let mut state = PickerState::new("Select branch", items, false); + let config = test_config(); + + // Move cursor near end (position 18) + for _ in 0..18 { + state.next(); + } + + let mut layout = LayoutTree::new(); + layout.vertical(None, crate::ui::layout::OPTS, |layout| { + layout_picker(layout, &state, &config, 40); + }); + layout.compute([40, 15]); + + insta::assert_snapshot!(render_to_string(layout, 40, 15)); + } + + #[test] + fn test_picker_filtered_with_navigation() { + let items = create_test_items(); + let mut state = PickerState::new("Select branch", items, false); + + // Filter to get feature branches + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + state.update_filter(); + + // Navigate to second item + state.next(); + + let config = test_config(); + + let mut layout = LayoutTree::new(); + layout.vertical(None, crate::ui::layout::OPTS, |layout| { + layout_picker(layout, &state, &config, 40); + }); + layout.compute([40, 15]); + + insta::assert_snapshot!(render_to_string(layout, 40, 15)); + } + + #[test] + fn test_picker_with_custom_input() { + let items = create_test_items(); + let mut state = PickerState::new("New branch", items, true); + + // Type a custom branch name + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty())); + state.update_filter(); + + let config = test_config(); + + let mut layout = LayoutTree::new(); + layout.vertical(None, crate::ui::layout::OPTS, |layout| { + layout_picker(layout, &state, &config, 40); + }); + layout.compute([40, 15]); + + insta::assert_snapshot!(render_to_string(layout, 40, 15)); + } + + #[test] + fn test_picker_no_matches() { + let items = create_test_items(); + let mut state = PickerState::new("Select branch", items, false); + + // Type something that doesn't match + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty())); + state + .input_state + .handle_key_event(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::empty())); + state.update_filter(); + + let config = test_config(); + + let mut layout = LayoutTree::new(); + layout.vertical(None, crate::ui::layout::OPTS, |layout| { + layout_picker(layout, &state, &config, 40); + }); + layout.compute([40, 15]); + + insta::assert_snapshot!(render_to_string(layout, 40, 15)); + } + + #[test] + fn test_picker_narrow_width() { + let items = create_test_items(); + let state = PickerState::new("Select", items, false); + let config = test_config(); + + let mut layout = LayoutTree::new(); + layout.vertical(None, crate::ui::layout::OPTS, |layout| { + layout_picker(layout, &state, &config, 20); + }); + layout.compute([20, 15]); + + insta::assert_snapshot!(render_to_string(layout, 20, 15)); + } +} diff --git a/src/ui/snapshots/gitu__ui__picker__tests__picker_cursor_movement.snap b/src/ui/snapshots/gitu__ui__picker__tests__picker_cursor_movement.snap new file mode 100644 index 0000000000..a247f5ad07 Binary files /dev/null and b/src/ui/snapshots/gitu__ui__picker__tests__picker_cursor_movement.snap differ diff --git a/src/ui/snapshots/gitu__ui__picker__tests__picker_empty_input.snap b/src/ui/snapshots/gitu__ui__picker__tests__picker_empty_input.snap new file mode 100644 index 0000000000..5957986572 Binary files /dev/null and b/src/ui/snapshots/gitu__ui__picker__tests__picker_empty_input.snap differ diff --git a/src/ui/snapshots/gitu__ui__picker__tests__picker_filtered_with_navigation.snap b/src/ui/snapshots/gitu__ui__picker__tests__picker_filtered_with_navigation.snap new file mode 100644 index 0000000000..91a9f8291f Binary files /dev/null and b/src/ui/snapshots/gitu__ui__picker__tests__picker_filtered_with_navigation.snap differ diff --git a/src/ui/snapshots/gitu__ui__picker__tests__picker_narrow_width.snap b/src/ui/snapshots/gitu__ui__picker__tests__picker_narrow_width.snap new file mode 100644 index 0000000000..c9479ad1f8 Binary files /dev/null and b/src/ui/snapshots/gitu__ui__picker__tests__picker_narrow_width.snap differ diff --git a/src/ui/snapshots/gitu__ui__picker__tests__picker_no_matches.snap b/src/ui/snapshots/gitu__ui__picker__tests__picker_no_matches.snap new file mode 100644 index 0000000000..c2a0057bf7 --- /dev/null +++ b/src/ui/snapshots/gitu__ui__picker__tests__picker_no_matches.snap @@ -0,0 +1,6 @@ +--- +source: src/ui/picker.rs +expression: "render_to_string(layout, 40, 15)" +--- +──────────────────────────────────────── + 0/5 Select branch › xyz█ diff --git a/src/ui/snapshots/gitu__ui__picker__tests__picker_scroll_many_items.snap b/src/ui/snapshots/gitu__ui__picker__tests__picker_scroll_many_items.snap new file mode 100644 index 0000000000..125651511d Binary files /dev/null and b/src/ui/snapshots/gitu__ui__picker__tests__picker_scroll_many_items.snap differ diff --git a/src/ui/snapshots/gitu__ui__picker__tests__picker_scroll_near_end.snap b/src/ui/snapshots/gitu__ui__picker__tests__picker_scroll_near_end.snap new file mode 100644 index 0000000000..b2a3248e50 Binary files /dev/null and b/src/ui/snapshots/gitu__ui__picker__tests__picker_scroll_near_end.snap differ diff --git a/src/ui/snapshots/gitu__ui__picker__tests__picker_with_custom_input.snap b/src/ui/snapshots/gitu__ui__picker__tests__picker_with_custom_input.snap new file mode 100644 index 0000000000..53fdd305f5 Binary files /dev/null and b/src/ui/snapshots/gitu__ui__picker__tests__picker_with_custom_input.snap differ diff --git a/src/ui/snapshots/gitu__ui__picker__tests__picker_with_filter.snap b/src/ui/snapshots/gitu__ui__picker__tests__picker_with_filter.snap new file mode 100644 index 0000000000..b3924265a4 Binary files /dev/null and b/src/ui/snapshots/gitu__ui__picker__tests__picker_with_filter.snap differ