crates/veilvoice-gui/src/autolock.rs

what this file is for · veilvoice-gui · 369 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
//! Locking the window again after a period of no use.
//!
//! **Roadmap item 92.** On at half an hour, and the delay is the user's to choose:
//! anything from five minutes to forty eight hours from a list, a number typed
//! in if none of those fit, and the ends of that range movable by anybody who
//! wants a shorter or longer one.
//!
//! # Why it is on by default, having been off
//!
//! It was off, on the argument that a lock engaging part way through a
//! recording is a lock that gets removed, and that VeilVoice cannot know
//! whether a given user is the one who walks away or the one who leaves a job
//! running -- so it should ask rather than assume.
//!
//! The asking is the part that was wrong. A default nobody is shown is not a
//! question, it is an answer, and the answer it gave was "no protection" to
//! everybody who never opened the settings tab. The people most helped by an
//! autolock are the least likely to go looking for one.
//!
//! So it is on, and the first-run setup shows it rather than leaving it to be
//! discovered: half an hour, with the choice and the off switch right there.
//! The original concern is answered by the delay rather than by the default --
//! thirty minutes of an untouched window is not somebody part way through
//! anything -- and by the fact that this has never counted a running job as
//! use, which is deliberate and explained below.
//!
//! # What counts as use
//!
//! Any keystroke, click, scroll or pointer movement over the window, which is
//! what egui reports as input. Deliberately **not** the passage of a job:
//! somebody who starts a long render and leaves the room has left the room,
//! and the recording they are producing is the thing worth locking away.
//!
//! The clock is egui's own frame time rather than the system clock, so moving
//! the machine's clock does not bring the lock forward or push it back. It also
//! means the countdown only advances while the window is being drawn, which is
//! the honest limit of this: a window nobody is drawing is a window nobody is
//! looking at, and it locks the moment it is looked at again.
//!
//! # In plain words
//!
//! Locks the window again if you have not touched it for half an hour.
//!
//! On to begin with, and setup shows you the switch. You pick how long, from
//! five minutes up to two days, or type your own, and you can turn it off.
//! Starting a long job does not count as using it: if you walk away while
//! something is rendering, that is exactly when you would want it locked.

use std::time::Duration;


/// The delay a fresh installation uses: half an hour.
///
/// Long enough that it does not interrupt somebody working, short enough to
/// matter if they walk out. It is the setup screen's suggestion as well as the
/// code's default, so the number a user is shown is the number they get.
pub const DEFAULT_SECS: u64 = 30 * 60;



/// The shortest delay the list offers, in seconds.
pub const FLOOR_SECS: u64 = 5 * 60;


/// The longest, in seconds. Forty eight hours.
pub const CEILING_SECS: u64 = 48 * 60 * 60;



/// The delays offered without typing anything, in seconds.
///
/// Chosen to be the ones people actually mean: a coffee, a lunch, an afternoon,
/// overnight, a weekend away. Anything else is typed.
pub const CHOICES: &[u64] = &[
    5 * 60,
    15 * 60,
    30 * 60,
    60 * 60,
    2 * 60 * 60,
    4 * 60 * 60,
    8 * 60 * 60,
    12 * 60 * 60,
    24 * 60 * 60,
    48 * 60 * 60,
];



/// How the autolock is configured.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Autolock {
    /// Whether it engages at all.
    pub enabled: bool,
    /// The delay, in seconds.
    pub after_secs: u64,
    /// The bottom of the range the user is offered.
    pub floor_secs: u64,
    /// The top of it.
    pub ceiling_secs: u64,
}


impl Default for Autolock {

    fn default() -> Self {
        Self {
            // On, at half an hour. See the module note for what changed.
            enabled: true,
            after_secs: DEFAULT_SECS,
            floor_secs: FLOOR_SECS,
            ceiling_secs: CEILING_SECS,
        }
    }

}

impl Autolock {

    /// Bring every field into a state that can be offered and obeyed.
    ///
    /// A settings file is editable, so every number that reaches here has been
    /// through somebody's text editor as far as this code knows. Rather than
    /// refuse, which would leave a user with an autolock they cannot fix from
    /// the interface, each value is brought back into range and the result is
    /// always something the interface can show.
    pub fn sane(mut self) -> Self {
        if self.floor_secs == 0 {
            self.floor_secs = FLOOR_SECS;
        }
        if self.ceiling_secs == 0 {
            self.ceiling_secs = CEILING_SECS;
        }
        // A range with its ends the wrong way round is a range with one end.
        if self.floor_secs > self.ceiling_secs {
            std::mem::swap(&mut self.floor_secs, &mut self.ceiling_secs);
        }
        self.after_secs = self.after_secs.clamp(self.floor_secs, self.ceiling_secs);
        self
    }



    /// The delay as a `Duration`.
    pub fn after(self) -> Duration {
        Duration::from_secs(self.after_secs)
    }



    /// Whether `idle` has reached the delay.
    ///
    /// False when the autolock is off, whatever `idle` says, so a caller cannot
    /// lock a window the user asked to leave unlocked by getting the condition
    /// the wrong way round.
    pub fn expired(self, idle: Duration) -> bool {
        self.enabled && idle >= self.after()
    }



    /// The delay in the words a person uses for it.
    pub fn describe(self) -> String {
        describe_secs(self.after_secs)
    }

}


/// A number of seconds as a phrase.
///
/// Whole units only, because every value this offers is a whole number of
/// minutes or hours and "1 hour 0 minutes" reads like a machine talking.
pub fn describe_secs(secs: u64) -> String {
    let plural = |n: u64, unit: &str| {
        if n == 1 {
            format!("1 {unit}")
        } else {
            format!("{n} {unit}s")
        }
    };
    match secs {
        0 => "immediately".to_string(),
        s if s % 86_400 == 0 => plural(s / 86_400, "day"),
        s if s % 3_600 == 0 => plural(s / 3_600, "hour"),
        s if s % 60 == 0 => plural(s / 60, "minute"),
        s => plural(s, "second"),
    }
}



/// Read a delay somebody typed.
///
/// Accepts a bare number of minutes, or a number with a unit: `90`, `90m`,
/// `90 min`, `2h`, `2 hours`, `1d`. Returns `None` rather than guessing when it
/// cannot tell, so the interface can say it did not understand instead of
/// silently applying a number the user did not mean.
pub fn parse(typed: &str) -> Option<u64> {
    let typed = typed.trim().to_ascii_lowercase();
    if typed.is_empty() {
        return None;
    }
    let split = typed
        .find(|c: char| !c.is_ascii_digit() && c != '.')
        .unwrap_or(typed.len());
    let (number, unit) = typed.split_at(split);
    let value: f64 = number.parse().ok()?;
    if !value.is_finite() || value <= 0.0 {
        return None;
    }
    let unit = unit.trim();
    let multiplier = match unit {
        // A bare number is minutes, because that is the unit somebody reaches
        // for when they say "lock after 20".
        "" | "m" | "min" | "mins" | "minute" | "minutes" => 60.0,
        "s" | "sec" | "secs" | "second" | "seconds" => 1.0,
        "h" | "hr" | "hrs" | "hour" | "hours" => 3_600.0,
        "d" | "day" | "days" => 86_400.0,
        _ => return None,
    };
    let secs = value * multiplier;
    // Guard the cast: a typed `99999999999999d` must not wrap into something
    // small and lock the window immediately.
    if !secs.is_finite() || secs < 1.0 || secs > u64::MAX as f64 {
        return None;
    }
    Some(secs as u64)
}


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

    #[test]
    fn it_is_on_at_half_an_hour_out_of_the_box() {
        let auto = Autolock::default();
        assert!(
            auto.enabled,
            "the default is protection, not the absence of it"
        );
        assert_eq!(auto.after_secs, DEFAULT_SECS);
        assert_eq!(auto.after_secs, 30 * 60);
        assert!(
            !auto.expired(Duration::from_secs(29 * 60)),
            "half an hour means half an hour"
        );
        assert!(auto.expired(Duration::from_secs(31 * 60)));
    }

    #[test]
    fn switching_it_off_switches_it_off() {
        // The default changed; the ability to refuse it must not have.
        let auto = Autolock {
            enabled: false,
            ..Autolock::default()
        };
        assert!(
            !auto.expired(Duration::from_secs(u64::MAX / 2)),
            "a window must not lock itself when somebody has said not to"
        );
    }

    #[test]
    fn the_default_delay_is_one_of_the_offered_choices() {
        // Otherwise the settings tab opens showing a custom value nobody
        // typed, which reads as a setting that has been meddled with.
        assert!(CHOICES.contains(&DEFAULT_SECS));
    }

    #[test]
    fn it_locks_once_the_delay_has_passed_and_not_before() {
        let auto = Autolock {
            enabled: true,
            after_secs: 900,
            ..Default::default()
        };
        assert!(!auto.expired(Duration::from_secs(899)));
        assert!(auto.expired(Duration::from_secs(900)));
        assert!(auto.expired(Duration::from_secs(901)));
    }

    #[test]
    fn every_offered_choice_is_inside_the_range_it_is_offered_from() {
        for choice in CHOICES {
            assert!(*choice >= FLOOR_SECS, "{choice} is below the floor");
            assert!(*choice <= CEILING_SECS, "{choice} is above the ceiling");
        }
        assert_eq!(CHOICES.first(), Some(&FLOOR_SECS));
        assert_eq!(CHOICES.last(), Some(&CEILING_SECS));
    }

    /// The settings file is editable, so every number here has been through a
    /// text editor as far as this code knows.
    #[test]
    fn a_hand_edited_settings_file_is_brought_back_into_range() {
        let mad = Autolock {
            enabled: true,
            after_secs: 1,
            floor_secs: 0,
            ceiling_secs: 0,
        }
        .sane();
        assert_eq!(mad.floor_secs, FLOOR_SECS);
        assert_eq!(mad.ceiling_secs, CEILING_SECS);
        assert_eq!(mad.after_secs, FLOOR_SECS, "clamped up to the floor");

        let backwards = Autolock {
            enabled: true,
            after_secs: 3_600,
            floor_secs: 48 * 3_600,
            ceiling_secs: 300,
        }
        .sane();
        assert!(
            backwards.floor_secs < backwards.ceiling_secs,
            "ends swapped"
        );
        assert!(backwards.after_secs >= backwards.floor_secs);
    }

    /// The user may move the ends of the range, so a delay outside the default
    /// one is not an error.
    #[test]
    fn a_range_the_user_widened_is_kept() {
        let wide = Autolock {
            enabled: true,
            after_secs: 60,
            floor_secs: 30,
            ceiling_secs: 7 * 86_400,
        }
        .sane();
        assert_eq!(wide.after_secs, 60, "a minute is inside the widened range");
        assert_eq!(wide.floor_secs, 30);
        assert_eq!(wide.ceiling_secs, 7 * 86_400);
    }

    #[test]
    fn typed_delays_are_read_the_way_they_are_written() {
        assert_eq!(parse("20"), Some(20 * 60), "a bare number is minutes");
        assert_eq!(parse("20m"), Some(20 * 60));
        assert_eq!(parse("20 minutes"), Some(20 * 60));
        assert_eq!(parse("2h"), Some(2 * 3_600));
        assert_eq!(parse("2 hours"), Some(2 * 3_600));
        assert_eq!(parse("1d"), Some(86_400));
        assert_eq!(parse("90 sec"), Some(90));
        assert_eq!(parse("  3 HRS "), Some(3 * 3_600));
    }

    /// Refusing beats guessing: a number nobody meant, silently applied, is a
    /// window that locks at a time its owner cannot explain.
    #[test]
    fn nonsense_is_refused_rather_than_guessed_at() {
        for bad in ["", "   ", "soon", "-5", "0", "5 fortnights", "m", "1e999d"] {
            assert_eq!(parse(bad), None, "{bad:?} should not parse");
        }
    }

    /// A huge typed number must not wrap into a small one and lock the window
    /// immediately, which is the opposite of what was asked for.
    #[test]
    fn an_absurd_number_does_not_wrap_into_a_tiny_delay() {
        for absurd in ["99999999999999999999d", "1e30h"] {
            match parse(absurd) {
                None => {}
                Some(secs) => assert!(
                    secs > CEILING_SECS,
                    "{absurd:?} parsed as {secs}s, which is shorter than the ceiling"
                ),
            }
        }
    }

    #[test]
    fn a_delay_is_described_the_way_a_person_would_say_it() {
        assert_eq!(describe_secs(300), "5 minutes");
        assert_eq!(describe_secs(3_600), "1 hour");
        assert_eq!(describe_secs(2 * 3_600), "2 hours");
        assert_eq!(describe_secs(86_400), "1 day");
        assert_eq!(describe_secs(48 * 3_600), "2 days");
        assert_eq!(describe_secs(90), "90 seconds");
    }

    #[test]
    fn every_choice_has_a_readable_name() {
        for choice in CHOICES {
            let text = describe_secs(*choice);
            assert!(!text.is_empty());
            assert!(!text.contains("second"), "{text} should be minutes or more");
        }
    }
}