1581 lines
72 KiB
Rust
1581 lines
72 KiB
Rust
//! 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.
|
||
const FADE_OUT_SECS: f32 = 0.15;
|
||
/// Seconds for the new results to wipe in from the top.
|
||
const FADE_IN_SECS: f32 = 0.50;
|
||
/// Fraction of the reveal over which section-wide opacity climbs to full.
|
||
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(¤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())
|
||
}
|
||
|
||
/// 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.
|
||
wipe: f32,
|
||
/// The section's own opacity for the fade/wipe transition.
|
||
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,
|
||
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 {
|
||
!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;
|
||
}
|
||
|
||
/// 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.
|
||
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.
|
||
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;
|
||
self.wipe = 1.0;
|
||
// Staged hits arrived in scan order; re-sort.
|
||
self.sort_dirty = true;
|
||
}
|
||
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.
|
||
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.
|
||
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(¤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());
|
||
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.
|
||
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)
|
||
}
|