2026-08-02 19:04:30 -04:00
|
|
|
|
//! 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};
|
2026-08-17 19:26:18 -04:00
|
|
|
|
use quicksearch_core::config::ColumnsConfig;
|
|
|
|
|
|
use quicksearch_core::live::{LiveUpdate, Target, WindowUpdate};
|
|
|
|
|
|
use quicksearch_core::search::{MatchField, SearchHit, SearchUpdate};
|
2026-08-02 19:04:30 -04:00
|
|
|
|
use quicksearch_core::snippet::Snippet;
|
|
|
|
|
|
|
2026-08-09 02:58:13 -04:00
|
|
|
|
use crate::color::rank_tier_color;
|
2026-08-02 19:04:30 -04:00
|
|
|
|
use crate::format::{fmt_elapsed, fmt_mtime, human_size};
|
|
|
|
|
|
use crate::platform;
|
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
|
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;
|
2026-08-17 19:26:18 -04:00
|
|
|
|
use snippet_render::{centered_match_job, marked_field_job, path_cell_job, snippet_job};
|
2026-08-09 16:25:43 -04:00
|
|
|
|
|
|
|
|
|
|
/// Fixed width (points) of the query strip's status slot, sized for the
|
|
|
|
|
|
/// longest `fmt_elapsed` output, so the query box never resizes.
|
2026-08-05 00:12:56 -04:00
|
|
|
|
const STATUS_SLOT_WIDTH: f32 = 52.0;
|
|
|
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
|
/// 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);
|
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
|
/// 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;
|
2026-08-09 16:25:43 -04:00
|
|
|
|
/// 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;
|
2026-08-09 16:25:43 -04:00
|
|
|
|
/// 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;
|
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
|
#[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 {
|
2026-08-09 16:25:43 -04:00
|
|
|
|
/// Re-run the search immediately (not debounced).
|
2026-08-02 19:04:30 -04:00
|
|
|
|
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>,
|
2026-08-17 19:26:18 -04:00
|
|
|
|
/// 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(¤t[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())
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 03:06:19 -04:00
|
|
|
|
/// Add `incoming` to `set`, keeping at most `limit` of them — the best by
|
2026-08-09 16:25:43 -04:00
|
|
|
|
/// **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.
|
2026-08-04 03:27:05 -04:00
|
|
|
|
fn admit(set: &mut Vec<SearchHit>, incoming: Vec<SearchHit>, limit: usize, limited: &mut bool) {
|
2026-08-03 03:06:19 -04:00
|
|
|
|
set.extend(incoming);
|
|
|
|
|
|
if set.len() > limit {
|
2026-08-09 16:25:43 -04:00
|
|
|
|
set.sort_by(|a, b| a.rank.total_cmp(&b.rank).then_with(|| a.path.cmp(&b.path)));
|
2026-08-03 03:06:19 -04:00
|
|
|
|
set.truncate(limit);
|
|
|
|
|
|
*limited = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
|
/// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
|
pub struct SearchTab {
|
|
|
|
|
|
pub query: String,
|
|
|
|
|
|
pub fuzzy: bool,
|
2026-08-17 19:26:18 -04:00
|
|
|
|
/// 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>,
|
2026-08-02 19:04:30 -04:00
|
|
|
|
/// Set on every edit; the app fires the search after the debounce.
|
|
|
|
|
|
pub pending_edit: Option<Instant>,
|
|
|
|
|
|
pub generation: u64,
|
|
|
|
|
|
pub results: Vec<SearchHit>,
|
2026-08-09 16:25:43 -04:00
|
|
|
|
/// The next search's hits, swapped into `results` at zero opacity.
|
2026-08-02 19:04:30 -04:00
|
|
|
|
staging: Vec<SearchHit>,
|
|
|
|
|
|
/// True from search start until the staged set has been swapped in.
|
|
|
|
|
|
swap_pending: bool,
|
2026-08-09 16:25:43 -04:00
|
|
|
|
/// 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,
|
2026-08-09 16:25:43 -04:00
|
|
|
|
/// The section's own opacity for the fade/wipe transition.
|
2026-08-07 20:44:07 -04:00
|
|
|
|
fade: f32,
|
2026-08-02 19:04:30 -04:00
|
|
|
|
/// 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,
|
2026-08-17 19:26:18 -04:00
|
|
|
|
/// 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>,
|
2026-08-09 16:25:43 -04:00
|
|
|
|
/// Display-row index hovered last frame; tracked via `contains_pointer()`
|
|
|
|
|
|
/// because `row.response().hovered()` is false whenever a selectable label
|
|
|
|
|
|
/// wins the hit-test.
|
2026-08-02 20:21:19 -04:00
|
|
|
|
hovered_row: Option<usize>,
|
2026-08-02 19:04:30 -04:00
|
|
|
|
focus_query: bool,
|
|
|
|
|
|
/// Query syntax-highlight segments, cached per text.
|
|
|
|
|
|
highlight: crate::query_highlight::HighlightCache,
|
2026-08-17 19:26:18 -04:00
|
|
|
|
/// Screen rects of last frame's Content Match cells, in display order — the
|
2026-08-09 16:25:43 -04:00
|
|
|
|
/// capture driver's hover targets.
|
2026-08-05 00:12:56 -04:00
|
|
|
|
#[cfg(feature = "capture")]
|
|
|
|
|
|
pub(crate) capture_match_rects: Vec<egui::Rect>,
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl SearchTab {
|
2026-08-17 19:26:18 -04:00
|
|
|
|
pub fn new(fuzzy_default: bool, columns: ColumnsConfig, live_enabled: bool) -> SearchTab {
|
2026-08-02 19:04:30 -04:00
|
|
|
|
SearchTab {
|
|
|
|
|
|
query: String::new(),
|
|
|
|
|
|
fuzzy: fuzzy_default,
|
2026-08-17 19:26:18 -04:00
|
|
|
|
columns,
|
|
|
|
|
|
live_enabled,
|
|
|
|
|
|
live_wanted: Vec::new(),
|
|
|
|
|
|
live_armed: Vec::new(),
|
|
|
|
|
|
live_changed_at: None,
|
|
|
|
|
|
gone: std::collections::HashSet::new(),
|
2026-08-02 19:04:30 -04:00
|
|
|
|
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,
|
2026-08-02 19:04:30 -04:00
|
|
|
|
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,
|
2026-08-17 19:26:18 -04:00
|
|
|
|
col_widths: Vec::new(),
|
|
|
|
|
|
col_wanted: Vec::new(),
|
|
|
|
|
|
col_drag: None,
|
2026-08-02 20:21:19 -04:00
|
|
|
|
hovered_row: None,
|
2026-08-02 19:04:30 -04:00
|
|
|
|
focus_query: true,
|
|
|
|
|
|
highlight: Default::default(),
|
2026-08-05 00:12:56 -04:00
|
|
|
|
#[cfg(feature = "capture")]
|
|
|
|
|
|
capture_match_rects: Vec::new(),
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
|
/// Pre-fill the query and let the normal debounce path run it.
|
2026-08-02 19:04:30 -04:00
|
|
|
|
pub fn seed(&mut self, query: String) {
|
|
|
|
|
|
self.query = query;
|
|
|
|
|
|
self.pending_edit = Some(Instant::now());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
|
/// 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()
|
2026-08-04 23:33:28 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
|
/// Re-arm the one-shot first-frame focus (tab switches drop egui focus).
|
2026-08-09 02:58:13 -04:00
|
|
|
|
pub(crate) fn request_focus(&mut self) {
|
2026-08-04 23:33:28 -04:00
|
|
|
|
self.focus_query = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
|
/// 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
|
2026-08-05 00:12:56 -04:00
|
|
|
|
/// 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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
|
/// A new search was submitted under `generation`; its hits stage until
|
|
|
|
|
|
/// the old results fade out.
|
2026-08-02 19:04:30 -04:00
|
|
|
|
pub fn on_search_started(&mut self, generation: u64) {
|
|
|
|
|
|
self.generation = generation;
|
|
|
|
|
|
self.staging.clear();
|
2026-08-17 19:26:18 -04:00
|
|
|
|
// 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();
|
2026-08-02 19:04:30 -04:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
|
/// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
|
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.
|
2026-08-03 03:06:19 -04:00
|
|
|
|
admit(&mut self.staging, hits, display_limit, &mut self.limited);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
} else {
|
2026-08-09 16:25:43 -04:00
|
|
|
|
// Admitting a batch may reorder or drop rows out from
|
|
|
|
|
|
// under the selection's index, so carry it by file id.
|
2026-08-03 03:06:19 -04:00
|
|
|
|
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)
|
|
|
|
|
|
});
|
2026-08-09 16:25:43 -04:00
|
|
|
|
// Hits arrive in scan order, not rank order; re-sort on
|
|
|
|
|
|
// every batch.
|
2026-08-03 03:06:19 -04:00
|
|
|
|
self.sort_dirty = true;
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
|
/// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
|
pub fn result_count_label(&self) -> Option<String> {
|
|
|
|
|
|
if self.query.trim().is_empty() && self.results.is_empty() {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
2026-08-17 19:26:18 -04:00
|
|
|
|
// 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 { "" }
|
|
|
|
|
|
))
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn resort(&mut self) {
|
2026-08-17 19:26:18 -04:00
|
|
|
|
let (key, ascending) = effective_sort(self.sort, &self.columns);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
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 {
|
2026-08-09 16:25:43 -04:00
|
|
|
|
SortKey::Rank => a.rank.total_cmp(&b.rank),
|
2026-08-02 19:04:30 -04:00
|
|
|
|
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),
|
|
|
|
|
|
};
|
2026-08-03 03:06:19 -04:00
|
|
|
|
let ord = if ascending { ord } else { ord.reverse() };
|
2026-08-09 16:25:43 -04:00
|
|
|
|
// Tie-break on the unique path so equal keys don't shuffle as
|
|
|
|
|
|
// batches stream in.
|
2026-08-03 03:06:19 -04:00
|
|
|
|
ord.then_with(|| a.path.cmp(&b.path))
|
2026-08-02 19:04:30 -04:00
|
|
|
|
});
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
|
/// 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);
|
2026-08-02 22:21:39 -04:00
|
|
|
|
let (rect, response) = ui.allocate_exact_size(ui.available_size(), egui::Sense::click());
|
2026-08-02 19:04:30 -04:00
|
|
|
|
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,
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-17 19:26:18 -04:00
|
|
|
|
if let Some(key) = key {
|
|
|
|
|
|
if response.clicked() {
|
|
|
|
|
|
self.sort = if selected { (key, !asc) } else { (key, true) };
|
|
|
|
|
|
self.sort_dirty = true;
|
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|
2026-08-17 19:26:18 -04:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn ui(&mut self, ui: &mut egui::Ui) -> SearchActions {
|
|
|
|
|
|
let mut actions = SearchActions::default();
|
|
|
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
|
// --- 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. --------------------------------------
|
2026-08-02 19:04:30 -04:00
|
|
|
|
ui.horizontal(|ui| {
|
2026-08-17 19:26:18 -04:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
2026-08-05 00:12:56 -04:00
|
|
|
|
}
|
2026-08-17 19:26:18 -04:00
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
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);
|
2026-08-05 00:12:56 -04:00
|
|
|
|
}
|
2026-08-17 19:26:18 -04:00
|
|
|
|
self.focus_query = false;
|
2026-08-09 02:58:13 -04:00
|
|
|
|
}
|
2026-08-17 19:26:18 -04:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Session ignore chips.
|
|
|
|
|
|
if !self.session_ignores.is_empty() {
|
|
|
|
|
|
ui.horizontal_wrapped(|ui| {
|
2026-08-09 16:25:43 -04:00
|
|
|
|
ui.label(hint("Ignoring:"));
|
2026-08-02 19:04:30 -04:00
|
|
|
|
let mut remove: Option<usize> = None;
|
|
|
|
|
|
for (i, pattern) in self.session_ignores.iter().enumerate() {
|
|
|
|
|
|
if ui
|
2026-08-02 22:21:39 -04:00
|
|
|
|
.small_button(format!("{} 🗙", pattern))
|
2026-08-02 19:04:30 -04:00
|
|
|
|
.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!(
|
2026-08-17 19:26:18 -04:00
|
|
|
|
"Showing first {} matches; refine the query (limit configurable on the Settings tab).",
|
2026-08-02 19:04:30 -04:00
|
|
|
|
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()
|
|
|
|
|
|
{
|
2026-08-09 16:25:43 -04:00
|
|
|
|
ui.label(hint("No results."));
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
|
// 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 {
|
2026-08-02 19:04:30 -04:00
|
|
|
|
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;
|
2026-08-09 16:25:43 -04:00
|
|
|
|
// Staged hits arrived in scan order; re-sort.
|
2026-08-03 03:06:19 -04:00
|
|
|
|
self.sort_dirty = true;
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|
2026-08-07 20:44:07 -04:00
|
|
|
|
if !self.fade_settled() {
|
|
|
|
|
|
ui.ctx().request_repaint();
|
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
|
|
|
|
|
|
|
if self.sort_dirty {
|
|
|
|
|
|
self.resort();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
|
// 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);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
|
|
|
|
|
|
// --- Results table ------------------------------------------------
|
2026-08-09 16:25:43 -04:00
|
|
|
|
// Reserve room for the preview strip; only content matches get one.
|
2026-08-17 19:26:18 -04:00
|
|
|
|
// Driven by the selection rather than by the column, so the snippet
|
|
|
|
|
|
// stays reachable even with the Content Match column switched off.
|
2026-08-02 19:04:30 -04:00
|
|
|
|
let preview_snippet: Option<Snippet> = self
|
|
|
|
|
|
.selected
|
|
|
|
|
|
.and_then(|i| self.results.get(i as usize))
|
2026-08-17 19:26:18 -04:00
|
|
|
|
.filter(|h| h.match_field() == MatchField::Contents)
|
2026-08-02 19:04:30 -04:00
|
|
|
|
.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);
|
|
|
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
|
// 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();
|
|
|
|
|
|
|
2026-08-05 18:05:04 -04:00
|
|
|
|
let body_font = egui::TextStyle::Body.resolve(ui.style());
|
|
|
|
|
|
let text_height = body_font.size + 4.0;
|
2026-08-02 19:04:30 -04:00
|
|
|
|
let mut open_ignore_dialog: Option<usize> = None;
|
2026-08-02 20:21:19 -04:00
|
|
|
|
let mut hovered_now: Option<usize> = None;
|
2026-08-17 19:26:18 -04:00
|
|
|
|
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();
|
2026-08-03 03:06:19 -04:00
|
|
|
|
let order = std::mem::take(&mut self.order);
|
2026-08-05 00:12:56 -04:00
|
|
|
|
#[cfg(feature = "capture")]
|
|
|
|
|
|
let mut capture_match_rects: Vec<egui::Rect> = Vec::new();
|
2026-08-02 19:04:30 -04:00
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
|
// 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();
|
|
|
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
|
// 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(¤t, &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(¤t)
|
|
|
|
|
|
.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(¤t, &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(¤t, &plans, budget, i)),
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
self.col_wanted = targets;
|
|
|
|
|
|
|
|
|
|
|
|
let mut measured: Vec<f32> = Vec::with_capacity(plans.len());
|
2026-08-02 22:21:39 -04:00
|
|
|
|
let table_scroll = ui
|
|
|
|
|
|
.push_id("results", |ui| {
|
2026-08-17 19:26:18 -04:00
|
|
|
|
// 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.
|
2026-08-02 22:21:39 -04:00
|
|
|
|
let mut table = TableBuilder::new(ui)
|
|
|
|
|
|
.striped(true)
|
|
|
|
|
|
.resizable(true)
|
|
|
|
|
|
.sense(egui::Sense::click())
|
|
|
|
|
|
.max_scroll_height(table_height)
|
2026-08-17 19:26:18 -04:00
|
|
|
|
.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),
|
|
|
|
|
|
);
|
2026-08-02 22:21:39 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
table
|
|
|
|
|
|
.header(text_height + 4.0, |mut header| {
|
2026-08-17 19:26:18 -04:00
|
|
|
|
// 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>| {
|
2026-08-02 22:21:39 -04:00
|
|
|
|
header.col(|ui| {
|
2026-08-17 19:26:18 -04:00
|
|
|
|
measured.push(ui.max_rect().width());
|
|
|
|
|
|
self.header_cell(ui, sort, label, &mut picked)
|
2026-08-02 22:21:39 -04:00
|
|
|
|
});
|
2026-08-17 19:26:18 -04:00
|
|
|
|
};
|
|
|
|
|
|
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);
|
2026-08-02 22:21:39 -04:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.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));
|
2026-08-17 19:26:18 -04:00
|
|
|
|
visible_now.push(result_ix as u32);
|
|
|
|
|
|
let missing = self.gone.contains(&hit.file_id);
|
2026-08-02 22:21:39 -04:00
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
|
// Selectable labels win egui's hit-test over the
|
|
|
|
|
|
// row, so union their responses into the row's or
|
|
|
|
|
|
// clicks over glyphs would miss.
|
2026-08-02 22:21:39 -04:00
|
|
|
|
let mut cell_responses: Vec<egui::Response> = Vec::new();
|
|
|
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
|
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));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-08-02 22:21:39 -04:00
|
|
|
|
row.col(|ui| {
|
2026-08-09 16:25:43 -04:00
|
|
|
|
// Center-elided: egui's own truncation would
|
|
|
|
|
|
// drop the deepest directories. Sizing-pass
|
|
|
|
|
|
// cell rects are not final, so don't measure
|
|
|
|
|
|
// against them.
|
2026-08-17 19:26:18 -04:00
|
|
|
|
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)
|
2026-08-05 18:05:04 -04:00
|
|
|
|
} else {
|
2026-08-17 19:26:18 -04:00
|
|
|
|
path_cell_job(
|
2026-08-05 18:05:04 -04:00
|
|
|
|
ui,
|
|
|
|
|
|
&hit.path,
|
2026-08-17 19:26:18 -04:00
|
|
|
|
marks,
|
2026-08-09 16:25:43 -04:00
|
|
|
|
// A point of slack against rounding
|
|
|
|
|
|
// disagreements with egui's layout.
|
2026-08-05 18:05:04 -04:00
|
|
|
|
ui.available_width() - 1.0,
|
|
|
|
|
|
&body_font,
|
|
|
|
|
|
)
|
|
|
|
|
|
};
|
2026-08-17 19:26:18 -04:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-08-05 18:05:04 -04:00
|
|
|
|
// egui offers a full-text tooltip only when
|
2026-08-09 16:25:43 -04:00
|
|
|
|
// *it* elided the galley — and it is handed
|
|
|
|
|
|
// the already-shortened string here.
|
2026-08-17 19:26:18 -04:00
|
|
|
|
let mut response =
|
|
|
|
|
|
ui.add(egui::Label::new(job).show_tooltip_when_elided(false));
|
2026-08-05 18:05:04 -04:00
|
|
|
|
if elided {
|
|
|
|
|
|
response = response.on_hover_text(&hit.path);
|
|
|
|
|
|
}
|
|
|
|
|
|
cell_responses.push(response);
|
2026-08-02 22:21:39 -04:00
|
|
|
|
});
|
2026-08-17 19:26:18 -04:00
|
|
|
|
if show_match {
|
|
|
|
|
|
let snippet = hit
|
|
|
|
|
|
.snippet
|
|
|
|
|
|
.as_ref()
|
|
|
|
|
|
.filter(|_| field == MatchField::Contents);
|
2026-08-02 22:21:39 -04:00
|
|
|
|
row.col(|ui| {
|
2026-08-17 19:26:18 -04:00
|
|
|
|
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
|
2026-08-02 22:21:39 -04:00
|
|
|
|
}
|
2026-08-17 19:26:18 -04:00
|
|
|
|
// 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);
|
2026-08-02 22:21:39 -04:00
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-08-17 19:26:18 -04:00
|
|
|
|
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);
|
2026-08-09 16:25:43 -04:00
|
|
|
|
});
|
2026-08-17 19:26:18 -04:00
|
|
|
|
}
|
|
|
|
|
|
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);
|
2026-08-09 16:25:43 -04:00
|
|
|
|
});
|
2026-08-17 19:26:18 -04:00
|
|
|
|
}
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
|
|
2026-08-02 22:21:39 -04:00
|
|
|
|
let mut response = row.response();
|
|
|
|
|
|
for r in cell_responses {
|
2026-08-05 18:05:04 -04:00
|
|
|
|
response |= r;
|
2026-08-02 20:21:19 -04:00
|
|
|
|
}
|
2026-08-02 22:21:39 -04:00
|
|
|
|
if response.contains_pointer() {
|
|
|
|
|
|
hovered_now = Some(display_ix);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|
2026-08-02 22:21:39 -04:00
|
|
|
|
if response.clicked() || response.secondary_clicked() {
|
|
|
|
|
|
self.selected = Some(result_ix as u32);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|
2026-08-02 22:21:39 -04:00
|
|
|
|
if response.double_clicked() {
|
|
|
|
|
|
platform::open_file(&self.results[result_ix].path);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|
2026-08-02 22:21:39 -04:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-08-17 19:26:18 -04:00
|
|
|
|
if ui.button("Open File").clicked() {
|
2026-08-02 22:21:39 -04:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-08-02 19:04:30 -04:00
|
|
|
|
});
|
2026-08-02 22:21:39 -04:00
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
.inner;
|
2026-08-17 19:26:18 -04:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-08-03 03:06:19 -04:00
|
|
|
|
self.order = order;
|
2026-08-17 19:26:18 -04:00
|
|
|
|
actions.save_columns = picked;
|
2026-08-02 20:21:19 -04:00
|
|
|
|
crate::ui_util::more_below_hint(ui, &table_scroll);
|
|
|
|
|
|
self.hovered_row = hovered_now;
|
2026-08-17 19:26:18 -04:00
|
|
|
|
|
|
|
|
|
|
// --- 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));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-05 00:12:56 -04:00
|
|
|
|
#[cfg(feature = "capture")]
|
|
|
|
|
|
{
|
|
|
|
|
|
self.capture_match_rects = capture_match_rects;
|
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
|
|
|
|
|
|
|
if let Some(ix) = open_ignore_dialog {
|
|
|
|
|
|
let hit = &self.results[ix];
|
|
|
|
|
|
self.ignore_dialog = Some(IgnoreDialog {
|
|
|
|
|
|
source_path: hit.path.clone(),
|
2026-08-02 20:21:19 -04:00
|
|
|
|
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()
|
2026-08-04 03:27:05 -04:00
|
|
|
|
.map(dir_ignore_pattern)
|
2026-08-02 20:21:19 -04:00
|
|
|
|
.unwrap_or_default(),
|
2026-08-02 19:04:30 -04:00
|
|
|
|
persist: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if let Some(snip) = &preview_snippet {
|
|
|
|
|
|
ui.separator();
|
|
|
|
|
|
let job = snippet_job(ui, snip, 2);
|
|
|
|
|
|
ui.label(job);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
|
// 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);
|
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
|
self.ignore_dialog_ui(ui.ctx(), &mut actions);
|
|
|
|
|
|
self.help_window_ui(ui.ctx());
|
|
|
|
|
|
actions
|
|
|
|
|
|
}
|
2026-08-05 19:17:11 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
|
/// Timestamp color: fresh files get a green tint that fades into the weak
|
2026-08-09 16:25:43 -04:00
|
|
|
|
/// text color over ~2 years on a log scale. The fade runs through OKLab —
|
|
|
|
|
|
/// blending sRGB bytes instead dips through a darker, muddier green.
|
2026-08-02 19:04:30 -04:00
|
|
|
|
fn recency_color(ui: &egui::Ui, mtime: i64) -> egui::Color32 {
|
2026-08-05 18:05:04 -04:00
|
|
|
|
let now = quicksearch_core::log::now_unix() as i64;
|
2026-08-02 19:04:30 -04:00
|
|
|
|
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);
|
2026-08-09 02:58:13 -04:00
|
|
|
|
let fresh = crate::color::palette(ui.visuals().dark_mode).green;
|
|
|
|
|
|
crate::color::oklab_lerp(fresh, ui.visuals().weak_text_color(), t)
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|