feat: filter preview pane in jv
3 files changed, +106 -79
+45-20src/views/browser.rs
| @@ -43,6 +43,8 @@ pub struct BrowserView { | ||
| 43 | 43 | current_list: crate::widgets::scrollable_list::ScrollableList, |
| 44 | 44 | // Filter for the center column |
| 45 | 45 | filter: crate::widgets::miller::MillerFilter, |
| 46 | + // Persistent filter for the right preview column (Ctrl-/) | |
| 47 | + preview_filter: crate::widgets::miller::MillerFilter, | |
| 46 | 48 | // jq bar |
| 47 | 49 | jq_bar: crate::widgets::jq_bar::JqBar, |
| 48 | 50 | jq_synced: bool, |
| @@ -59,7 +61,8 @@ impl BrowserView { | ||
| 59 | 61 | |
| 60 | 62 | restore_key: None, |
| 61 | 63 | 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"), | |
| 63 | 66 | jq_bar: crate::widgets::jq_bar::JqBar::new(), |
| 64 | 67 | jq_synced: true, |
| 65 | 68 | jq_result: None, |
| @@ -208,12 +211,18 @@ impl BrowserView { | ||
| 208 | 211 | |
| 209 | 212 | // --- Keyboard handling (only when no text input has focus) --- |
| 210 | 213 | 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; | |
| 213 | 217 | |
| 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(); | |
| 217 | 226 | } |
| 218 | 227 | |
| 219 | 228 | if !skip_keys { |
| @@ -225,7 +234,6 @@ impl BrowserView { | ||
| 225 | 234 | if action == crate::widgets::MillerAction::Enter { |
| 226 | 235 | if let Some(entry) = current_entries.get(self.selection) { |
| 227 | 236 | if entry.is_container { |
| 228 | - self.filter.active = false; | |
| 229 | 237 | self.filter.query.clear(); |
| 230 | 238 | if self.path.is_empty() { |
| 231 | 239 | self.path.push(PathSegment::Key(entry.label.clone())); |
| @@ -239,7 +247,6 @@ impl BrowserView { | ||
| 239 | 247 | } |
| 240 | 248 | } |
| 241 | 249 | if action == crate::widgets::MillerAction::Back && !self.path.is_empty() { |
| 242 | - self.filter.active = false; | |
| 243 | 250 | self.filter.query.clear(); |
| 244 | 251 | self.go_up(); |
| 245 | 252 | } |
| @@ -306,7 +313,7 @@ impl BrowserView { | ||
| 306 | 313 | ui.set_width(col_widths[1]); |
| 307 | 314 | ui.set_height(col_height); |
| 308 | 315 | 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"); | |
| 310 | 317 | |
| 311 | 318 | // Filter + snap selection |
| 312 | 319 | let fr = self.filter.apply( |
| @@ -332,7 +339,6 @@ impl BrowserView { | ||
| 332 | 339 | if filter_resp.accept { |
| 333 | 340 | if let Some(entry) = current_entries.get(self.selection) { |
| 334 | 341 | if entry.is_container { |
| 335 | - self.filter.active = false; | |
| 336 | 342 | self.filter.query.clear(); |
| 337 | 343 | if self.path.is_empty() { |
| 338 | 344 | self.path.push(PathSegment::Key(entry.label.clone())); |
| @@ -357,12 +363,13 @@ impl BrowserView { | ||
| 357 | 363 | |
| 358 | 364 | Self::draw_separator(ui, col_height); |
| 359 | 365 | |
| 360 | - // Right: preview | |
| 366 | + // Right: preview (with persistent filter) | |
| 361 | 367 | ui.vertical(|ui| { |
| 362 | 368 | let remaining = ui.available_width(); |
| 363 | 369 | ui.set_width(remaining); |
| 364 | 370 | ui.set_height(col_height); |
| 365 | 371 | crate::widgets::miller::pane_title(ui, &right_title); |
| 372 | + self.preview_filter.show(ui, "ctrl-/ to filter"); | |
| 366 | 373 | self.render_preview_column(ui, selected_child, ¤t_entries, col_height); |
| 367 | 374 | }); |
| 368 | 375 | }); |
| @@ -764,17 +771,27 @@ impl BrowserView { | ||
| 764 | 771 | |
| 765 | 772 | match val { |
| 766 | 773 | 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(); | |
| 767 | 780 | ui.label( |
| 768 | 781 | RichText::new(format!( |
| 769 | - "{} Object — {} fields", | |
| 782 | + "{} Object — {} fields{}", | |
| 770 | 783 | 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 | + }, | |
| 772 | 790 | )) |
| 773 | 791 | .color(CatppuccinMocha::LAVENDER) |
| 774 | 792 | .small(), |
| 775 | 793 | ); |
| 776 | 794 | ui.add_space(4.0); |
| 777 | - let keys: Vec<(&String, &serde_json::Value)> = map.iter().collect(); | |
| 778 | 795 | egui::ScrollArea::vertical() |
| 779 | 796 | .id_salt("browser_preview") |
| 780 | 797 | .auto_shrink(false) |
| @@ -813,24 +830,32 @@ impl BrowserView { | ||
| 813 | 830 | }); |
| 814 | 831 | } |
| 815 | 832 | 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(); | |
| 816 | 837 | ui.label( |
| 817 | 838 | RichText::new(format!( |
| 818 | - "{} Array — {} items", | |
| 839 | + "{} Array — {} items{}", | |
| 819 | 840 | 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 | + }, | |
| 821 | 847 | )) |
| 822 | 848 | .color(CatppuccinMocha::YELLOW) |
| 823 | 849 | .small(), |
| 824 | 850 | ); |
| 825 | 851 | ui.add_space(4.0); |
| 826 | - let show_count = arr.len().min(200); | |
| 827 | 852 | egui::ScrollArea::vertical() |
| 828 | 853 | .id_salt("browser_preview") |
| 829 | 854 | .auto_shrink(false) |
| 830 | 855 | .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]; | |
| 834 | 859 | let (icon, color) = type_icon_color(v); |
| 835 | 860 | let preview = value_preview(v); |
| 836 | 861 | ui.horizontal(|ui| { |
+56-51src/widgets/miller.rs
| @@ -106,97 +106,102 @@ pub struct MillerFilterResponse { | ||
| 106 | 106 | pub prev: bool, |
| 107 | 107 | /// Enter pressed — navigate into selected entry |
| 108 | 108 | pub accept: bool, |
| 109 | - /// Escape pressed — filter closed | |
| 110 | - pub closed: bool, | |
| 111 | 109 | } |
| 112 | 110 | |
| 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. | |
| 114 | 113 | pub struct MillerFilter { |
| 115 | - pub active: bool, | |
| 116 | 114 | pub query: String, |
| 115 | + id: &'static str, | |
| 116 | + focus_next: bool, | |
| 117 | 117 | } |
| 118 | 118 | |
| 119 | 119 | impl MillerFilter { |
| 120 | - pub fn new() -> Self { | |
| 120 | + pub fn new(id: &'static str) -> Self { | |
| 121 | 121 | Self { |
| 122 | - active: false, | |
| 123 | 122 | query: String::new(), |
| 123 | + id, | |
| 124 | + focus_next: false, | |
| 124 | 125 | } |
| 125 | 126 | } |
| 126 | 127 | |
| 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)) | |
| 137 | 137 | } |
| 138 | 138 | |
| 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 { | |
| 141 | 142 | let mut resp = MillerFilterResponse { |
| 142 | 143 | next: false, |
| 143 | 144 | prev: false, |
| 144 | 145 | accept: false, |
| 145 | - closed: false, | |
| 146 | 146 | }; |
| 147 | 147 | |
| 148 | - if !self.active { | |
| 149 | - return resp; | |
| 150 | - } | |
| 151 | - | |
| 152 | 148 | ui.horizontal(|ui| { |
| 153 | 149 | ui.label( |
| 154 | 150 | 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 | + }) | |
| 156 | 156 | .size(12.0), |
| 157 | 157 | ); |
| 158 | + | |
| 159 | + let id = egui::Id::new(self.id); | |
| 158 | 160 | let r = ui.add( |
| 159 | 161 | egui::TextEdit::singleline(&mut self.query) |
| 160 | - .id(egui::Id::new("miller_filter_input")) | |
| 162 | + .id(id) | |
| 161 | 163 | .font(egui::FontId::monospace(12.0)) |
| 162 | 164 | .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 | + ), | |
| 164 | 171 | ); |
| 165 | - r.request_focus(); | |
| 166 | 172 | |
| 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; | |
| 176 | 176 | } |
| 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 | + } | |
| 180 | 195 | } |
| 181 | 196 | }); |
| 182 | 197 | ui.add_space(2.0); |
| 183 | 198 | |
| 184 | - if resp.closed { | |
| 185 | - self.active = false; | |
| 186 | - self.query.clear(); | |
| 187 | - } | |
| 188 | - | |
| 189 | 199 | resp |
| 190 | 200 | } |
| 191 | 201 | |
| 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). | |
| 198 | 203 | pub fn matches(&self, label: &str) -> bool { |
| 199 | - if !self.active || self.query.is_empty() { | |
| 204 | + if self.query.is_empty() { | |
| 200 | 205 | return true; |
| 201 | 206 | } |
| 202 | 207 | fuzzy_matches(&self.query, label) |