diff --git a/Cargo.toml b/Cargo.toml index 652f7bc..50dcb5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = [ ] [workspace.package] -version = "0.9.2" +version = "0.9.3" edition = "2021" license = "GPL-3.0-or-later" authors = ["Jeremy "] diff --git a/crates/quicksearch-gui/src/app.rs b/crates/quicksearch-gui/src/app.rs index 6d1c659..a8837c0 100644 --- a/crates/quicksearch-gui/src/app.rs +++ b/crates/quicksearch-gui/src/app.rs @@ -1119,6 +1119,10 @@ impl QuickSearchApp { pub(crate) fn capture_focus_search(&mut self) { self.search.capture_focus(); } + + pub(crate) fn capture_match_cell(&self, n: usize) -> Option { + self.search.capture_match_cell(n) + } } /// Overwrite the fields a config draft must never carry back. diff --git a/crates/quicksearch-gui/src/capture.rs b/crates/quicksearch-gui/src/capture.rs index fccbd34..9d609aa 100644 --- a/crates/quicksearch-gui/src/capture.rs +++ b/crates/quicksearch-gui/src/capture.rs @@ -46,6 +46,9 @@ use crate::app::{QuickSearchApp, Tab}; /// clear_query | focus_search /// window INT INT # resize to width x height, in the same /// # logical points as the startup size +/// hover_match INT # pin the pointer over the Nth visible +/// # Match cell (0-based) until hover_off +/// hover_off # release the pinned pointer /// tab (search|manage|duplicates|logs|help) /// wait_index_running [max INT] # caps in ms; a capped wait cannot fail /// wait_index_idle [max INT] @@ -62,6 +65,8 @@ pub(crate) enum Cmd { ClearQuery, FocusSearch, Window { w: f32, h: f32 }, + HoverMatch(usize), + HoverOff, Tab(Tab), WaitIndexRunning { max_ms: Option }, WaitIndexIdle { max_ms: Option }, @@ -198,6 +203,10 @@ fn parse_line(tokens: &[Token], line_no: usize) -> Result, ParseErro h: h as f32, } } + "hover_match" => Cmd::HoverMatch( + parse_int("row", next_word(rest, line_no, "row index")?, line_no)? as usize, + ), + "hover_off" => Cmd::HoverOff, "tab" => Cmd::Tab(match next_word(rest, line_no, "tab name")? { "search" => Tab::Search, "manage" => Tab::Manage, @@ -343,6 +352,17 @@ pub(crate) struct CaptureDriver { /// Screenshot in flight: requested, PNG not yet written. shot: Option, rec: Option, + /// Match-cell row the pointer is pinned to (`hover_match`), and the + /// on-screen position it resolved to on the last rendered frame. + hover: Option, + hover_pos: Option, + /// The position last injected. Kept separate from `hover_pos` because + /// injection must be edge-triggered: egui resets its pointer-stillness + /// clock on *every* `PointerMoved` event, moved or not, and tooltips + /// only appear once that clock outlives the tooltip delay. + hover_injected: Option, + /// One-shot `Event::PointerGone` injection, armed by `hover_off`. + pointer_gone_pending: bool, out_dir: PathBuf, /// Set by `quit`; the app drops the driver once it is. pub(crate) finished: bool, @@ -391,6 +411,10 @@ impl CaptureDriver { typing: None, shot: None, rec: None, + hover: None, + hover_pos: None, + hover_injected: None, + pointer_gone_pending: false, out_dir, finished: false, })) @@ -420,6 +444,13 @@ impl CaptureDriver { } } + // Resolve the pinned hover against what the last frame rendered: + // rows can move while results stream, and the tooltip should track + // the cell, not a stale point. + if let Some(n) = self.hover { + self.hover_pos = app.capture_match_cell(n).map(|r| r.center()); + } + let Some(cmd) = self.cmds.get(self.pc).cloned() else { self.quit(ctx); return; @@ -473,6 +504,16 @@ impl CaptureDriver { } Cmd::ClearQuery => app.capture_clear_query(), Cmd::FocusSearch => app.capture_focus_search(), + Cmd::HoverMatch(n) => { + self.hover = Some(*n); + self.hover_pos = None; // resolved from the next rendered frame + } + Cmd::HoverOff => { + self.hover = None; + self.hover_pos = None; + self.hover_injected = None; + self.pointer_gone_pending = true; + } Cmd::Window { w, h } => { // Scenario sizes use the same logical points as the startup // size in main.rs, so `window 1000 700` restores it exactly. @@ -517,8 +558,11 @@ impl CaptureDriver { match cmd { Cmd::WaitMs(ms) => elapsed >= Duration::from_millis(*ms), Cmd::Type { .. } => self.typing.is_none(), + // Done once the cell exists on screen and the pointer is on it. + Cmd::HoverMatch(_) => self.hover_pos.is_some(), Cmd::ClearQuery | Cmd::FocusSearch + | Cmd::HoverOff | Cmd::Window { .. } | Cmd::Tab(_) | Cmd::RecordStart(_) @@ -575,6 +619,31 @@ impl CaptureDriver { self.typing = None; } + if self.pointer_gone_pending { + self.pointer_gone_pending = false; + raw.events.push(egui::Event::PointerGone); + } + if let Some(pos) = self.hover_pos { + // Edge-triggered on purpose: egui resets its pointer-stillness + // clock on every `PointerMoved` event even at an unchanged + // position, and the tooltip appears only after that clock + // outlives the tooltip delay. Inject when the pin moves — or + // after a real OS pointer event, which would otherwise unpin us + // (appending after it means the pin wins the frame). + let foreign_pointer = raw.events.iter().any(|e| { + matches!( + e, + egui::Event::PointerMoved(_) + | egui::Event::PointerGone + | egui::Event::PointerButton { .. } + ) + }); + if foreign_pointer || self.hover_injected != Some(pos) { + raw.events.push(egui::Event::PointerMoved(pos)); + self.hover_injected = Some(pos); + } + } + // One pass over the incoming events harvests both kinds of // framebuffer readback: recording frames and still screenshots. for event in &raw.events { @@ -740,11 +809,14 @@ fn hard_timeout_ms(cmd: &Cmd) -> u64 { Cmd::Type { text, cps } => (text.chars().count() as f32 / cps * 1000.0) as u64 + 30_000, Cmd::ClearQuery | Cmd::FocusSearch + | Cmd::HoverOff | Cmd::Window { .. } | Cmd::Tab(_) | Cmd::RecordStart(_) | Cmd::RecordStop | Cmd::Quit => 10_000, + // Fails when the scenario asks for a row that never rendered. + Cmd::HoverMatch(_) => 10_000, Cmd::Screenshot(_) => 10_000, Cmd::WaitIndexRunning { .. } => 120_000, Cmd::WaitIndexIdle { .. } => 1_800_000, @@ -815,6 +887,8 @@ mod tests { clear_query focus_search window 500 350 + hover_match 2 + hover_off tab search tab manage tab duplicates @@ -846,6 +920,8 @@ mod tests { Cmd::ClearQuery, Cmd::FocusSearch, Cmd::Window { w: 500.0, h: 350.0 }, + Cmd::HoverMatch(2), + Cmd::HoverOff, Cmd::Tab(Tab::Search), Cmd::Tab(Tab::Manage), Cmd::Tab(Tab::Duplicates), @@ -943,6 +1019,7 @@ mod tests { assert!(parse_err("screenshot").msg.contains("missing")); assert!(parse_err("window").msg.contains("missing")); assert!(parse_err("window 500").msg.contains("missing")); + assert!(parse_err("hover_match").msg.contains("missing")); } #[test] diff --git a/crates/quicksearch-gui/src/search_tab.rs b/crates/quicksearch-gui/src/search_tab.rs index ebf191a..bc682b7 100644 --- a/crates/quicksearch-gui/src/search_tab.rs +++ b/crates/quicksearch-gui/src/search_tab.rs @@ -11,6 +11,11 @@ use quicksearch_core::snippet::Snippet; use crate::format::{fmt_elapsed, fmt_mtime, human_size}; use crate::platform; +/// Width of the query strip's status slot, in points. Wide enough for the +/// longest query time `fmt_elapsed` produces, and held whether the slot is +/// showing the spinner, a time or nothing, so the query box stays put. +const STATUS_SLOT_WIDTH: f32 = 52.0; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SortKey { Rank, @@ -111,6 +116,10 @@ pub struct SearchTab { focus_query: bool, /// Query syntax-highlight segments, cached per text. highlight: crate::query_highlight::HighlightCache, + /// Screen rects of the Match cells rendered last frame, in display + /// order — the capture driver's coordinate-free hover targets. + #[cfg(feature = "capture")] + pub(crate) capture_match_rects: Vec, } impl SearchTab { @@ -140,6 +149,8 @@ impl SearchTab { hovered_row: None, focus_query: true, highlight: Default::default(), + #[cfg(feature = "capture")] + capture_match_rects: Vec::new(), } } @@ -164,6 +175,13 @@ impl SearchTab { self.focus_query = true; } + /// Screen rect of the Nth visible Match cell from the last rendered + /// frame, if that many are on screen. + #[cfg(feature = "capture")] + pub(crate) fn capture_match_cell(&self, n: usize) -> Option { + self.capture_match_rects.get(n).copied() + } + /// A new search was submitted under `generation`. The previous /// results stay on screen (fading out); the new ones stage until the /// fade reaches zero. @@ -337,55 +355,64 @@ impl SearchTab { let mut actions = SearchActions::default(); // --- Query strip ------------------------------------------------- + // Laid out right to left: help, fuzzy and the status slot pin to the + // right edge, and the query box takes whatever is left. The status + // slot keeps a fixed width whether it holds the spinner, the query + // time or nothing at all, so the box never resizes as you type. ui.horizontal(|ui| { - let show_elapsed = - !self.running && self.elapsed.is_some() && !self.query.trim().is_empty(); - let slot_room = if self.running { - 24.0 - } else if show_elapsed { - 60.0 - } else { - 0.0 - }; - let width = ui.available_width() - 170.0 - slot_room; - let highlight = &mut self.highlight; - let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, _wrap: f32| { - crate::query_highlight::galley(ui, highlight, buf.as_str()) - }; - let response = ui.add( - egui::TextEdit::singleline(&mut self.query) - .desired_width(width.max(120.0)) - .hint_text("Search names and contents… (type:Document regex:… budget*)") - .layouter(&mut layouter), - ); - if self.focus_query { - response.request_focus(); - self.focus_query = false; - } - if response.changed() { - self.pending_edit = Some(Instant::now()); - } - // One slot right of the box: spinner while searching, then the - // total wall time of all cascade passes once it lands. - if self.running { - ui.add(egui::Spinner::new().size(16.0)); - } else if show_elapsed { - if let Some(elapsed) = self.elapsed { - ui.label(egui::RichText::new(fmt_elapsed(elapsed)).small().weak()) - .on_hover_text("Time to run all search passes"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("?").on_hover_text("Query syntax help").clicked() { + self.help_open = !self.help_open; } - } - if ui - .checkbox(&mut self.fuzzy, "Fuzzy") - .on_hover_text("Also run fuzzy filename and full-text passes (slower)") - .changed() - { - actions.save_fuzzy_default = Some(self.fuzzy); - actions.rerun = true; - } - if ui.button("?").on_hover_text("Query syntax help").clicked() { - self.help_open = !self.help_open; - } + if ui + .checkbox(&mut self.fuzzy, "Fuzzy") + .on_hover_text("Also run fuzzy filename and full-text passes (slower)") + .changed() + { + actions.save_fuzzy_default = Some(self.fuzzy); + actions.rerun = true; + } + // Spinner while searching, then the total wall time of all + // cascade passes once it lands. + let show_elapsed = + !self.running && self.elapsed.is_some() && !self.query.trim().is_empty(); + ui.allocate_ui_with_layout( + egui::vec2(STATUS_SLOT_WIDTH, ui.spacing().interact_size.y), + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + // The child shrinks to its content when it finishes, + // so hold the width from the inside — otherwise an + // empty slot would give its space back to the box. + ui.set_min_width(STATUS_SLOT_WIDTH); + if self.running { + ui.add(egui::Spinner::new().size(16.0)); + } else if show_elapsed { + if let Some(elapsed) = self.elapsed { + ui.label(egui::RichText::new(fmt_elapsed(elapsed)).small().weak()) + .on_hover_text("Time to run all search passes"); + } + } + }, + ); + let width = ui.available_width(); + let highlight = &mut self.highlight; + let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, _wrap: f32| { + crate::query_highlight::galley(ui, highlight, buf.as_str()) + }; + let response = ui.add( + egui::TextEdit::singleline(&mut self.query) + .desired_width(width.max(120.0)) + .hint_text("Search names and contents… (type:Document regex:… budget*)") + .layouter(&mut layouter), + ); + if self.focus_query { + response.request_focus(); + self.focus_query = false; + } + if response.changed() { + self.pending_edit = Some(Instant::now()); + } + }); }); // Session ignore chips. @@ -431,14 +458,14 @@ impl SearchTab { } // Result-set transitions pulse instead of strobing: the old table - // fades out over 0.25 s while the new hits stage, the sets swap at - // zero opacity, and the new table fades back in over 0.25 s. + // fades out over 0.15 s while the new hits stage, the sets swap at + // zero opacity, and the new table fades back in over 0.15 s. // `animate_value_with_time` keeps requesting repaints until the // value settles. let fade_target = if self.swap_pending { 0.0 } else { 1.0 }; let fade = ui.ctx() - .animate_value_with_time(egui::Id::new("qs-results-fade"), fade_target, 0.25); + .animate_value_with_time(egui::Id::new("qs-results-fade"), fade_target, 0.15); if self.swap_pending && fade <= 0.01 { self.results = std::mem::take(&mut self.staging); self.has_snippets = self.staging_has_snippets; @@ -476,6 +503,10 @@ impl SearchTab { // self` for selection and hover, so a field read would conflict — but // a plain local does not, and this runs every frame. let order = std::mem::take(&mut self.order); + // Same local-then-assign dance for the capture driver's hover + // targets: rebuilt every frame from what actually rendered. + #[cfg(feature = "capture")] + let mut capture_match_rects: Vec = Vec::new(); let table_scroll = ui .push_id("results", |ui| { @@ -565,6 +596,8 @@ impl SearchTab { ui.label(job); }); } + #[cfg(feature = "capture")] + capture_match_rects.push(response.rect); cell_responses.push(response); } }); @@ -649,6 +682,10 @@ impl SearchTab { self.order = order; crate::ui_util::more_below_hint(ui, &table_scroll); self.hovered_row = hovered_now; + #[cfg(feature = "capture")] + { + self.capture_match_rects = capture_match_rects; + } if let Some(ix) = open_ignore_dialog { let hit = &self.results[ix]; diff --git a/packaging/capture-scenario.txt b/packaging/capture-scenario.txt index d32f40c..ce2391d 100644 --- a/packaging/capture-scenario.txt +++ b/packaging/capture-scenario.txt @@ -30,12 +30,24 @@ window 1120 750 # compact clip: the smallest layout at # away, defeating the demo), at 1.5x wait_ms 800 # the resize lands asynchronously record_start search -type "fn main" cps 4 # a phrase that lives in file bodies, not - # names -- results stream in with content - # snippets in the Match column +# The query is typed in quick bursts with human hesitations; each pause +# outlives the debounce, so results visibly refine at every stop. The phrase +# lives in file bodies, not names -- results stream in with content snippets, +# and its ~130 matches stay under the display cap so the footer shows a real +# count. +type "race con" cps 10 +wait_ms 400 +type "dit" cps 7 +wait_ms 300 +type "ion" cps 10 wait_search_done max 8000 -wait_ms 2000 -record_stop +wait_ms 1000 +hover_match 2 # pin the pointer on the 3rd result's Match + # cell: the tooltip expands the snippet + # with surrounding file content +wait_ms 2500 # tooltip delay, then linger on it +record_stop # end the clip with the tooltip on screen +hover_off window 1400 980 # back to full size for the screenshots wait_ms 800