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]
version = "0.9.2"
version = "0.9.3"
edition = "2021"
license = "GPL-3.0-or-later"
authors = ["Jeremy <jeremy@karsttech.com>"]

View file

@ -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<egui::Rect> {
self.search.capture_match_cell(n)
}
}
/// 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
/// 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<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,
}
}
"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<PathBuf>,
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,
/// 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]

View file

@ -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<egui::Rect>,
}
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<egui::Rect> {
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<egui::Rect> = 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];

View file

@ -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