quick_search/crates/quicksearch-gui/src/search_tab.rs

1582 lines
72 KiB
Rust
Raw Normal View History

//! The Search tab: query strip, streaming results table, snippet
//! preview, context menu, ignore-filter dialog, and syntax help.
use std::time::Instant;
use egui::text::{LayoutJob, TextFormat};
use egui_extras::{Column, TableBuilder};
use quicksearch_core::config::ColumnsConfig;
use quicksearch_core::live::{LiveUpdate, Target, WindowUpdate};
use quicksearch_core::search::{MatchField, SearchHit, SearchUpdate};
use quicksearch_core::snippet::Snippet;
use crate::color::rank_tier_color;
use crate::format::{fmt_elapsed, fmt_mtime, human_size};
use crate::platform;
mod help_window;
mod ignore_dialog;
mod snippet_render;
#[cfg(test)]
mod tests;
use crate::ui_util::hint;
use ignore_dialog::dir_ignore_pattern;
pub use ignore_dialog::IgnoreDialog;
use snippet_render::{centered_match_job, marked_field_job, path_cell_job, snippet_job};
/// Fixed width (points) of the query strip's status slot, sized for the
/// longest `fmt_elapsed` output, so the query box never resizes.
const STATUS_SLOT_WIDTH: f32 = 52.0;
/// Points reserved inside the query box for the repeat-search button, held
/// whether or not the button is showing: a text field whose contents shift
/// sideways every time a search finishes is worse than 20 lost points.
const REPEAT_SLOT_W: i8 = 20;
/// Width (points) of the Fuzzy label-plus-box slot, sized to hold both with a
/// little slack so the strip's spacing does not depend on the font.
const FUZZY_SLOT_WIDTH: f32 = 66.0;
/// Shared by the Fuzzy checkbox and its label, which are separate widgets so
/// the label can sit on the left — `egui::Checkbox` pushes its icon leftmost
/// unconditionally, so no layout direction can flip them.
const FUZZY_HINT: &str = "Also run fuzzy filename and full-text passes (slower)";
/// What the Content Match column shows for a row that did not match on
/// content. An em dash, not a hyphen: at body size `-` reads as a typo and ``
/// is indistinguishable from one.
const NO_CONTENT_MATCH: &str = "";
/// How long the visible rows must hold still before they are watched. Scrolling
/// through a long result list would otherwise re-register on every frame.
const LIVE_ARM_DELAY: std::time::Duration = std::time::Duration::from_millis(400);
/// Seconds for the old results to fade out; the swap waits on this.
2026-08-07 20:44:07 -04:00
const FADE_OUT_SECS: f32 = 0.15;
/// Seconds for the new results to wipe in from the top.
2026-08-07 20:44:07 -04:00
const FADE_IN_SECS: f32 = 0.50;
/// Fraction of the reveal over which section-wide opacity climbs to full.
2026-08-07 20:44:07 -04:00
const FADE_ALPHA_SPAN: f32 = 0.50;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortKey {
Rank,
Name,
Path,
Size,
Modified,
}
/// What the tab asks the app to do after this frame.
#[derive(Default)]
pub struct SearchActions {
/// Re-run the search immediately (not debounced).
pub rerun: bool,
/// Persist an ignore pattern into the config.
pub persist_ignore: Option<String>,
/// The fuzzy toggle changed; remember it in the config.
pub save_fuzzy_default: Option<bool>,
/// The column picker changed; remember it in the config.
pub save_columns: Option<ColumnsConfig>,
/// Replace the live-result watch set. `Some(vec![])` clears it; `None`
/// leaves whatever is registered alone.
pub live_targets: Option<Vec<Target>>,
}
/// Whether the live watchers should be pointed at the visible rows this frame.
///
/// Every clause earns its place. `settled` folds in three things — no search
/// running, no edit pending, and the reveal animation finished — because
/// watching rows that do not correspond to the text in the box would re-cut
/// their snippets against the wrong query. The delay is what stops a scroll
/// from re-registering on every frame.
fn should_arm(
enabled: bool,
armed_already: bool,
changed_at: Option<Instant>,
settled: bool,
now: Instant,
) -> bool {
enabled
&& settled
&& !armed_already
&& changed_at.is_some_and(|t| now.duration_since(t) >= LIVE_ARM_DELAY)
}
/// Whether two target lists ask for the same watches.
///
/// Compares what decides *what is watched* and nothing else. `Target` also
/// carries the size and modified time the row is displaying, but those are
/// only the baseline for the watcher's arm-time sweep — re-registering every
/// inotify watch because a file's size moved would tear down and rebuild the
/// whole set on every write.
fn same_watch_set(a: &[Target], b: &[Target]) -> bool {
a.len() == b.len()
&& a.iter()
.zip(b)
.all(|(x, y)| x.path == y.path && x.text == y.text)
}
/// Recolour a laid-out cell as "the file behind this row is gone".
///
/// The Name column says so with a `RichText`, but that column is optional —
/// with it hidden, a struck-through name is no indication at all. Every other
/// column carries its own share instead of relying on it. Match highlighting
/// goes with it: nothing about a file that is not there is still a hit.
fn mark_missing_job(ui: &egui::Ui, job: &mut egui::text::LayoutJob, strike: bool) {
let color = ui.visuals().weak_text_color();
for section in &mut job.sections {
section.format.color = color;
section.format.background = egui::Color32::TRANSPARENT;
section.format.strikethrough = if strike {
egui::Stroke::new(1.0, color)
} else {
egui::Stroke::NONE
};
}
}
/// The watch target a row asks for.
fn target_for(hit: &SearchHit) -> Target {
Target {
path: hit.path.clone(),
// Only a row showing body text needs its snippet re-cut — and the
// watcher has to know which matcher cut it; a filename match costs
// one metadata call per change and never opens the file.
text: hit.content_tier(),
// What the row is *displaying*, which on a fresh result is whatever
// the index said. Sweeping the disk against it at arm time is what
// turns "watch these rows" into "and tell me if the index was already
// out of date about them".
size: hit.size,
mtime: hit.mtime,
}
}
/// Which of the two kinds a results column is, in the sense every table
/// library means it: `QHeaderView::Interactive` against `Stretch`, AG Grid's
/// plain `width` against `flex`, GTK's `expand`.
///
/// Columns holding variable-length text flex, so a wider window gives them the
/// room; the ones holding a number or a date do not, because 52 points is as
/// much Rank as there will ever be to read.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ColumnKind {
/// Keeps whatever width it was last given, and can be dragged to another.
Fixed,
/// Shares the space the fixed columns leave, in proportion to its current
/// width — which is also its weight, so a column dragged to a new width
/// keeps that *share* through the next window resize rather than those
/// pixels.
Flex,
}
/// One results column's fixed characteristics: which kind it is, how narrow it
/// may ever get, and how wide it starts before anyone has dragged anything.
#[derive(Debug, Clone, Copy, PartialEq)]
struct ColumnPlan {
kind: ColumnKind,
floor: f32,
initial: f32,
}
/// Lay the columns out across `budget`: the standard fixed/flex allocation.
///
/// The fixed columns take their current width off the top and the flex ones
/// share what is left, in proportion to `current` — which doubles as their
/// weight. A flex column that would land under its floor is pinned there and
/// drops out of the split, and the rest re-share, repeatedly, since pinning
/// one can push another under. AG Grid states the same rule: "if a column with
/// flex is being constrained by its minWidth/maxWidth rules, other flex
/// columns should take up the remaining available space".
///
/// Once the flex columns are all at their floors there is nothing left to give
/// and the fixed ones have to shrink too, so they join the split rather than
/// letting the table overflow. Past that — the floors alone over budget — the
/// window is narrower than the table can be, and the floors are returned:
/// overflowing honestly beats collapsing a column to nothing.
///
/// Doing this here rather than with `Column::remainder()` is forced.
/// `egui_extras` reloads a **resizable** column as `Size::exact(stored_width)`
/// and drops its `width_range`, so a remainder stops being one the moment the
/// table is resizable; and only the *last* column gets the fill-the-remainder
/// special case, so a second flex column could never absorb anything. Marking
/// a flex column `resizable(false)` is worse still — that path floors it at
/// `max_used`, which for a clipped column is its own laid-out width, so it
/// grows and never shrinks (emilk/egui#8048, fixed upstream in 0.35).
fn fit_widths(current: &[f32], plans: &[ColumnPlan], budget: f32) -> Vec<f32> {
debug_assert_eq!(current.len(), plans.len());
let floor_total: f32 = plans.iter().map(|p| p.floor).sum();
if plans.is_empty() || floor_total >= budget {
return plans.iter().map(|p| p.floor).collect();
}
let mut out: Vec<f32> = plans
.iter()
.zip(current)
.map(|(p, &w)| w.max(p.floor))
.collect();
// The columns still sharing what is left. Fixed ones are not in the split
// at all until the flex ones have nothing left to give; the rest have been
// pinned to a floor.
let mut free: Vec<usize> = (0..plans.len())
.filter(|&i| plans[i].kind == ColumnKind::Flex)
.collect();
let mut fixed_joined = false;
loop {
if free.is_empty() {
// Every flex column bottomed out and the total still does not fit:
// the fixed columns give up the difference in proportion, once.
if fixed_joined || out.iter().sum::<f32>() <= budget {
return out;
}
fixed_joined = true;
free = (0..plans.len())
.filter(|&i| plans[i].kind == ColumnKind::Fixed)
.collect();
continue;
}
let taken: f32 = (0..plans.len())
.filter(|i| !free.contains(i))
.map(|i| out[i])
.sum();
let share_budget = budget - taken;
// The weight is the width as it stands, not as it will be clamped:
// floors decide what a column *gets*, never what it is owed.
let share: f32 = free.iter().map(|&i| current[i]).sum();
// Nothing to take proportions from (a first frame, or every free
// column measured zero): split what is left evenly.
let widths: Vec<f32> = if share > 0.0 {
free.iter()
.map(|&i| current[i] / share * share_budget)
.collect()
} else {
vec![share_budget / free.len() as f32; free.len()]
};
let Some(under) = free
.iter()
.zip(&widths)
.position(|(&i, &w)| w < plans[i].floor)
else {
for (&i, &w) in free.iter().zip(&widths) {
out[i] = w;
}
return out;
};
let pinned = free.remove(under);
out[pinned] = plans[pinned].floor;
}
}
/// [`fit_widths`], holding one column at the width the pointer just gave it.
///
/// A drag is the user stating a width, so the layout takes it as given and the
/// others absorb the difference; refitting the dragged column too would fight
/// the pointer. It is still bounded — held no wider than leaves every other
/// column its floor — so a drag can never make the table overflow.
fn fit_around(current: &[f32], plans: &[ColumnPlan], budget: f32, held: Option<usize>) -> Vec<f32> {
let Some(held) = held.filter(|&i| i < plans.len()) else {
return fit_widths(current, plans, budget);
};
// Everything up to and including the dragged column keeps the width it
// has; only what lies to its right gives way.
//
// This is the whole of what makes a drag controllable. `egui_extras` sets
// the dragged column to `column_width + pointer.x - x`, and `x` is the
// running right edge — which already contains `column_width`, so the
// expression is really "put this column's right edge on the pointer,
// measured from its left one". Move anything to its left and that left
// edge shifts, so the divider resizes on its own and slides out from under
// the cursor. Refitting every column but the held one, which is what this
// used to do, moved them on every frame of every drag.
let mut out: Vec<f32> = current.to_vec();
let held_width = current[held].clamp(
plans[held].floor,
grow_ceiling(current, plans, budget, held),
);
out[held] = held_width;
let left: f32 = out[..=held].iter().sum();
let tail = fit_widths(&current[held + 1..], &plans[held + 1..], budget - left);
out[held + 1..].copy_from_slice(&tail);
out
}
/// The widest a column may be dragged: everything the columns to its *right*
/// could give up, and nothing more.
///
/// Only the right-hand side is on offer, for the reason in [`fit_around`] —
/// taking from the left would move the divider away from the pointer. So the
/// last column's ceiling is its own width, and its divider is inert: its right
/// edge is the window's edge, and there is nothing beyond it to trade with.
fn grow_ceiling(current: &[f32], plans: &[ColumnPlan], budget: f32, i: usize) -> f32 {
let left: f32 = current[..i].iter().sum();
let right_floor: f32 = plans[i + 1..].iter().map(|p| p.floor).sum();
(budget - left - right_floor).max(plans[i].floor)
}
/// The sort to actually apply: the requested one, or Rank when the column it
/// keys on is not on screen.
///
/// Hiding the column you are sorted by would otherwise strand you in a sort
/// you can neither see nor click your way out of. Rank is the fallback because
/// it is what a fresh search uses and it needs no column of its own to mean
/// something.
fn effective_sort(sort: (SortKey, bool), cols: &ColumnsConfig) -> (SortKey, bool) {
let shown = match sort.0 {
// The path column is mandatory, and rank ordering is meaningful with
// or without its column.
SortKey::Path | SortKey::Rank => true,
SortKey::Name => cols.name,
SortKey::Size => cols.size,
SortKey::Modified => cols.modified,
};
if shown {
sort
} else {
(SortKey::Rank, true)
}
}
/// Byte ranges into `field` to highlight, or `None` when the hit's snippet is
/// not that field verbatim.
///
/// Core promises that name- and path-tier snippets are the whole field, which
/// is what lets a column paint its own text and mark the match inside it. This
/// re-checks rather than trusting: ranges cut for a *window* would index the
/// wrong glyphs here, and painting a confidently wrong highlight is worse than
/// painting none. Cheap per visible row — the table is virtualized and a name
/// or a path is short.
fn whole_field_ranges<'a>(snip: Option<&'a Snippet>, field: &str) -> Option<&'a [(usize, usize)]> {
let snip = snip?;
if snip.truncated_start || snip.truncated_end || snip.window != field {
return None;
}
snip.ranges
.iter()
.all(|&(a, b)| {
a <= b && b <= field.len() && field.is_char_boundary(a) && field.is_char_boundary(b)
})
.then_some(snip.ranges.as_slice())
}
/// Add `incoming` to `set`, keeping at most `limit` of them — the best by
/// **rank**, whatever column the table is currently sorted by, so a good hit
/// found late in a scan still displaces a bad one found early.
fn admit(set: &mut Vec<SearchHit>, incoming: Vec<SearchHit>, limit: usize, limited: &mut bool) {
set.extend(incoming);
if set.len() > limit {
set.sort_by(|a, b| a.rank.total_cmp(&b.rank).then_with(|| a.path.cmp(&b.path)));
set.truncate(limit);
*limited = true;
}
}
/// egui_extras cell wrapper: one widget, centered and filling the cell.
fn centered_cell<R>(ui: &mut egui::Ui, contents: impl FnOnce(&mut egui::Ui) -> R) -> R {
ui.with_layout(
egui::Layout::centered_and_justified(egui::Direction::LeftToRight),
contents,
)
.inner
}
pub struct SearchTab {
pub query: String,
pub fuzzy: bool,
/// Which columns to paint, mirrored from `[search.columns]`.
pub columns: ColumnsConfig,
/// Mirrored from `[search] live_results`.
pub live_enabled: bool,
/// The rows rendered last frame — the "visually shown" set — as the
/// targets they would be watched as.
///
/// Targets rather than row indices because a row's path is what the
/// watcher keys on, and a rename changes the path without moving the row:
/// keyed on indices, a renamed row would never be re-armed and would stop
/// tracking after its first move.
live_wanted: Vec<Target>,
/// The targets the watcher is currently registered for.
live_armed: Vec<Target>,
/// When `live_wanted` last changed; the arm delay runs from here.
live_changed_at: Option<Instant>,
/// Files that have vanished from under a row on screen, by `file_id`.
/// The row stays put and is struck through rather than being removed —
/// dropping it would shift every index below it while someone is reading.
gone: std::collections::HashSet<i64>,
/// Set on every edit; the app fires the search after the debounce.
pub pending_edit: Option<Instant>,
pub generation: u64,
pub results: Vec<SearchHit>,
/// The next search's hits, swapped into `results` at zero opacity.
staging: Vec<SearchHit>,
/// True from search start until the staged set has been swapped in.
swap_pending: bool,
/// How much of the results section the reveal still hides: 1 at the swap, 0 fully shown.
2026-08-07 20:44:07 -04:00
wipe: f32,
/// The section's own opacity for the fade/wipe transition.
2026-08-07 20:44:07 -04:00
fade: f32,
/// Display permutation over `results`.
order: Vec<u32>,
sort: (SortKey, bool),
sort_dirty: bool,
pub selected: Option<u32>,
pub running: bool,
/// When the in-flight search was submitted.
search_started: Option<Instant>,
/// Wall time of the last completed search (all cascade passes).
elapsed: Option<std::time::Duration>,
pub limited: bool,
pub error: Option<String>,
pub session_ignores: Vec<String>,
pub ignore_dialog: Option<IgnoreDialog>,
pub help_open: bool,
/// Each results column's width as it was actually laid out last frame,
/// and the width the table had to lay them out in.
///
/// Measured rather than remembered: once the table is resizable
/// `egui_extras` owns the widths and offers no way to read them back, and
/// this is what [`fit_widths`] needs to keep the user's proportions
/// across a window resize.
col_widths: Vec<f32>,
/// The widths asked for last frame. A column that came back a different
/// width is the one under the pointer — `egui_extras` offers no way to ask.
col_wanted: Vec<f32>,
/// The column being dragged, held for the length of the drag.
///
/// Re-deciding it every frame does not work: the table lays out from the
/// widths it stored a frame earlier, so on the frames where the lag has
/// caught up there is nothing to tell a drag from a settled layout, and
/// the ceiling that keeps the drag inside the window would come and go.
col_drag: Option<usize>,
/// Display-row index hovered last frame; tracked via `contains_pointer()`
/// because `row.response().hovered()` is false whenever a selectable label
/// wins the hit-test.
hovered_row: Option<usize>,
focus_query: bool,
/// Query syntax-highlight segments, cached per text.
highlight: crate::query_highlight::HighlightCache,
/// Screen rects of last frame's Content Match cells, in display order — the
/// capture driver's hover targets.
#[cfg(feature = "capture")]
pub(crate) capture_match_rects: Vec<egui::Rect>,
}
impl SearchTab {
pub fn new(fuzzy_default: bool, columns: ColumnsConfig, live_enabled: bool) -> SearchTab {
SearchTab {
query: String::new(),
fuzzy: fuzzy_default,
columns,
live_enabled,
live_wanted: Vec::new(),
live_armed: Vec::new(),
live_changed_at: None,
gone: std::collections::HashSet::new(),
pending_edit: None,
generation: 0,
results: Vec::new(),
staging: Vec::new(),
swap_pending: false,
2026-08-07 20:44:07 -04:00
wipe: 0.0,
fade: 1.0,
order: Vec::new(),
sort: (SortKey::Rank, true),
sort_dirty: false,
selected: None,
running: false,
search_started: None,
elapsed: None,
limited: false,
error: None,
session_ignores: Vec::new(),
ignore_dialog: None,
help_open: false,
col_widths: Vec::new(),
col_wanted: Vec::new(),
col_drag: None,
hovered_row: None,
focus_query: true,
highlight: Default::default(),
#[cfg(feature = "capture")]
capture_match_rects: Vec::new(),
}
}
/// Pre-fill the query and let the normal debounce path run it.
pub fn seed(&mut self, query: String) {
self.query = query;
self.pending_edit = Some(Instant::now());
}
/// Whether what the table shows corresponds to the text in the query box:
/// the query executed, the swap landed, and the reveal finished.
///
/// Two things need exactly this. Arming the live watchers does, because
/// watching rows that do not match the box would re-cut their snippets
/// against the wrong query; and the capture driver's `wait_search_done`
/// does, because a screenshot mid-reveal catches a half-drawn table. They
/// were the same expression written twice, one of them behind the capture
/// feature and so absent from every test build.
pub(crate) fn settled(&self) -> bool {
2026-08-07 20:44:07 -04:00
!self.running && self.pending_edit.is_none() && self.fade_settled()
}
/// Re-arm the one-shot first-frame focus (tab switches drop egui focus).
pub(crate) fn request_focus(&mut self) {
self.focus_query = true;
}
/// Re-sort before the next paint. Needed when the columns change from
/// outside the tab: hiding the sorted column demotes the sort to Rank
/// (see [`effective_sort`]), and the order has to be rebuilt for it.
pub(crate) fn mark_sort_dirty(&mut self) {
self.sort_dirty = true;
}
/// Screen rect of the Nth visible Content 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`; its hits stage until
/// the old results fade out.
pub fn on_search_started(&mut self, generation: u64) {
self.generation = generation;
self.staging.clear();
// The watches belong to the results being replaced. The app drops the
// registration itself; this is the tab-side half.
self.live_armed.clear();
self.live_wanted.clear();
self.live_changed_at = None;
self.gone.clear();
self.swap_pending = true;
self.running = true;
self.search_started = Some(Instant::now());
self.elapsed = None;
self.limited = false;
self.error = None;
}
2026-08-07 20:44:07 -04:00
/// Nothing left to animate: the section is fully on screen and no result
/// swap is waiting on it.
fn fade_settled(&self) -> bool {
!self.swap_pending && self.wipe <= 0.0 && self.fade >= 1.0
}
/// Move the transition on by `dt` seconds. Clearing the old results holds
/// `wipe` still — a search fired mid-reveal must not flash already-covered
/// rows back on screen on the way out.
2026-08-07 20:44:07 -04:00
fn advance_fade(&mut self, dt: f32) {
let dt = dt.max(0.0);
if self.swap_pending {
self.fade = (self.fade - dt / FADE_OUT_SECS).max(0.0);
} else {
self.wipe = (self.wipe - dt / FADE_IN_SECS).max(0.0);
self.fade = ((1.0 - self.wipe) / FADE_ALPHA_SPAN).min(1.0);
}
}
pub fn apply_update(&mut self, update: SearchUpdate, display_limit: usize) {
if update.generation() != self.generation {
return;
}
match update {
SearchUpdate::Started { .. } => {}
SearchUpdate::Hits { hits, .. } => {
if self.swap_pending {
// Old results are still fading out; hold the new ones.
admit(&mut self.staging, hits, display_limit, &mut self.limited);
} else {
// Admitting a batch may reorder or drop rows out from
// under the selection's index, so carry it by file id.
let selected_id = self
.selected
.and_then(|i| self.results.get(i as usize))
.map(|h| h.file_id);
admit(&mut self.results, hits, display_limit, &mut self.limited);
self.selected = selected_id.and_then(|id| {
self.results
.iter()
.position(|h| h.file_id == id)
.map(|i| i as u32)
});
// Hits arrive in scan order, not rank order; re-sort on
// every batch.
self.sort_dirty = true;
}
}
SearchUpdate::Completed { limited, .. } => {
self.running = false;
self.elapsed = self.search_started.map(|t| t.elapsed());
self.limited |= limited;
}
SearchUpdate::Error { message, .. } => {
self.running = false;
self.elapsed = self.search_started.map(|t| t.elapsed());
self.error = Some(message);
}
}
}
/// Apply one live filesystem update to the row it names.
///
/// Rows are found by path — the same key the watcher was armed with. The
/// display order, the selection and `file_id` are all left alone: a row
/// that jumps or vanishes under the pointer while someone is reading it is
/// worse than a row that is briefly out of position.
pub fn apply_live(&mut self, update: LiveUpdate) {
match update {
LiveUpdate::Renamed { path, to, name } => {
let Some(hit) = self.results.iter_mut().find(|h| h.path == path) else {
return;
};
// A name- or path-tier snippet *is* the old field, so it has
// to be rewritten. The marks go with it: nothing here says the
// new name still matches the query, and an unhighlighted new
// name is the honest rendering of that.
let field = hit.match_field();
if let Some(snip) = hit.snippet.as_mut() {
match field {
MatchField::Name => {
snip.window = name.clone();
snip.ranges.clear();
}
MatchField::Path => {
snip.window = to.clone();
snip.ranges.clear();
}
// A move does not touch the body.
MatchField::Contents => {}
}
}
self.gone.remove(&hit.file_id);
hit.path = to;
hit.name = name;
}
LiveUpdate::Changed {
path,
size,
mtime,
window,
} => {
let Some(hit) = self.results.iter_mut().find(|h| h.path == path) else {
return;
};
hit.size = size;
hit.mtime = mtime;
match window {
// Either not a body-text row, or one whose body could not
// be re-read. Both mean the cell is better left as it is
// than blanked on no evidence.
WindowUpdate::Unchanged => {}
WindowUpdate::Cut(snippet) => hit.snippet = Some(snippet),
WindowUpdate::NoMatch => hit.snippet = None,
}
self.gone.remove(&hit.file_id);
}
LiveUpdate::Gone { path } => {
if let Some(hit) = self.results.iter().find(|h| h.path == path) {
self.gone.insert(hit.file_id);
}
}
}
}
/// Whether `live_wanted` already describes exactly these rows.
///
/// Compared field by field rather than by building the targets and
/// testing equality, because the common frame is "nothing moved" and
/// that frame must allocate nothing at all.
fn live_wanted_current(&self, visible: &[u32]) -> bool {
visible.len() == self.live_wanted.len()
&& visible.iter().zip(&self.live_wanted).all(|(&ix, want)| {
self.results.get(ix as usize).is_some_and(|hit| {
hit.path == want.path
&& hit.size == want.size
&& hit.mtime == want.mtime
&& hit.content_tier() == want.text
})
})
}
/// Drop the tab-side live state. The app drops the registration itself.
pub(crate) fn reset_live(&mut self) {
self.live_armed.clear();
self.live_wanted.clear();
self.live_changed_at = None;
}
pub fn result_count_label(&self) -> Option<String> {
if self.query.trim().is_empty() && self.results.is_empty() {
return None;
}
// The `+` is the whole warning here: it says the count is a floor.
// The reason and the remedy live in the tab body's own notice, which
// has room for a sentence; the status bar does not.
Some(format!(
"{}{} results",
self.results.len(),
if self.limited { "+" } else { "" }
))
}
fn resort(&mut self) {
let (key, ascending) = effective_sort(self.sort, &self.columns);
let selected_id = self
.selected
.and_then(|i| self.results.get(i as usize))
.map(|h| h.file_id);
self.order = (0..self.results.len() as u32).collect();
let results = &self.results;
self.order.sort_by(|&a, &b| {
let (a, b) = (&results[a as usize], &results[b as usize]);
let ord = match key {
SortKey::Rank => a.rank.total_cmp(&b.rank),
SortKey::Name => a.name.cmp(&b.name),
SortKey::Path => a.path.cmp(&b.path),
SortKey::Size => a.size.cmp(&b.size),
SortKey::Modified => a.mtime.cmp(&b.mtime),
};
let ord = if ascending { ord } else { ord.reverse() };
// Tie-break on the unique path so equal keys don't shuffle as
// batches stream in.
ord.then_with(|| a.path.cmp(&b.path))
});
// Selection follows the file, not the visual slot.
self.selected = selected_id.and_then(|id| {
self.results
.iter()
.position(|h| h.file_id == id)
.map(|i| i as u32)
});
self.sort_dirty = false;
}
/// One column header: its label, the sort indicator when it is the active
/// key, the click that re-keys the sort, and the right-click menu that
/// picks columns. `key` is `None` for a header that does not sort.
///
/// The sort indicator is a painter-drawn triangle: the default egui fonts
/// have no ▲/▼ glyphs — they render as boxes.
fn header_cell(
&mut self,
ui: &mut egui::Ui,
key: Option<SortKey>,
label: &str,
picked: &mut Option<ColumnsConfig>,
) {
let (cur, asc) = effective_sort(self.sort, &self.columns);
let selected = key == Some(cur);
let (rect, response) = ui.allocate_exact_size(ui.available_size(), egui::Sense::click());
if ui.is_rect_visible(rect) {
if response.hovered() {
ui.painter()
.rect_filled(rect, 2.0, ui.visuals().widgets.hovered.weak_bg_fill);
}
let font_id = egui::TextStyle::Body.resolve(ui.style());
let color = ui.visuals().strong_text_color();
let galley = ui
.painter()
.layout_no_wrap(label.to_string(), font_id, color);
let text_size = galley.size();
let arrow_space = if selected { 11.0 } else { 0.0 };
let text_pos = egui::pos2(
rect.center().x - (text_size.x + arrow_space) / 2.0,
rect.center().y - text_size.y / 2.0,
);
ui.painter().galley(text_pos, galley, color);
if selected {
let cx = text_pos.x + text_size.x + 7.0;
let cy = rect.center().y;
let (w, h) = (3.5, 3.0);
let points = if asc {
vec![
egui::pos2(cx, cy - h),
egui::pos2(cx - w, cy + h),
egui::pos2(cx + w, cy + h),
]
} else {
vec![
egui::pos2(cx, cy + h),
egui::pos2(cx - w, cy - h),
egui::pos2(cx + w, cy - h),
]
};
ui.painter().add(egui::Shape::convex_polygon(
points,
color,
egui::Stroke::NONE,
));
}
}
if let Some(key) = key {
if response.clicked() {
self.sort = if selected { (key, !asc) } else { (key, true) };
self.sort_dirty = true;
}
}
// Every header carries the same picker, so a right-click lands wherever
// the pointer happens to be along the row.
response.context_menu(|ui| {
let mut next = self.columns.clone();
ui.label(hint("Columns"));
let row = |ui: &mut egui::Ui, on: &mut bool, label: &str| {
ui.checkbox(on, label);
};
row(ui, &mut next.name, "Name");
// Shown checked and greyed rather than omitted: an absent entry
// reads as an oversight, a disabled one answers the question.
ui.add_enabled(false, egui::Checkbox::new(&mut true, "Path"))
.on_disabled_hover_text(
"The path is always shown — it is the only column that \
identifies a result on its own.",
);
row(ui, &mut next.content_match, "Content Match");
row(ui, &mut next.size, "Size");
row(ui, &mut next.modified, "Modified");
row(ui, &mut next.rank, "Rank");
if next != self.columns {
self.columns = next.clone();
self.sort_dirty = true;
*picked = Some(next);
}
});
}
pub fn ui(&mut self, ui: &mut egui::Ui) -> SearchActions {
let mut actions = SearchActions::default();
// --- Query strip: the syntax-help button anchors the left edge, then
// everything else is laid out right to left with the query box taking
// whatever is left of the row. --------------------------------------
ui.horizontal(|ui| {
if ui.button("?").on_hover_text("Query syntax help").clicked() {
self.help_open = !self.help_open;
}
// Sized to what the `?` left behind, not `with_layout`: a
// right-to-left child takes the row's *full* width, so after the
// button has advanced the cursor its right edge lands a button's
// width past the panel and the rightmost widget falls off screen.
let rest = egui::vec2(
(ui.max_rect().right() - ui.next_widget_position().x).max(0.0),
ui.available_height(),
);
ui.allocate_ui_with_layout(
rest,
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
// The label is a separate widget from the box so it can sit on
// the left: in a right-to-left layout the first widget added is
// the rightmost, and `egui::Checkbox` pushes its own icon
// leftmost whatever the direction, so the two have to be
// separate widgets in this order. Sensing clicks on the label
// keeps the target the combined widget used to have.
// The pair gets its own fixed-width, left-to-right slot,
// the way the status slot below does. A bare `ui.horizontal`
// here would be laid out by the surrounding right-to-left
// strip and land its contents past the panel's edge.
//
// Two widgets rather than one because `egui::Checkbox`
// pushes its own icon leftmost whatever the direction — and
// sensing clicks on the label keeps the target the combined
// widget gave it for free.
let toggled = ui
.allocate_ui_with_layout(
egui::vec2(FUZZY_SLOT_WIDTH, ui.spacing().interact_size.y),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
let mut toggled = false;
if ui
.add(egui::Label::new("Fuzzy").sense(egui::Sense::click()))
.on_hover_text(FUZZY_HINT)
.clicked()
{
self.fuzzy = !self.fuzzy;
toggled = true;
}
toggled
| ui.add(egui::Checkbox::without_text(&mut self.fuzzy))
.on_hover_text(FUZZY_HINT)
.changed()
},
)
.inner;
if toggled {
actions.save_fuzzy_default = Some(self.fuzzy);
actions.rerun = true;
}
ui.separator();
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| {
// Hold the width from the inside — the child otherwise
// shrinks to its content.
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(hint(fmt_elapsed(elapsed)))
.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))
// The gutter is held whether or not the button is
// showing. `TextEdit` sizes its frame as
// `wrap_width + margin`, with `wrap_width` capped by
// what is available, so widening the right margin
// leaves the outer box exactly where it was and only
// insets the text — which is what keeps the query from
// shifting sideways every time a search finishes.
.margin(egui::Margin {
right: 4 + REPEAT_SLOT_W,
..egui::Margin::symmetric(4, 2)
})
.hint_text(
"Search names and contents… (type:Document regex:… budget*)",
)
.layouter(&mut layouter),
);
if self.focus_query {
response.request_focus();
// Select the existing text, written straight to widget
// state so the selection is in place the frame focus lands.
if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), response.id) {
let all = egui::text::CCursorRange::two(
egui::text::CCursor::new(0),
egui::text::CCursor::new(self.query.chars().count()),
);
state.cursor.set_char_range(Some(all));
state.store(ui.ctx(), response.id);
}
self.focus_query = false;
}
if response.changed() {
self.pending_edit = Some(Instant::now());
// The watches belong to the results the old query
// produced. Dropping them here rather than at the next
// search means they go the instant the query stops
// describing what is on screen.
self.reset_live();
actions.live_targets = Some(Vec::new());
}
// Read *after* the edit above, so a keystroke hides the button
// on its own frame. `pending_edit.is_none()` is also what makes
// "repeat the last search" and "run what is in the box" the
// same thing: the flag is set on every edit and cleared in the
// same statement that fires the search, so whenever this is
// true the box holds exactly what last executed.
let show_repeat = !self.running
&& self.elapsed.is_some()
&& self.pending_edit.is_none()
&& !self.query.trim().is_empty();
if show_repeat {
let slot = egui::Rect::from_min_max(
egui::pos2(
response.rect.right() - REPEAT_SLOT_W as f32,
response.rect.top(),
),
response.rect.right_bottom(),
)
.shrink(2.0);
// `place`, not `put`: `put` advances the cursor, which in
// this right-to-left row would shove the query box sideways.
//
// This must stay *after* the TextEdit. egui derives widget
// ids from how many widgets precede them, so a button that
// comes and goes ahead of the box would rename it every
// time a search finished — and a TextEdit whose id changes
// loses focus and its in-progress edit. Nothing follows the
// button here, so the ordering alone is the fix.
if ui
.place(slot, egui::Button::new("").frame_when_inactive(false))
.on_hover_text("Run this search again")
.clicked()
{
actions.rerun = true;
}
}
},
);
});
// Session ignore chips.
if !self.session_ignores.is_empty() {
ui.horizontal_wrapped(|ui| {
ui.label(hint("Ignoring:"));
let mut remove: Option<usize> = None;
for (i, pattern) in self.session_ignores.iter().enumerate() {
if ui
.small_button(format!("{} ×", pattern))
.on_hover_text("Remove this session filter")
.clicked()
{
remove = Some(i);
}
}
if let Some(i) = remove {
self.session_ignores.remove(i);
actions.rerun = true;
}
});
}
// Notices.
if let Some(err) = &self.error {
ui.colored_label(ui.visuals().error_fg_color, err);
} else if self.limited {
ui.label(
egui::RichText::new(format!(
"Showing first {} matches; refine the query (limit configurable on the Settings tab).",
self.results.len()
))
.small()
.weak(),
);
} else if !self.running
&& !self.swap_pending
&& self.results.is_empty()
&& !self.query.trim().is_empty()
&& self.error.is_none()
{
ui.label(hint("No results."));
}
// Repaints have to be asked for by hand, since the fade/wipe values
// are ours rather than the animation manager's — and the swap frame
// needs one too.
2026-08-07 20:44:07 -04:00
self.advance_fade(ui.input(|i| i.stable_dt));
if self.swap_pending && self.fade <= 0.0 {
self.results = std::mem::take(&mut self.staging);
self.selected = None;
self.swap_pending = false;
2026-08-07 20:44:07 -04:00
self.wipe = 1.0;
// Staged hits arrived in scan order; re-sort.
self.sort_dirty = true;
}
2026-08-07 20:44:07 -04:00
if !self.fade_settled() {
ui.ctx().request_repaint();
}
if self.sort_dirty {
self.resort();
}
// Section-wide opacity; modal windows and the notices above render at
// full opacity on their own layers.
2026-08-07 20:44:07 -04:00
ui.set_opacity(self.fade);
// --- Results table ------------------------------------------------
// Reserve room for the preview strip; only content matches get one.
// Driven by the selection rather than by the column, so the snippet
// stays reachable even with the Content Match column switched off.
let preview_snippet: Option<Snippet> = self
.selected
.and_then(|i| self.results.get(i as usize))
.filter(|h| h.match_field() == MatchField::Contents)
.and_then(|h| h.snippet.clone());
let preview_height = if preview_snippet.is_some() { 44.0 } else { 0.0 };
let table_height = (ui.available_height() - preview_height).max(60.0);
// Shown whenever it is picked, whatever the results turned out to be.
// A result set that matched only on names gets a column of em dashes,
// which is the honest reading of "no content match here" — hiding the
// column instead would make a checked box mean nothing.
let show_match = self.columns.content_match;
let cols = self.columns.clone();
let body_font = egui::TextStyle::Body.resolve(ui.style());
let text_height = body_font.size + 4.0;
let mut open_ignore_dialog: Option<usize> = None;
let mut hovered_now: Option<usize> = None;
let mut picked: Option<ColumnsConfig> = None;
// egui_extras invokes the body closure only for the rows it actually
// renders, so collecting here *is* the "visually shown, not all
// returned" set, for free.
let mut visible_now: Vec<u32> = Vec::new();
let order = std::mem::take(&mut self.order);
#[cfg(feature = "capture")]
let mut capture_match_rects: Vec<egui::Rect> = Vec::new();
// Top of the wiped section; its bottom is known only after the
// preview strip is laid out.
2026-08-07 20:44:07 -04:00
let section_top = ui.cursor().top();
// What each enabled column may never go under, and what it starts at.
// Order must match the `header.col` and `row.col` calls below.
let mut plans: Vec<ColumnPlan> = Vec::with_capacity(6);
let flex = |floor: f32, initial: f32| ColumnPlan {
kind: ColumnKind::Flex,
floor,
initial,
};
if cols.name {
plans.push(flex(80.0, 220.0));
}
// The path column is not optional.
plans.push(flex(120.0, 320.0));
if show_match {
plans.push(flex(120.0, 320.0));
}
// Natural widths, and no reason to grow: a date is as long as a date.
// The floor is under the natural width all the same, so a window too
// narrow for the table squeezes these before anything runs off the
// edge — the flex columns give first, and only then these.
for (on, floor, w) in [
(cols.size, 52.0, 72.0),
(cols.modified, 78.0, 110.0),
(cols.rank, 40.0, 52.0),
] {
if on {
plans.push(ColumnPlan {
kind: ColumnKind::Fixed,
floor,
initial: w,
});
}
}
// What the columns themselves have to divide up: the width the table
// is given, less what the table spends around them. Both parts are
// read from the style rather than inferred from where the columns
// ended up last frame — inferring it is a feedback loop, since a
// frame in which the columns do not fill the width reads as a frame
// with more overhead, which shrinks the budget, which keeps them from
// filling it. `egui_extras` charges the same two: the scrollbar comes
// off `available_rect_before_wrap`, and `Sizing::to_lengths` bills
// one spacing between each pair of columns.
let table_avail = ui.available_width();
let stale = self.col_widths.len() != plans.len();
let gaps = plans.len().saturating_sub(1) as f32 * ui.spacing().item_spacing.x;
let budget = (table_avail - ui.spacing().scroll.allocated_width() - gaps).max(0.0);
// A column toggled on or off means egui_extras has dropped the stored
// widths anyway, so start from the plan.
let current: Vec<f32> = if stale {
plans.iter().map(|p| p.initial).collect()
} else {
self.col_widths.clone()
};
// Which column the pointer is on, if any: the one egui_extras sized
// differently from what was asked for. There is no way to ask it
// directly, and it has to be left alone — refitting a column while it
// is being dragged fights the pointer.
//
// Gated on a held button because a width can differ from the request
// for a duller reason: egui_extras lays a frame out from the widths it
// stored at the end of the *previous* one, so every refit shows up a
// frame late and would otherwise read as a drag. Nothing can be
// dragged with nothing pressed, which settles it.
if !ui.input(|i| i.pointer.any_down()) {
self.col_drag = None;
} else if self.col_drag.is_none() && !stale && self.col_wanted.len() == plans.len() {
self.col_drag = current
.iter()
.zip(&self.col_wanted)
.position(|(a, b)| (a - b).abs() > 0.5);
}
let dragged = self.col_drag.filter(|&i| i < plans.len());
let targets = fit_around(&current, &plans, budget, dragged);
// `width_range` is the only handle on a resizable table's widths, and
// the clamp it drives is what actually moves them — so a column that
// has to move is pinned, and one already where it belongs is left free
// to be dragged. Pinning only what must move is what keeps a drag able
// to start: were every column pinned to its target, no drag could ever
// produce the first pixel of movement that identifies it.
let ranges: Vec<(f32, f32)> = targets
.iter()
.zip(&current)
.zip(&plans)
.enumerate()
.map(|(i, ((&target, &width), plan))| {
match dragged {
// Left of the divider, and so not the drag's to touch: held
// exactly where it is, because the divider's position is
// measured from this column's edge. See `fit_around`.
Some(held) if i < held => (width, width),
// The dragged column itself follows the pointer, as far as
// the columns to its right can pay for.
Some(held) if i == held => {
(plan.floor, grow_ceiling(&current, &plans, budget, i))
}
// Right of the divider: absorbing, so pinned to its share.
Some(_) => (target, target),
None if (target - width).abs() > 0.5 => (target, target),
// Settled, so left free for a drag to start on — with the
// ceiling it will be held to once it does, rather than a
// looser one that would let the first frame jump.
None => (plan.floor, grow_ceiling(&current, &plans, budget, i)),
}
})
.collect();
self.col_wanted = targets;
let mut measured: Vec<f32> = Vec::with_capacity(plans.len());
let table_scroll = ui
.push_id("results", |ui| {
// Changing the column count makes egui_extras drop any widths
// the user had dragged. That is the cost of the picker, and a
// deliberate action on their part, so it is not worked around.
let mut table = TableBuilder::new(ui)
.striped(true)
.resizable(true)
.sense(egui::Sense::click())
.max_scroll_height(table_height)
.min_scrolled_height(60.0);
for (plan, &(lo, hi)) in plans.iter().zip(&ranges) {
table = table.column(
Column::initial(plan.initial)
.at_least(lo)
.at_most(hi)
.clip(true),
);
}
table
.header(text_height + 4.0, |mut header| {
// Each header cell reports its column's laid-out
// width — the only way to read back what a resizable
// table decided, and what the next frame refits from.
let mut head = |sort, label, measured: &mut Vec<f32>| {
header.col(|ui| {
measured.push(ui.max_rect().width());
self.header_cell(ui, sort, label, &mut picked)
});
};
if cols.name {
head(Some(SortKey::Name), "Name", &mut measured);
}
head(Some(SortKey::Path), "Path", &mut measured);
if show_match {
head(None, "Content Match", &mut measured);
}
if cols.size {
head(Some(SortKey::Size), "Size", &mut measured);
}
if cols.modified {
head(Some(SortKey::Modified), "Modified", &mut measured);
}
if cols.rank {
head(Some(SortKey::Rank), "Rank", &mut measured);
}
})
.body(|body| {
body.rows(text_height, order.len(), |mut row| {
let display_ix = row.index();
let result_ix = order[display_ix] as usize;
let hit = &self.results[result_ix];
row.set_selected(self.selected == Some(result_ix as u32));
row.set_hovered(self.hovered_row == Some(display_ix));
visible_now.push(result_ix as u32);
let missing = self.gone.contains(&hit.file_id);
// Selectable labels win egui's hit-test over the
// row, so union their responses into the row's or
// clicks over glyphs would miss.
let mut cell_responses: Vec<egui::Response> = Vec::new();
let field = hit.match_field();
if cols.name {
row.col(|ui| {
// A filename match is highlighted here
// rather than in the Content Match column,
// which shows a dash for it instead.
let marks = (field == MatchField::Name)
.then(|| {
whole_field_ranges(hit.snippet.as_ref(), &hit.name)
})
.flatten()
.unwrap_or(&[]);
let mut job = marked_field_job(ui, &hit.name, marks);
// A file that has gone from under the row
// reads as struck through rather than
// disappearing, so nothing below it moves
// while it is being read. Same helper the
// other columns use, so one concept has
// one rendering.
if missing {
mark_missing_job(ui, &mut job, true);
}
cell_responses.push(ui.label(job));
});
}
row.col(|ui| {
// Center-elided: egui's own truncation would
// drop the deepest directories. Sizing-pass
// cell rects are not final, so don't measure
// against them.
let marks = (field == MatchField::Path)
.then(|| whole_field_ranges(hit.snippet.as_ref(), &hit.path))
.flatten()
.unwrap_or(&[]);
let (mut job, elided) = if ui.is_sizing_pass() {
// Nothing to elide against, so this is
// the plain marked field.
(marked_field_job(ui, &hit.path, marks), false)
} else {
path_cell_job(
ui,
&hit.path,
marks,
// A point of slack against rounding
// disagreements with egui's layout.
ui.available_width() - 1.0,
&body_font,
)
};
// The path *is* the thing that no longer
// exists, so it is struck through like the
// name rather than merely dimmed.
if missing {
mark_missing_job(ui, &mut job, true);
}
// egui offers a full-text tooltip only when
// *it* elided the galley — and it is handed
// the already-shortened string here.
let mut response =
ui.add(egui::Label::new(job).show_tooltip_when_elided(false));
if elided {
response = response.on_hover_text(&hit.path);
}
cell_responses.push(response);
});
if show_match {
let snippet = hit
.snippet
.as_ref()
.filter(|_| field == MatchField::Contents);
row.col(|ui| {
let response = match snippet {
Some(snip) => {
let width = ui.available_width();
let mut job = centered_match_job(ui, snip, width);
// Dimmed, not struck through: the
// text is what the file *held*,
// not a name that has gone stale.
if missing {
mark_missing_job(ui, &mut job, false);
}
let mut response =
centered_cell(ui, |ui| ui.label(job));
if !snip.ranges.is_empty() {
response = response.on_hover_ui(|ui| {
ui.set_max_width(520.0);
let job = snippet_job(ui, snip, 10);
ui.label(job);
});
}
response
}
// No tooltip: for a name hit it would
// restate the filename already on
// screen, highlighted, two columns left.
None => centered_cell(ui, |ui| {
ui.label(egui::RichText::new(NO_CONTENT_MATCH).weak())
}),
};
#[cfg(feature = "capture")]
capture_match_rects.push(response.rect);
cell_responses.push(response);
});
}
if cols.size {
row.col(|ui| {
let text = egui::RichText::new(human_size(hit.size));
let text = if missing { text.weak() } else { text };
let response = centered_cell(ui, |ui| ui.label(text));
cell_responses.push(response);
});
}
if cols.modified {
row.col(|ui| {
// Recency colouring says "this file was
// touched recently", which is a claim
// about a file that still exists.
let color = if missing {
ui.visuals().weak_text_color()
} else {
recency_color(ui, hit.mtime)
};
let response = centered_cell(ui, |ui| {
ui.label(
egui::RichText::new(fmt_mtime(hit.mtime)).color(color),
)
});
cell_responses.push(response);
});
}
if cols.rank {
row.col(|ui| {
let response = centered_cell(ui, |ui| {
ui.label(
egui::RichText::new(format!(" {:.2} ", hit.rank))
.background_color(rank_tier_color(hit.stage))
.color(egui::Color32::from_rgb(32, 32, 32)),
)
});
cell_responses.push(response);
});
}
let mut response = row.response();
for r in cell_responses {
response |= r;
}
if response.contains_pointer() {
hovered_now = Some(display_ix);
}
if response.clicked() || response.secondary_clicked() {
self.selected = Some(result_ix as u32);
}
if response.double_clicked() {
platform::open_file(&self.results[result_ix].path);
}
response.context_menu(|ui| {
let path = self.results[result_ix].path.clone();
if ui.button("Open containing folder").clicked() {
platform::reveal_in_folder(&path);
ui.close();
}
if ui.button("Open File").clicked() {
platform::open_file(&path);
ui.close();
}
if ui.button("Copy path").clicked() {
ui.ctx().copy_text(path.clone());
ui.close();
}
ui.separator();
if ui.button("Build ignore filter…").clicked() {
open_ignore_dialog = Some(result_ix);
ui.close();
}
});
});
})
})
.inner;
// Sizing passes lay out a throwaway sample; taking their widths would
// feed the next refit a measurement of nothing.
if measured.len() == plans.len() {
self.col_widths = measured;
}
self.order = order;
actions.save_columns = picked;
crate::ui_util::more_below_hint(ui, &table_scroll);
self.hovered_row = hovered_now;
// --- Live results: watch what is on screen once it holds still -----
{
let now = Instant::now();
// Rebuilt only when it actually differs: this runs every frame,
// and cloning a screenful of paths each time would be a steady
// drip of allocation for nothing.
if !self.live_wanted_current(&visible_now) {
let rebuilt: Vec<Target> = visible_now
.iter()
.filter_map(|&ix| self.results.get(ix as usize))
.map(target_for)
.collect();
// Only a different watch *set* restarts the arm delay. A row
// whose size or modified time moved under it needs a fresh
// baseline for the next sweep, not a fresh registration.
if !same_watch_set(&rebuilt, &self.live_wanted) {
self.live_changed_at = Some(now);
}
self.live_wanted = rebuilt;
}
let settled = self.settled();
let armed_already = same_watch_set(&self.live_wanted, &self.live_armed);
if should_arm(
self.live_enabled,
armed_already,
self.live_changed_at,
settled,
now,
) {
self.live_armed = self.live_wanted.clone();
actions.live_targets = Some(self.live_wanted.clone());
} else if settled && !armed_already {
// Once the reveal settles nothing else asks for frames, so a
// bare `Instant` deadline would never come due. Same shape as
// the search debounce in `app::tick_debounce`.
if let Some(changed) = self.live_changed_at {
let waited = now.duration_since(changed);
ui.ctx()
.request_repaint_after(LIVE_ARM_DELAY.saturating_sub(waited));
}
}
}
#[cfg(feature = "capture")]
{
self.capture_match_rects = capture_match_rects;
}
if let Some(ix) = open_ignore_dialog {
let hit = &self.results[ix];
self.ignore_dialog = Some(IgnoreDialog {
source_path: hit.path.clone(),
ext_pattern: std::path::Path::new(&hit.name)
.extension()
.and_then(|e| e.to_str())
.map(|e| format!("*.{}", e)),
name_pattern: hit.name.clone(),
dir_pattern: std::path::Path::new(&hit.path)
.parent()
.map(dir_ignore_pattern)
.unwrap_or_default(),
persist: false,
});
}
if let Some(snip) = &preview_snippet {
ui.separator();
let job = snippet_job(ui, snip, 2);
ui.label(job);
}
// Painted last so the scrim covers the whole section, and outside the
// opacity set above so it is not itself faded.
2026-08-07 20:44:07 -04:00
let section = egui::Rect::from_x_y_ranges(
ui.max_rect().x_range(),
section_top..=ui.min_rect().bottom(),
);
crate::ui_util::wipe_scrim(ui, section, self.wipe);
self.ignore_dialog_ui(ui.ctx(), &mut actions);
self.help_window_ui(ui.ctx());
actions
}
}
/// Timestamp color: fresh files get a green tint that fades into the weak
/// text color over ~2 years on a log scale. The fade runs through OKLab —
/// blending sRGB bytes instead dips through a darker, muddier green.
fn recency_color(ui: &egui::Ui, mtime: i64) -> egui::Color32 {
let now = quicksearch_core::log::now_unix() as i64;
let age_hours = ((now - mtime).max(0) as f32 / 3600.0).max(1.0);
const HORIZON_HOURS: f32 = 24.0 * 365.0 * 2.0;
let t = (age_hours.ln() / HORIZON_HOURS.ln()).clamp(0.0, 1.0);
let fresh = crate::color::palette(ui.visuals().dark_mode).green;
crate::color::oklab_lerp(fresh, ui.visuals().weak_text_color(), t)
}