//! 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. //! //! `[bracketed]` words in a page's prose are keywords: the brackets are //! stripped, and the n-th keyword is painted in the same pulsing colour as //! the glow around the n-th entry of `spots`. The window is not modal or //! anchored, so the user can drag it aside and keep using the app. 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 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: 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, } /// 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: a Search tab page that points at nothing. const PLAIN: Page = Page { title: "", lead: None, body: &[], pointer: None, tab: Tab::Search, 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.", "Answers come from the index rather than the disk, so results \ appear as you type, even across hundreds of thousands of files.", ], pointer: Some( "A short tour for new users. Drag this window by its title bar if it \ is in the way, or click Skip to leave.", ), extra: Some(Extra::Scale), ..PLAIN }, Page { title: "Open QuickSearch hotkey", body: &[ "Want a key that brings QuickSearch up from anywhere? Click the \ button below and press the keys; it works at once.", "QuickSearch doesn't run in the background, so once closed the key \ can't reach it. To have the key start it too, bind the command \ below in your desktop's keyboard settings.", ], pointer: Some("Both of these are on the Settings tab too, under Interface."), 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: Tab::Manage, spots: &[Spot::IndexedFolderAdd], ..PLAIN }, Page { title: "What is indexing?", body: &[ "Indexing is QuickSearch reading your folders once and remembering \ what it found, so later searches are instant. It runs in the \ background and keeps up as your files change.", "You decide what gets read: [ignore patterns] keep files and folders \ out of the index entirely, and the [extensions whitelist] limits \ which file types have their text read. Both are under Content \ filters on this tab.", "QuickSearch never connects to the internet, and it can encrypt the \ index with a password.", ], pointer: Some("The password setting is near the bottom of the Settings tab."), tab: Tab::Manage, spots: &[Spot::IgnorePatterns, Spot::ExtWhitelist], ..PLAIN }, Page { title: "Search: Input", body: &[ "QuickSearch searches as you type in the [search bar]. It takes \ words, file names, extensions and filters; click the \"[?]\" \ button for the full syntax.", ], pointer: Some("Filters look like type:Document or modified:>=2024-01-01."), spots: &[Spot::SearchBar, Spot::QueryHelp], type_query: Some("QuickSearch"), ..PLAIN }, Page { title: "How results are ranked", body: &[ "The best matches come first, with the lowest rank: exact file \ names, then names containing what you typed, then files whose \ contents contain it (most mentions first), and last, files matched \ only by a folder in their path.", "The coloured number in the [Rank] column shows a result's tier: \ blue is a close match, red a distant one. Click a column heading \ to sort by something else.", ], pointer: Some("Right-click any column heading to choose the columns shown."), spots: &[Spot::RankColumn], ..PLAIN }, Page { title: "The status bar", body: &[ "The [status bar] along the bottom shows what QuickSearch is \ doing: while indexing, the phase, progress and speed; when idle, \ how many files are indexed.", "Searching works throughout, even during the first indexing run, \ though files not yet reached won't appear until the scan gets to \ them.", ], spots: &[Spot::StatusBar], ..PLAIN }, Page { title: "Fuzzy finding", body: &[ "Tick [Fuzzy] beside the search box to also match words with typos, \ so \"repot\" finds \"report\". The extra searching takes a little \ longer, but exact matches arrive just as fast and come first.", "Right-click any result to open it or its folder, copy its path, or \ build a filter that hides files like it from future searches.", ], spots: &[Spot::FuzzyToggle], ..PLAIN }, Page { title: "Duplicates", body: &[ "The [Duplicates] tab finds copies of the same file across your \ indexed folders, grouped with the most wasted space first, or by \ extension if you prefer.", "It is a quick way to find the same download sitting in three \ places. It only shows the groups; deleting is up to you.", "For speed, files are matched on size and their first few \ kilobytes, so a group is a strong suspicion, not a certainty. \ Right-click a group and choose \"Verify copies are identical\" to \ compare every byte before you delete anything.", ], pointer: Some( "From the same menu you can hide a group for good, or exclude a \ folder that is meant to hold copies.", ), tab: Tab::Duplicates, spots: &[Spot::TabButton(Tab::Duplicates)], ..PLAIN }, Page { title: "Settings", body: &[ "The [Settings] tab, at the right end of the tab strip, is where you \ tune QuickSearch. Hover any setting for a short description.", "Most changes wait for the Apply & Save button at the bottom.", "To see this introduction again, use the \"Show the introduction \ again\" button at the top of the [Help] tab.", "QuickSearch is free for anyone to use. If you like it, please tell \ your friends!", ], tab: 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> { 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 note size: 2 points up on `hint`'s `Small`, which is a touch /// fine for a first-time user's first window, and still below Body so a note /// reads as an aside. fn note(ui: &egui::Ui, text: impl Into) -> 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 slider's position while it differs from `zoom`: chosen, not yet /// applied. `None` once Apply is clicked or nothing is pending. staged_scale: &'a mut Option, /// 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 => { // Staged until Apply, like the Settings tab's slider: applying // mid-drag rescales the slider under the pointer, so the handle // chases its own tail and the value cannot be chosen. let mut scale = live.staged_scale.unwrap_or(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; ui.add( egui::Slider::new(&mut scale, crate::app::SCALE_RANGE) .step_by(0.05) .fixed_decimals(2), ); let pending = (scale - live.zoom).abs() > f32::EPSILON; *live.staged_scale = pending.then_some(scale); if ui.add_enabled(pending, egui::Button::new("Apply")).clicked() { actions.set_scale = Some(scale); *live.staged_scale = None; } }); ui.label(note( ui, "Makes everything larger or smaller. You can change it later 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, live.hotkey); } } } /// 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 { 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, pub set_query: Option, pub focus_search: bool, /// A new UI scale from the welcome page's slider, sent only by its Apply /// button (see `extra_ui`), to apply and save. pub set_scale: Option, /// A shortcut captured on the shortcut page, to register and save. pub set_hotkey: Option, } /// A footer click. Applied after the window, so the page rendered stays the /// one its buttons were laid out for. enum Nav { Back, Next, /// Skip, or Finish on the last page. Dismiss, } #[derive(Default)] 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. entered_page: Option, typing: Option, /// The user has dragged the window, so where it sits is their business. user_dragged: bool, /// The shortcut button is armed and the next key combination is the /// answer: the Settings tab's own capture, with its own flag. capturing_hotkey: bool, /// The welcome page's scale slider, between moving and Apply. staged_scale: Option, } impl Tutorial { /// Arrival on the current page: its tab, and its typing from empty. fn enter_page(&mut self, page: &Page, now: f64, actions: &mut TourActions) { self.entered_page = Some(self.page); actions.goto_tab = Some(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; } } /// `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 nav = None; let now = ctx.input(|i| i.time); if self.entered_page != Some(self.page) { self.enter_page(page, now, &mut actions); } 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(); 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.user_dragged { // Re-centre every frame until the user drags: the viewport may // not have its final size on the first frame, so a one-off // position can land anywhere. 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, ¶graph, &mut keyword, lit); ui.label(job); ui.add_space(6.0); } // The page's control, then its note, which may mention it. if let Some(extra) = page.extra { let mut live = Live { zoom, staged_scale: &mut self.staged_scale, hotkey, capturing_hotkey: &mut self.capturing_hotkey, }; 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() { nav = Some(Nav::Back); } }); cols[1].vertical_centered(|ui| { if ui.button("Skip").clicked() { nav = Some(Nav::Dismiss); } }); // `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() { nav = Some(if last { Nav::Dismiss } else { Nav::Next }); } ui.label(hint(format!("{} of {}", self.page + 1, PAGES.len()))); }); }); }); if shown.is_some_and(|window| window.response.dragged()) { self.user_dragged = true; } // 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); } let last_page = PAGES.len() - 1; match nav { Some(Nav::Back) => self.page = self.page.saturating_sub(1).min(last_page), Some(Nav::Next) => self.page = (self.page + 1).min(last_page), Some(Nav::Dismiss) => { // 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()); } None => {} } actions } } #[cfg(test)] mod tests;