fix: scrollable list focuses
3 files changed, +185 -55
+60-55src/widgets/jq_bar.rs
| @@ -20,7 +20,7 @@ pub struct JqBarResponse { | ||
| 20 | 20 | pub struct JqBar { |
| 21 | 21 | pub query: String, |
| 22 | 22 | pub completions: Vec<String>, |
| 23 | - completion_index: usize, | |
| 23 | + comp_list: super::scrollable_list::ScrollableList, | |
| 24 | 24 | show_completions: bool, |
| 25 | 25 | refocus: bool, |
| 26 | 26 | } |
| @@ -30,7 +30,7 @@ impl JqBar { | ||
| 30 | 30 | Self { |
| 31 | 31 | query: ".".to_string(), |
| 32 | 32 | completions: Vec::new(), |
| 33 | - completion_index: 0, | |
| 33 | + comp_list: super::scrollable_list::ScrollableList::new(), | |
| 34 | 34 | show_completions: false, |
| 35 | 35 | refocus: false, |
| 36 | 36 | } |
| @@ -110,28 +110,30 @@ impl JqBar { | ||
| 110 | 110 | ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::Space)); |
| 111 | 111 | |
| 112 | 112 | 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)) | |
| 115 | 115 | || 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)) | |
| 117 | 117 | || 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); | |
| 128 | 130 | self.refocus = true; |
| 129 | 131 | response.previewing = true; |
| 130 | 132 | } |
| 131 | 133 | |
| 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()); | |
| 135 | 137 | } |
| 136 | 138 | } else if enter { |
| 137 | 139 | response.run = true; |
| @@ -167,49 +169,52 @@ impl JqBar { | ||
| 167 | 169 | |
| 168 | 170 | // Show completion popup |
| 169 | 171 | 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 | + | |
| 171 | 176 | egui::Frame::new() |
| 172 | 177 | .fill(CatppuccinMocha::SURFACE0) |
| 173 | 178 | .inner_margin(6.0) |
| 174 | 179 | .corner_radius(4.0) |
| 175 | 180 | .stroke(egui::Stroke::new(1.0, CatppuccinMocha::SURFACE1)) |
| 176 | 181 | .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 | + ); | |
| 210 | 214 | }); |
| 211 | - if let Some(c) = clicked { | |
| 212 | - self.apply_completion(&c); | |
| 215 | + | |
| 216 | + if let Some(comp) = clicked_comp { | |
| 217 | + self.apply_completion(&comp); | |
| 213 | 218 | self.show_completions = false; |
| 214 | 219 | self.refocus = true; |
| 215 | 220 | response.accepted = true; |
| @@ -238,7 +243,7 @@ impl JqBar { | ||
| 238 | 243 | scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); |
| 239 | 244 | self.completions = scored.into_iter().map(|(_, p)| p).collect(); |
| 240 | 245 | } |
| 241 | - self.completion_index = 0; | |
| 246 | + self.comp_list.reset(); | |
| 242 | 247 | self.show_completions = !self.completions.is_empty(); |
| 243 | 248 | } |
| 244 | 249 |
+1-0src/widgets/mod.rs
| @@ -2,6 +2,7 @@ pub mod cache_guard; | ||
| 2 | 2 | pub mod hover_row; |
| 3 | 3 | pub mod jq_bar; |
| 4 | 4 | pub mod miller; |
| 5 | +pub mod scrollable_list; | |
| 5 | 6 | |
| 6 | 7 | pub use cache_guard::{CacheGuard, hash_key}; |
| 7 | 8 | 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 | +} |