//! 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, /// The fuzzy toggle changed; remember it in the config. pub save_fuzzy_default: Option, /// The column picker changed; remember it in the config. pub save_columns: Option, /// Replace the live-result watch set. `Some(vec![])` clears it; `None` /// leaves whatever is registered alone. pub live_targets: Option>, } /// 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, 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 { 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 = 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 = (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::() <= 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 = 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) -> Vec { 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 = 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, incoming: Vec, 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(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, /// The targets the watcher is currently registered for. live_armed: Vec, /// When `live_wanted` last changed; the arm delay runs from here. live_changed_at: Option, /// 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, /// Set on every edit; the app fires the search after the debounce. pub pending_edit: Option, pub generation: u64, pub results: Vec, /// The next search's hits, swapped into `results` at zero opacity. staging: Vec, /// 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, sort: (SortKey, bool), sort_dirty: bool, pub selected: Option, pub running: bool, /// When the in-flight search was submitted. search_started: Option, /// Wall time of the last completed search (all cascade passes). elapsed: Option, pub limited: bool, pub error: Option, pub session_ignores: Vec, pub ignore_dialog: Option, 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, /// 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, /// 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, /// 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, 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, } 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 { 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 { 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, label: &str, picked: &mut Option, ) { 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 = 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 = 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 = None; let mut hovered_now: Option = None; let mut picked: Option = 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 = Vec::new(); let order = std::mem::take(&mut self.order); #[cfg(feature = "capture")] let mut capture_match_rects: Vec = 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 = 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 = 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 = 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| { 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 = 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 = 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) }