irongit

fix: scrollable list focuses

huncholanehuncholaneauthored
parent 7277301commit 6dc271cff55bd14468d5cfbaa0faa3d29f806814Browse files

3 files changed, +185 -55

+60-55src/widgets/jq_bar.rs
@@ -20,7 +20,7 @@ pub struct JqBarResponse {
2020 pub struct JqBar {
2121 pub query: String,
2222 pub completions: Vec<String>,
23- completion_index: usize,
23+ comp_list: super::scrollable_list::ScrollableList,
2424 show_completions: bool,
2525 refocus: bool,
2626 }
@@ -30,7 +30,7 @@ impl JqBar {
3030 Self {
3131 query: ".".to_string(),
3232 completions: Vec::new(),
33- completion_index: 0,
33+ comp_list: super::scrollable_list::ScrollableList::new(),
3434 show_completions: false,
3535 refocus: false,
3636 }
@@ -110,28 +110,30 @@ impl JqBar {
110110 ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::Space));
111111
112112 if self.show_completions && !self.completions.is_empty() {
113- // Arrow keys / Ctrl-N/P cycle and live-preview the selection
114- let down = ui.input(|i| i.key_pressed(egui::Key::ArrowDown))
113+ // Ctrl-N/P or Arrow Down/Up: cycle suggestions
114+ let cycle_down = ui.input(|i| i.key_pressed(egui::Key::ArrowDown))
115115 || ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::N));
116- let up = ui.input(|i| i.key_pressed(egui::Key::ArrowUp))
116+ let cycle_up = ui.input(|i| i.key_pressed(egui::Key::ArrowUp))
117117 || ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::P));
118- if down {
119- self.completion_index =
120- (self.completion_index + 1).min(self.completions.len() - 1);
121- self.apply_completion(&self.completions[self.completion_index].clone());
122- self.refocus = true;
123- response.previewing = true;
124- }
125- if up {
126- self.completion_index = self.completion_index.saturating_sub(1);
127- self.apply_completion(&self.completions[self.completion_index].clone());
118+
119+ let moved = if cycle_down {
120+ self.comp_list.down(self.completions.len())
121+ } else if cycle_up {
122+ self.comp_list.up()
123+ } else {
124+ false
125+ };
126+
127+ if moved {
128+ let comp = self.completions[self.comp_list.selection].clone();
129+ self.apply_completion(&comp);
128130 self.refocus = true;
129131 response.previewing = true;
130132 }
131133
132- // Enter/Tab: accept current selection and close
133- if tab || enter {
134- accepted_completion = Some(self.completions[self.completion_index].clone());
134+ // Enter/Tab: accept current selection
135+ if enter || tab {
136+ accepted_completion = Some(self.completions[self.comp_list.selection].clone());
135137 }
136138 } else if enter {
137139 response.run = true;
@@ -167,49 +169,52 @@ impl JqBar {
167169
168170 // Show completion popup
169171 if self.show_completions && !self.completions.is_empty() {
170- let mut clicked: Option<String> = None;
172+ let row_height = ui.text_style_height(&egui::TextStyle::Monospace) + 4.0;
173+ let count = self.completions.len();
174+ let mut clicked_comp: Option<String> = None;
175+
171176 egui::Frame::new()
172177 .fill(CatppuccinMocha::SURFACE0)
173178 .inner_margin(6.0)
174179 .corner_radius(4.0)
175180 .stroke(egui::Stroke::new(1.0, CatppuccinMocha::SURFACE1))
176181 .show(ui, |ui| {
177- ui.set_max_height(200.0);
178- egui::ScrollArea::vertical()
179- .id_salt("jq_bar_completions")
180- .show(ui, |ui| {
181- for (i, comp) in self.completions.iter().enumerate() {
182- let selected = i == self.completion_index;
183- let text_color = if selected {
184- CatppuccinMocha::BLUE
185- } else {
186- CatppuccinMocha::TEXT
187- };
188- let bg = if selected {
189- CatppuccinMocha::SURFACE1
190- } else {
191- CatppuccinMocha::SURFACE0
192- };
193- let r = ui.add(
194- egui::Label::new(
195- RichText::new(comp)
196- .color(text_color)
197- .family(egui::FontFamily::Monospace)
198- .background_color(bg),
199- )
200- .sense(egui::Sense::click()),
201- );
202- if selected {
203- r.scroll_to_me(Some(egui::Align::Center));
204- }
205- if r.clicked() {
206- clicked = Some(comp.clone());
207- }
208- }
209- });
182+ let completions = &self.completions;
183+ self.comp_list.show(
184+ ui,
185+ "jq_bar_completions",
186+ count,
187+ row_height,
188+ Some(200.0),
189+ &mut |ui, i, is_selected| {
190+ let text_color = if is_selected {
191+ CatppuccinMocha::BLUE
192+ } else {
193+ CatppuccinMocha::TEXT
194+ };
195+ let bg = if is_selected {
196+ CatppuccinMocha::SURFACE1
197+ } else {
198+ CatppuccinMocha::SURFACE0
199+ };
200+ ui.add(
201+ egui::Label::new(
202+ RichText::new(&completions[i])
203+ .color(text_color)
204+ .family(egui::FontFamily::Monospace)
205+ .background_color(bg),
206+ )
207+ .sense(egui::Sense::click()),
208+ )
209+ },
210+ &mut |idx| {
211+ clicked_comp = Some(completions[idx].clone());
212+ },
213+ );
210214 });
211- if let Some(c) = clicked {
212- self.apply_completion(&c);
215+
216+ if let Some(comp) = clicked_comp {
217+ self.apply_completion(&comp);
213218 self.show_completions = false;
214219 self.refocus = true;
215220 response.accepted = true;
@@ -238,7 +243,7 @@ impl JqBar {
238243 scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
239244 self.completions = scored.into_iter().map(|(_, p)| p).collect();
240245 }
241- self.completion_index = 0;
246+ self.comp_list.reset();
242247 self.show_completions = !self.completions.is_empty();
243248 }
244249
+1-0src/widgets/mod.rs
@@ -2,6 +2,7 @@ pub mod cache_guard;
22 pub mod hover_row;
33 pub mod jq_bar;
44 pub mod miller;
5+pub mod scrollable_list;
56
67 pub use cache_guard::{CacheGuard, hash_key};
78 pub use hover_row::{prev_frame_hover, check_hover, store_hover};
+124-0src/widgets/scrollable_list.rs
@@ -0,0 +1,124 @@
1+use egui::{self, Ui};
2+
3+/// A virtually-scrolled list that tracks selection and auto-scrolls.
4+///
5+/// The caller owns keyboard logic and sets `selection` directly.
6+/// The list handles rendering, virtual scrolling, click detection,
7+/// and scrolling to keep the selection visible.
8+pub struct ScrollableList {
9+ pub selection: usize,
10+ prev_selection: usize,
11+ force_scroll: bool,
12+}
13+
14+impl ScrollableList {
15+ pub fn new() -> Self {
16+ Self {
17+ selection: 0,
18+ prev_selection: usize::MAX,
19+ force_scroll: false,
20+ }
21+ }
22+
23+ /// Render the list.
24+ ///
25+ /// - `render_row(ui, index, is_selected)` — draw one row, return its Response
26+ /// - `on_select(index)` — called when selection changes (arrow scroll or click)
27+ /// - `on_accept(index)` — called when a row is double-clicked
28+ ///
29+ /// Returns true if a click changed the selection.
30+ pub fn show(
31+ &mut self,
32+ ui: &mut Ui,
33+ id_salt: &str,
34+ count: usize,
35+ row_height: f32,
36+ max_height: Option<f32>,
37+ render_row: &mut dyn FnMut(&mut Ui, usize, bool) -> egui::Response,
38+ on_click: &mut dyn FnMut(usize),
39+ ) {
40+ if count == 0 {
41+ return;
42+ }
43+
44+ if self.selection >= count {
45+ self.selection = count.saturating_sub(1);
46+ }
47+
48+ let needs_scroll = self.selection != self.prev_selection || self.force_scroll;
49+ self.prev_selection = self.selection;
50+ self.force_scroll = false;
51+
52+ let visible_h = max_height.unwrap_or(f32::MAX);
53+
54+ // When selection changes, set scroll offset directly so show_rows
55+ // renders the selected row (scroll_to_me only works for visible rows).
56+ let scroll_offset = if needs_scroll {
57+ let target_y = self.selection as f32 * row_height;
58+ let centered = (target_y - visible_h / 2.0 + row_height / 2.0).max(0.0);
59+ Some(egui::vec2(0.0, centered))
60+ } else {
61+ None
62+ };
63+
64+ let mut area = egui::ScrollArea::vertical()
65+ .id_salt(id_salt)
66+ .auto_shrink(false);
67+ if let Some(h) = max_height {
68+ area = area.max_height(h);
69+ }
70+ if let Some(offset) = scroll_offset {
71+ area = area.vertical_scroll_offset(offset.y);
72+ }
73+
74+ let mut clicked_idx: Option<usize> = None;
75+
76+ area.show_rows(ui, row_height, count, |ui, range| {
77+ for i in range {
78+ let is_selected = i == self.selection;
79+ let r = render_row(ui, i, is_selected);
80+
81+ if r.clicked() {
82+ clicked_idx = Some(i);
83+ }
84+ }
85+ });
86+
87+ if let Some(idx) = clicked_idx {
88+ self.selection = idx;
89+ on_click(idx);
90+ }
91+ }
92+
93+ /// Move selection down by 1. Returns true if it moved.
94+ pub fn down(&mut self, count: usize) -> bool {
95+ if count > 0 && self.selection + 1 < count {
96+ self.selection += 1;
97+ true
98+ } else {
99+ false
100+ }
101+ }
102+
103+ /// Move selection up by 1. Returns true if it moved.
104+ pub fn up(&mut self) -> bool {
105+ if self.selection > 0 {
106+ self.selection -= 1;
107+ true
108+ } else {
109+ false
110+ }
111+ }
112+
113+ /// Force scroll to current selection next frame.
114+ pub fn scroll_to_selection(&mut self) {
115+ self.force_scroll = true;
116+ }
117+
118+ /// Reset selection to 0.
119+ pub fn reset(&mut self) {
120+ self.selection = 0;
121+ self.prev_selection = usize::MAX;
122+ self.force_scroll = true;
123+ }
124+}