//! The Help tab: what QuickSearch is, how to start, how results are ranked //! and what ends up in the index. Anything belonging to one control lives on //! that control's hover tip (`tips.rs`); the query language lives in the //! Search tab's syntax window; the complete technical reference stays in //! README.md. 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; // Cap the column like a document page: a maximized window would // otherwise stretch every paragraph into one long line. Only ever a cap, // never a floor: `set_max_width` widens a `Ui` as readily as it narrows // one, and text laid out wider than the panel is *clipped* by the scroll // area rather than wrapped, so the ends of the lines simply vanish. // // Measured out here, on the panel: inside a vertical `ScrollArea` the // content `Ui` is free to be wider than the viewport, so its own // `available_width` is no guide to what will be visible. The bar's // allocation comes off the top, or the last few characters of every line // would sit under it (zero while the bars float, as they do by default). 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); ui.heading(egui::RichText::new("Welcome to QuickSearch").strong()); ui.add_space(4.0); ui.label( "QuickSearch is a search engine for the files on this computer \ and for the text inside them. It reads through your folders \ once, remembers what it found, and then answers from what it \ remembers rather than by going back to the disk. That is why \ results appear as fast as you can type, across hundreds of \ thousands of files.", ); ui.add_space(4.0); ui.label( "It never connects to the internet and nothing ever leaves this \ computer. What it remembers 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 easier bug reporting \ and issue tracking:", ); 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( "Where to look for what: hover any control for what that one \ control does and what to set it to. This tab covers the ideas \ behind them. The ? button beside the search box covers the \ query language. README.md is the complete reference.", )); ui.add_space(12.0); ui.heading(egui::RichText::new("Getting started").strong()); ui.add_space(4.0); ui.label( "1. The first time QuickSearch runs it starts indexing your home \ folder on its own. The status bar along the bottom shows the \ progress, and searching already works while it runs, though a \ file it has not reached yet cannot appear until it does.", ); ui.label( "2. To search other places, open the Manage Index tab and add them \ to the folder list. Indexed folders are watched, so the index \ follows your files as they change, and adding one leaves \ everything already indexed alone.", ); ui.label( "3. Type in the search box on the Search tab. Results appear as \ you type, best matches first.", ); ui.label( "4. If the index grows larger than you would like, narrow it with \ the two filters under Content filters on the Manage Index tab. \ They are explained under What gets indexed, below.", ); ui.add_space(12.0); ui.heading(egui::RichText::new("Searching").strong()); ui.add_space(4.0); ui.label("Plain words match file names, file contents, and paths:"); ui.monospace("quarterly budget"); ui.label( "Filters narrow the results and combine freely with the search \ 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 file's name or path is highlighted in that \ column; a match in its contents shows a snippet of the \ surrounding text in the Content Match column, with the rest on \ hover.", ); ui.label( "• Right-click a result to open it, open its containing folder, \ or hide files like it from the results.", ); ui.label( "• Click a column header to sort by it; right-click any header to \ choose which columns are 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); ui.heading(egui::RichText::new("Updates").strong()); ui.add_space(4.0); ui.label( "QuickSearch never sends anything off of your computer, so it \ never updates itself automatically.", ); ui.label("To update, install the latest version yourself from:"); link(ui, "https://quicksearch.karsttech.com"); ui.add_space(12.0); ui.heading(egui::RichText::new("The other tabs").strong()); ui.add_space(4.0); let prose = prose_width(ui.available_width()); egui::Grid::new("help-tabs") .num_columns(2) .spacing([CELL_SPACING, 5.0]) .show(ui, |ui| { let row = |ui: &mut egui::Ui, name: &str, what: &str| { ui.strong(name); cell(ui, prose, what); ui.end_row(); }; row( ui, "Manage Index", "indexing status and controls, the indexed folder list, \ and the filters that decide what is skipped", ); row( ui, "Duplicates", "files that are probably identical copies of each other, \ grouped; verify before deleting anything", ); row(ui, "Logs", "warnings from issues during indexing"); row( ui, "Settings", "everything QuickSearch can be told to do, in one \ place; hover any control for what it means", ); }); ui.add_space(12.0); ui.heading(egui::RichText::new("Terminal").strong()); ui.add_space(4.0); ui.label("QuickSearch also searches straight from a terminal:"); ui.monospace("quicksearch \"quarterly budget\""); ui.label( "On Windows use quicksearch-cli. Either way, --help lists all \ the flags.", ); ui.add_space(12.0); ui.horizontal_wrapped(|ui| { // The sentence is assembled from several widgets, so the // spacing between them has to come from the text itself. ui.spacing_mut().item_spacing.x = 0.0; let quiet = |text: &str| crate::ui_util::hint(text); ui.label(quiet( "Building from source, configuration, query structuring and more \ 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(quiet("README.md")); } ui.label(quiet(".")); }); }); crate::ui_util::more_below_hint(ui, &scroll); replay } /// 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, and the pairing is an implementation detail here. fn ranking_section(ui: &mut egui::Ui) { ui.heading(egui::RichText::new("How results are ranked").strong()); ui.add_space(4.0); ui.label( "Every result is placed in a tier by where it matched, and the tiers \ come out in this order. Within a tier, a match with the case you \ typed comes before one that only matches ignoring case, and a file \ mentioning your words more often comes before one mentioning them \ once.", ); 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| { let row = |ui: &mut egui::Ui, tier: &str, what: &str| { ui.strong(tier); cell(ui, prose, what); ui.end_row(); }; row( ui, "Exact name", "the file is called exactly what you typed", ); row( ui, "Name contains", "what you typed appears somewhere in the file's name", ); row( ui, "Text inside", "the words are in the file's contents, most mentions first", ); row( ui, "Close spelling", "a name or some text within a typo or two of what you typed, \ only while Fuzzy is ticked", ); row( ui, "Path only", "nothing in the name or the text matched, but a folder along \ the way did", ); }); ui.add_space(6.0); ui.label( "The Rank column carries the tier a result came from, coloured from \ blue for a close match to red for a distant one. Sorting by any other \ column sets that ordering aside until you sort by Rank again.", ); ui.label(hint( "Results stream in while a search runs and are only ever added to the \ end, so the list never reshuffles under you as you read it.", )); } /// 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) { ui.heading(egui::RichText::new("What gets indexed").strong()); ui.add_space(4.0); ui.label( "Everything inside your indexed folders, minus whatever the two \ filters on the Manage Index tab take out. The filters do different \ jobs: one decides which files exist in the index at all, the other \ decides which of them 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( "A pattern is compared against file and folder names, and against \ whole paths. It is never compared against what is inside a file, so \ nothing is excluded for the words it contains. A file kept out this \ way is gone from the index completely, contents included.", ); ui.add_space(4.0); ui.label( "• A pattern with no slash in it is a name: it matches any file or \ folder called that, anywhere under your indexed folders, and it has \ to match the whole name. Excluding an extension therefore needs a \ wildcard.", ); ui.label( "• A pattern with a slash in it is a path: it is matched against the \ whole path of a file or folder, and takes out everything underneath \ it.", ); ui.label("• * stands for any run of characters, including none; ? for exactly one."); ui.add_space(6.0); ignore_examples(ui); ui.add_space(6.0); ui.label(hint( "A path pattern has to match from the start, so Vacation/Diary on its \ own matches nothing: the leading */ is what lets it find that folder \ wherever it sits. Whether case matters follows the filesystem, so \ *diary* also catches Diary on Windows and macOS, but not on Linux.", )); ui.label(hint( "Adding a pattern takes out the entries it matches. Deleting a pattern \ from the list is what brings those files back, at the next indexing \ run; deleting the files it matched is never something QuickSearch does.", )); 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 one limits contents only. Files it leaves out are still indexed \ and still turn up in results by their name and their path; what you \ lose is finding them by the words inside them.", ); ui.add_space(4.0); ui.label( "• Empty, which is how it starts, means QuickSearch reads the text of \ every file type it understands.", ); ui.label( "• To narrow it, enter one extension per line, the leading dot \ optional. Listing only txt, md and pdf keeps the stored text small \ and focused on documents, while every other file stays findable by \ name.", ); ui.label( "• A list that has anything in it 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. Written out here because the tab /// itself has room for the caution and not for the reasoning, and because the /// question it answers — "it says these differ, but they look the same" — is /// the one the feature reliably provokes. fn duplicates_section(ui: &mut egui::Ui) { ui.heading(egui::RichText::new("Duplicates").strong()); ui.add_space(4.0); ui.label( "The Duplicates tab groups files that look like copies of each other, \ biggest waste first, so the space worth reclaiming is at the top. \ Nothing there is ever deleted or moved for you; the tab only shows \ you the groups.", ); ui.add_space(4.0); ui.label( "A group is a strong suspicion, not a verdict. Indexing reads each \ file's size and its first few kilobytes and hashes those, and that \ hash is what puts two files in a group — the rest of the file was \ never read. Right-click a group and choose Verify copies are \ identical to read all of it and compare every byte, which is the \ answer to have before deleting anything.", ); ui.add_space(8.0); ui.strong("When the verification says two files differ and they look the same"); ui.add_space(4.0); ui.label( "They do differ, in bytes you never see. PDFs and Office documents \ carry the date they were created and last modified, a document ID, \ and a revision number, all stored inside the file itself. Two \ invoices printed from one template, or one document saved twice, \ are different files on disk however identical they look on screen.", ); ui.label(hint( "The report says which byte disagreed and how big the file was. A \ difference in the first few hundred bytes of a document is almost \ always that bookkeeping; one in the middle of a large file is not.", )); ui.add_space(8.0); ui.strong("Groups you do not want to be shown again"); ui.add_space(4.0); ui.label( "• Hide this group, from the right-click menu, drops one group for \ good — the group that is not really a duplicate at all. It is \ remembered by content, so it stays hidden when those files are \ renamed or moved, and comes back if they are edited. Show hidden \ lists them again and Clear forgets the lot.", ); ui.label( "• Exclude leaves out whole sets of files: the box above the list \ takes the same patterns as the ignore filters, and the right-click \ menu offers this file's type and this file's folder. That is the one \ for a backup folder that is meant to hold copies.", ); ui.label(hint( "Neither hides anything from search. An excluded file is still \ indexed, still found by name and still found by its contents; it is \ only left out of this one listing.", )); } /// 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. Inside a [`crate::ui_util::stable_section`] because the two /// layouts allocate different numbers of widgets, and a resize across the /// threshold would otherwise rename everything below them. 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 one of this tab's tables, laid out in a child `Ui` of /// exactly `width`. /// /// Two things force the explicit width. Grid cells default to /// `TextWrapMode::Extend`, which lays a long cell out past the panel and, far /// worse, *widens the `Ui`* it was drawn in, so every paragraph below the /// table wraps to that width and is then clipped by the scroll area. Wrapping /// the cell instead fixes that but breaks the other way: a wrapped `Label` /// reports its narrowest possible width as what it wants, and `Grid` sizes /// the column to that, squeezing prose into a two-word ribbon. A child `Ui` /// of a width we chose settles both. Pinned by /// `tests::a_window_narrower_than_the_column_reflows_rather_than_clipping`. fn cell(ui: &mut egui::Ui, width: f32, text: impl Into) { ui.allocate_ui(egui::vec2(width, 0.0), |ui| { ui.add(egui::Label::new(text).wrap()); }); } /// 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 { 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 { /// 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 = crate::test_ui::ctx(); let input = crate::test_ui::raw_input(egui::vec2(1000.0, 900.0), vec![]); let out = ctx.run(input, |ctx| { egui::CentralPanel::default().show(ctx, |ui| { super::ui(ui); }); }); 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 ctx = crate::test_ui::ctx(); let input = crate::test_ui::raw_input(egui::vec2(1000.0, 4000.0), vec![]); let out = ctx.run(input, |ctx| { egui::CentralPanel::default().show(ctx, |ui| { super::ui(ui); }); }); 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", ] { 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. #[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. for width in [400.0_f32, 480.0, 560.0, 620.0] { let ctx = crate::test_ui::ctx(); let input = crate::test_ui::raw_input(egui::vec2(width, 6000.0), vec![]); let out = ctx.run(input, |ctx| { egui::CentralPanel::default().show(ctx, |ui| { super::ui(ui); }); }); 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 ); } } /// 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 ctx = crate::test_ui::ctx(); let input = crate::test_ui::raw_input(egui::vec2(400.0, 6000.0), vec![]); let out = ctx.run(input, |ctx| { egui::CentralPanel::default().show(ctx, |ui| { super::ui(ui); }); }); 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); } } } }