From acb0d71f23b6604ef15d482aeb55b44ca4e29b55 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:36:19 -0500 Subject: [PATCH 1/2] Add configurable query results grid layout --- src/gui/mod.rs | 323 ++++++++++++++++++++++++++++++++++++++++- src/plugin.rs | 3 + src/settings.rs | 65 +++++++++ src/settings_editor.rs | 92 +++++++++++- 4 files changed, 476 insertions(+), 7 deletions(-) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 4f763771..a1d85bf3 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -75,6 +75,7 @@ pub use volume_dialog::VolumeDialog; use crate::actions::folders; use crate::actions::{load_actions, Action}; use crate::actions_editor::ActionsEditor; +use crate::common::query::split_action_filters; use crate::dashboard::config::DashboardConfig; use crate::dashboard::widgets::{WidgetRegistry, WidgetSettingsContext}; use crate::dashboard::{ @@ -86,11 +87,11 @@ use crate::indexer; use crate::launcher::launch_action; use crate::mouse_gestures::db::{load_gestures, save_gestures, GESTURES_FILE}; use crate::mouse_gestures::selection::{GestureFocusArgs, GestureToggleArgs}; -use crate::plugin::PluginManager; +use crate::plugin::{PluginManager, CAP_FORCE_LIST_RESULTS, CAP_GRID_RESULTS_COMPATIBLE}; use crate::plugin_editor::PluginEditor; use crate::plugins::note::{NoteExternalOpen, NotePluginSettings}; use crate::plugins::snippets::{remove_snippet, SNIPPETS_FILE}; -use crate::settings::Settings; +use crate::settings::{QueryResultsLayoutSettings, Settings}; use crate::settings_editor::SettingsEditor; use crate::toast_log::{append_toast_log, TOAST_LOG_FILE}; use crate::usage::{self, USAGE_FILE}; @@ -512,6 +513,8 @@ pub struct LauncherApp { pub fuzzy_weight: f32, pub usage_weight: f32, pub page_jump: usize, + pub query_results_layout: QueryResultsLayoutSettings, + resolved_grid_layout: bool, pub note_panel_default_size: (f32, f32), pub note_save_on_close: bool, pub note_always_overwrite: bool, @@ -1060,6 +1063,7 @@ impl LauncherApp { if let Some(v) = show_dashboard_diagnostics { self.show_dashboard_diagnostics = v; } + self.recompute_query_results_layout(); crate::plugins::mouse_gestures::sync_enabled_plugins(self.enabled_plugins.as_ref()); } @@ -1431,6 +1435,8 @@ impl LauncherApp { fuzzy_weight: settings.fuzzy_weight, usage_weight: settings.usage_weight, page_jump: settings.page_jump, + query_results_layout: settings.query_results_layout.clone(), + resolved_grid_layout: false, note_panel_default_size: settings.note_panel_default_size, note_save_on_close: settings.note_save_on_close, note_always_overwrite: settings.note_always_overwrite, @@ -1518,6 +1524,7 @@ impl LauncherApp { app.rebuild_completion_index_now(); app.search(); crate::plugins::mouse_gestures::sync_enabled_plugins(app.enabled_plugins.as_ref()); + app.recompute_query_results_layout(); app } @@ -1546,6 +1553,7 @@ impl LauncherApp { } self.results = res; self.selected = None; + self.recompute_query_results_layout(); return; } @@ -1582,6 +1590,7 @@ impl LauncherApp { self.last_search_query = self.query.clone(); self.last_results_valid = true; self.update_suggestions(); + self.recompute_query_results_layout(); } fn search_actions(&self, query: &str, query_lc: &str) -> Vec<(Action, f32)> { @@ -1787,14 +1796,105 @@ impl LauncherApp { false } + fn should_use_grid_layout(&self) -> bool { + if !self.query_results_layout.enabled { + return false; + } + + let rows = self.query_results_layout.rows.max(1); + let cols = self.query_results_layout.cols.max(1); + if rows == 0 || cols == 0 { + return false; + } + + let trimmed = self.query.trim(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case(APP_PREFIX) { + return false; + } + if trimmed + .to_ascii_lowercase() + .starts_with(&format!("{} ", APP_PREFIX)) + { + return false; + } + + let (filtered_query, _) = split_action_filters(trimmed); + let query_head = filtered_query + .split_whitespace() + .next() + .map(str::to_ascii_lowercase); + + let mut matched_plugins = Vec::new(); + for plugin in self.plugins.iter() { + if let Some(enabled) = self.enabled_plugins.as_ref() { + if !enabled.contains(plugin.name()) { + continue; + } + } + if !plugin.always_search() { + let prefixes = plugin.query_prefixes(); + if !prefixes.is_empty() { + let Some(head) = query_head.as_deref() else { + continue; + }; + if !prefixes + .iter() + .any(|prefix| prefix.eq_ignore_ascii_case(head)) + { + continue; + } + } + } + matched_plugins.push(plugin.as_ref()); + } + + if matched_plugins.len() != 1 { + return false; + } + + let plugin = matched_plugins[0]; + if self + .query_results_layout + .plugin_opt_out + .iter() + .any(|name| name.eq_ignore_ascii_case(plugin.name())) + { + return false; + } + + if self.query_results_layout.respect_plugin_capability { + let capabilities = plugin.capabilities(); + if capabilities.contains(&CAP_FORCE_LIST_RESULTS) { + return false; + } + if !capabilities.contains(&CAP_GRID_RESULTS_COMPATIBLE) { + return false; + } + } + + true + } + + pub fn recompute_query_results_layout(&mut self) { + self.resolved_grid_layout = self.should_use_grid_layout(); + } + /// Handle a keyboard navigation key. Returns the index of a selected /// action when `Enter` is pressed and a selection is available. pub fn handle_key(&mut self, key: egui::Key) -> Option { + let cols = self.query_results_layout.cols.max(1); + let move_to = |current: usize, delta: isize, max: usize| -> usize { + current.saturating_add_signed(delta).min(max) + }; + match key { - egui::Key::ArrowDown => { + egui::Key::ArrowDown | egui::Key::Num2 => { if !self.results.is_empty() { let max = self.results.len() - 1; self.selected = match self.selected { + Some(i) if self.resolved_grid_layout => { + Some(move_to(i, cols as isize, max)) + } Some(i) if i < max => Some(i + 1), Some(i) => Some(i), None => Some(0), @@ -1802,10 +1902,13 @@ impl LauncherApp { } None } - egui::Key::ArrowUp => { + egui::Key::ArrowUp | egui::Key::Num8 => { if !self.results.is_empty() { let max = self.results.len() - 1; self.selected = match self.selected { + Some(i) if self.resolved_grid_layout => { + Some(move_to(i, -(cols as isize), max)) + } Some(i) if i > 0 => Some(i - 1), Some(i) => Some(i.min(max)), None => Some(0), @@ -1813,6 +1916,27 @@ impl LauncherApp { } None } + egui::Key::ArrowRight | egui::Key::Num6 => { + if self.resolved_grid_layout && !self.results.is_empty() { + let max = self.results.len() - 1; + self.selected = match self.selected { + Some(i) if i < max => Some(i + 1), + Some(i) => Some(i), + None => Some(0), + }; + } + None + } + egui::Key::ArrowLeft | egui::Key::Num4 => { + if self.resolved_grid_layout && !self.results.is_empty() { + self.selected = match self.selected { + Some(i) if i > 0 => Some(i - 1), + Some(i) => Some(i), + None => Some(0), + }; + } + None + } egui::Key::PageDown => { if !self.results.is_empty() { let max = self.results.len() - 1; @@ -3932,6 +4056,30 @@ impl eframe::App for LauncherApp { if ctx.input(|i| i.key_pressed(egui::Key::PageUp)) { self.handle_key(egui::Key::PageUp); } + if ctx.input(|i| i.key_pressed(egui::Key::ArrowLeft)) { + self.handle_key(egui::Key::ArrowLeft); + } + + if ctx.input(|i| i.key_pressed(egui::Key::ArrowRight)) { + self.handle_key(egui::Key::ArrowRight); + } + + if ctx.input(|i| i.key_pressed(egui::Key::Num8)) { + self.handle_key(egui::Key::Num8); + } + + if ctx.input(|i| i.key_pressed(egui::Key::Num2)) { + self.handle_key(egui::Key::Num2); + } + + if ctx.input(|i| i.key_pressed(egui::Key::Num4)) { + self.handle_key(egui::Key::Num4); + } + + if ctx.input(|i| i.key_pressed(egui::Key::Num6)) { + self.handle_key(egui::Key::Num6); + } + let tab = ctx.input(|i| i.key_pressed(egui::Key::Tab)); let enter = ctx.input(|i| i.key_pressed(egui::Key::Enter)); @@ -4031,8 +4179,41 @@ impl eframe::App for LauncherApp { .and_then(|m| m.get("folders")) .map(|caps| caps.contains(&"show_full_path".to_string())) .unwrap_or(false); - for idx in 0..self.results.len() { - let a = self.results[idx].clone(); + if self.resolved_grid_layout { + let cols = self.query_results_layout.cols.max(1); + egui::Grid::new("query_results_grid") + .num_columns(cols) + .spacing([8.0, 6.0]) + .show(ui, |ui| { + for idx in 0..self.results.len() { + let action = self.results[idx].clone(); + let text = format!("{}\n{}", action.label, action.desc); + let resp = ui.add_sized( + [ui.available_width().max(180.0), 44.0], + egui::SelectableLabel::new( + self.selected == Some(idx), + text, + ), + ); + if self.selected == Some(idx) { + resp.scroll_to_me(Some(egui::Align::Center)); + } + if resp.clicked() { + self.selected = Some(idx); + self.activate_action( + action, + None, + ActivationSource::Click, + ); + } + if (idx + 1) % cols == 0 { + ui.end_row(); + } + } + }); + } else { + for idx in 0..self.results.len() { + let a = self.results[idx].clone(); let aliased = self .folder_aliases .get(&a.action) @@ -4506,6 +4687,7 @@ impl eframe::App for LauncherApp { self.activate_action(a.clone(), None, ActivationSource::Click); } } + } if refresh { self.last_results_valid = false; self.search(); @@ -5100,6 +5282,40 @@ mod tests { ) } + #[derive(Clone)] + struct TestPlugin { + name: &'static str, + caps: Vec<&'static str>, + prefixes: Vec<&'static str>, + } + + impl crate::plugin::Plugin for TestPlugin { + fn search(&self, _query: &str) -> Vec { + vec![Action { + label: "plugin".into(), + desc: "test".into(), + action: "plugin:test".into(), + args: None, + }] + } + + fn name(&self) -> &str { + self.name + } + + fn description(&self) -> &str { + "test" + } + + fn capabilities(&self) -> &[&str] { + &self.caps + } + + fn query_prefixes(&self) -> &[&str] { + &self.prefixes + } + } + #[test] fn action_search_remains_case_insensitive_with_cached_aliases() { let ctx = egui::Context::default(); @@ -5749,4 +5965,99 @@ mod tests { assert_eq!(app.static_pos, None); assert_eq!(app.static_size, None); } + + #[test] + fn handle_key_grid_navigation_arrows_and_numpad() { + let ctx = egui::Context::default(); + let mut app = new_app(&ctx); + app.query_results_layout.enabled = true; + app.query_results_layout.cols = 3; + app.query_results_layout.rows = 2; + app.resolved_grid_layout = true; + app.results = (0..8) + .map(|i| Action { + label: format!("A{i}"), + desc: "d".into(), + action: format!("act:{i}"), + args: None, + }) + .collect(); + + app.selected = Some(4); + app.handle_key(egui::Key::ArrowRight); + assert_eq!(app.selected, Some(5)); + app.handle_key(egui::Key::ArrowLeft); + assert_eq!(app.selected, Some(4)); + app.handle_key(egui::Key::ArrowUp); + assert_eq!(app.selected, Some(1)); + app.handle_key(egui::Key::ArrowDown); + assert_eq!(app.selected, Some(4)); + + app.handle_key(egui::Key::Num6); + assert_eq!(app.selected, Some(5)); + app.handle_key(egui::Key::Num4); + assert_eq!(app.selected, Some(4)); + app.handle_key(egui::Key::Num8); + assert_eq!(app.selected, Some(1)); + app.handle_key(egui::Key::Num2); + assert_eq!(app.selected, Some(4)); + + app.selected = Some(7); + app.handle_key(egui::Key::ArrowDown); + assert_eq!(app.selected, Some(7)); + } + + #[test] + fn handle_key_list_mode_remains_compatible() { + let ctx = egui::Context::default(); + let mut app = new_app(&ctx); + app.resolved_grid_layout = false; + app.results = (0..4) + .map(|i| Action { + label: format!("A{i}"), + desc: "d".into(), + action: format!("act:{i}"), + args: None, + }) + .collect(); + + app.selected = Some(1); + app.handle_key(egui::Key::ArrowDown); + assert_eq!(app.selected, Some(2)); + app.handle_key(egui::Key::ArrowUp); + assert_eq!(app.selected, Some(1)); + app.handle_key(egui::Key::ArrowRight); + assert_eq!(app.selected, Some(1)); + } + + #[test] + fn grid_layout_selection_respects_plugin_capability_and_opt_out() { + let ctx = egui::Context::default(); + let mut app = new_app(&ctx); + app.query = "note hi".into(); + app.query_results_layout.enabled = true; + app.query_results_layout.respect_plugin_capability = true; + + app.plugins.register(Box::new(TestPlugin { + name: "note", + caps: vec![CAP_GRID_RESULTS_COMPATIBLE], + prefixes: vec!["note"], + })); + app.recompute_query_results_layout(); + assert!(app.resolved_grid_layout); + + app.query_results_layout.plugin_opt_out = vec!["note".into()]; + app.recompute_query_results_layout(); + assert!(!app.resolved_grid_layout); + + app.query_results_layout.plugin_opt_out.clear(); + app.plugins.clear_plugins(); + app.plugins.register(Box::new(TestPlugin { + name: "note", + caps: vec![CAP_FORCE_LIST_RESULTS], + prefixes: vec!["note"], + })); + app.recompute_query_results_layout(); + assert!(!app.resolved_grid_layout); + } } diff --git a/src/plugin.rs b/src/plugin.rs index 71bd252e..ea6361a8 100644 --- a/src/plugin.rs +++ b/src/plugin.rs @@ -59,6 +59,9 @@ use serde_json::Value; use std::collections::HashSet; use std::sync::Arc; +pub const CAP_GRID_RESULTS_COMPATIBLE: &str = "grid_results_compatible"; +pub const CAP_FORCE_LIST_RESULTS: &str = "force_list_results"; + pub trait Plugin: Send + Sync { /// Return actions based on the query string fn search(&self, query: &str) -> Vec; diff --git a/src/settings.rs b/src/settings.rs index 6ead039e..04b3b200 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -53,6 +53,32 @@ pub struct DashboardSettings { pub show_when_query_empty: bool, } +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct QueryResultsLayoutSettings { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_query_results_layout_rows")] + pub rows: usize, + #[serde(default = "default_query_results_layout_cols")] + pub cols: usize, + #[serde(default = "default_true")] + pub respect_plugin_capability: bool, + #[serde(default)] + pub plugin_opt_out: Vec, +} + +impl Default for QueryResultsLayoutSettings { + fn default() -> Self { + Self { + enabled: false, + rows: default_query_results_layout_rows(), + cols: default_query_results_layout_cols(), + respect_plugin_capability: true, + plugin_opt_out: Vec::new(), + } + } +} + fn default_note_graph_max_nodes() -> usize { 220 } @@ -442,6 +468,8 @@ pub struct Settings { pub theme: ThemeSettings, #[serde(default)] pub note_graph: NoteGraphSettings, + #[serde(default)] + pub query_results_layout: QueryResultsLayoutSettings, } static SETTINGS_PATH: OnceCell = OnceCell::new(); @@ -533,6 +561,14 @@ fn default_note_more_limit() -> usize { 5 } +fn default_query_results_layout_rows() -> usize { + 3 +} + +fn default_query_results_layout_cols() -> usize { + 2 +} + fn default_log_path() -> PathBuf { std::env::current_exe() .ok() @@ -608,6 +644,7 @@ impl Default for Settings { dashboard: DashboardSettings::default(), theme: ThemeSettings::default(), note_graph: NoteGraphSettings::default(), + query_results_layout: QueryResultsLayoutSettings::default(), } } } @@ -688,3 +725,31 @@ impl Settings { } } } + +#[cfg(test)] +mod tests { + use super::{QueryResultsLayoutSettings, Settings}; + + #[test] + fn query_results_layout_defaults_are_backward_compatible() { + let parsed: Settings = serde_json::from_str("{}").expect("settings should deserialize"); + assert_eq!( + parsed.query_results_layout, + QueryResultsLayoutSettings::default() + ); + } + + #[test] + fn query_results_layout_round_trip_serialization() { + let mut settings = Settings::default(); + settings.query_results_layout.enabled = true; + settings.query_results_layout.rows = 4; + settings.query_results_layout.cols = 5; + settings.query_results_layout.respect_plugin_capability = false; + settings.query_results_layout.plugin_opt_out = vec!["note".into(), "todo".into()]; + + let json = serde_json::to_string(&settings).expect("serialize settings"); + let restored: Settings = serde_json::from_str(&json).expect("deserialize settings"); + assert_eq!(restored.query_results_layout, settings.query_results_layout); + } +} diff --git a/src/settings_editor.rs b/src/settings_editor.rs index 7b125f63..7df415c5 100644 --- a/src/settings_editor.rs +++ b/src/settings_editor.rs @@ -3,7 +3,7 @@ use crate::gui::LauncherApp; use crate::hotkey::parse_hotkey; use crate::plugins::note::{NoteExternalOpen, NotePluginSettings}; use crate::plugins::screenshot::ScreenshotPluginSettings; -use crate::settings::Settings; +use crate::settings::{QueryResultsLayoutSettings, Settings}; use eframe::egui; use egui_toast::{Toast, ToastKind, ToastOptions}; use std::sync::Arc; @@ -41,6 +41,11 @@ pub struct SettingsEditor { fuzzy_weight: f32, usage_weight: f32, page_jump: usize, + query_results_layout_enabled: bool, + query_results_layout_rows: usize, + query_results_layout_cols: usize, + query_results_layout_respect_plugin_capability: bool, + query_results_layout_plugin_opt_out: String, follow_mouse: bool, static_enabled: bool, static_x: i32, @@ -164,6 +169,16 @@ impl SettingsEditor { fuzzy_weight: settings.fuzzy_weight, usage_weight: settings.usage_weight, page_jump: settings.page_jump, + query_results_layout_enabled: settings.query_results_layout.enabled, + query_results_layout_rows: settings.query_results_layout.rows.max(1), + query_results_layout_cols: settings.query_results_layout.cols.max(1), + query_results_layout_respect_plugin_capability: settings + .query_results_layout + .respect_plugin_capability, + query_results_layout_plugin_opt_out: settings + .query_results_layout + .plugin_opt_out + .join(", "), follow_mouse, static_enabled, static_x: settings.static_pos.unwrap_or((0, 0)).0, @@ -329,6 +344,19 @@ impl SettingsEditor { fuzzy_weight: self.fuzzy_weight, usage_weight: self.usage_weight, page_jump: self.page_jump, + query_results_layout: QueryResultsLayoutSettings { + enabled: self.query_results_layout_enabled, + rows: self.query_results_layout_rows.max(1), + cols: self.query_results_layout_cols.max(1), + respect_plugin_capability: self.query_results_layout_respect_plugin_capability, + plugin_opt_out: self + .query_results_layout_plugin_opt_out + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned) + .collect(), + }, follow_mouse: self.follow_mouse, static_location_enabled, static_pos, @@ -525,6 +553,39 @@ impl SettingsEditor { ); }); + ui.checkbox( + &mut self.query_results_layout_enabled, + "Display results in grid layout", + ); + ui.add_enabled_ui(self.query_results_layout_enabled, |ui| { + ui.horizontal(|ui| { + ui.label("Grid rows"); + ui.add( + egui::DragValue::new(&mut self.query_results_layout_rows) + .clamp_range(1..=100) + .speed(1), + ); + ui.label("Columns"); + ui.add( + egui::DragValue::new(&mut self.query_results_layout_cols) + .clamp_range(1..=100) + .speed(1), + ); + }); + self.query_results_layout_rows = self.query_results_layout_rows.max(1); + self.query_results_layout_cols = self.query_results_layout_cols.max(1); + ui.checkbox( + &mut self.query_results_layout_respect_plugin_capability, + "Respect plugin list/grid capability", + ); + ui.horizontal(|ui| { + ui.label("Force list for plugins (comma separated)"); + ui.text_edit_singleline( + &mut self.query_results_layout_plugin_opt_out, + ); + }); + }); + ui.horizontal(|ui| { ui.label("Off-screen X"); ui.add(egui::DragValue::new(&mut self.offscreen_x)); @@ -857,6 +918,8 @@ impl SettingsEditor { app.clear_query_after_run = new_settings.clear_query_after_run; app.require_confirm_destructive = new_settings.require_confirm_destructive; app.query_autocomplete = new_settings.query_autocomplete; + app.query_results_layout = new_settings.query_results_layout.clone(); + app.recompute_query_results_layout(); app.net_refresh = new_settings.net_refresh; app.net_unit = new_settings.net_unit; app.screenshot_dir = new_settings.screenshot_dir.clone(); @@ -998,4 +1061,31 @@ mod tests { assert_eq!(saved.static_pos, None); assert_eq!(saved.static_size, None); } + + #[test] + fn query_results_layout_round_trip_editor_conversion() { + let mut initial = Settings::default(); + initial.query_results_layout.enabled = true; + initial.query_results_layout.rows = 6; + initial.query_results_layout.cols = 4; + initial.query_results_layout.respect_plugin_capability = false; + initial.query_results_layout.plugin_opt_out = vec!["note".into(), "todo".into()]; + + let editor = SettingsEditor::new(&initial); + let saved = editor.to_settings(&initial); + assert_eq!(saved.query_results_layout, initial.query_results_layout); + } + + #[test] + fn query_results_layout_clamps_rows_and_cols_to_one() { + let initial = Settings::default(); + let mut editor = SettingsEditor::new(&initial); + editor.query_results_layout_enabled = true; + editor.query_results_layout_rows = 0; + editor.query_results_layout_cols = 0; + + let saved = editor.to_settings(&initial); + assert_eq!(saved.query_results_layout.rows, 1); + assert_eq!(saved.query_results_layout.cols, 1); + } } From b34a763258ad7b9076b37e6c2a98905598544015 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:11:03 -0500 Subject: [PATCH 2/2] Fix grid results layout activation and column sizing --- src/gui/mod.rs | 85 +++++++++++++++++++++++++++----------------------- 1 file changed, 46 insertions(+), 39 deletions(-) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index a1d85bf3..a6441078 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1801,20 +1801,13 @@ impl LauncherApp { return false; } - let rows = self.query_results_layout.rows.max(1); - let cols = self.query_results_layout.cols.max(1); - if rows == 0 || cols == 0 { - return false; + // Global grid mode when capability gating is disabled. + if !self.query_results_layout.respect_plugin_capability { + return true; } let trimmed = self.query.trim(); - if trimmed.is_empty() || trimmed.eq_ignore_ascii_case(APP_PREFIX) { - return false; - } - if trimmed - .to_ascii_lowercase() - .starts_with(&format!("{} ", APP_PREFIX)) - { + if trimmed.is_empty() { return false; } @@ -1824,35 +1817,40 @@ impl LauncherApp { .next() .map(str::to_ascii_lowercase); - let mut matched_plugins = Vec::new(); + let mut prefixed_matches = Vec::new(); for plugin in self.plugins.iter() { if let Some(enabled) = self.enabled_plugins.as_ref() { if !enabled.contains(plugin.name()) { continue; } } - if !plugin.always_search() { - let prefixes = plugin.query_prefixes(); - if !prefixes.is_empty() { - let Some(head) = query_head.as_deref() else { - continue; - }; - if !prefixes - .iter() - .any(|prefix| prefix.eq_ignore_ascii_case(head)) - { - continue; - } - } + + let prefixes = plugin.query_prefixes(); + if prefixes.is_empty() { + continue; + } + let Some(head) = query_head.as_deref() else { + continue; + }; + if prefixes + .iter() + .any(|prefix| prefix.eq_ignore_ascii_case(head)) + { + prefixed_matches.push(plugin.as_ref()); } - matched_plugins.push(plugin.as_ref()); } - if matched_plugins.len() != 1 { + // No plugin-prefixed context: use the configured grid layout. + if prefixed_matches.is_empty() { + return true; + } + + // Ambiguous/mixed plugin context: safely fall back to list mode. + if prefixed_matches.len() != 1 { return false; } - let plugin = matched_plugins[0]; + let plugin = prefixed_matches[0]; if self .query_results_layout .plugin_opt_out @@ -1862,17 +1860,11 @@ impl LauncherApp { return false; } - if self.query_results_layout.respect_plugin_capability { - let capabilities = plugin.capabilities(); - if capabilities.contains(&CAP_FORCE_LIST_RESULTS) { - return false; - } - if !capabilities.contains(&CAP_GRID_RESULTS_COMPATIBLE) { - return false; - } + let capabilities = plugin.capabilities(); + if capabilities.contains(&CAP_FORCE_LIST_RESULTS) { + return false; } - - true + capabilities.contains(&CAP_GRID_RESULTS_COMPATIBLE) } pub fn recompute_query_results_layout(&mut self) { @@ -4181,6 +4173,9 @@ impl eframe::App for LauncherApp { .unwrap_or(false); if self.resolved_grid_layout { let cols = self.query_results_layout.cols.max(1); + let col_width = ((ui.available_width() - ((cols.saturating_sub(1)) as f32 * 8.0)) + / cols as f32) + .max(160.0); egui::Grid::new("query_results_grid") .num_columns(cols) .spacing([8.0, 6.0]) @@ -4189,7 +4184,7 @@ impl eframe::App for LauncherApp { let action = self.results[idx].clone(); let text = format!("{}\n{}", action.label, action.desc); let resp = ui.add_sized( - [ui.available_width().max(180.0), 44.0], + [col_width, 44.0], egui::SelectableLabel::new( self.selected == Some(idx), text, @@ -6030,6 +6025,18 @@ mod tests { assert_eq!(app.selected, Some(1)); } + #[test] + fn grid_layout_defaults_to_enabled_for_non_prefixed_queries() { + let ctx = egui::Context::default(); + let mut app = new_app(&ctx); + app.query = "hello world".into(); + app.query_results_layout.enabled = true; + app.query_results_layout.respect_plugin_capability = true; + + app.recompute_query_results_layout(); + assert!(app.resolved_grid_layout); + } + #[test] fn grid_layout_selection_respects_plugin_capability_and_opt_out() { let ctx = egui::Context::default();