quick_search/crates/quicksearch-gui/src/help_tab.rs
Jeremy Karst be30ddc9af
All checks were successful
CI / linux (push) Successful in 16m22s
CI / windows-cross (push) Successful in 5m9s
CI / release (push) Successful in 15s
Tail-check duplicates, re-measure benchmarks and fix CI's release-only test failures
- Duplicates tab: groups are confirmed by each file's last 8 KiB and
  keyed by head+tail; unchecked files are never shown. False-positive
  duplicates drop from 3.4% to 0.1%, and indexing is unchanged.
- Encrypted search cache cap raised to 192 MiB at 150 B per file: a
  keystroke at 1M files takes 59 ms, down from 99 ms.
- Case-insensitive filename matching uses a memchr2 scan (1.18x).
- Benches run again on the compact index, seed with the indexer's 2 GiB
  WAL cap (peak scratch 6.2 GB -> 2.45 GB), and every recorded figure
  is re-measured.
- A fresh index no longer logs a rebuild warning.
- CI: the sandbox memory test's allocation survives release builds, and
  --toggle retries an interrupted read.
2026-10-12 00:14:10 -04:00

696 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The Help tab: what QuickSearch is, how to start, how results are ranked
//! and what ends up in the index. A control's own help lives on its hover tip
//! (`tips.rs`), and the query language in the Search tab's syntax window;
//! README.md covers building, installing and configuring.
use crate::ui_util::hint;
/// Returns true when the "Show the introduction again" button was clicked.
pub fn ui(ui: &mut egui::Ui) -> bool {
let mut replay = false;
// A page-like column, so a maximized window does not stretch paragraphs
// into single lines. Only ever a cap: `set_max_width` widens as readily
// as it narrows, and text wider than the panel is clipped, not wrapped.
// Measured here because a `ScrollArea`'s content may be wider than its
// viewport; the scroll bar's width comes off the top.
let column = (ui.available_width() - ui.spacing().scroll.allocated_width()).min(620.0);
let scroll = egui::ScrollArea::vertical()
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.set_max_width(column);
section_heading(ui, "Welcome to QuickSearch");
ui.label(
"QuickSearch searches the files on this computer and the text \
inside them. It reads your folders once and answers from that \
index instead of the disk, so results appear as you type, even \
across hundreds of thousands of files.",
);
ui.add_space(4.0);
ui.label(
"It never connects to the internet. The index can be encrypted \
with a password from the Settings tab.",
);
ui.add_space(6.0);
ui.label("The primary home of this software is:");
let link = |ui: &mut egui::Ui, url: &str| ui.hyperlink_to(url, url);
link(ui, "https://quicksearch.karsttech.com");
link(ui, "https://code.karsttech.com/jeremy/quick_search");
ui.add_space(6.0);
ui.label("The code is also mirrored to GitHub for bug reports and issues:");
link(ui, "https://github.com/DataScienceDIY/quick_search");
ui.add_space(6.0);
if ui.button("Show the introduction again").clicked() {
replay = true;
}
ui.add_space(6.0);
ui.label(hint(
"Hover any control to see what it does and what to set it to. \
This tab covers the ideas behind them; the ? button beside the \
search box covers the query language.",
));
ui.add_space(12.0);
section_heading(ui, "Getting started");
ui.label(
"1. On first run QuickSearch indexes your home folder. The status \
bar shows progress; you can search meanwhile, but files not yet \
reached won't appear.",
);
ui.label(
"2. To search other places, add them on the Manage Index tab. \
Indexed folders are watched, so the index keeps up as files \
change.",
);
ui.label(
"3. Type in the search box on the Search tab. Results appear as \
you type, best matches first.",
);
ui.label(
"4. To shrink the index, use the Content filters on the Manage \
Index tab (see What gets indexed, below).",
);
ui.add_space(12.0);
section_heading(ui, "Searching");
ui.label("Plain words match names, contents and paths:");
ui.monospace("quarterly budget");
ui.label("Filters narrow the results and combine with the words:");
ui.monospace("type:Document modified:>=2024-01-01 report");
ui.label("The ? button left of the search box shows the full query syntax.");
ui.add_space(6.0);
ui.label(
"• A match in a name or path is highlighted in that column; a \
match in the contents shows the surrounding text in the Content \
Match column, with more on hover.",
);
ui.label(
"• Double-click a result to open it. Right-click it to open it or \
its folder, copy its path, or hide files like it from the results.",
);
ui.label(
"• While you type, Up and Down (or Page Up and Page Down) move \
through the results, and Enter opens the selected one, or the top \
one if none is selected.",
);
ui.label(
"• Click a column header to sort by it; right-click any header to \
choose the columns shown.",
);
ui.add_space(12.0);
ranking_section(ui);
ui.add_space(12.0);
what_gets_indexed_section(ui);
ui.add_space(12.0);
duplicates_section(ui);
ui.add_space(12.0);
section_heading(ui, "Updates");
ui.label(
"QuickSearch never sends anything off your computer, so it does \
not update itself.",
);
ui.label("To update, install the latest version yourself from:");
link(ui, "https://quicksearch.karsttech.com");
ui.add_space(12.0);
section_heading(ui, "The other tabs");
let prose = prose_width(ui.available_width());
egui::Grid::new("help-tabs")
.num_columns(2)
.spacing([CELL_SPACING, 5.0])
.show(ui, |ui| {
for (name, what) in OTHER_TABS {
ui.strong(name);
cell(ui, prose, what);
ui.end_row();
}
});
ui.add_space(12.0);
section_heading(ui, "Terminal");
ui.label("QuickSearch also searches from a terminal:");
ui.monospace("quicksearch \"quarterly budget\"");
ui.label("On Windows use quicksearch-cli. Either way, --help lists the flags.");
ui.add_space(12.0);
ui.horizontal_wrapped(|ui| {
// Several widgets make one sentence, so the spacing between
// them comes from the text.
ui.spacing_mut().item_spacing.x = 0.0;
ui.label(hint(
"Building from source, installing and configuring are covered in ",
));
if let Some(path) = readme_path() {
let path = path.display().to_string();
if ui
.link(egui::RichText::new("README.md").small())
.on_hover_text(&path)
.clicked()
{
crate::platform::open_file(&path);
}
} else {
ui.label(hint("README.md"));
}
ui.label(hint("."));
});
});
crate::ui_util::more_below_hint(ui, &scroll);
replay
}
/// A section's heading and the gap under it.
fn section_heading(ui: &mut egui::Ui, text: &str) {
ui.heading(egui::RichText::new(text).strong());
ui.add_space(4.0);
}
/// The other tabs, each with what it is for.
const OTHER_TABS: [(&str, &str); 4] = [
(
"Manage Index",
"indexing status and controls, the indexed folders, and the filters \
that decide what is skipped",
),
(
"Duplicates",
"groups of files that are probably copies; verify before deleting",
),
("Logs", "warnings from indexing"),
(
"Settings",
"every setting in one place; hover any control for what it does",
),
];
/// `(stage, tier, what)`. Each chip wears the Rank column's colour for the
/// *first* cascade stage its tier covers (see the rank table in
/// `search::cascade`): exact name 1–2, name contains 3–4, text inside 5–6,
/// fuzzy 7–8, path 9–11.
const RANKING: [(u8, &str, &str); 5] = [
(1, "Exact name", "the file is called exactly what you typed"),
(
3,
"Name contains",
"what you typed is somewhere in the file's name",
),
(
5,
"Text inside",
"the words are in the file's contents, most mentions first",
),
(
7,
"Close spelling",
"a name or some text within a typo or two of what you typed, \
only while Fuzzy is ticked",
),
(
9,
"Path only",
"no match in the name or text, but a folder along the way matched",
),
];
/// The tiers of `search::cascade`, collapsed to the ones a reader can act on:
/// its eleven ranks pair up (exact case, then any case) everywhere but the
/// fuzzy stages.
fn ranking_section(ui: &mut egui::Ui) {
section_heading(ui, "How results are ranked");
ui.label(
"Each result falls in a tier by where it matched, and tiers come out in \
this order. Within a tier, a match in the case you typed beats one \
that ignores case, and a file that mentions your words more often \
comes first.",
);
ui.add_space(6.0);
let prose = prose_width(ui.available_width());
egui::Grid::new("help-ranking")
.num_columns(2)
.spacing([CELL_SPACING, 5.0])
.striped(true)
.show(ui, |ui| {
for (stage, tier, what) in RANKING {
ui.label(
egui::RichText::new(format!(" {} ", tier))
.strong()
.background_color(crate::color::rank_tier_color(stage))
// The same near-black the Search tab's chips carry,
// which every ramp colour holds contrast against.
.color(egui::Color32::from_rgb(32, 32, 32)),
);
cell(ui, prose, what);
ui.end_row();
}
});
ui.add_space(6.0);
ui.label(
"The Rank column shows each result's tier, coloured from blue for a \
close match to red for a distant one. Sorting by another column sets \
this order aside until you sort by Rank again.",
);
ui.label(hint(
"Results stream in while a search runs, so a better match can still \
appear above ones already shown.",
));
}
/// The two Manage Index filters, side by side, because the confusion they
/// cause is about which one keeps a file out of the index (ignore patterns)
/// and which one only stops its text being read (the whitelist).
fn what_gets_indexed_section(ui: &mut egui::Ui) {
section_heading(ui, "What gets indexed");
ui.label(
"Everything in your indexed folders, minus what the two filters on the \
Manage Index tab take out. One decides which files are in the index at \
all; the other decides which of those have their text read.",
);
ui.add_space(8.0);
ui.strong("Ignore patterns: files and folders never indexed");
ui.add_space(4.0);
ui.label(
"Patterns match file and folder names and whole paths, never file \
contents. A file they match is gone from the index entirely, text \
included.",
);
ui.add_space(4.0);
ui.label(
"• No slash: a name. It must match a whole file or folder name, \
anywhere in your indexed folders, so excluding an extension needs a \
wildcard (*.jpg).",
);
ui.label(
"• With a slash: a path. It must match a whole path, and takes out \
everything under it.",
);
ui.label("• * matches any run of characters, including none; ? exactly one.");
ui.add_space(6.0);
ignore_examples(ui);
ui.add_space(6.0);
ui.label(hint(
"Path patterns match from the start, so Vacation/Diary alone matches \
nothing; */Vacation/Diary finds that folder anywhere. Case matters on \
Linux but not on Windows or macOS, so *diary* catches Diary only there.",
));
ui.label(hint(
"Removing a pattern brings its files back at the next indexing run. \
QuickSearch never deletes the files themselves.",
));
ui.add_space(8.0);
ui.strong("Full-text extensions whitelist: which files have their text read");
ui.add_space(4.0);
ui.label(
"This limits contents only. Files it leaves out are still indexed and \
found by name and path, just not by the words inside them.",
);
ui.add_space(4.0);
ui.label(
"• Empty, the default, means QuickSearch reads the text of every file \
type it understands.",
);
ui.label(
"• To narrow it, enter one extension per line, leading dot optional. \
Listing only txt, md and pdf keeps the stored text small and focused \
on documents; every other file stays findable by name.",
);
ui.label(
"• A non-empty list also leaves out files with no extension, such as \
Makefile or README. Add the line (none) to include them.",
);
ui.label("• Anything after a # is a comment, so a line can be switched off in place.");
}
/// What a duplicate group is and is not. The tab has room for the caution
/// but not the reasoning, and "they differ but look the same" is the
/// question the feature reliably provokes.
fn duplicates_section(ui: &mut egui::Ui) {
section_heading(ui, "Duplicates");
ui.label(
"The Duplicates tab groups files that look like copies, biggest waste \
first. It only lists them; it never deletes or moves anything.",
);
ui.add_space(4.0);
ui.label(
"A group is a strong suspicion, not a verdict: files are grouped by \
their size and a hash of their first few kilobytes, then split apart \
where their last few kilobytes differ, and the rest is never read. \
A file whose end cannot be read is left out. Before deleting \
anything, right-click the group and choose Verify copies are \
identical to compare every byte.",
);
ui.add_space(8.0);
ui.strong("Verified as different, but they look the same?");
ui.add_space(4.0);
ui.label(
"They differ in bytes you never see. PDFs and Office documents store \
their creation and modification dates, a document ID and a revision \
number inside the file, so two invoices from one template, or one \
document saved twice, are different files on disk.",
);
ui.label(hint(
"The report names the byte that differs and the file's size. A \
difference in the first few hundred bytes is almost always that \
bookkeeping; one deep inside a large file is not.",
));
ui.add_space(8.0);
ui.strong("Deleted a copy, but no space came back?");
ui.add_space(4.0);
ui.label(
"Hard links and bind-mounted folders show one file under several \
paths. They group as copies and verify as identical, because they \
are the same bytes, but only one file is on disk, so deleting a \
path frees nothing.",
);
ui.add_space(8.0);
ui.strong("Groups you never want to see again");
ui.add_space(4.0);
ui.label(
"• Hide this group (right-click) drops one group for good. It is \
remembered by content, so it stays hidden when the files are renamed \
or moved and returns if they are edited. Show hidden lists hidden \
groups; Clear forgets them all.",
);
ui.label(
"• Exclude drops whole sets of files, such as a backup folder meant to \
hold copies. The box above the list takes ignore-filter patterns, and \
the right-click menu offers the file's type or folder.",
);
ui.label(hint(
"Neither affects search: excluded files stay indexed and findable by \
name and content.",
));
}
/// One ignore-pattern example: what it takes out, and the near miss it
/// leaves alone. The near miss is half the point, so it survives every
/// layout.
struct Example {
pattern: &'static str,
excluded: &'static str,
kept: &'static str,
}
const IGNORE_EXAMPLES: &[Example] = &[
Example {
pattern: "node_modules",
excluded: "a file or folder named exactly that, and all it holds",
kept: "node_modules_old",
},
Example {
pattern: "*.jpg",
excluded: "holiday.jpg, 1.jpg",
kept: "holiday.jpeg, holiday.jpg.exe",
},
Example {
pattern: "?.jpg",
excluded: "a.jpg, 1.jpg",
kept: "12.jpg, holiday.jpg",
},
Example {
pattern: "*diary*",
excluded: "Mydiary.jpg, and a folder named Diary with all it holds",
kept: "Pictures/Vacation",
},
Example {
pattern: "*/Vacation/Diary",
excluded: "that one folder, and everything under it",
kept: "Pictures/Diary",
},
];
/// Under this, three columns get so little each that the middle one wraps to
/// one or two words a line and the table is harder to read than a list.
const EXAMPLE_TABLE_MIN_WIDTH: f32 = 560.0;
/// The examples as a table where there is room for one, stacked where there
/// is not. The two layouts differ in widget count, hence
/// [`crate::ui_util::stable_section`].
fn ignore_examples(ui: &mut egui::Ui) {
crate::ui_util::stable_section(ui, |ui| {
let available = ui.available_width();
if available < EXAMPLE_TABLE_MIN_WIDTH {
for example in IGNORE_EXAMPLES {
ui.monospace(example.pattern);
cell(ui, available, format!("Excluded: {}", example.excluded));
cell(ui, available, format!("Still indexed: {}", example.kept));
ui.add_space(6.0);
}
return;
}
// The two prose columns share what the pattern column and the two
// gaps leave over.
let prose = (available - KEY_COL_WIDTH - 2.0 * CELL_SPACING) / 2.0;
egui::Grid::new("help-ignore-examples")
.num_columns(3)
.spacing([CELL_SPACING, 5.0])
.striped(true)
.show(ui, |ui| {
ui.strong("Pattern");
cell(ui, prose, egui::RichText::new("Excluded").strong());
cell(ui, prose, egui::RichText::new("Still indexed").strong());
ui.end_row();
for example in IGNORE_EXAMPLES {
ui.monospace(example.pattern);
cell(ui, prose, example.excluded);
cell(ui, prose, example.kept);
ui.end_row();
}
});
});
}
/// Width allowed for a table's leading key column, generous enough for the
/// longest of them (`*/Vacation/Diary`, in monospace).
const KEY_COL_WIDTH: f32 = 140.0;
/// Both tables' horizontal cell spacing.
const CELL_SPACING: f32 = 14.0;
/// A prose cell in a child `Ui` of exactly `width` and exactly the wrapped
/// text's height. The fixed width keeps `Grid`'s default `Extend` wrap from
/// widening the whole tab, and a wrapped `Label` from shrinking its column to
/// a ribbon. The measured height keeps `Grid` from centring a zero-height box
/// on the row's midpoint, which would put the text half a row below its
/// stripe.
fn cell(ui: &mut egui::Ui, width: f32, text: impl Into<egui::WidgetText>) {
let galley = text.into().into_galley(
ui,
Some(egui::TextWrapMode::Wrap),
width,
egui::TextStyle::Body,
);
ui.allocate_ui(egui::vec2(width, galley.size().y), |ui| {
ui.add(egui::Label::new(galley));
});
}
/// The prose width for a two-column table: everything the key column and the
/// spacing leave over.
fn prose_width(available: f32) -> f32 {
(available - KEY_COL_WIDTH - CELL_SPACING).max(120.0)
}
/// Where this build left the README: under the install prefix's `share/doc`
/// (the .deb puts it in `/usr/share/doc/quicksearch/`), beside the executable
/// (the Windows installer and portable copies), or at the top of a build tree
/// a few levels above `target/`.
fn readme_path() -> Option<std::path::PathBuf> {
let exe = std::env::current_exe().ok()?;
let dir = exe.parent()?;
let installed = dir
.parent()
.map(|prefix| prefix.join("share/doc/quicksearch/README.md"));
installed
.into_iter()
.chain(dir.ancestors().take(4).map(|d| d.join("README.md")))
.find(|p| p.is_file())
}
#[cfg(test)]
mod tests {
/// One pass of the whole tab in a `w` by `h` viewport.
fn render(w: f32, h: f32) -> (egui::Context, egui::FullOutput) {
let ctx = crate::test_ui::ctx();
let out = crate::test_ui::run_panel(&ctx, egui::vec2(w, h), vec![], |ui| {
super::ui(ui);
});
(ctx, out)
}
/// The Help tab is the largest block of prose in the app and the only tab
/// with no other test, which makes it the widest net for the one thing
/// dropping egui's emoji faces could break: a character with no glyph,
/// painted as `◻`.
#[test]
fn the_help_tab_paints_no_missing_glyphs() {
let (ctx, out) = render(1000.0, 900.0);
assert!(
!crate::test_ui::painted_text(&out).is_empty(),
"the tab painted nothing, so the glyph check proves nothing"
);
crate::test_ui::assert_no_tofu(&ctx, &out);
}
/// Ranking and the filter rules live on this tab and nowhere else a user
/// can read without hovering, so a section quietly dropped from `ui`
/// would take the only copy with it. Tall enough a viewport that the
/// scroll area paints the whole document.
#[test]
fn the_tab_carries_the_sections_that_live_nowhere_else() {
let (_, out) = render(1000.0, 4000.0);
let painted = crate::test_ui::painted_text(&out).join("\n");
for expected in [
"How results are ranked",
"What gets indexed",
// One row of each table: the grids are the part most easily lost
// to a refactor, and the tooltips point here for them.
"Path only",
"*/Vacation/Diary",
// The duplicates section exists to answer one question in full,
// and the tab has room for the caution but not the reasoning.
"Verify copies are identical",
"revision number",
"Hard links",
] {
assert!(painted.contains(expected), "no {:?}: {}", expected, painted);
}
}
/// The column is capped at 620 points so a maximized window does not
/// stretch a paragraph into one long line. A cap that is also a floor
/// lays the text out past the scroll area, which clips it rather than
/// wrapping it: the ends of the lines simply vanish. Galley rects are
/// pre-clip, so this measures the layout rather than the paint. Also
/// what `cell`'s fixed width is for.
#[test]
fn a_window_narrower_than_the_column_reflows_rather_than_clipping() {
// 640 is the smallest window the app allows, and the UI scale
// divides it: 400 is roughly that window at 1.6x, and 250 is it at
// the 2.5x ceiling, the narrowest layout the app can produce.
for width in [250.0_f32, 320.0, 400.0, 480.0, 560.0, 620.0] {
let (_, out) = render(width, 6000.0);
let overflowing: Vec<_> = crate::test_ui::painted(&out)
.into_iter()
.filter(|(_, rect)| rect.max.x > width)
.map(|(text, rect)| format!("{:?} reaches {}", text, rect.max.x))
.collect();
assert!(
overflowing.is_empty(),
"text laid out past the {}pt panel: {:#?}",
width,
overflowing
);
}
}
/// Every tier chip wears the Rank column's own colour for its first
/// cascade stage: the chips exist to demonstrate the blue-to-red ramp the
/// paragraph under the table describes.
#[test]
fn the_ranking_tiers_wear_the_rank_colors() {
let (_, out) = render(1000.0, 4000.0);
// A RichText background is a section format in the galley, not a
// separate rect shape.
let backgrounds: Vec<egui::Color32> = crate::test_ui::painted_backgrounds(&out)
.into_iter()
.map(|(_, color)| color)
.collect();
for (stage, _, _) in super::RANKING {
let color = crate::color::rank_tier_color(stage);
assert!(
backgrounds.contains(&color),
"no chip painted in stage {}'s colour {:?}",
stage,
color
);
}
}
/// A table cell allocated with no height of its own is centred on its
/// row's midpoint by `Grid` and then laid out downward from there, which
/// leaves every prose column half a row below its key. Also what `cell`'s
/// measured height is for.
#[test]
fn both_halves_of_a_table_row_share_a_centre_line() {
let (_, out) = render(1000.0, 4000.0);
// One single-line and one wrapping row from each striped table: the
// offset is half the row's height, so a wrapping row misses by twice
// as much as a single-line one.
let [exact, _, _, fuzzy, _] = super::RANKING;
let [node_modules, jpg, ..] = super::IGNORE_EXAMPLES else {
panic!("fewer ignore examples than the test reads");
};
for (key, prose) in [
(format!(" {} ", exact.1), exact.2),
(format!(" {} ", fuzzy.1), fuzzy.2),
(jpg.pattern.to_string(), jpg.excluded),
(node_modules.pattern.to_string(), node_modules.excluded),
] {
let key_rect = crate::test_ui::painted_rect(&out, &key);
let prose_rect = crate::test_ui::painted_rect(&out, prose);
assert!(
(key_rect.center().y - prose_rect.center().y).abs() < 1.0,
"{:?} is centred at {} but {:?} at {}",
key,
key_rect.center().y,
prose,
prose_rect.center().y
);
}
}
/// The stripes themselves, so the rows are checked against what the user
/// sees behind them rather than only against each other.
#[test]
fn table_text_sits_inside_its_stripe() {
let (ctx, out) = render(1000.0, 4000.0);
let faint = ctx.style().visuals.faint_bg_color;
let stripes: Vec<egui::Rect> = crate::test_ui::painted_rects(&out)
.into_iter()
.filter(|r| r.fill == faint)
.map(|r| r.rect)
.collect();
assert!(!stripes.is_empty(), "no striped rows painted");
// Only every other row carries a stripe, so both of these are rows
// egui paints one behind: the ranking table's fourth, the examples'
// second.
for prose in [super::RANKING[3].2, super::IGNORE_EXAMPLES[0].excluded] {
let rect = crate::test_ui::painted_rect(&out, prose);
let stripe = stripes
.iter()
.find(|s| s.contains(rect.center()))
.unwrap_or_else(|| panic!("{:?} sits on no stripe: {:#?}", prose, stripes));
assert!(
stripe.y_range().contains(rect.top()) && stripe.y_range().contains(rect.bottom()),
"{:?} spans {:?} but its stripe only {:?}",
prose,
rect.y_range(),
stripe.y_range()
);
}
}
/// The near-miss column is half of what the examples teach, so the
/// narrow layout has to keep it rather than dropping to pattern-only.
#[test]
fn the_stacked_examples_keep_both_halves_of_each_row() {
let (_, out) = render(400.0, 6000.0);
let painted = crate::test_ui::painted_text(&out).join("\n");
for example in super::IGNORE_EXAMPLES {
for expected in [example.pattern, example.excluded, example.kept] {
assert!(painted.contains(expected), "no {:?}", expected);
}
}
}
}