A couple minor improvements to results display and image capture.
Some checks failed
CI / linux (push) Successful in 2m38s
CI / windows-cross (push) Failing after 1m19s
CI / release (push) Has been skipped

This commit is contained in:
= 2026-08-05 00:12:56 -04:00
parent cf40196546
commit 1411224cc0
5 changed files with 186 additions and 56 deletions

View file

@ -6,7 +6,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.9.2" version = "0.9.3"
edition = "2021" edition = "2021"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
authors = ["Jeremy <jeremy@karsttech.com>"] authors = ["Jeremy <jeremy@karsttech.com>"]

View file

@ -1119,6 +1119,10 @@ impl QuickSearchApp {
pub(crate) fn capture_focus_search(&mut self) { pub(crate) fn capture_focus_search(&mut self) {
self.search.capture_focus(); self.search.capture_focus();
} }
pub(crate) fn capture_match_cell(&self, n: usize) -> Option<egui::Rect> {
self.search.capture_match_cell(n)
}
} }
/// Overwrite the fields a config draft must never carry back. /// Overwrite the fields a config draft must never carry back.

View file

@ -46,6 +46,9 @@ use crate::app::{QuickSearchApp, Tab};
/// clear_query | focus_search /// clear_query | focus_search
/// window INT INT # resize to width x height, in the same /// window INT INT # resize to width x height, in the same
/// # logical points as the startup size /// # 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) /// tab (search|manage|duplicates|logs|help)
/// wait_index_running [max INT] # caps in ms; a capped wait cannot fail /// wait_index_running [max INT] # caps in ms; a capped wait cannot fail
/// wait_index_idle [max INT] /// wait_index_idle [max INT]
@ -62,6 +65,8 @@ pub(crate) enum Cmd {
ClearQuery, ClearQuery,
FocusSearch, FocusSearch,
Window { w: f32, h: f32 }, Window { w: f32, h: f32 },
HoverMatch(usize),
HoverOff,
Tab(Tab), Tab(Tab),
WaitIndexRunning { max_ms: Option<u64> }, WaitIndexRunning { max_ms: Option<u64> },
WaitIndexIdle { max_ms: Option<u64> }, WaitIndexIdle { max_ms: Option<u64> },
@ -198,6 +203,10 @@ fn parse_line(tokens: &[Token], line_no: usize) -> Result<Option<Cmd>, ParseErro
h: h as f32, 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")? { "tab" => Cmd::Tab(match next_word(rest, line_no, "tab name")? {
"search" => Tab::Search, "search" => Tab::Search,
"manage" => Tab::Manage, "manage" => Tab::Manage,
@ -343,6 +352,17 @@ pub(crate) struct CaptureDriver {
/// Screenshot in flight: requested, PNG not yet written. /// Screenshot in flight: requested, PNG not yet written.
shot: Option<PathBuf>, shot: Option<PathBuf>,
rec: Option<Recorder>, rec: Option<Recorder>,
/// 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<usize>,
hover_pos: Option<egui::Pos2>,
/// 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<egui::Pos2>,
/// One-shot `Event::PointerGone` injection, armed by `hover_off`.
pointer_gone_pending: bool,
out_dir: PathBuf, out_dir: PathBuf,
/// Set by `quit`; the app drops the driver once it is. /// Set by `quit`; the app drops the driver once it is.
pub(crate) finished: bool, pub(crate) finished: bool,
@ -391,6 +411,10 @@ impl CaptureDriver {
typing: None, typing: None,
shot: None, shot: None,
rec: None, rec: None,
hover: None,
hover_pos: None,
hover_injected: None,
pointer_gone_pending: false,
out_dir, out_dir,
finished: false, 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 { let Some(cmd) = self.cmds.get(self.pc).cloned() else {
self.quit(ctx); self.quit(ctx);
return; return;
@ -473,6 +504,16 @@ impl CaptureDriver {
} }
Cmd::ClearQuery => app.capture_clear_query(), Cmd::ClearQuery => app.capture_clear_query(),
Cmd::FocusSearch => app.capture_focus_search(), 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 } => { Cmd::Window { w, h } => {
// Scenario sizes use the same logical points as the startup // Scenario sizes use the same logical points as the startup
// size in main.rs, so `window 1000 700` restores it exactly. // size in main.rs, so `window 1000 700` restores it exactly.
@ -517,8 +558,11 @@ impl CaptureDriver {
match cmd { match cmd {
Cmd::WaitMs(ms) => elapsed >= Duration::from_millis(*ms), Cmd::WaitMs(ms) => elapsed >= Duration::from_millis(*ms),
Cmd::Type { .. } => self.typing.is_none(), 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::ClearQuery
| Cmd::FocusSearch | Cmd::FocusSearch
| Cmd::HoverOff
| Cmd::Window { .. } | Cmd::Window { .. }
| Cmd::Tab(_) | Cmd::Tab(_)
| Cmd::RecordStart(_) | Cmd::RecordStart(_)
@ -575,6 +619,31 @@ impl CaptureDriver {
self.typing = None; 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 // One pass over the incoming events harvests both kinds of
// framebuffer readback: recording frames and still screenshots. // framebuffer readback: recording frames and still screenshots.
for event in &raw.events { 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::Type { text, cps } => (text.chars().count() as f32 / cps * 1000.0) as u64 + 30_000,
Cmd::ClearQuery Cmd::ClearQuery
| Cmd::FocusSearch | Cmd::FocusSearch
| Cmd::HoverOff
| Cmd::Window { .. } | Cmd::Window { .. }
| Cmd::Tab(_) | Cmd::Tab(_)
| Cmd::RecordStart(_) | Cmd::RecordStart(_)
| Cmd::RecordStop | Cmd::RecordStop
| Cmd::Quit => 10_000, | Cmd::Quit => 10_000,
// Fails when the scenario asks for a row that never rendered.
Cmd::HoverMatch(_) => 10_000,
Cmd::Screenshot(_) => 10_000, Cmd::Screenshot(_) => 10_000,
Cmd::WaitIndexRunning { .. } => 120_000, Cmd::WaitIndexRunning { .. } => 120_000,
Cmd::WaitIndexIdle { .. } => 1_800_000, Cmd::WaitIndexIdle { .. } => 1_800_000,
@ -815,6 +887,8 @@ mod tests {
clear_query clear_query
focus_search focus_search
window 500 350 window 500 350
hover_match 2
hover_off
tab search tab search
tab manage tab manage
tab duplicates tab duplicates
@ -846,6 +920,8 @@ mod tests {
Cmd::ClearQuery, Cmd::ClearQuery,
Cmd::FocusSearch, Cmd::FocusSearch,
Cmd::Window { w: 500.0, h: 350.0 }, Cmd::Window { w: 500.0, h: 350.0 },
Cmd::HoverMatch(2),
Cmd::HoverOff,
Cmd::Tab(Tab::Search), Cmd::Tab(Tab::Search),
Cmd::Tab(Tab::Manage), Cmd::Tab(Tab::Manage),
Cmd::Tab(Tab::Duplicates), Cmd::Tab(Tab::Duplicates),
@ -943,6 +1019,7 @@ mod tests {
assert!(parse_err("screenshot").msg.contains("missing")); assert!(parse_err("screenshot").msg.contains("missing"));
assert!(parse_err("window").msg.contains("missing")); assert!(parse_err("window").msg.contains("missing"));
assert!(parse_err("window 500").msg.contains("missing")); assert!(parse_err("window 500").msg.contains("missing"));
assert!(parse_err("hover_match").msg.contains("missing"));
} }
#[test] #[test]

View file

@ -11,6 +11,11 @@ use quicksearch_core::snippet::Snippet;
use crate::format::{fmt_elapsed, fmt_mtime, human_size}; use crate::format::{fmt_elapsed, fmt_mtime, human_size};
use crate::platform; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortKey { pub enum SortKey {
Rank, Rank,
@ -111,6 +116,10 @@ pub struct SearchTab {
focus_query: bool, focus_query: bool,
/// Query syntax-highlight segments, cached per text. /// Query syntax-highlight segments, cached per text.
highlight: crate::query_highlight::HighlightCache, 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<egui::Rect>,
} }
impl SearchTab { impl SearchTab {
@ -140,6 +149,8 @@ impl SearchTab {
hovered_row: None, hovered_row: None,
focus_query: true, focus_query: true,
highlight: Default::default(), highlight: Default::default(),
#[cfg(feature = "capture")]
capture_match_rects: Vec::new(),
} }
} }
@ -164,6 +175,13 @@ impl SearchTab {
self.focus_query = true; 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<egui::Rect> {
self.capture_match_rects.get(n).copied()
}
/// A new search was submitted under `generation`. The previous /// A new search was submitted under `generation`. The previous
/// results stay on screen (fading out); the new ones stage until the /// results stay on screen (fading out); the new ones stage until the
/// fade reaches zero. /// fade reaches zero.
@ -337,17 +355,46 @@ impl SearchTab {
let mut actions = SearchActions::default(); let mut actions = SearchActions::default();
// --- Query strip ------------------------------------------------- // --- 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| { ui.horizontal(|ui| {
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;
}
// Spinner while searching, then the total wall time of all
// cascade passes once it lands.
let show_elapsed = let show_elapsed =
!self.running && self.elapsed.is_some() && !self.query.trim().is_empty(); !self.running && self.elapsed.is_some() && !self.query.trim().is_empty();
let slot_room = if self.running { ui.allocate_ui_with_layout(
24.0 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 { } else if show_elapsed {
60.0 if let Some(elapsed) = self.elapsed {
} else { ui.label(egui::RichText::new(fmt_elapsed(elapsed)).small().weak())
0.0 .on_hover_text("Time to run all search passes");
}; }
let width = ui.available_width() - 170.0 - slot_room; }
},
);
let width = ui.available_width();
let highlight = &mut self.highlight; let highlight = &mut self.highlight;
let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, _wrap: f32| { let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, _wrap: f32| {
crate::query_highlight::galley(ui, highlight, buf.as_str()) crate::query_highlight::galley(ui, highlight, buf.as_str())
@ -365,27 +412,7 @@ impl SearchTab {
if response.changed() { if response.changed() {
self.pending_edit = Some(Instant::now()); 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");
}
}
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;
}
}); });
// Session ignore chips. // Session ignore chips.
@ -431,14 +458,14 @@ impl SearchTab {
} }
// Result-set transitions pulse instead of strobing: the old table // 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 // 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.25 s. // zero opacity, and the new table fades back in over 0.15 s.
// `animate_value_with_time` keeps requesting repaints until the // `animate_value_with_time` keeps requesting repaints until the
// value settles. // value settles.
let fade_target = if self.swap_pending { 0.0 } else { 1.0 }; let fade_target = if self.swap_pending { 0.0 } else { 1.0 };
let fade = let fade =
ui.ctx() 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 { if self.swap_pending && fade <= 0.01 {
self.results = std::mem::take(&mut self.staging); self.results = std::mem::take(&mut self.staging);
self.has_snippets = self.staging_has_snippets; 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 // self` for selection and hover, so a field read would conflict — but
// a plain local does not, and this runs every frame. // a plain local does not, and this runs every frame.
let order = std::mem::take(&mut self.order); 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<egui::Rect> = Vec::new();
let table_scroll = ui let table_scroll = ui
.push_id("results", |ui| { .push_id("results", |ui| {
@ -565,6 +596,8 @@ impl SearchTab {
ui.label(job); ui.label(job);
}); });
} }
#[cfg(feature = "capture")]
capture_match_rects.push(response.rect);
cell_responses.push(response); cell_responses.push(response);
} }
}); });
@ -649,6 +682,10 @@ impl SearchTab {
self.order = order; self.order = order;
crate::ui_util::more_below_hint(ui, &table_scroll); crate::ui_util::more_below_hint(ui, &table_scroll);
self.hovered_row = hovered_now; self.hovered_row = hovered_now;
#[cfg(feature = "capture")]
{
self.capture_match_rects = capture_match_rects;
}
if let Some(ix) = open_ignore_dialog { if let Some(ix) = open_ignore_dialog {
let hit = &self.results[ix]; let hit = &self.results[ix];

View file

@ -30,12 +30,24 @@ window 1120 750 # compact clip: the smallest layout at
# away, defeating the demo), at 1.5x # away, defeating the demo), at 1.5x
wait_ms 800 # the resize lands asynchronously wait_ms 800 # the resize lands asynchronously
record_start search record_start search
type "fn main" cps 4 # a phrase that lives in file bodies, not # The query is typed in quick bursts with human hesitations; each pause
# names -- results stream in with content # outlives the debounce, so results visibly refine at every stop. The phrase
# snippets in the Match column # 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_search_done max 8000
wait_ms 2000 wait_ms 1000
record_stop 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 window 1400 980 # back to full size for the screenshots
wait_ms 800 wait_ms 800