947 lines
37 KiB
Rust
947 lines
37 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::search::{SearchHit, SearchUpdate};
|
|
use quicksearch_core::snippet::Snippet;
|
|
|
|
use crate::format::{fmt_elapsed, fmt_mtime, human_size};
|
|
use crate::platform;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SortKey {
|
|
Rank,
|
|
Name,
|
|
Path,
|
|
Size,
|
|
Modified,
|
|
}
|
|
|
|
pub struct IgnoreDialog {
|
|
pub source_name: String,
|
|
pub source_path: String,
|
|
pub pattern: String,
|
|
pub persist: bool,
|
|
}
|
|
|
|
/// What the tab asks the app to do after this frame.
|
|
#[derive(Default)]
|
|
pub struct SearchActions {
|
|
/// Re-run the search (query/fuzzy/session filters changed *now*, not
|
|
/// debounced — e.g. a chip was removed).
|
|
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>,
|
|
}
|
|
|
|
pub struct SearchTab {
|
|
pub query: String,
|
|
pub fuzzy: bool,
|
|
/// 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, held back while the old table fades out;
|
|
/// swapped into `results` at zero opacity. Prevents the empty-refill
|
|
/// strobe while typing.
|
|
staging: Vec<SearchHit>,
|
|
staging_has_snippets: bool,
|
|
/// True from search start until the staged set has been swapped in.
|
|
swap_pending: bool,
|
|
/// 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,
|
|
has_snippets: bool,
|
|
focus_query: bool,
|
|
/// Query syntax-highlight segments, cached per text.
|
|
highlight: crate::query_highlight::HighlightCache,
|
|
}
|
|
|
|
impl SearchTab {
|
|
pub fn new(fuzzy_default: bool) -> SearchTab {
|
|
SearchTab {
|
|
query: String::new(),
|
|
fuzzy: fuzzy_default,
|
|
pending_edit: None,
|
|
generation: 0,
|
|
results: Vec::new(),
|
|
staging: Vec::new(),
|
|
staging_has_snippets: false,
|
|
swap_pending: false,
|
|
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,
|
|
has_snippets: false,
|
|
focus_query: true,
|
|
highlight: Default::default(),
|
|
}
|
|
}
|
|
|
|
/// Pre-fill the query and let the normal debounce path run it, so a
|
|
/// command-line query lands the user on results rather than an empty box.
|
|
pub fn seed(&mut self, query: String) {
|
|
self.query = query;
|
|
self.pending_edit = Some(Instant::now());
|
|
}
|
|
|
|
/// A new search was submitted under `generation`. The previous
|
|
/// results stay on screen (fading out); the new ones stage until the
|
|
/// fade reaches zero.
|
|
pub fn on_search_started(&mut self, generation: u64) {
|
|
self.generation = generation;
|
|
self.staging.clear();
|
|
self.staging_has_snippets = false;
|
|
self.swap_pending = true;
|
|
self.running = true;
|
|
self.search_started = Some(Instant::now());
|
|
self.elapsed = None;
|
|
self.limited = false;
|
|
self.error = None;
|
|
}
|
|
|
|
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.
|
|
for hit in hits {
|
|
if self.staging.len() >= display_limit {
|
|
self.limited = true;
|
|
break;
|
|
}
|
|
self.staging_has_snippets |= hit.snippet.is_some();
|
|
self.staging.push(hit);
|
|
}
|
|
} else {
|
|
// Post-swap stream: later cascade passes append live.
|
|
for hit in hits {
|
|
if self.results.len() >= display_limit {
|
|
self.limited = true;
|
|
break;
|
|
}
|
|
self.has_snippets |= hit.snippet.is_some();
|
|
self.results.push(hit);
|
|
}
|
|
// Arrival order *is* rank order, so the default sort
|
|
// needs no work; anything else re-sorts on the set.
|
|
if self.sort != (SortKey::Rank, true) {
|
|
self.sort_dirty = true;
|
|
} else {
|
|
self.order = (0..self.results.len() as u32).collect();
|
|
}
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn result_count_label(&self) -> Option<String> {
|
|
if self.query.trim().is_empty() && self.results.is_empty() {
|
|
return None;
|
|
}
|
|
Some(if self.limited {
|
|
format!("{}+ results (truncated)", self.results.len())
|
|
} else {
|
|
format!("{} results", self.results.len())
|
|
})
|
|
}
|
|
|
|
fn resort(&mut self) {
|
|
let (key, ascending) = self.sort;
|
|
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.partial_cmp(&b.rank).unwrap_or(std::cmp::Ordering::Equal),
|
|
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),
|
|
};
|
|
if ascending {
|
|
ord
|
|
} else {
|
|
ord.reverse()
|
|
}
|
|
});
|
|
// 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;
|
|
}
|
|
|
|
/// A sortable column header: the whole cell is the click target, the
|
|
/// label is centered, and the sort indicator is a painter-drawn
|
|
/// triangle (the default egui fonts have no ▲/▼ glyphs — they render
|
|
/// as boxes).
|
|
fn sort_header(&mut self, ui: &mut egui::Ui, key: SortKey, label: &str) {
|
|
let (cur, asc) = self.sort;
|
|
let selected = cur == key;
|
|
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 response.clicked() {
|
|
self.sort = if selected { (key, !asc) } else { (key, true) };
|
|
self.sort_dirty = true;
|
|
}
|
|
}
|
|
|
|
pub fn ui(&mut self, ui: &mut egui::Ui) -> SearchActions {
|
|
let mut actions = SearchActions::default();
|
|
|
|
// --- Query strip -------------------------------------------------
|
|
ui.horizontal(|ui| {
|
|
let show_elapsed =
|
|
!self.running && self.elapsed.is_some() && !self.query.trim().is_empty();
|
|
let slot_room = if self.running {
|
|
24.0
|
|
} else if show_elapsed {
|
|
60.0
|
|
} else {
|
|
0.0
|
|
};
|
|
let width = ui.available_width() - 170.0 - slot_room;
|
|
let highlight = &mut self.highlight;
|
|
let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, _wrap: f32| {
|
|
crate::query_highlight::galley(ui, highlight, buf.as_str())
|
|
};
|
|
let response = ui.add(
|
|
egui::TextEdit::singleline(&mut self.query)
|
|
.desired_width(width.max(120.0))
|
|
.hint_text("Search names and contents… (type:Document regex:… budget*)")
|
|
.layouter(&mut layouter),
|
|
);
|
|
if self.focus_query {
|
|
response.request_focus();
|
|
self.focus_query = false;
|
|
}
|
|
if response.changed() {
|
|
self.pending_edit = Some(Instant::now());
|
|
}
|
|
// One slot right of the box: spinner while searching, then the
|
|
// total wall time of all cascade passes once it lands.
|
|
if self.running {
|
|
ui.add(egui::Spinner::new().size(16.0));
|
|
} else if show_elapsed {
|
|
if let Some(elapsed) = self.elapsed {
|
|
ui.label(egui::RichText::new(fmt_elapsed(elapsed)).small().weak())
|
|
.on_hover_text("Time to run all search passes");
|
|
}
|
|
}
|
|
if ui
|
|
.checkbox(&mut self.fuzzy, "Fuzzy")
|
|
.on_hover_text("Also run fuzzy filename and full-text passes (slower)")
|
|
.changed()
|
|
{
|
|
actions.save_fuzzy_default = Some(self.fuzzy);
|
|
actions.rerun = true;
|
|
}
|
|
if ui.button("?").on_hover_text("Query syntax help").clicked() {
|
|
self.help_open = !self.help_open;
|
|
}
|
|
});
|
|
|
|
// Session ignore chips.
|
|
if !self.session_ignores.is_empty() {
|
|
ui.horizontal_wrapped(|ui| {
|
|
ui.label(egui::RichText::new("Ignoring:").small().weak());
|
|
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 in Options).",
|
|
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(egui::RichText::new("No results.").small().weak());
|
|
}
|
|
|
|
// Result-set transitions pulse instead of strobing: the old table
|
|
// fades out over 0.25 s while the new hits stage, the sets swap at
|
|
// zero opacity, and the new table fades back in over 0.25 s.
|
|
// `animate_value_with_time` keeps requesting repaints until the
|
|
// value settles.
|
|
let fade_target = if self.swap_pending { 0.0 } else { 1.0 };
|
|
let fade = ui.ctx().animate_value_with_time(
|
|
egui::Id::new("qs-results-fade"),
|
|
fade_target,
|
|
0.25,
|
|
);
|
|
if self.swap_pending && fade <= 0.01 {
|
|
self.results = std::mem::take(&mut self.staging);
|
|
self.has_snippets = self.staging_has_snippets;
|
|
self.selected = None;
|
|
self.swap_pending = false;
|
|
if self.sort == (SortKey::Rank, true) {
|
|
self.order = (0..self.results.len() as u32).collect();
|
|
} else {
|
|
self.sort_dirty = true;
|
|
}
|
|
}
|
|
|
|
if self.sort_dirty {
|
|
self.resort();
|
|
}
|
|
|
|
// Fade covers the table and the preview strip below it; the modal
|
|
// windows and notices render at full opacity on their own layers.
|
|
ui.set_opacity(fade);
|
|
|
|
// --- Results table ------------------------------------------------
|
|
// Reserve room for the selected-row snippet preview strip. Only
|
|
// content matches get one — a filename match's "snippet" is the
|
|
// name, already on screen.
|
|
let preview_snippet: Option<Snippet> = self
|
|
.selected
|
|
.and_then(|i| self.results.get(i as usize))
|
|
.filter(|h| matches!(h.stage, 5 | 6 | 8))
|
|
.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);
|
|
|
|
let text_height = egui::TextStyle::Body.resolve(ui.style()).size + 4.0;
|
|
let mut open_ignore_dialog: Option<usize> = None;
|
|
|
|
ui.push_id("results", |ui| {
|
|
let mut table = TableBuilder::new(ui)
|
|
.striped(true)
|
|
.resizable(true)
|
|
.sense(egui::Sense::click())
|
|
.max_scroll_height(table_height)
|
|
.min_scrolled_height(60.0)
|
|
.column(Column::initial(220.0).at_least(80.0).clip(true)) // name
|
|
.column(Column::remainder().at_least(120.0).clip(true)); // path
|
|
if self.has_snippets {
|
|
table = table.column(Column::remainder().at_least(120.0).clip(true));
|
|
}
|
|
table = table
|
|
.column(Column::exact(72.0)) // size
|
|
.column(Column::exact(110.0)) // modified
|
|
.column(Column::exact(52.0)); // rank
|
|
|
|
table
|
|
.header(text_height + 4.0, |mut header| {
|
|
header.col(|ui| self.sort_header(ui, SortKey::Name, "Name"));
|
|
header.col(|ui| self.sort_header(ui, SortKey::Path, "Path"));
|
|
if self.has_snippets {
|
|
header.col(|ui| {
|
|
ui.with_layout(
|
|
egui::Layout::centered_and_justified(
|
|
egui::Direction::LeftToRight,
|
|
),
|
|
|ui| {
|
|
ui.label(egui::RichText::new("Match").strong());
|
|
},
|
|
);
|
|
});
|
|
}
|
|
header.col(|ui| self.sort_header(ui, SortKey::Size, "Size"));
|
|
header.col(|ui| self.sort_header(ui, SortKey::Modified, "Modified"));
|
|
header.col(|ui| self.sort_header(ui, SortKey::Rank, "Rank"));
|
|
})
|
|
.body(|body| {
|
|
let order = self.order.clone();
|
|
body.rows(text_height, order.len(), |mut row| {
|
|
let result_ix = order[row.index()] as usize;
|
|
let hit = &self.results[result_ix];
|
|
row.set_selected(self.selected == Some(result_ix as u32));
|
|
|
|
row.col(|ui| {
|
|
ui.label(&hit.name);
|
|
});
|
|
row.col(|ui| {
|
|
ui.label(egui::RichText::new(&hit.path).weak());
|
|
});
|
|
if self.has_snippets {
|
|
let snippet = hit.snippet.clone();
|
|
// Name and path matches show a whole field, so
|
|
// they render bracketed: [matched field].
|
|
let whole_field =
|
|
hit.stage <= 4 || hit.stage == 7 || hit.stage >= 9;
|
|
row.col(|ui| {
|
|
if let Some(snip) = &snippet {
|
|
let width = ui.available_width();
|
|
let job = centered_match_job(ui, snip, width, whole_field);
|
|
let response = ui
|
|
.with_layout(
|
|
egui::Layout::centered_and_justified(
|
|
egui::Direction::LeftToRight,
|
|
),
|
|
|ui| ui.label(job),
|
|
)
|
|
.inner;
|
|
if !snip.ranges.is_empty() {
|
|
let hover = snip.clone();
|
|
response.on_hover_ui(|ui| {
|
|
ui.set_max_width(520.0);
|
|
let job = snippet_job(ui, &hover, 10);
|
|
ui.label(job);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
row.col(|ui| {
|
|
ui.with_layout(
|
|
egui::Layout::centered_and_justified(
|
|
egui::Direction::LeftToRight,
|
|
),
|
|
|ui| {
|
|
ui.label(human_size(hit.size));
|
|
},
|
|
);
|
|
});
|
|
row.col(|ui| {
|
|
let color = recency_color(ui, hit.mtime);
|
|
ui.with_layout(
|
|
egui::Layout::centered_and_justified(
|
|
egui::Direction::LeftToRight,
|
|
),
|
|
|ui| {
|
|
ui.label(
|
|
egui::RichText::new(fmt_mtime(hit.mtime)).color(color),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
row.col(|ui| {
|
|
ui.with_layout(
|
|
egui::Layout::centered_and_justified(
|
|
egui::Direction::LeftToRight,
|
|
),
|
|
|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)),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
let response = row.response();
|
|
if response.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").clicked() {
|
|
platform::open_file(&path);
|
|
ui.close();
|
|
}
|
|
if ui.button("Open containing folder").clicked() {
|
|
platform::reveal_in_folder(&path);
|
|
ui.close();
|
|
}
|
|
ui.separator();
|
|
if ui.button("Build ignore filter…").clicked() {
|
|
open_ignore_dialog = Some(result_ix);
|
|
ui.close();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
if let Some(ix) = open_ignore_dialog {
|
|
let hit = &self.results[ix];
|
|
self.ignore_dialog = Some(IgnoreDialog {
|
|
source_name: hit.name.clone(),
|
|
source_path: hit.path.clone(),
|
|
pattern: hit.name.clone(),
|
|
persist: false,
|
|
});
|
|
}
|
|
|
|
// Selected-row preview strip: the full snippet, wrapped.
|
|
if let Some(snip) = &preview_snippet {
|
|
ui.separator();
|
|
let job = snippet_job(ui, snip, 2);
|
|
ui.label(job);
|
|
}
|
|
|
|
self.ignore_dialog_ui(ui.ctx(), &mut actions);
|
|
self.help_window_ui(ui.ctx());
|
|
actions
|
|
}
|
|
|
|
fn ignore_dialog_ui(&mut self, ctx: &egui::Context, actions: &mut SearchActions) {
|
|
let Some(dialog) = &mut self.ignore_dialog else {
|
|
return;
|
|
};
|
|
let mut apply = false;
|
|
let mut cancel = false;
|
|
egui::Window::new("Ignore filter")
|
|
.collapsible(false)
|
|
.resizable(false)
|
|
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
|
.show(ctx, |ui| {
|
|
ui.label(format!("From: {}", dialog.source_path));
|
|
ui.add_space(4.0);
|
|
ui.horizontal(|ui| {
|
|
if ui.button("This name").clicked() {
|
|
dialog.pattern = dialog.source_name.clone();
|
|
}
|
|
if let Some(ext) = std::path::Path::new(&dialog.source_name)
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
{
|
|
if ui.button(format!("*.{}", ext)).clicked() {
|
|
dialog.pattern = format!("*.{}", ext);
|
|
}
|
|
}
|
|
if let Some(parent) = std::path::Path::new(&dialog.source_path)
|
|
.parent()
|
|
.and_then(|p| p.to_str())
|
|
{
|
|
if ui.button("This directory").clicked() {
|
|
dialog.pattern = format!("{}/*", parent);
|
|
}
|
|
}
|
|
});
|
|
ui.add(
|
|
egui::TextEdit::singleline(&mut dialog.pattern)
|
|
.desired_width(360.0)
|
|
.hint_text("glob pattern"),
|
|
);
|
|
ui.checkbox(&mut dialog.persist, "Persist to config");
|
|
ui.label(
|
|
egui::RichText::new(
|
|
"Session filters hide results immediately. Persisted filters also \
|
|
exclude files from the index at the next reindex.",
|
|
)
|
|
.small()
|
|
.weak(),
|
|
);
|
|
ui.horizontal(|ui| {
|
|
if ui.button("Apply").clicked() {
|
|
apply = true;
|
|
}
|
|
if ui.button("Cancel").clicked() {
|
|
cancel = true;
|
|
}
|
|
});
|
|
});
|
|
if apply {
|
|
let dialog = self.ignore_dialog.take().unwrap();
|
|
let pattern = dialog.pattern.trim().to_string();
|
|
if !pattern.is_empty() {
|
|
if !self.session_ignores.contains(&pattern) {
|
|
self.session_ignores.push(pattern.clone());
|
|
}
|
|
if dialog.persist {
|
|
actions.persist_ignore = Some(pattern);
|
|
}
|
|
actions.rerun = true;
|
|
}
|
|
} else if cancel {
|
|
self.ignore_dialog = None;
|
|
}
|
|
}
|
|
|
|
fn help_window_ui(&mut self, ctx: &egui::Context) {
|
|
let mut open = self.help_open;
|
|
egui::Window::new("Query syntax")
|
|
.open(&mut open)
|
|
.resizable(false)
|
|
.default_width(540.0)
|
|
.show(ctx, |ui| {
|
|
ui.label(
|
|
"Everything that is not a filter is matched as one phrase, in order. \
|
|
Filters combine freely with the search text.",
|
|
);
|
|
ui.add_space(6.0);
|
|
egui::Grid::new("query-syntax-table")
|
|
.num_columns(2)
|
|
.spacing([18.0, 5.0])
|
|
.striped(true)
|
|
.show(ui, |ui| {
|
|
let row = |ui: &mut egui::Ui, syntax: &str, meaning: &str| {
|
|
ui.monospace(syntax);
|
|
ui.label(meaning);
|
|
ui.end_row();
|
|
};
|
|
row(
|
|
ui,
|
|
"budget report",
|
|
"names, contents, and paths containing \"budget report\"",
|
|
);
|
|
row(
|
|
ui,
|
|
"\"exact phrase\"",
|
|
"quotes keep spaces, stars, and filter-like words literal",
|
|
);
|
|
row(
|
|
ui,
|
|
"bud*report",
|
|
"* matches any run of characters (within a line); \
|
|
also works in name: values",
|
|
);
|
|
row(
|
|
ui,
|
|
"regex:\"(foo|bar)\\d+\"",
|
|
"regular expression, matched against names, contents, \
|
|
and paths; case-insensitive — use (?-i:…) to override; \
|
|
quote patterns containing spaces",
|
|
);
|
|
row(
|
|
ui,
|
|
"type:Document",
|
|
"file class: Audio, Image, Video, Document, Text, \
|
|
Archive, Spreadsheet, Presentation, Folder",
|
|
);
|
|
row(
|
|
ui,
|
|
"modified:>=2024-01-01",
|
|
"modification date (yyyy-mm-dd); also <, <=, > and =",
|
|
);
|
|
row(
|
|
ui,
|
|
"path:/home/me/docs",
|
|
"only results in that folder and its subfolders; \
|
|
quote paths containing spaces",
|
|
);
|
|
row(ui, "mime:application/pdf", "exact MIME type");
|
|
row(
|
|
ui,
|
|
"name:report",
|
|
"filename contains, applied as an unranked filter",
|
|
);
|
|
});
|
|
ui.add_space(6.0);
|
|
ui.label("Example:");
|
|
ui.monospace("type:Document modified:>=2024-01-01 quarterly budget");
|
|
ui.add_space(6.0);
|
|
ui.label(
|
|
egui::RichText::new(
|
|
"Ranking: exact filename matches, then filename substrings, then \
|
|
full-text matches (ordered by occurrences), then fuzzy matches \
|
|
when enabled, and finally matches on the rest of the file path.",
|
|
)
|
|
.small()
|
|
.weak(),
|
|
);
|
|
ui.label(
|
|
egui::RichText::new(
|
|
"The complete reference, including ranking details and the \
|
|
fuzzy edit budget, is the \"Query syntax\" section of \
|
|
README.md in the QuickSearch folder.",
|
|
)
|
|
.small()
|
|
.weak(),
|
|
);
|
|
});
|
|
self.help_open = open;
|
|
}
|
|
}
|
|
|
|
struct SnippetFormats {
|
|
normal: TextFormat,
|
|
highlight: TextFormat,
|
|
weak: TextFormat,
|
|
}
|
|
|
|
fn snippet_formats(ui: &egui::Ui) -> SnippetFormats {
|
|
let font_id = egui::TextStyle::Body.resolve(ui.style());
|
|
SnippetFormats {
|
|
normal: TextFormat {
|
|
font_id: font_id.clone(),
|
|
color: ui.visuals().text_color(),
|
|
..Default::default()
|
|
},
|
|
highlight: TextFormat {
|
|
font_id: font_id.clone(),
|
|
color: ui.visuals().strong_text_color(),
|
|
background: ui.visuals().selection.bg_fill.gamma_multiply(0.4),
|
|
..Default::default()
|
|
},
|
|
weak: TextFormat {
|
|
font_id,
|
|
color: ui.visuals().weak_text_color(),
|
|
..Default::default()
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Build a highlighted snippet LayoutJob from byte ranges, wrapped to at
|
|
/// most `max_rows`. Cheap enough to run per visible row per frame.
|
|
fn snippet_job(ui: &egui::Ui, snip: &Snippet, max_rows: usize) -> LayoutJob {
|
|
let fmt = snippet_formats(ui);
|
|
let mut job = LayoutJob::default();
|
|
job.wrap.max_rows = max_rows;
|
|
if max_rows == 1 {
|
|
job.wrap.break_anywhere = true;
|
|
}
|
|
if snip.truncated_start {
|
|
job.append("… ", 0.0, fmt.weak.clone());
|
|
}
|
|
let mut cursor = 0;
|
|
for &(start, end) in &snip.ranges {
|
|
if start > cursor {
|
|
job.append(&snip.window[cursor..start], 0.0, fmt.normal.clone());
|
|
}
|
|
job.append(&snip.window[start..end], 0.0, fmt.highlight.clone());
|
|
cursor = end;
|
|
}
|
|
if cursor < snip.window.len() {
|
|
job.append(&snip.window[cursor..], 0.0, fmt.normal.clone());
|
|
}
|
|
if snip.truncated_end {
|
|
job.append(" …", 0.0, fmt.weak);
|
|
}
|
|
job
|
|
}
|
|
|
|
/// The Match column cell: one line with the (first) matched span centered
|
|
/// and an equal amount of context on both sides, trimmed to what fits the
|
|
/// column width. Matches on a whole field — a filename or a path — are
|
|
/// wrapped in brackets: `[name]`.
|
|
fn centered_match_job(
|
|
ui: &egui::Ui,
|
|
snip: &Snippet,
|
|
width_px: f32,
|
|
whole_field: bool,
|
|
) -> LayoutJob {
|
|
let fmt = snippet_formats(ui);
|
|
let font_id = egui::TextStyle::Body.resolve(ui.style());
|
|
let char_width = ui.fonts(|f| f.glyph_width(&font_id, '0')).max(1.0);
|
|
let mut budget = ((width_px / char_width) as usize).saturating_sub(2).max(8);
|
|
if whole_field {
|
|
budget = budget.saturating_sub(2); // room for the brackets
|
|
}
|
|
|
|
// Newlines force line breaks even in a one-row LayoutJob, wrecking the
|
|
// centered single-line cell. Flatten them to spaces — a byte-for-byte
|
|
// ASCII replacement, so the match ranges stay valid. The mouseover
|
|
// renders the original window untouched.
|
|
let flattened = snip.window.replace(['\n', '\r', '\t'], " ");
|
|
let window = flattened.as_str();
|
|
let (start, end) = match snip.ranges.first().copied() {
|
|
Some((a, b)) => {
|
|
let match_chars = window[a..b].chars().count();
|
|
let side = budget.saturating_sub(match_chars) / 2;
|
|
let before = &window[..a];
|
|
let after = &window[b..];
|
|
let before_count = before.chars().count();
|
|
let after_count = after.chars().count();
|
|
// Equal context on both sides; leftover budget from a short
|
|
// side flows to the other.
|
|
let take_before = (side + side.saturating_sub(after_count)).min(before_count);
|
|
let take_after = (side + side.saturating_sub(before_count)).min(after_count);
|
|
let start = if take_before == 0 {
|
|
a
|
|
} else {
|
|
before
|
|
.char_indices()
|
|
.nth_back(take_before - 1)
|
|
.map(|(i, _)| i)
|
|
.unwrap_or(0)
|
|
};
|
|
let end = b + after
|
|
.char_indices()
|
|
.nth(take_after)
|
|
.map(|(i, _)| i)
|
|
.unwrap_or(after.len());
|
|
(start, end)
|
|
}
|
|
None => {
|
|
// No ranges (shouldn't happen for match cells) — head trim.
|
|
let end = window
|
|
.char_indices()
|
|
.nth(budget)
|
|
.map(|(i, _)| i)
|
|
.unwrap_or(window.len());
|
|
(0, end)
|
|
}
|
|
};
|
|
|
|
let mut job = LayoutJob::default();
|
|
job.wrap.max_rows = 1;
|
|
job.wrap.break_anywhere = true;
|
|
if whole_field {
|
|
job.append("[", 0.0, fmt.weak.clone());
|
|
}
|
|
if start > 0 || snip.truncated_start {
|
|
job.append("…", 0.0, fmt.weak.clone());
|
|
}
|
|
let mut cursor = start;
|
|
for &(a, b) in &snip.ranges {
|
|
let (a, b) = (a.max(start), b.min(end));
|
|
if a >= b || a >= end {
|
|
continue;
|
|
}
|
|
if a > cursor {
|
|
job.append(&window[cursor..a], 0.0, fmt.normal.clone());
|
|
}
|
|
job.append(&window[a..b], 0.0, fmt.highlight.clone());
|
|
cursor = b;
|
|
}
|
|
if cursor < end {
|
|
job.append(&window[cursor..end], 0.0, fmt.normal.clone());
|
|
}
|
|
if end < window.len() || snip.truncated_end {
|
|
job.append("…", 0.0, fmt.weak.clone());
|
|
}
|
|
if whole_field {
|
|
job.append("]", 0.0, fmt.weak);
|
|
}
|
|
job
|
|
}
|
|
|
|
/// Tier-list chip color per cascade stage — lower rank, higher tier:
|
|
/// S-red for exact case-sensitive filename matches down through the
|
|
/// pastel ramp to purple for fuzzy full-text and on to the grey path
|
|
/// tiers. Dark text on these pastels stays readable in both themes.
|
|
fn rank_tier_color(stage: u8) -> egui::Color32 {
|
|
match stage {
|
|
1 => egui::Color32::from_rgb(255, 127, 127), // S
|
|
2 => egui::Color32::from_rgb(255, 191, 127), // A
|
|
3 => egui::Color32::from_rgb(255, 223, 127), // B
|
|
4 => egui::Color32::from_rgb(255, 255, 127), // C
|
|
5 => egui::Color32::from_rgb(191, 255, 127), // D
|
|
6 => egui::Color32::from_rgb(127, 255, 127), // E
|
|
7 => egui::Color32::from_rgb(127, 191, 255), // F
|
|
8 => egui::Color32::from_rgb(191, 127, 255), // G
|
|
9 => egui::Color32::from_rgb(223, 159, 255), // H — path, exact case
|
|
10 => egui::Color32::from_rgb(239, 191, 239), // I — path, any case
|
|
_ => egui::Color32::from_rgb(199, 199, 199), // J — fuzzy path
|
|
}
|
|
}
|
|
|
|
/// Timestamp color: fresh files get a green tint that fades into the weak
|
|
/// text color over ~2 years on a log scale.
|
|
fn recency_color(ui: &egui::Ui, mtime: i64) -> egui::Color32 {
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0);
|
|
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 = egui::Color32::from_rgb(87, 187, 122);
|
|
let old = ui.visuals().weak_text_color();
|
|
let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8;
|
|
egui::Color32::from_rgb(
|
|
lerp(fresh.r(), old.r()),
|
|
lerp(fresh.g(), old.g()),
|
|
lerp(fresh.b(), old.b()),
|
|
)
|
|
}
|