irongit

feat: filter preview pane in jv

huncholanehuncholaneauthored
parent 155833bcommit 96314fb2f0f7ba164429d5b7a707516d0328634fBrowse files

3 files changed, +106 -79

+45-20src/views/browser.rs
@@ -43,6 +43,8 @@ pub struct BrowserView {
4343 current_list: crate::widgets::scrollable_list::ScrollableList,
4444 // Filter for the center column
4545 filter: crate::widgets::miller::MillerFilter,
46+ // Persistent filter for the right preview column (Ctrl-/)
47+ preview_filter: crate::widgets::miller::MillerFilter,
4648 // jq bar
4749 jq_bar: crate::widgets::jq_bar::JqBar,
4850 jq_synced: bool,
@@ -59,7 +61,8 @@ impl BrowserView {
5961
6062 restore_key: None,
6163 current_list: crate::widgets::scrollable_list::ScrollableList::new(),
62- filter: crate::widgets::miller::MillerFilter::new(),
64+ filter: crate::widgets::miller::MillerFilter::new("browser_center_filter"),
65+ preview_filter: crate::widgets::miller::MillerFilter::new("browser_preview_filter"),
6366 jq_bar: crate::widgets::jq_bar::JqBar::new(),
6467 jq_synced: true,
6568 jq_result: None,
@@ -208,12 +211,18 @@ impl BrowserView {
208211
209212 // --- Keyboard handling (only when no text input has focus) ---
210213 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;
214+ let filter_has_focus = self.filter.has_focus(ui);
215+ let preview_filter_has_focus = self.preview_filter.has_focus(ui);
216+ let skip_keys = jq_has_focus || filter_has_focus || preview_filter_has_focus;
213217
214- // '?' activates filter
215- if !skip_keys {
216- self.filter.check_activate(ui);
218+ // Ctrl-/ focuses preview filter
219+ if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::Slash)) {
220+ self.preview_filter.focus();
221+ }
222+
223+ // '?' focuses center column filter
224+ if !skip_keys && ui.input(|i| i.key_pressed(egui::Key::Questionmark)) {
225+ self.filter.focus();
217226 }
218227
219228 if !skip_keys {
@@ -225,7 +234,6 @@ impl BrowserView {
225234 if action == crate::widgets::MillerAction::Enter {
226235 if let Some(entry) = current_entries.get(self.selection) {
227236 if entry.is_container {
228- self.filter.active = false;
229237 self.filter.query.clear();
230238 if self.path.is_empty() {
231239 self.path.push(PathSegment::Key(entry.label.clone()));
@@ -239,7 +247,6 @@ impl BrowserView {
239247 }
240248 }
241249 if action == crate::widgets::MillerAction::Back && !self.path.is_empty() {
242- self.filter.active = false;
243250 self.filter.query.clear();
244251 self.go_up();
245252 }
@@ -306,7 +313,7 @@ impl BrowserView {
306313 ui.set_width(col_widths[1]);
307314 ui.set_height(col_height);
308315 crate::widgets::miller::pane_title(ui, &mid_title);
309- let filter_resp = self.filter.show(ui);
316+ let filter_resp = self.filter.show(ui, "? to filter");
310317
311318 // Filter + snap selection
312319 let fr = self.filter.apply(
@@ -332,7 +339,6 @@ impl BrowserView {
332339 if filter_resp.accept {
333340 if let Some(entry) = current_entries.get(self.selection) {
334341 if entry.is_container {
335- self.filter.active = false;
336342 self.filter.query.clear();
337343 if self.path.is_empty() {
338344 self.path.push(PathSegment::Key(entry.label.clone()));
@@ -357,12 +363,13 @@ impl BrowserView {
357363
358364 Self::draw_separator(ui, col_height);
359365
360- // Right: preview
366+ // Right: preview (with persistent filter)
361367 ui.vertical(|ui| {
362368 let remaining = ui.available_width();
363369 ui.set_width(remaining);
364370 ui.set_height(col_height);
365371 crate::widgets::miller::pane_title(ui, &right_title);
372+ self.preview_filter.show(ui, "ctrl-/ to filter");
366373 self.render_preview_column(ui, selected_child, &current_entries, col_height);
367374 });
368375 });
@@ -764,17 +771,27 @@ impl BrowserView {
764771
765772 match val {
766773 serde_json::Value::Object(map) => {
774+ let keys: Vec<(&String, &serde_json::Value)> = map.iter()
775+ .filter(|(k, v)| {
776+ self.preview_filter.matches(k)
777+ || self.preview_filter.matches(&value_preview(v))
778+ })
779+ .collect();
767780 ui.label(
768781 RichText::new(format!(
769- "{} Object — {} fields",
782+ "{} Object — {} fields{}",
770783 egui_phosphor::regular::BRACKETS_CURLY,
771- map.len()
784+ map.len(),
785+ if keys.len() != map.len() {
786+ format!(" ({} shown)", keys.len())
787+ } else {
788+ String::new()
789+ },
772790 ))
773791 .color(CatppuccinMocha::LAVENDER)
774792 .small(),
775793 );
776794 ui.add_space(4.0);
777- let keys: Vec<(&String, &serde_json::Value)> = map.iter().collect();
778795 egui::ScrollArea::vertical()
779796 .id_salt("browser_preview")
780797 .auto_shrink(false)
@@ -813,24 +830,32 @@ impl BrowserView {
813830 });
814831 }
815832 serde_json::Value::Array(arr) => {
833+ let items: Vec<(usize, &serde_json::Value)> = arr.iter()
834+ .enumerate()
835+ .filter(|(_, v)| self.preview_filter.matches(&value_preview(v)))
836+ .collect();
816837 ui.label(
817838 RichText::new(format!(
818- "{} Array — {} items",
839+ "{} Array — {} items{}",
819840 egui_phosphor::regular::BRACKETS_SQUARE,
820- arr.len()
841+ arr.len(),
842+ if items.len() != arr.len() {
843+ format!(" ({} shown)", items.len())
844+ } else {
845+ String::new()
846+ },
821847 ))
822848 .color(CatppuccinMocha::YELLOW)
823849 .small(),
824850 );
825851 ui.add_space(4.0);
826- let show_count = arr.len().min(200);
827852 egui::ScrollArea::vertical()
828853 .id_salt("browser_preview")
829854 .auto_shrink(false)
830855 .max_height(height - 24.0)
831- .show_rows(ui, row_height, show_count, |ui, range| {
832- for i in range {
833- let v = &arr[i];
856+ .show_rows(ui, row_height, items.len(), |ui, range| {
857+ for idx in range {
858+ let (i, v) = items[idx];
834859 let (icon, color) = type_icon_color(v);
835860 let preview = value_preview(v);
836861 ui.horizontal(|ui| {
+5-8src/views/shared_browser.rs
@@ -53,7 +53,7 @@ impl SharedBrowserView {
5353 entries_cache_key: vec!["__invalid__".to_string()], // force initial rebuild
5454 cached_current_entries: Vec::new(),
5555 cached_parent_entries: None,
56- filter: crate::widgets::miller::MillerFilter::new(),
56+ filter: crate::widgets::miller::MillerFilter::new("groups_center_filter"),
5757 values_cache_key: (Vec::new(), String::new()),
5858 values_rows: Vec::new(),
5959 }
@@ -124,9 +124,9 @@ impl SharedBrowserView {
124124 }
125125
126126 // 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);
127+ let skip_keys = self.filter.has_focus(ui);
128+ if !skip_keys && ui.input(|i| i.key_pressed(egui::Key::Questionmark)) {
129+ self.filter.focus();
130130 }
131131
132132 let action = crate::widgets::read_miller_keys(ui, skip_keys);
@@ -136,7 +136,6 @@ impl SharedBrowserView {
136136 if action == crate::widgets::MillerAction::Enter {
137137 if let Some(entry) = self.cached_current_entries.get(self.selection) {
138138 if entry.is_container {
139- self.filter.active = false;
140139 self.filter.query.clear();
141140 self.path.push(entry.label.clone());
142141 self.selection = 0;
@@ -151,7 +150,6 @@ impl SharedBrowserView {
151150 }
152151 }
153152 if action == crate::widgets::MillerAction::Back && !self.path.is_empty() {
154- self.filter.active = false;
155153 self.filter.query.clear();
156154 let popped = self.path.pop().unwrap();
157155 self.restore_key = Some(popped);
@@ -225,7 +223,7 @@ impl SharedBrowserView {
225223 ui.set_width(col_widths[1]);
226224 ui.set_height(col_height);
227225 render_pane_title(ui, &mid_title);
228- let filter_resp = self.filter.show(ui);
226+ let filter_resp = self.filter.show(ui, "? to filter");
229227
230228 // Filter + snap selection
231229 let fr = self.filter.apply(
@@ -319,7 +317,6 @@ impl SharedBrowserView {
319317 if filter_accepted {
320318 if let Some(entry) = self.cached_current_entries.get(self.selection) {
321319 if entry.is_container {
322- self.filter.active = false;
323320 self.filter.query.clear();
324321 self.path.push(entry.label.clone());
325322 self.selection = 0;
+56-51src/widgets/miller.rs
@@ -106,97 +106,102 @@ pub struct MillerFilterResponse {
106106 pub prev: bool,
107107 /// Enter pressed — navigate into selected entry
108108 pub accept: bool,
109- /// Escape pressed — filter closed
110- pub closed: bool,
111109 }
112110
113-/// Filter state for a miller column. Activated by `?`, fuzzy-filters entries.
111+/// Always-visible filter bar for a miller column.
112+/// Shows a text input with placeholder. Focus with shortcut key, Escape unfocuses.
114113 pub struct MillerFilter {
115- pub active: bool,
116114 pub query: String,
115+ id: &'static str,
116+ focus_next: bool,
117117 }
118118
119119 impl MillerFilter {
120- pub fn new() -> Self {
120+ pub fn new(id: &'static str) -> Self {
121121 Self {
122- active: false,
123122 query: String::new(),
123+ id,
124+ focus_next: false,
124125 }
125126 }
126127
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- }
128+ /// Request focus on this filter's input next frame.
129+ pub fn focus(&mut self) {
130+ self.focus_next = true;
131+ }
132+
133+ /// Returns true if this filter's text input currently has focus.
134+ pub fn has_focus(&self, ui: &Ui) -> bool {
135+ let id = egui::Id::new(self.id);
136+ ui.ctx().memory(|m| m.focused().map_or(false, |f| f == id))
137137 }
138138
139- /// Render the filter input bar.
140- pub fn show(&mut self, ui: &mut Ui) -> MillerFilterResponse {
139+ /// Render the filter bar. Always visible.
140+ /// `hint` is the placeholder text (e.g. "? to filter" or "ctrl-/ to filter").
141+ pub fn show(&mut self, ui: &mut Ui, hint: &str) -> MillerFilterResponse {
141142 let mut resp = MillerFilterResponse {
142143 next: false,
143144 prev: false,
144145 accept: false,
145- closed: false,
146146 };
147147
148- if !self.active {
149- return resp;
150- }
151-
152148 ui.horizontal(|ui| {
153149 ui.label(
154150 egui::RichText::new(egui_phosphor::regular::MAGNIFYING_GLASS)
155- .color(crate::theme::CatppuccinMocha::MAUVE)
151+ .color(if self.query.is_empty() {
152+ crate::theme::CatppuccinMocha::SURFACE2
153+ } else {
154+ crate::theme::CatppuccinMocha::MAUVE
155+ })
156156 .size(12.0),
157157 );
158+
159+ let id = egui::Id::new(self.id);
158160 let r = ui.add(
159161 egui::TextEdit::singleline(&mut self.query)
160- .id(egui::Id::new("miller_filter_input"))
162+ .id(id)
161163 .font(egui::FontId::monospace(12.0))
162164 .desired_width(ui.available_width() - 10.0)
163- .text_color(crate::theme::CatppuccinMocha::GREEN),
165+ .text_color(crate::theme::CatppuccinMocha::GREEN)
166+ .hint_text(
167+ egui::RichText::new(hint)
168+ .color(crate::theme::CatppuccinMocha::SURFACE2)
169+ .family(egui::FontFamily::Monospace)
170+ ),
164171 );
165- r.request_focus();
166172
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;
173+ if self.focus_next {
174+ r.request_focus();
175+ self.focus_next = false;
176176 }
177- // Escape: close
178- if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
179- resp.closed = true;
177+
178+ let has_focus = r.has_focus();
179+
180+ if has_focus {
181+ // Escape: unfocus (don't clear)
182+ if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
183+ r.surrender_focus();
184+ }
185+ // Ctrl-N / ArrowDown
186+ resp.next = ui.input(|i| i.key_pressed(egui::Key::ArrowDown))
187+ || ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::N));
188+ // Ctrl-P / ArrowUp
189+ resp.prev = ui.input(|i| i.key_pressed(egui::Key::ArrowUp))
190+ || ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::P));
191+ // Enter
192+ if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
193+ resp.accept = true;
194+ }
180195 }
181196 });
182197 ui.add_space(2.0);
183198
184- if resp.closed {
185- self.active = false;
186- self.query.clear();
187- }
188-
189199 resp
190200 }
191201
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.
202+ /// Fuzzy match a label against the query. Returns true if it matches (or query is empty).
198203 pub fn matches(&self, label: &str) -> bool {
199- if !self.active || self.query.is_empty() {
204+ if self.query.is_empty() {
200205 return true;
201206 }
202207 fuzzy_matches(&self.query, label)