quick_search/crates/quicksearch-gui/src/tutorial.rs

638 lines
26 KiB
Rust
Raw Normal View History

//! The first-start tour. Shown once: `[ui] tutorial_seen` is `Some(false)`
//! only in a config file this version created, so upgrading does not summon
//! it. The Help tab can bring it back afterwards.
//!
//! Every page points at what it is talking about. Words wrapped in `[square
//! brackets]` in a page's prose are keywords: the brackets are markup,
//! stripped before anything is painted, and what was inside is coloured and
//! pulsed. The n-th keyword on a page pairs with the n-th entry of its
//! `spots` and the two share a colour, so "[search bar]" pulsing blue in the
//! prose is unambiguously about the box pulsing blue on screen. The pairing
//! is only as good as the two lists agreeing, which
//! `every_keyword_has_a_spot_to_point_at` holds them to.
//!
//! The window is deliberately not modal and not anchored: the user can drag
//! it off whatever it is covering, and can use the app underneath while it
//! is up.
use std::borrow::Cow;
use egui::text::{LayoutJob, TextFormat};
use crate::app::Tab;
use crate::spotlight::{self, Spot};
use crate::ui_util::hint;
struct Page {
title: &'static str,
/// A first paragraph built from live state, ahead of `body`.
lead: Option<fn(&[String]) -> String>,
body: &'static [&'static str],
/// Rendered small under the body — where to go, not what the thing is.
pointer: Option<&'static str>,
/// Switched to as the page is entered, so the page's subject is on screen.
tab: Option<Tab>,
/// Paired in order with the keywords in `lead` then `body`.
spots: &'static [Spot],
/// Typed into the search box a character at a time, on entry.
type_query: Option<&'static str>,
/// A control of the page's own, under its prose.
extra: Option<Extra>,
}
/// What a page offers in the tour's own window. Most pages point at a widget
/// in the app instead; these two are setup the user can do from here.
#[derive(Debug, Clone, Copy, PartialEq)]
enum Extra {
/// The zoom slider, on the welcome page.
Scale,
/// The command to bind to a key, with Copy and a way to the desktop's
/// keyboard settings.
Shortcut,
}
/// Field defaults for the pages that do not point anywhere.
const PLAIN: Page = Page {
title: "",
lead: None,
body: &[],
pointer: None,
tab: None,
spots: &[],
type_query: None,
extra: None,
};
const PAGES: &[Page] = &[
Page {
title: "Welcome to QuickSearch",
body: &[
"QuickSearch is a search engine for your local files and their contents.",
"By default, only your user folder is indexed and searchable.",
"Because the answers come from the index rather than from reading \
your disk, results appear as fast as you can type, even across \
hundreds of thousands of files.",
],
pointer: Some(
"A quick tutorial for new users! Drag this window by its title bar \
if it covers something. Hit Skip to exit.",
),
tab: Some(Tab::Search),
extra: Some(Extra::Scale),
..PLAIN
},
Page {
title: "Open QuickSearch hotkey",
body: &[
"Would you like a keyboard shortcut that brings QuickSearch up \
from anywhere? Click the button below and press the keys you \
want — it takes effect at once.",
"QuickSearch does not sit in the background waiting for that key, \
though. When you close it, it closes completely and gives back \
the memory it was using; it starts again, index and all, in a \
moment. So to have a key start it as well, bind the command \
underneath in your desktop's own keyboard settings.",
],
pointer: Some("Both of these are on the Settings tab too, under Interface."),
tab: Some(Tab::Search),
extra: Some(Extra::Shortcut),
..PLAIN
},
Page {
title: "Which folders are searched",
lead: Some(folders_line),
pointer: Some(
"A folder you add is indexed in the background; everything already \
in the index is left alone.",
),
tab: Some(Tab::Manage),
spots: &[Spot::IndexedFolderAdd],
..PLAIN
},
Page {
title: "What is indexing?",
body: &[
"Indexing is QuickSearch reading through your folders once and \
remembering what it found, so that searching later is instant. It \
runs on its own in the background and keeps up with changes as you \
make them.",
"You decide what gets read: [ignore patterns] keep files and folders \
out of the index entirely, while the [extensions whitelist] limits \
which file types have their contents read. Both are under Content \
filters on this tab.",
"QuickSearch never connects to the internet, and always respects your privacy. \
QuickSearch can encrypt your index to make this remembered data more secure.",
],
pointer: Some("To set an index password, look near the bottom of the Settings tab."),
tab: Some(Tab::Manage),
spots: &[Spot::IgnorePatterns, Spot::ExtWhitelist],
..PLAIN
},
Page {
title: "Search: Input",
body: &[
"QuickSearch begins searching as soon as you start typing in the \
[search bar]. It accepts words, filenames, file extensions, and \
several other specifiers. Click the \"[?]\" block for full details.",
],
pointer: Some(
"Filters you can type into a query look like type:Document or \
modified:>=2024-01-01.",
),
tab: Some(Tab::Search),
spots: &[Spot::SearchBar, Spot::QueryHelp],
type_query: Some("QuickSearch"),
..PLAIN
},
Page {
title: "How results are ranked",
body: &[
"The best search matches come first (have the lowest rank). \
Exact file-name matches are best, then names that contain what you typed; then \
files whose contents contain the search terms, the ones mentioning it most often \
first; and last, files matched only by their full folder path.",
"The coloured number in the [Rank] column is which of those tiers a \
result came from: blue is a great match, red is a distant one. \
Clicking a column heading sorts by something else instead.",
],
pointer: Some("Right-click any column heading to choose which columns are shown."),
tab: Some(Tab::Search),
spots: &[Spot::RankColumn],
..PLAIN
},
Page {
title: "The status bar",
body: &[
"The [status bar] along the bottom of the window is what QuickSearch \
is doing. While it is indexing it shows the phase, how far through \
it is, and how fast; when it has nothing to do it shows how many \
files are indexed.",
"Searching works the whole time, including during that first indexing run, \
but some files might not be shown in the results until the scan completes.",
],
tab: Some(Tab::Search),
spots: &[Spot::StatusBar],
..PLAIN
},
Page {
title: "Fuzzy Finding",
body: &[
"Tick [Fuzzy] beside the search box to also match words with typos \
in them! When checked, \"repot\" will find \"report\". It searches more \
thoroughly, so it is a little slower to find all results, but the exact \
matches will be found just as fast and be displayed first.",
"Right-click any result for more options: open it, open the folder holding \
it, copy its path, or build a filter that hides files like it from \
future searches.",
],
tab: Some(Tab::Search),
spots: &[Spot::FuzzyToggle],
..PLAIN
},
Page {
title: "Duplicates",
body: &[
"The [Duplicates] tab looks for files across all indexed folders for identical copies. \
They are shown grouped together, with the largest wasted space first — or grouped \
by file extension, if that is how you would rather work through them.",
"It is a quick way to find the same download sitting in three \
places. QuickSearch only shows you the groups; deleting anything is \
left to you.",
"For speed, files are matched on their size and on how they begin — the \
first few kilobytes — which makes a group a strong suspicion rather \
than a certainty. Right-click any group and choose \"Verify copies are \
identical\" to read every byte before you delete anything. The tab says \
so at the top, every time.",
],
pointer: Some(
"A group you never want to see again can be hidden, and a folder that is \
meant to hold copies can be excluded — both from that same right-click \
menu.",
),
tab: Some(Tab::Duplicates),
spots: &[Spot::TabButton(Tab::Duplicates)],
..PLAIN
},
Page {
title: "Settings",
body: &[
"The [Settings] tab, at the right-hand end of the tab strip, is where \
you can tweak and tune the software. Mouse over any of \
the settings for a brief description of what they do.",
"Most changes wait for the Apply & Save button at the bottom.",
"You can see this introduction again at any time from the [Help] tab \
— the \"Show the introduction again\" button at the top.",
"QuickSearch is completely free for anyone to use. If you love it, please let your friends know about us!",
],
tab: Some(Tab::Settings),
spots: &[
Spot::TabButton(Tab::Settings),
Spot::TabButton(Tab::Help),
],
..PLAIN
},
];
/// One glow cycle, seconds.
const PULSE_PERIOD: f64 = 1.6;
/// How often the glow and the demonstration typing are stepped.
const ANIMATION_TICK: std::time::Duration = std::time::Duration::from_millis(50);
/// Seconds per character on a page that types into the search box.
const TYPE_INTERVAL: f64 = 0.07;
/// The tour's window keeps one id across pages: the title changes on every
/// Next, and an id derived from it would put the window back in the middle
/// each time, undoing wherever the user dragged it.
const WINDOW_ID: &str = "quicksearch-tutorial";
/// Whether the home folder is what is being indexed decides how the folders
/// page opens; a tour re-run from Help can find any set of roots at all.
fn folders_line(roots: &[String]) -> String {
let trim = |p: &str| p.trim_end_matches(['/', '\\']).to_string();
let home = quicksearch_core::platform::home_dir().map(|h| trim(&h.to_string_lossy()));
match home {
Some(home) if roots.iter().any(|root| trim(root) == home) => format!(
"Your home folder ({home}) is indexed by default. To search other \
locations, add them [here]."
),
_ if roots.is_empty() => "No folders are indexed yet, so there is nothing to search. \
Add the first one [here]."
.to_string(),
_ => format!(
"These folders are indexed: {}. To search other locations, add them [here].",
roots.join(", ")
),
}
}
/// The page's prose, live parts and all, in the order it is painted.
fn paragraphs(page: &Page, roots: &[String]) -> Vec<Cow<'static, str>> {
page.lead
.map(|build| Cow::Owned(build(roots)))
.into_iter()
.chain(page.body.iter().copied().map(Cow::Borrowed))
.collect()
}
/// Split prose into runs, `true` for the ones that were bracketed. An
/// unbalanced `[` is prose, not markup.
fn runs(text: &str) -> Vec<(&str, bool)> {
let mut out = Vec::new();
let mut rest = text;
while let Some(open) = rest.find('[') {
let Some(close) = rest[open + 1..].find(']').map(|i| open + 1 + i) else {
break;
};
if open > 0 {
out.push((&rest[..open], false));
}
out.push((&rest[open + 1..close], true));
rest = &rest[close + 1..];
}
if !rest.is_empty() {
out.push((rest, false));
}
out
}
/// 0 at the dimmest, 1 at the brightest.
fn pulse(time: f64) -> f32 {
0.5 + 0.5 * (time * std::f64::consts::TAU / PULSE_PERIOD).sin() as f32
}
/// The n-th keyword's colour, shared with the n-th spot it points at.
fn accent(n: usize, dark_mode: bool) -> egui::Color32 {
let p = crate::color::palette(dark_mode);
[p.blue, p.green, p.orange][n % 3]
}
/// One paragraph, keywords coloured and tinted. `next_keyword` runs across
/// the whole page, not the paragraph, so it stays in step with `Page::spots`.
fn paragraph_job(ui: &egui::Ui, text: &str, next_keyword: &mut usize, pulse: f32) -> LayoutJob {
let font_id = egui::TextStyle::Body.resolve(ui.style());
let dark_mode = ui.visuals().dark_mode;
let mut job = LayoutJob::default();
for (run, keyword) in runs(text) {
let format = if keyword {
let color = accent(*next_keyword, dark_mode);
*next_keyword += 1;
TextFormat {
font_id: font_id.clone(),
color,
background: color.gamma_multiply(0.10 + 0.25 * pulse),
..Default::default()
}
} else {
TextFormat {
font_id: font_id.clone(),
color: ui.visuals().text_color(),
..Default::default()
}
};
job.append(run, 0.0, format);
}
job
}
/// The tour's own note size. `hint` is egui's `Small`, which is a touch fine
/// to read in the one window a first-time user meets the app through; full
/// Body would stop a note reading as an aside.
fn note(ui: &egui::Ui, text: impl Into<egui::RichText>) -> egui::RichText {
hint(text).size(egui::TextStyle::Small.resolve(ui.style()).size + 2.0)
}
/// The live state the tour's own controls read, and the flag the shortcut
/// capture keeps between frames.
struct Live<'a> {
/// `ctx.zoom_factor()`, read before the window is laid out.
zoom: f32,
/// The shortcut in force, as the config spells it.
hotkey: &'a str,
capturing_hotkey: &'a mut bool,
}
/// The page's own control, under its prose. What they were used for goes
/// back through `actions`: the config is the app's to write, not the tour's.
fn extra_ui(ui: &mut egui::Ui, extra: Extra, live: &mut Live, actions: &mut TourActions) {
ui.add_space(6.0);
ui.separator();
match extra {
Extra::Scale => {
let mut scale = live.zoom;
ui.horizontal(|ui| {
ui.label("UI scale");
// Scoped to this row, which is the whole of its use.
ui.spacing_mut().slider_width = 220.0;
let slider = ui.add(
egui::Slider::new(&mut scale, crate::app::SCALE_RANGE)
.step_by(0.05)
.fixed_decimals(2),
);
// Applied on every frame it moves, saved once it settles: a
// drag would otherwise rewrite the config file dozens of
// times on its way across. The release frame is not itself a
// change — the value stopped moving — so it is asked about
// separately, and carries the value with it so the app has
// one thing to act on.
let moved = slider.changed();
let settled = slider.drag_stopped() || (moved && !slider.dragged());
if moved || settled {
actions.set_scale = Some(scale);
actions.save_scale = settled;
}
});
ui.label(note(
ui,
"Makes everything larger or smaller together. You can change \
it again on the Settings tab.",
));
}
// Both of the Settings tab's shortcut controls, and the same two
// widgets rather than a second pair that could drift from them: the
// button that claims a key while QuickSearch is running, and under
// it the command a desktop shortcut binds to start it.
Extra::Shortcut => {
let mut setting = live.hotkey.to_string();
ui.horizontal(|ui| {
ui.label("Search shortcut");
crate::settings_tab::hotkey_edit(ui, &mut setting, live.capturing_hotkey);
});
if setting != live.hotkey {
actions.set_hotkey = Some(setting);
}
crate::settings_tab::shortcut_note(ui);
}
}
}
/// Rings around a widget, painted into the *panel* layer: over the widget,
/// but under the tour's own window, which the user can drag aside rather
/// than have the glow drawn across.
fn glow(ctx: &egui::Context, rect: egui::Rect, color: egui::Color32, pulse: f32) {
let painter = ctx.layer_painter(egui::LayerId::background());
let strength = 0.45 + 0.55 * pulse;
painter.rect_filled(rect.expand(2.0), 4.0, color.gamma_multiply(0.08 * strength));
for ring in 0..3u8 {
let grow = 2.0 + 3.0 * f32::from(ring);
let alpha = (0.65 - 0.18 * f32::from(ring)) * strength;
painter.rect_stroke(
rect.expand(grow),
4.0 + grow,
egui::Stroke::new(2.0, color.gamma_multiply(alpha)),
egui::StrokeKind::Outside,
);
}
}
/// The search box being filled in a character at a time.
struct Typing {
text: &'static str,
started: f64,
emitted: usize,
}
impl Typing {
/// The prefix typed by `now`, but only when it has grown. `SearchTab::seed`
/// re-arms the debounce on every call, so re-emitting the same text every
/// frame would hold the search off for as long as the page is up.
fn advance(&mut self, now: f64) -> Option<String> {
let total = self.text.chars().count();
let due = (((now - self.started) / TYPE_INTERVAL).max(0.0) as usize).min(total);
if due <= self.emitted {
return None;
}
self.emitted = due;
Some(self.text.chars().take(due).collect())
}
}
/// What the tour asks the app to do after this frame.
#[derive(Default)]
pub struct TourActions {
/// The tour is over — skipped or read to the end.
pub dismissed: bool,
pub goto_tab: Option<Tab>,
pub set_query: Option<String>,
pub focus_search: bool,
/// A new UI scale from the welcome page's slider, to apply live.
pub set_scale: Option<f32>,
/// The drag ended, so the scale above is worth writing to the config.
pub save_scale: bool,
/// A shortcut captured on the shortcut page, to register and save.
pub set_hotkey: Option<String>,
}
pub struct Tutorial {
page: usize,
/// The page whose arrival has already been acted on. Entering a page
/// switches tabs once, not every frame: a user who clicks another tab
/// mid-page is not dragged back.
shown: Option<usize>,
typing: Option<Typing>,
/// The user has dragged the window, so where it sits is their business.
moved: bool,
/// The shortcut button is armed and the next key combination is the
/// answer — the Settings tab's own capture, and its own flag.
capturing_hotkey: bool,
}
impl Tutorial {
pub fn new() -> Tutorial {
Tutorial {
page: 0,
shown: None,
typing: None,
moved: false,
capturing_hotkey: false,
}
}
/// `roots` is the live indexed-folder list, which the folders page reads;
/// `hotkey` is the shortcut in force, which the shortcut page both shows
/// and rebinds.
pub fn ui(&mut self, ctx: &egui::Context, roots: &[String], hotkey: &str) -> TourActions {
let page = &PAGES[self.page.min(PAGES.len() - 1)];
let (first, last) = (self.page == 0, self.page + 1 == PAGES.len());
let mut actions = TourActions::default();
let mut step: i64 = 0;
let now = ctx.input(|i| i.time);
if self.shown != Some(self.page) {
self.shown = Some(self.page);
actions.goto_tab = page.tab;
self.typing = page.type_query.map(|text| Typing {
text,
started: now,
emitted: 0,
});
if self.typing.is_some() {
// From empty, with the caret in the box: what follows should
// read as someone typing, not as a value appearing.
actions.set_query = Some(String::new());
actions.focus_search = true;
}
}
if let Some(typing) = &mut self.typing {
if let Some(typed) = typing.advance(now) {
actions.set_query = Some(typed);
}
}
let lit = pulse(now);
// Read before the window: `set_zoom_factor` stores the new value at
// once, so the slider reads back what it asked for on the next frame
// rather than snapping to the old value mid-drag. It also follows an
// ad-hoc Ctrl +/- zoom, which is the size the user is looking at.
let zoom = ctx.zoom_factor();
// Lifted out of `self` for the window's closure, and put back after:
// the closure already holds the page and the actions.
let mut capturing = self.capturing_hotkey;
let mut dismissed = false;
let mut window = egui::Window::new(page.title)
.id(egui::Id::new(WINDOW_ID))
.collapsible(false)
.resizable(false)
.pivot(egui::Align2::CENTER_CENTER);
if !self.moved {
// Re-centred every frame rather than positioned once: the window
// is measured on its first frame and the viewport is not
// necessarily its final size on ours, so a position chosen then
// can be anywhere. Dropped the moment the user drags — the drag
// delta is applied after this, so the first drag frame already
// moves and the next one stops overriding.
window = window.current_pos(ctx.screen_rect().center());
}
let shown = window.show(ctx, |ui| {
ui.set_max_width(520.0);
let mut keyword = 0usize;
for paragraph in paragraphs(page, roots) {
let job = paragraph_job(ui, &paragraph, &mut keyword, lit);
ui.label(job);
ui.add_space(6.0);
}
// The page's control first, then its note: a note that mentions
// what is on the page has to come after it, and it is the last
// and smallest thing before the footer either way.
if let Some(extra) = page.extra {
let mut live = Live {
zoom,
hotkey,
capturing_hotkey: &mut capturing,
};
extra_ui(ui, extra, &mut live, &mut actions);
}
if let Some(pointer) = page.pointer {
ui.label(note(ui, pointer));
}
ui.add_space(10.0);
ui.separator();
// Three equal thirds: the only layout that centres Skip without
// measuring the buttons either side, whose widths change per page.
ui.columns(3, |cols| {
// A column lays out *justified*: a button put straight into
// one is stretched to the full third.
cols[0].with_layout(egui::Layout::left_to_right(egui::Align::Min), |ui| {
if ui.add_enabled(!first, egui::Button::new("Back")).clicked() {
step = -1;
}
});
cols[1].vertical_centered(|ui| {
if ui.button("Skip").clicked() {
dismissed = true;
}
});
// `Align::Min`: a column is as tall as the window, so
// centring drops the button far below the two beside it.
cols[2].with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
let p = crate::color::palette(ui.visuals().dark_mode);
let next = if last { "Finish" } else { "Next" };
if ui
.add(crate::ui_util::bordered_button(next, p.blue))
.clicked()
{
if last {
dismissed = true;
} else {
step = 1;
}
}
ui.label(hint(format!("{} of {}", self.page + 1, PAGES.len())));
});
});
});
if shown.is_some_and(|window| window.response.dragged()) {
self.moved = true;
}
self.capturing_hotkey = capturing;
// The widgets this page names, in the colours its keywords were given.
let dark_mode = ctx.style().visuals.dark_mode;
for (n, spot) in page.spots.iter().enumerate() {
if let Some(rect) = spotlight::rect(ctx, *spot) {
glow(ctx, rect, accent(n, dark_mode), lit);
}
}
if !page.spots.is_empty() || self.typing.is_some() {
ctx.request_repaint_after(ANIMATION_TICK);
}
// After the closure, so the rendered page stays the one its buttons
// were laid out for.
if step != 0 {
let next = self.page as i64 + step;
self.page = next.clamp(0, PAGES.len() as i64 - 1) as usize;
}
if dismissed {
// Leave the user on the tab they will actually work in, with a
// box holding only what they typed themselves.
actions.dismissed = true;
actions.goto_tab = Some(Tab::Search);
actions.set_query = Some(String::new());
}
actions
}
}
#[cfg(test)]
mod tests;