irongit

feat: center miller pane filter

huncholanehuncholaneauthored
parent 5ab7ffccommit bcf95237182e07ded0fdeb4b1855965177eb7cd2Browse files

3 files changed, +288 -20

+72-10src/views/browser.rs
@@ -41,6 +41,8 @@ pub struct BrowserView {
4141 restore_key: Option<String>,
4242 // Scrollable list for the center column
4343 current_list: crate::widgets::scrollable_list::ScrollableList,
44+ // Filter for the center column
45+ filter: crate::widgets::miller::MillerFilter,
4446 // jq bar
4547 jq_bar: crate::widgets::jq_bar::JqBar,
4648 jq_synced: bool,
@@ -57,6 +59,7 @@ impl BrowserView {
5759
5860 restore_key: None,
5961 current_list: crate::widgets::scrollable_list::ScrollableList::new(),
62+ filter: crate::widgets::miller::MillerFilter::new(),
6063 jq_bar: crate::widgets::jq_bar::JqBar::new(),
6164 jq_synced: true,
6265 jq_result: None,
@@ -203,10 +206,17 @@ impl BrowserView {
203206
204207 ui.add_space(4.0);
205208
206- // --- Keyboard handling (only when jq input NOT focused) ---
209+ // --- Keyboard handling (only when no text input has focus) ---
207210 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;
208213
209- if !jq_has_focus {
214+ // '?' activates filter
215+ if !skip_keys {
216+ self.filter.check_activate(ui);
217+ }
218+
219+ if !skip_keys {
210220 let action = crate::widgets::read_miller_keys(ui, false);
211221 if crate::widgets::apply_selection(&mut self.selection, action, current_entries.len()) {
212222 // selection changed
@@ -215,11 +225,11 @@ impl BrowserView {
215225 if action == crate::widgets::MillerAction::Enter {
216226 if let Some(entry) = current_entries.get(self.selection) {
217227 if entry.is_container {
228+ self.filter.active = false;
229+ self.filter.query.clear();
218230 if self.path.is_empty() {
219- // Root: enter a file by name
220231 self.path.push(PathSegment::Key(entry.label.clone()));
221232 self.selection = 0;
222- // selection changed
223233 self.jq_synced = true;
224234 self.sync_jq_from_path();
225235 } else {
@@ -229,6 +239,8 @@ impl BrowserView {
229239 }
230240 }
231241 if action == crate::widgets::MillerAction::Back && !self.path.is_empty() {
242+ self.filter.active = false;
243+ self.filter.query.clear();
232244 self.go_up();
233245 }
234246
@@ -289,14 +301,64 @@ impl BrowserView {
289301
290302 Self::draw_separator(ui, col_height);
291303
292- // Middle: current
304+ // Middle: current (with optional filter)
293305 ui.vertical(|ui| {
294306 ui.set_width(col_widths[1]);
295307 ui.set_height(col_height);
296308 crate::widgets::miller::pane_title(ui, &mid_title);
297- let (c, d) = self.render_current_column(ui, &current_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, &current_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));
300362 });
301363
302364 Self::draw_separator(ui, col_height);
@@ -497,7 +559,7 @@ impl BrowserView {
497559 fn render_current_column(
498560 &mut self,
499561 ui: &mut Ui,
500- entries: &[Entry],
562+ entries: &[(usize, &Entry)],
501563 current_value: &serde_json::Value,
502564 height: f32,
503565 ) -> (Option<usize>, Option<usize>) {
@@ -524,7 +586,7 @@ impl BrowserView {
524586 row_height,
525587 Some(height),
526588 &mut |ui, i, is_selected| {
527- let entry = &entries[i];
589+ let (orig_idx, entry) = &entries[i];
528590 let (is_hovered, row_id) = crate::widgets::prev_frame_hover(ui.ctx(), ui.id(), i);
529591
530592 let bg = if is_selected {
+93-10src/views/shared_browser.rs
@@ -18,6 +18,8 @@ pub struct SharedBrowserView {
1818 entries_cache_key: Vec<String>,
1919 cached_current_entries: Vec<Entry>,
2020 cached_parent_entries: Option<Vec<Entry>>,
21+ /// Filter for the center column
22+ filter: crate::widgets::miller::MillerFilter,
2123 /// Cached values column data: (path, selection_label) -> flattened rows
2224 values_cache_key: (Vec<String>, String),
2325 values_rows: Vec<ValueRow>,
@@ -51,6 +53,7 @@ impl SharedBrowserView {
5153 entries_cache_key: vec!["__invalid__".to_string()], // force initial rebuild
5254 cached_current_entries: Vec::new(),
5355 cached_parent_entries: None,
56+ filter: crate::widgets::miller::MillerFilter::new(),
5457 values_cache_key: (Vec::new(), String::new()),
5558 values_rows: Vec::new(),
5659 }
@@ -120,26 +123,47 @@ impl SharedBrowserView {
120123 self.selection = self.cached_current_entries.len().saturating_sub(1);
121124 }
122125
123- // Keyboard navigation
124- let action = crate::widgets::read_miller_keys(ui, false);
126+ // Keyboard navigation (skip when filter has focus)
127+ let skip_keys = self.filter.has_focus();
128+ if !skip_keys {
129+ self.filter.check_activate(ui);
130+ }
131+
132+ let action = crate::widgets::read_miller_keys(ui, skip_keys);
125133 if crate::widgets::apply_selection(&mut self.selection, action, self.cached_current_entries.len()) {
126134 self.scroll_to_selection = true;
127135 }
128136 if action == crate::widgets::MillerAction::Enter {
129137 if let Some(entry) = self.cached_current_entries.get(self.selection) {
130138 if entry.is_container {
139+ self.filter.active = false;
140+ self.filter.query.clear();
131141 self.path.push(entry.label.clone());
132142 self.selection = 0;
133143 self.scroll_to_selection = true;
134- self.entries_cache_key.clear(); // force rebuild next frame
144+ // Rebuild immediately so this frame renders the new entries
145+ self.cached_current_entries = build_entries_at_path(&self.path, schema, all);
146+ self.cached_parent_entries = Some(build_entries_at_path(
147+ &self.path[..self.path.len() - 1], schema, all,
148+ ));
149+ self.entries_cache_key = self.path.clone();
135150 }
136151 }
137152 }
138153 if action == crate::widgets::MillerAction::Back && !self.path.is_empty() {
154+ self.filter.active = false;
155+ self.filter.query.clear();
139156 let popped = self.path.pop().unwrap();
140157 self.restore_key = Some(popped);
141158 self.scroll_to_selection = true;
142- self.entries_cache_key.clear(); // force rebuild next frame
159+ // Rebuild immediately
160+ self.cached_current_entries = build_entries_at_path(&self.path, schema, all);
161+ self.cached_parent_entries = if self.path.is_empty() {
162+ None
163+ } else {
164+ Some(build_entries_at_path(&self.path[..self.path.len() - 1], schema, all))
165+ };
166+ self.entries_cache_key = self.path.clone();
143167 }
144168
145169 // 'A' toggles alphabetical sort on the structs pane
@@ -173,6 +197,7 @@ impl SharedBrowserView {
173197 let mut clicked_entry: Option<usize> = None;
174198 let mut dbl_clicked_entry: Option<usize> = None;
175199 let mut type_nav: Option<Vec<String>> = None;
200+ let mut filter_accepted = false;
176201
177202 // Compute pane titles based on current path
178203 let (left_title, mid_title, right_title) = pane_titles(
@@ -195,20 +220,49 @@ impl SharedBrowserView {
195220
196221 draw_separator(ui, col_height);
197222
198- // Middle: current entries
223+ // Middle: current entries (with optional filter)
199224 ui.vertical(|ui| {
200225 ui.set_width(col_widths[1]);
201226 ui.set_height(col_height);
202227 render_pane_title(ui, &mid_title);
228+ let filter_resp = self.filter.show(ui);
229+
230+ let filtered: Vec<(usize, &Entry)> = current_entries.iter()
231+ .enumerate()
232+ .filter(|(_, e)| self.filter.matches(&e.label))
233+ .collect();
234+
235+ // Ctrl-N/P navigate filtered entries, Enter sets flag for post-render
236+ if filter_resp.accept {
237+ filter_accepted = true;
238+ }
239+ if !filtered.is_empty() {
240+ if filter_resp.next {
241+ let next = filtered.iter()
242+ .find(|(orig, _)| *orig > self.selection)
243+ .or(filtered.first())
244+ .map(|(orig, _)| *orig);
245+ if let Some(idx) = next { self.selection = idx; }
246+ }
247+ if filter_resp.prev {
248+ let prev = filtered.iter().rev()
249+ .find(|(orig, _)| *orig < self.selection)
250+ .or(filtered.last())
251+ .map(|(orig, _)| *orig);
252+ if let Some(idx) = prev { self.selection = idx; }
253+ }
254+ }
255+
203256 let (c, d) = render_current_column(
204257 ui,
205- &current_entries,
258+ &filtered,
206259 self.selection,
207260 self.scroll_to_selection,
208261 col_height,
209262 );
210- clicked_entry = c;
211- dbl_clicked_entry = d;
263+ // Map filtered index back to original
264+ clicked_entry = c.and_then(|fi| filtered.get(fi).map(|(orig, _)| *orig));
265+ dbl_clicked_entry = d.and_then(|fi| filtered.get(fi).map(|(orig, _)| *orig));
212266 });
213267
214268 draw_separator(ui, col_height);
@@ -256,6 +310,28 @@ impl SharedBrowserView {
256310 self.path.push(entry.label.clone());
257311 self.selection = 0;
258312 self.scroll_to_selection = true;
313+ self.cached_current_entries = build_entries_at_path(&self.path, schema, all);
314+ self.cached_parent_entries = Some(build_entries_at_path(
315+ &self.path[..self.path.len() - 1], schema, all,
316+ ));
317+ self.entries_cache_key = self.path.clone();
318+ }
319+ }
320+ }
321+ // Filter Enter: navigate into selected
322+ if filter_accepted {
323+ if let Some(entry) = self.cached_current_entries.get(self.selection) {
324+ if entry.is_container {
325+ self.filter.active = false;
326+ self.filter.query.clear();
327+ self.path.push(entry.label.clone());
328+ self.selection = 0;
329+ self.scroll_to_selection = true;
330+ self.cached_current_entries = build_entries_at_path(&self.path, schema, all);
331+ self.cached_parent_entries = Some(build_entries_at_path(
332+ &self.path[..self.path.len() - 1], schema, all,
333+ ));
334+ self.entries_cache_key = self.path.clone();
259335 }
260336 }
261337 }
@@ -264,6 +340,13 @@ impl SharedBrowserView {
264340 self.path = nav_path;
265341 self.selection = 0;
266342 self.scroll_to_selection = true;
343+ self.cached_current_entries = build_entries_at_path(&self.path, schema, all);
344+ self.cached_parent_entries = if self.path.is_empty() {
345+ None
346+ } else {
347+ Some(build_entries_at_path(&self.path[..self.path.len() - 1], schema, all))
348+ };
349+ self.entries_cache_key = self.path.clone();
267350 }
268351 }
269352 }
@@ -488,7 +571,7 @@ fn render_parent_column(
488571
489572 fn render_current_column(
490573 ui: &mut Ui,
491- entries: &[Entry],
574+ entries: &[(usize, &Entry)],
492575 selection: usize,
493576 scroll_to_selection: bool,
494577 height: f32,
@@ -515,7 +598,7 @@ fn render_current_column(
515598 .max_height(height)
516599 .show_rows(ui, row_height, entries.len(), |ui, range| {
517600 for i in range {
518- let entry = &entries[i];
601+ let (_orig_idx, entry) = &entries[i];
519602 let is_selected = i == selection;
520603
521604 let (is_hovered, row_id) = crate::widgets::prev_frame_hover(ui.ctx(), ui.id(), i);
+123-0src/widgets/miller.rs
@@ -98,6 +98,129 @@ pub fn apply_selection(selection: &mut usize, action: MillerAction, count: usize
9898 false
9999 }
100100
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+
101224 /// Render a pane title above a miller column.
102225 pub fn pane_title(ui: &mut Ui, title: &str) {
103226 if title.is_empty() {