Skip to content

Commit 954f455

Browse files
sei40krclaude
andauthored
feat: interactive picker functionality and ui
* feat: add interactive picker Implement an interactive fuzzy finder picker component: - Fuzzy matching with real-time filtering - Keyboard navigation (↓/↑, Ctrl-n/p) - Select with Enter, cancel with Esc/Ctrl-c - Visual highlighting of matched characters Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * test: add picker UI tests and remove duplicates - Add picker UI layout tests with snapshots - Remove duplicate picker tests from src/tests/picker.rs - Consolidate case-insensitive test into src/picker.rs - Clean up 39 unreferenced snapshot files * test(picker): add comprehensive tests for common use cases Add tests covering generic picker functionality needed for branch selection and similar use cases: Logic tests (src/picker.rs): - Scrolling through many items (20+) - Navigation after filtering - Custom input selection with state transitions - Navigation with custom input at end of list UI tests (src/ui/picker.rs): - Scroll display at middle position - Scroll display near end - No matches display - Filtered results with navigation All tests are organized by category (basic → edge cases) and placed next to related tests for better maintainability. * feat(picker): make keybindings configurable Add support for customizing picker keybindings through the config file. Users can now configure next, previous, done, and cancel actions under [bindings.picker] section in their config.toml. * fix: update picker UI to use separator style from config * test: update non_utf8_diff snapshot after merge --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 2f52099 commit 954f455

22 files changed

Lines changed: 1687 additions & 10 deletions

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/nix/store/5jr06kaid73ilblgxp0njd5k35nxynnx-pre-commit-config.json

Cargo.lock

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,5 @@ strum = { version = "0.26.3", features = ["strum_macros"] }
7676
tinyvec = "1.10.0"
7777
smashquote = "0.1.2"
7878
imara-diff = { version = "0.2.0", default-features = false }
79+
fuzzy-matcher = "0.3.7"
7980
url = "2.5.7"

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,19 @@ Configuration is also loaded from:
3838
- Windows: `%USERPROFILE%\AppData\Roaming\gitu\config.toml`
3939

4040
, refer to the [default configuration](src/default_config.toml).
41+
42+
#### Picker Style Customization
43+
44+
You can customize the appearance of the interactive picker by adding the following to your config:
45+
46+
```toml
47+
[style.picker]
48+
prompt = { fg = "cyan" } # Prompt text color
49+
info = { mods = "DIM" } # Status line style (e.g., "3/10 matches")
50+
selection_line = { mods = "BOLD" } # Selected item style
51+
matched = { fg = "yellow", mods = "BOLD" } # Fuzzy-matched characters highlight
52+
```
53+
4154
### Installing Gitu
4255
Follow the install instructions: [Installing Gitu](docs/installing.md)\
4356
Or install from your package manager:

src/app.rs

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ use crate::item_data::RefKind;
3333
use crate::menu::Menu;
3434
use crate::menu::PendingMenu;
3535
use crate::ops::Op;
36+
use crate::picker::PickerData;
37+
use crate::picker::PickerState;
3638
use crate::prompt;
3739
use crate::screen;
3840
use crate::screen::Screen;
@@ -52,6 +54,7 @@ pub(crate) struct State {
5254
enable_async_cmds: bool,
5355
pub current_cmd_log: CmdLog,
5456
pub prompt: prompt::Prompt,
57+
pub picker: Option<PickerState>,
5558
pub clipboard: Option<Clipboard>,
5659
needs_redraw: bool,
5760
file_watcher: Option<FileWatcher>,
@@ -103,6 +106,7 @@ impl App {
103106
pending_menu,
104107
current_cmd_log: CmdLog::new(),
105108
prompt: prompt::Prompt::new(),
109+
picker: None,
106110
clipboard,
107111
file_watcher: None,
108112
needs_redraw: true,
@@ -213,7 +217,9 @@ impl App {
213217
self.state.current_cmd_log.clear();
214218
}
215219

216-
if self.state.prompt.state.is_focused() {
220+
if self.state.picker.is_some() {
221+
self.handle_picker_input(key);
222+
} else if self.state.prompt.state.is_focused() {
217223
self.state.prompt.state.handle_key_event(key);
218224
} else {
219225
self.handle_key_input(term, key)?;
@@ -618,6 +624,95 @@ impl App {
618624
self.redraw_now(term)?;
619625
}
620626
}
627+
628+
/// Show a picker and wait for user to select an item or cancel.
629+
///
630+
/// Returns:
631+
/// - `Ok(Some(data))` - User selected an item
632+
/// - `Ok(None)` - User cancelled (Esc or Ctrl-C)
633+
/// - `Err(e)` - An error occurred
634+
///
635+
/// # Example
636+
/// ```ignore
637+
/// let items = vec![
638+
/// PickerItem::new("main", PickerData::Revision("main".to_string())),
639+
/// PickerItem::new("develop", PickerData::Revision("develop".to_string())),
640+
/// ];
641+
/// let picker = PickerState::new("Select branch", items, false);
642+
///
643+
/// match app.picker(term, picker)? {
644+
/// Some(PickerData::Revision(name)) => {
645+
/// // User selected a branch
646+
/// println!("Selected: {}", name);
647+
/// }
648+
/// Some(PickerData::CustomInput(_)) => {
649+
/// // Should not happen when allow_custom_input is false
650+
/// }
651+
/// None => {
652+
/// // User cancelled
653+
/// println!("Cancelled");
654+
/// }
655+
/// }
656+
/// ```
657+
#[allow(dead_code)]
658+
pub fn picker(
659+
&mut self,
660+
term: &mut Term,
661+
picker_state: PickerState,
662+
) -> Res<Option<PickerData>> {
663+
self.state.picker = Some(picker_state);
664+
let result = self.handle_picker(term);
665+
666+
self.state.picker = None;
667+
668+
result
669+
}
670+
671+
fn handle_picker(&mut self, term: &mut Term) -> Res<Option<PickerData>> {
672+
self.redraw_now(term)?;
673+
674+
loop {
675+
let event = term.backend_mut().read_event()?;
676+
self.handle_event(term, event)?;
677+
678+
if let Some(ref picker) = self.state.picker {
679+
if picker.is_done() {
680+
// User selected an item
681+
return Ok(picker.selected().map(|item| item.data.clone()));
682+
} else if picker.is_cancelled() {
683+
// User cancelled - this is not an error
684+
return Ok(None);
685+
}
686+
}
687+
688+
self.redraw_now(term)?;
689+
}
690+
}
691+
692+
fn handle_picker_input(&mut self, key: event::KeyEvent) {
693+
if let Some(ref mut picker) = self.state.picker {
694+
// The character received in the KeyEvent changes as shift is pressed,
695+
// e.g. '/' becomes '?' on a US keyboard. So just ignore SHIFT.
696+
let mods_without_shift = key.modifiers.difference(KeyModifiers::SHIFT);
697+
let key_combo = vec![(mods_without_shift, key.code)];
698+
699+
let bindings = &self.state.config.picker_bindings;
700+
701+
if bindings.next.iter().any(|b| b == &key_combo) {
702+
picker.next();
703+
} else if bindings.previous.iter().any(|b| b == &key_combo) {
704+
picker.previous();
705+
} else if bindings.done.iter().any(|b| b == &key_combo) {
706+
picker.done();
707+
} else if bindings.cancel.iter().any(|b| b == &key_combo) {
708+
picker.cancel();
709+
} else {
710+
// Text input - delegate to text state
711+
picker.input_state.handle_key_event(key);
712+
picker.update_filter();
713+
}
714+
}
715+
}
621716
}
622717

623718
fn get_prompt_result(params: &PromptParams, app: &mut App) -> Res<String> {

src/config.rs

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::{collections::BTreeMap, path::PathBuf};
22

3-
use crate::{Bindings, Res, error::Error, menu::Menu, ops::Op};
3+
use crate::{Bindings, Res, error::Error, key_parser, menu::Menu, ops::Op};
4+
use crossterm::event::{KeyCode, KeyModifiers};
45
use etcetera::{BaseStrategy, choose_base_strategy};
56
use figment::{
67
Figment,
@@ -15,6 +16,27 @@ pub struct Config {
1516
pub general: GeneralConfig,
1617
pub style: StyleConfig,
1718
pub bindings: Bindings,
19+
pub picker_bindings: PickerBindings,
20+
}
21+
22+
#[derive(Default, Deserialize)]
23+
pub(crate) struct PickerBindingsConfig {
24+
#[serde(default)]
25+
pub next: Vec<String>,
26+
#[serde(default)]
27+
pub previous: Vec<String>,
28+
#[serde(default)]
29+
pub done: Vec<String>,
30+
#[serde(default)]
31+
pub cancel: Vec<String>,
32+
}
33+
34+
#[derive(Default, Deserialize)]
35+
pub(crate) struct BindingsConfig {
36+
#[serde(flatten)]
37+
pub menus: BTreeMap<Menu, BTreeMap<Op, Vec<String>>>,
38+
#[serde(default)]
39+
pub picker: PickerBindingsConfig,
1840
}
1941

2042
#[derive(Default, Deserialize)]
@@ -23,7 +45,7 @@ pub struct Config {
2345
pub(crate) struct FigmentConfig {
2446
pub general: GeneralConfig,
2547
pub style: StyleConfig,
26-
pub bindings: BTreeMap<Menu, BTreeMap<Op, Vec<String>>>,
48+
pub bindings: BindingsConfig,
2749
}
2850

2951
#[derive(Default, Debug, Deserialize)]
@@ -78,6 +100,9 @@ pub struct StyleConfig {
78100
#[serde(default)]
79101
pub syntax_highlight: SyntaxHighlightConfig,
80102

103+
#[serde(default)]
104+
pub picker: PickerStyleConfig,
105+
81106
pub cursor: SymbolStyleConfigEntry,
82107
pub selection_bar: SymbolStyleConfigEntry,
83108
pub selection_line: StyleConfigEntry,
@@ -170,6 +195,18 @@ pub struct SyntaxHighlightConfig {
170195
pub variable_parameter: StyleConfigEntry,
171196
}
172197

198+
#[derive(Default, Debug, Deserialize)]
199+
pub struct PickerStyleConfig {
200+
#[serde(default)]
201+
pub prompt: StyleConfigEntry,
202+
#[serde(default)]
203+
pub info: StyleConfigEntry,
204+
#[serde(default)]
205+
pub selection_line: StyleConfigEntry,
206+
#[serde(default)]
207+
pub matched: StyleConfigEntry,
208+
}
209+
173210
#[derive(Default, Debug, Deserialize)]
174211
pub struct StyleConfigEntry {
175212
#[serde(default)]
@@ -216,6 +253,55 @@ impl From<&SymbolStyleConfigEntry> for Style {
216253
}
217254
}
218255

256+
pub struct PickerBindings {
257+
pub next: Vec<Vec<(KeyModifiers, KeyCode)>>,
258+
pub previous: Vec<Vec<(KeyModifiers, KeyCode)>>,
259+
pub done: Vec<Vec<(KeyModifiers, KeyCode)>>,
260+
pub cancel: Vec<Vec<(KeyModifiers, KeyCode)>>,
261+
}
262+
263+
impl TryFrom<PickerBindingsConfig> for PickerBindings {
264+
type Error = crate::error::Error;
265+
266+
fn try_from(config: PickerBindingsConfig) -> Result<Self, Self::Error> {
267+
let mut bad_bindings = Vec::new();
268+
269+
let next = parse_picker_keys(&config.next, "picker.next", &mut bad_bindings);
270+
let previous = parse_picker_keys(&config.previous, "picker.previous", &mut bad_bindings);
271+
let done = parse_picker_keys(&config.done, "picker.done", &mut bad_bindings);
272+
let cancel = parse_picker_keys(&config.cancel, "picker.cancel", &mut bad_bindings);
273+
274+
if !bad_bindings.is_empty() {
275+
return Err(Error::Bindings { bad_key_bindings: bad_bindings });
276+
}
277+
278+
Ok(Self {
279+
next,
280+
previous,
281+
done,
282+
cancel,
283+
})
284+
}
285+
}
286+
287+
fn parse_picker_keys(
288+
raw_keys: &[String],
289+
action_name: &str,
290+
bad_bindings: &mut Vec<String>,
291+
) -> Vec<Vec<(KeyModifiers, KeyCode)>> {
292+
raw_keys
293+
.iter()
294+
.filter_map(|keys| {
295+
if let Ok(("", parsed)) = key_parser::parse_config_keys(keys) {
296+
Some(parsed)
297+
} else {
298+
bad_bindings.push(format!("- {} = {}", action_name, keys));
299+
None
300+
}
301+
})
302+
.collect()
303+
}
304+
219305
pub fn init_config(path: Option<PathBuf>) -> Res<Config> {
220306
let config_path = path.unwrap_or_else(config_path);
221307

@@ -228,19 +314,21 @@ pub fn init_config(path: Option<PathBuf>) -> Res<Config> {
228314
let FigmentConfig {
229315
general,
230316
style,
231-
bindings: raw_bindings,
317+
bindings: bindings_config,
232318
} = Figment::new()
233319
.merge(Toml::string(DEFAULT_CONFIG))
234320
.merge(Toml::file(config_path))
235321
.extract()
236322
.map_err(Box::new)
237323
.map_err(Error::Config)?;
238-
let bindings = Bindings::try_from(raw_bindings)?;
324+
let bindings = Bindings::try_from(bindings_config.menus)?;
325+
let picker_bindings = PickerBindings::try_from(bindings_config.picker)?;
239326

240327
Ok(Config {
241328
general,
242329
style,
243330
bindings,
331+
picker_bindings,
244332
})
245333
}
246334

@@ -256,7 +344,7 @@ pub(crate) fn init_test_config() -> Res<Config> {
256344
let FigmentConfig {
257345
mut general,
258346
style,
259-
bindings: raw_bindings,
347+
bindings: bindings_config,
260348
} = Figment::new()
261349
.merge(Toml::string(DEFAULT_CONFIG))
262350
.extract()
@@ -269,7 +357,8 @@ pub(crate) fn init_test_config() -> Res<Config> {
269357
Ok(Config {
270358
general,
271359
style,
272-
bindings: Bindings::try_from(raw_bindings).unwrap(),
360+
bindings: Bindings::try_from(bindings_config.menus).unwrap(),
361+
picker_bindings: PickerBindings::try_from(bindings_config.picker).unwrap(),
273362
})
274363
}
275364

src/default_config.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ syntax_highlight.type_builtin = { fg = "yellow" }
8383
syntax_highlight.variable_builtin = {}
8484
syntax_highlight.variable_parameter = {}
8585

86+
picker.prompt = { fg = "cyan" }
87+
picker.info = { mods = "DIM" }
88+
picker.selection_line = { mods = "BOLD" }
89+
picker.matched = { fg = "yellow", mods = "BOLD" }
90+
8691
cursor = { symbol = "", fg = "blue" }
8792
selection_bar = { symbol = "", fg = "blue", mods = "DIM" }
8893
selection_line = { mods = "BOLD" }
@@ -227,3 +232,8 @@ stash_menu.stash_pop = ["p"]
227232
stash_menu.stash_apply = ["a"]
228233
stash_menu.stash_drop = ["k"]
229234
stash_menu.quit = ["q", "esc"]
235+
236+
picker.next = ["down", "ctrl+n"]
237+
picker.previous = ["up", "ctrl+p"]
238+
picker.done = ["enter"]
239+
picker.cancel = ["esc", "ctrl+c"]

0 commit comments

Comments
 (0)