A couple minor improvements to results display and image capture.
This commit is contained in:
parent
cf40196546
commit
1411224cc0
5 changed files with 186 additions and 56 deletions
|
|
@ -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>"]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue