feat: center miller pane filter
3 files changed, +288 -20
+72-10src/views/browser.rs
| @@ -41,6 +41,8 @@ pub struct BrowserView { | ||
| 41 | 41 | restore_key: Option<String>, |
| 42 | 42 | // Scrollable list for the center column |
| 43 | 43 | current_list: crate::widgets::scrollable_list::ScrollableList, |
| 44 | + // Filter for the center column | |
| 45 | + filter: crate::widgets::miller::MillerFilter, | |
| 44 | 46 | // jq bar |
| 45 | 47 | jq_bar: crate::widgets::jq_bar::JqBar, |
| 46 | 48 | jq_synced: bool, |
| @@ -57,6 +59,7 @@ impl BrowserView { | ||
| 57 | 59 | |
| 58 | 60 | restore_key: None, |
| 59 | 61 | current_list: crate::widgets::scrollable_list::ScrollableList::new(), |
| 62 | + filter: crate::widgets::miller::MillerFilter::new(), | |
| 60 | 63 | jq_bar: crate::widgets::jq_bar::JqBar::new(), |
| 61 | 64 | jq_synced: true, |
| 62 | 65 | jq_result: None, |
| @@ -203,10 +206,17 @@ impl BrowserView { | ||
| 203 | 206 | |
| 204 | 207 | ui.add_space(4.0); |
| 205 | 208 | |
| 206 | - // --- Keyboard handling (only when jq input NOT focused) --- | |
| 209 | + // --- Keyboard handling (only when no text input has focus) --- | |
| 207 | 210 | let jq_has_focus = crate::widgets::jq_bar::JqBar::has_focus(ui); |
| 211 | + let filter_has_focus = self.filter.has_focus(); | |
| 212 | + let skip_keys = jq_has_focus || filter_has_focus; | |
| 208 | 213 | |
| 209 | - if !jq_has_focus { | |
| 214 | + // '?' activates filter | |
| 215 | + if !skip_keys { | |
| 216 | + self.filter.check_activate(ui); | |
| 217 | + } | |
| 218 | + | |
| 219 | + if !skip_keys { | |
| 210 | 220 | let action = crate::widgets::read_miller_keys(ui, false); |
| 211 | 221 | if crate::widgets::apply_selection(&mut self.selection, action, current_entries.len()) { |
| 212 | 222 | // selection changed |
| @@ -215,11 +225,11 @@ impl BrowserView { | ||
| 215 | 225 | if action == crate::widgets::MillerAction::Enter { |
| 216 | 226 | if let Some(entry) = current_entries.get(self.selection) { |
| 217 | 227 | if entry.is_container { |
| 228 | + self.filter.active = false; | |
| 229 | + self.filter.query.clear(); | |
| 218 | 230 | if self.path.is_empty() { |
| 219 | - // Root: enter a file by name | |
| 220 | 231 | self.path.push(PathSegment::Key(entry.label.clone())); |
| 221 | 232 | self.selection = 0; |
| 222 | - // selection changed | |
| 223 | 233 | self.jq_synced = true; |
| 224 | 234 | self.sync_jq_from_path(); |
| 225 | 235 | } else { |
| @@ -229,6 +239,8 @@ impl BrowserView { | ||
| 229 | 239 | } |
| 230 | 240 | } |
| 231 | 241 | if action == crate::widgets::MillerAction::Back && !self.path.is_empty() { |
| 242 | + self.filter.active = false; | |
| 243 | + self.filter.query.clear(); | |
| 232 | 244 | self.go_up(); |
| 233 | 245 | } |
| 234 | 246 | |
| @@ -289,14 +301,64 @@ impl BrowserView { | ||
| 289 | 301 | |
| 290 | 302 | Self::draw_separator(ui, col_height); |
| 291 | 303 | |
| 292 | - // Middle: current | |
| 304 | + // Middle: current (with optional filter) | |
| 293 | 305 | ui.vertical(|ui| { |
| 294 | 306 | ui.set_width(col_widths[1]); |
| 295 | 307 | ui.set_height(col_height); |
| 296 | 308 | crate::widgets::miller::pane_title(ui, &mid_title); |
| 297 | - let (c, d) = self.render_current_column(ui, ¤t_entries, current, col_height); | |
| 298 | - clicked_entry = c; | |
| 299 | - dbl_clicked_entry = d; | |
| 309 | + let filter_resp = self.filter.show(ui); | |
| 310 | + | |
| 311 | + // Filter entries: (original_index, &Entry) | |
| 312 | + let filtered: Vec<(usize, &Entry)> = current_entries.iter() | |
| 313 | + .enumerate() | |
| 314 | + .filter(|(_, e)| self.filter.matches(&e.label)) | |
| 315 | + .collect(); | |
| 316 | + | |
| 317 | + // Ctrl-N/P navigate filtered entries, Enter accepts | |
| 318 | + if !filtered.is_empty() { | |
| 319 | + if filter_resp.next { | |
| 320 | + // Find next filtered entry after current selection | |
| 321 | + let next = filtered.iter() | |
| 322 | + .find(|(orig, _)| *orig > self.selection) | |
| 323 | + .or(filtered.first()) | |
| 324 | + .map(|(orig, _)| *orig); | |
| 325 | + if let Some(idx) = next { | |
| 326 | + self.selection = idx; | |
| 327 | + } | |
| 328 | + } | |
| 329 | + if filter_resp.prev { | |
| 330 | + let prev = filtered.iter().rev() | |
| 331 | + .find(|(orig, _)| *orig < self.selection) | |
| 332 | + .or(filtered.last()) | |
| 333 | + .map(|(orig, _)| *orig); | |
| 334 | + if let Some(idx) = prev { | |
| 335 | + self.selection = idx; | |
| 336 | + } | |
| 337 | + } | |
| 338 | + if filter_resp.accept { | |
| 339 | + if let Some(entry) = current_entries.get(self.selection) { | |
| 340 | + if entry.is_container { | |
| 341 | + self.filter.active = false; | |
| 342 | + self.filter.query.clear(); | |
| 343 | + if self.path.is_empty() { | |
| 344 | + self.path.push(PathSegment::Key(entry.label.clone())); | |
| 345 | + self.selection = 0; | |
| 346 | + self.jq_synced = true; | |
| 347 | + self.sync_jq_from_path(); | |
| 348 | + } else { | |
| 349 | + self.enter_selected(current, ¤t_entries); | |
| 350 | + } | |
| 351 | + } | |
| 352 | + } | |
| 353 | + } | |
| 354 | + } | |
| 355 | + | |
| 356 | + let (c, d) = self.render_current_column( | |
| 357 | + ui, &filtered, current, col_height, | |
| 358 | + ); | |
| 359 | + // Map filtered index back to original | |
| 360 | + clicked_entry = c.and_then(|fi| filtered.get(fi).map(|(orig, _)| *orig)); | |
| 361 | + dbl_clicked_entry = d.and_then(|fi| filtered.get(fi).map(|(orig, _)| *orig)); | |
| 300 | 362 | }); |
| 301 | 363 | |
| 302 | 364 | Self::draw_separator(ui, col_height); |
| @@ -497,7 +559,7 @@ impl BrowserView { | ||
| 497 | 559 | fn render_current_column( |
| 498 | 560 | &mut self, |
| 499 | 561 | ui: &mut Ui, |
| 500 | - entries: &[Entry], | |
| 562 | + entries: &[(usize, &Entry)], | |
| 501 | 563 | current_value: &serde_json::Value, |
| 502 | 564 | height: f32, |
| 503 | 565 | ) -> (Option<usize>, Option<usize>) { |
| @@ -524,7 +586,7 @@ impl BrowserView { | ||
| 524 | 586 | row_height, |
| 525 | 587 | Some(height), |
| 526 | 588 | &mut |ui, i, is_selected| { |
| 527 | - let entry = &entries[i]; | |
| 589 | + let (orig_idx, entry) = &entries[i]; | |
| 528 | 590 | let (is_hovered, row_id) = crate::widgets::prev_frame_hover(ui.ctx(), ui.id(), i); |
| 529 | 591 | |
| 530 | 592 | let bg = if is_selected { |
+123-0src/widgets/miller.rs
| @@ -98,6 +98,129 @@ pub fn apply_selection(selection: &mut usize, action: MillerAction, count: usize | ||
| 98 | 98 | false |
| 99 | 99 | } |
| 100 | 100 | |
| 101 | +/// Response from the filter bar. | |
| 102 | +pub struct MillerFilterResponse { | |
| 103 | + /// Ctrl-N or ArrowDown pressed — move selection down | |
| 104 | + pub next: bool, | |
| 105 | + /// Ctrl-P or ArrowUp pressed — move selection up | |
| 106 | + pub prev: bool, | |
| 107 | + /// Enter pressed — navigate into selected entry | |
| 108 | + pub accept: bool, | |
| 109 | + /// Escape pressed — filter closed | |
| 110 | + pub closed: bool, | |
| 111 | +} | |
| 112 | + | |
| 113 | +/// Filter state for a miller column. Activated by `?`, fuzzy-filters entries. | |
| 114 | +pub struct MillerFilter { | |
| 115 | + pub active: bool, | |
| 116 | + pub query: String, | |
| 117 | +} | |
| 118 | + | |
| 119 | +impl MillerFilter { | |
| 120 | + pub fn new() -> Self { | |
| 121 | + Self { | |
| 122 | + active: false, | |
| 123 | + query: String::new(), | |
| 124 | + } | |
| 125 | + } | |
| 126 | + | |
| 127 | + /// Check if `?` was pressed (only when no text input has focus). | |
| 128 | + /// Returns true if the filter was just activated. | |
| 129 | + pub fn check_activate(&mut self, ui: &Ui) -> bool { | |
| 130 | + if !self.active && ui.input(|i| i.key_pressed(egui::Key::Questionmark)) { | |
| 131 | + self.active = true; | |
| 132 | + self.query.clear(); | |
| 133 | + true | |
| 134 | + } else { | |
| 135 | + false | |
| 136 | + } | |
| 137 | + } | |
| 138 | + | |
| 139 | + /// Render the filter input bar. | |
| 140 | + pub fn show(&mut self, ui: &mut Ui) -> MillerFilterResponse { | |
| 141 | + let mut resp = MillerFilterResponse { | |
| 142 | + next: false, | |
| 143 | + prev: false, | |
| 144 | + accept: false, | |
| 145 | + closed: false, | |
| 146 | + }; | |
| 147 | + | |
| 148 | + if !self.active { | |
| 149 | + return resp; | |
| 150 | + } | |
| 151 | + | |
| 152 | + ui.horizontal(|ui| { | |
| 153 | + ui.label( | |
| 154 | + egui::RichText::new(egui_phosphor::regular::MAGNIFYING_GLASS) | |
| 155 | + .color(crate::theme::CatppuccinMocha::MAUVE) | |
| 156 | + .size(12.0), | |
| 157 | + ); | |
| 158 | + let r = ui.add( | |
| 159 | + egui::TextEdit::singleline(&mut self.query) | |
| 160 | + .id(egui::Id::new("miller_filter_input")) | |
| 161 | + .font(egui::FontId::monospace(12.0)) | |
| 162 | + .desired_width(ui.available_width() - 10.0) | |
| 163 | + .text_color(crate::theme::CatppuccinMocha::GREEN), | |
| 164 | + ); | |
| 165 | + r.request_focus(); | |
| 166 | + | |
| 167 | + // Ctrl-N / ArrowDown: next match | |
| 168 | + resp.next = ui.input(|i| i.key_pressed(egui::Key::ArrowDown)) | |
| 169 | + || ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::N)); | |
| 170 | + // Ctrl-P / ArrowUp: prev match | |
| 171 | + resp.prev = ui.input(|i| i.key_pressed(egui::Key::ArrowUp)) | |
| 172 | + || ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::P)); | |
| 173 | + // Enter: navigate into selection | |
| 174 | + if ui.input(|i| i.key_pressed(egui::Key::Enter)) { | |
| 175 | + resp.accept = true; | |
| 176 | + } | |
| 177 | + // Escape: close | |
| 178 | + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { | |
| 179 | + resp.closed = true; | |
| 180 | + } | |
| 181 | + }); | |
| 182 | + ui.add_space(2.0); | |
| 183 | + | |
| 184 | + if resp.closed { | |
| 185 | + self.active = false; | |
| 186 | + self.query.clear(); | |
| 187 | + } | |
| 188 | + | |
| 189 | + resp | |
| 190 | + } | |
| 191 | + | |
| 192 | + /// Returns true if the filter is active (caller should skip miller keys). | |
| 193 | + pub fn has_focus(&self) -> bool { | |
| 194 | + self.active | |
| 195 | + } | |
| 196 | + | |
| 197 | + /// Fuzzy match a label against the query. Returns true if it matches. | |
| 198 | + pub fn matches(&self, label: &str) -> bool { | |
| 199 | + if !self.active || self.query.is_empty() { | |
| 200 | + return true; | |
| 201 | + } | |
| 202 | + fuzzy_matches(&self.query, label) | |
| 203 | + } | |
| 204 | +} | |
| 205 | + | |
| 206 | +/// Simple fuzzy match: all chars in needle appear in order in haystack (case-insensitive). | |
| 207 | +fn fuzzy_matches(needle: &str, haystack: &str) -> bool { | |
| 208 | + let mut needle_chars = needle.chars().flat_map(|c| c.to_lowercase()); | |
| 209 | + let mut current = match needle_chars.next() { | |
| 210 | + Some(c) => c, | |
| 211 | + None => return true, | |
| 212 | + }; | |
| 213 | + for h in haystack.chars().flat_map(|c| c.to_lowercase()) { | |
| 214 | + if h == current { | |
| 215 | + current = match needle_chars.next() { | |
| 216 | + Some(c) => c, | |
| 217 | + None => return true, | |
| 218 | + }; | |
| 219 | + } | |
| 220 | + } | |
| 221 | + false | |
| 222 | +} | |
| 223 | + | |
| 101 | 224 | /// Render a pane title above a miller column. |
| 102 | 225 | pub fn pane_title(ui: &mut Ui, title: &str) { |
| 103 | 226 | if title.is_empty() { |