crates/veilvoice-gui/src/tour.rs

what this file is for · veilvoice-gui · 299 lines · the same file on GitHub

The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.


// SPDX-License-Identifier: GPL-3.0-or-later
//! The short tour on a first run, and after an upgrade.
//!
//! # What it is for
//!
//! The window has nine tabs and nothing said what any of them were. Somebody
//! opening this for the first time met a tab strip and had to guess, and two
//! of the nine, Monitor and Lock, are not what their names suggest to a person
//! who has not read the documentation.
//!
//! So: one card per tab, one sentence each, skippable at any point, and gone
//! for good once seen. It is not a walkthrough with arrows pointing at
//! controls. It is the paragraph a person would have read in a manual, offered
//! at the moment they would have wanted it, and it takes about twenty seconds.
//!
//! # Why it comes back after an upgrade
//!
//! Only as far as the tabs that are new. A tour that replays in full on every
//! upgrade is a tour people learn to skip, and one that never comes back means
//! a tab added in a later release is never introduced to anybody who was
//! already a user.
//!
//! What is stored is the list of tabs that were toured, not a "seen" flag and
//! not the version number. A flag cannot answer the question an upgrade asks,
//! and the version can only answer it indirectly: comparing versions tells you
//! *that* something changed, and the tab list tells you *what*, which is the
//! thing being shown. It also means a release that adds no tab shows nobody
//! anything, which is the common case and the right behaviour for it.
//!
//! # Portable or installed
//!
//! The last card says which one this copy is, in those words, because it is
//! the question behind "where did my settings go" and "why is it not in my
//! menu". It is a statement rather than a prompt: `Install` is a tab, the
//! decision is made there, and a tour is a bad place to ask somebody to commit
//! to anything.

use crate::theme::palette as p;
use egui::{RichText, Ui};


/// One card: the tab it is about, and what that tab is for.
///
/// The keys match `Tab::key`, and `app.rs` has a test that every tab has a
/// card and every card has a tab, so a tab added without a sentence fails the
/// build rather than shipping unexplained.
pub const CARDS: &[(&str, &str, &str)] = &[
    (
        "file",
        "Anonymise file",
        "A recording in, the same words in a voice nobody owns out. Encrypted \
         at rest by default, because the words survive on purpose and a file \
         anybody can read is a transcript anybody can read.",
    ),
    (
        "group",
        "Group",
        "One recording with several people in it. Each gets a different voice, \
         so a listener can still follow who is who, and every voiceprint is \
         destroyed just as thoroughly.",
    ),
    (
        "studio",
        "Studio",
        "The same thing on a microphone as you speak, into a virtual cable \
         that other programs can listen to, and a locked vault to keep what \
         was said in if you want one. The vault opens with both of your \
         passphrases at once, the one on this application and the one on your \
         recordings, and neither on its own.",
    ),
    (
        "browser",
        "Browser",
        "What is in the vault. The names and dates are sealed with the \
         recordings, so a disk shows how many files there are and roughly how \
         large, and nothing about what any of them is.",
    ),
    (
        "monitor",
        "Monitor",
        "Not a level meter. It watches for another program picking up a real \
         microphone while you are being veiled, which is the way this can \
         quietly fail to protect you.",
    ),
    (
        "lock",
        "Lock",
        "A passphrase on this application, separate from the one on any \
         recording. Worth what a lock on a drawer is worth: it stops somebody \
         at your keyboard, not somebody with your disk.",
    ),
    (
        "verify",
        "Verify",
        "Check that a VeilVoice download is the one that was published, using \
         the signature and, if you have GnuPG, your own copy of it as well.",
    ),
    (
        "settings",
        "Settings",
        "Theme, animation, autolock, and what the interface tells you. \
         Everything here is stored beside the application and goes nowhere.",
    ),
    (
        "install",
        "Install",
        "Put this copy somewhere permanent, or leave it where it is. Either \
         works. The tab takes itself away once there is nothing left to do.",
    ),
    (
        "about",
        "About",
        "Versions, what drew the window, what the lock covers, and somewhere \
         to report a fault.",
    ),
];



/// Where the tour is up to.
#[derive(Default)]
pub struct Tour {
    /// Which card is showing. `None` means it is not running.
    at: Option<usize>,
    /// The cards this run is showing, as indices into [`CARDS`].
    showing: Vec<usize>,
}



/// Every tab key the tour knows, for storing once it has run.
pub fn all_keys() -> Vec<String> {
    CARDS.iter().map(|(key, _, _)| (*key).to_string()).collect()
}


impl Tour {

    /// Start the tour from the beginning, showing every card.
    pub fn start(&mut self) {
        self.showing = (0..CARDS.len()).collect();
        self.at = Some(0);
    }



    /// Start it showing only the cards whose tabs are not in `known`.
    ///
    /// Used after an upgrade: somebody who has been using this for months is
    /// shown what is new and nothing else. If nothing is new, nothing runs.
    pub fn start_new_only(&mut self, known: &[String]) {
        self.showing = CARDS
            .iter()
            .enumerate()
            .filter(|(_, (key, _, _))| !known.iter().any(|seen| seen == key))
            .map(|(index, _)| index)
            .collect();
        self.at = if self.showing.is_empty() {
            None
        } else {
            Some(0)
        };
    }



    /// Whether the tour is on screen.
    pub fn running(&self) -> bool {
        self.at.is_some()
    }



    /// Stop it.
    pub fn stop(&mut self) {
        self.at = None;
        self.showing.clear();
    }



    /// Draw the current card. Returns true once the tour has finished.
    ///
    /// `installed` decides the sentence on the last card, and it is a fact
    /// about where this binary is rather than a preference.
    pub fn panel(&mut self, ui: &mut Ui, installed: bool) -> bool {
        let Some(at) = self.at else { return false };
        let Some(&card) = self.showing.get(at) else {
            self.stop();
            return true;
        };
        let (_, title, body) = CARDS[card];
        let last = at + 1 >= self.showing.len();

        // A reading measure rather than the window's width. At 1400 pixels a
        // sentence runs the whole way across and the eye loses the line coming
        // back; the usual advice is 60 to 80 characters and this is about 75 at
        // the default size. The window can be any width and the card should not
        // get harder to read as it grows.
        ui.set_max_width(720.0);
        ui.add_space(18.0);
        ui.label(
            RichText::new(format!("{} of {}", at + 1, self.showing.len()))
                .small()
                .color(p::muted()),
        );
        ui.add_space(4.0);
        ui.label(RichText::new(title).size(18.0).color(p::fg()).strong());
        ui.add_space(8.0);
        ui.label(RichText::new(body).color(p::fg()));

        if last {
            ui.add_space(16.0);
            ui.label(RichText::new("This copy").color(p::cyan()).strong());
            ui.label(
                RichText::new(if installed {
                    "Installed. It is on this machine for good, it is on your \
                     menu or path, and its settings live in your account. \
                     Removing it is the same as removing any other program."
                } else {
                    "Portable. It runs from wherever you put it and installs \
                     nothing: move the folder and VeilVoice moves with it, \
                     delete the folder and it is gone. That is a perfectly \
                     good way to keep using it. The Install tab is there if \
                     you would rather it were permanent."
                })
                .color(p::fg()),
            );
        }

        ui.add_space(18.0);
        let mut finished = false;
        ui.horizontal(|ui| {
            let next = if last { "done" } else { "next" };
            if ui.button(next).clicked() {
                if last {
                    self.stop();
                    finished = true;
                } else {
                    self.at = Some(at + 1);
                }
            }
            if at > 0 && ui.button("back").clicked() {
                self.at = Some(at - 1);
            }
            if !last && ui.button("skip the rest").clicked() {
                self.stop();
                finished = true;
            }
        });
        finished
    }

}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn every_card_has_a_sentence_and_no_two_share_a_tab() {
        let mut keys: Vec<&str> = CARDS.iter().map(|(key, _, _)| *key).collect();
        let before = keys.len();
        keys.sort_unstable();
        keys.dedup();
        assert_eq!(keys.len(), before, "two cards claim the same tab");
        for (key, title, body) in CARDS {
            assert!(!key.is_empty() && !title.is_empty());
            assert!(
                body.len() > 60,
                "{key} has a sentence too short to explain anything"
            );
        }
    }

    #[test]
    fn a_first_run_sees_everything() {
        let mut tour = Tour::default();
        assert!(!tour.running());
        tour.start();
        assert!(tour.running());
        assert_eq!(tour.showing.len(), CARDS.len());
    }

    #[test]
    fn an_upgrade_shows_only_what_is_new() {
        let known: Vec<String> = CARDS
            .iter()
            .take(CARDS.len() - 2)
            .map(|(key, _, _)| (*key).to_string())
            .collect();
        let mut tour = Tour::default();
        tour.start_new_only(&known);
        assert!(tour.running(), "two new tabs should start a tour");
        assert_eq!(tour.showing.len(), 2);
    }

    #[test]
    fn what_is_stored_is_every_tab_the_tour_covered() {
        // The stored list is what "which of these is new to you" is answered
        // against, so it has to be complete when the tour finishes.
        assert_eq!(all_keys().len(), CARDS.len());
    }

    #[test]
    fn an_upgrade_that_adds_no_tabs_shows_nothing() {
        let known: Vec<String> = CARDS.iter().map(|(key, _, _)| (*key).to_string()).collect();
        let mut tour = Tour::default();
        tour.start_new_only(&known);
        assert!(
            !tour.running(),
            "a tour with nothing new to say must not run"
        );
    }
}