crates/veilvoice-gui/src/settings.rs
what this file is for · veilvoice-gui · 1434 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 settings panel: a menu of pages, each a titled group of choices.
//!
//! # Why a menu rather than one long list
//!
//! There are three kinds of setting here and they answer different questions:
//! what the app *looks* like, how it *moves*, and what it does with the files
//! it writes. Stacked in one column they read as an undifferentiated wall of
//! tick boxes, and the one that matters most -- at-rest encryption -- ends up
//! looking exactly as important as the colour scheme. A menu with a page per
//! group keeps each question next to its own explanation.
//!
//! # Every change applies immediately, and is saved immediately
//!
//! There is no "apply" button and no "unsaved changes" state. Both are ways to
//! lose a choice silently. If saving fails the choice still applies for this
//! session and the panel says, in the panel, that it could not be remembered
//! and why -- rather than failing quietly and letting the setting reappear
//! wrong on the next launch.
//!
//! # What is deliberately not in here
//!
//! The app lock and the at-rest passphrase have their own tab and stay there.
//! A password field sitting between "animations" and "colour scheme" invites
//! being treated with the same weight, and it is not the same weight.
//!
//! # In plain words
//!
//! The settings, arranged as a short menu rather than one long list.
//!
//! There are enough of them now that a single column meant scrolling past things
//! you were not looking for, and finding a setting is most of what anybody does in
//! a settings screen.
//!
//! Each page is a titled group with a sentence saying what it covers, and every
//! choice applies as you make it and is remembered.
use crate::prefs::{Motion, Prefs};
use crate::theme::palette as p;
use crate::theme::themes;
use egui::{RichText, Ui};
use std::path::PathBuf;
/// Which page of the settings menu is showing.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Page {
/// Colour scheme.
Appearance,
/// Animation and the animated mark.
Motion,
/// Which tabs the window offers.
Interface,
/// Locking the window again after a period of no use.
Security,
/// Where settings live, and how to reset them.
Storage,
}
impl Page {
/// Every page, in menu order, with its label and one-line summary.
pub const ALL: &'static [(Page, &'static str, &'static str)] = &[
(Page::Appearance, "appearance", "Colour scheme"),
(Page::Motion, "motion", "Animation, and the mark"),
(Page::Interface, "interface", "Which tabs are shown"),
(Page::Security, "security", "Locking"),
(Page::Storage, "storage", "Where this is kept"),
];
}
/// The settings tab's own state.
pub struct Settings {
/// The live preferences. Changes here take effect on the next frame.
pub prefs: Prefs,
/// Where they are persisted, or `None` if this platform did not say.
path: Option<PathBuf>,
/// Which page is showing.
page: Page,
/// Why the last save failed, if it did.
save_error: Option<String>,
/// Whether the first-run choice is still to be made.
first_run: bool,
/// The autolock delay being typed, before it is understood.
autolock_typed: String,
/// Why the last typed delay was not understood.
autolock_error: Option<String>,
/// What the operating system said about reducing motion, read once at
/// startup. Every platform answers through a subprocess, so asking per
/// frame is out of the question.
system_motion: crate::reduced_motion::Query,
/// Complaints from reading the user's own palette files, shown verbatim.
///
/// Carried here rather than logged because there is nowhere for a desktop
/// application to log to that a user will look. Somebody who wrote a
/// palette file and sees it missing from the picker needs to be told why,
/// in the place they went to look for it.
pub palette_problems: Vec<String>,
}
impl Default for Settings {
fn default() -> Self {
Self {
prefs: Prefs::default(),
path: None,
page: Page::Appearance,
save_error: None,
first_run: false,
autolock_typed: String::new(),
autolock_error: None,
system_motion: crate::reduced_motion::Query::Unknown,
palette_problems: Vec::new(),
}
}
}
impl Settings {
/// Load preferences from this platform's config directory and apply the
/// chosen theme to `ctx`.
///
/// Never fails: an unreadable or unparseable file leaves the defaults in
/// force, and the panel says so.
pub fn load(ctx: &egui::Context) -> Self {
let path = crate::prefs::default_path();
let prefs = match &path {
Some(p) => Prefs::load(p),
None => Prefs::default(),
};
crate::theme::set_by_id(ctx, &prefs.theme);
let first_run = !prefs.configured;
Self {
first_run,
prefs,
path,
page: Page::Appearance,
save_error: None,
autolock_typed: String::new(),
autolock_error: None,
system_motion: crate::reduced_motion::query(),
// Filled in by the caller, which reads the palettes before this
// runs -- see `VeilVoiceApp::new` for why that order matters.
palette_problems: Vec::new(),
}
}
/// How much movement is allowed this frame.
///
/// Takes `&egui::Context` for symmetry with the rest of the UI even though
/// it does not need it: the platform answer is cached from startup, since
/// reading it costs a subprocess.
pub fn motion(&self, _ctx: &egui::Context) -> Motion {
Motion::resolve(&self.prefs, self.system_motion.reduces())
}
/// Whether the first-run choice has still to be made.
pub fn needs_first_run(&self) -> bool {
self.first_run
}
/// Write the settings out, encrypted and obfuscated as they are at rest.
fn persist(&mut self) {
let Some(path) = &self.path else {
self.save_error = Some(
"this platform did not say where to keep configuration \
(no APPDATA, XDG_CONFIG_HOME or HOME), so choices apply for \
this session only"
.to_string(),
);
return;
};
self.save_error = self.prefs.save(path).err();
}
/// Whether the app should open in group mode, and a way to change it.
///
/// Exposed as a pair of methods rather than as a public field so the group
/// panel can ask to have a choice remembered without knowing where
/// preferences live or what happens when the platform will not say. A
/// failed write is not fatal here either: the tick applies for this
/// session and the settings page reports why it was not kept.
/// Why the last write failed, if it did.
pub fn save_error(&self) -> Option<&str> {
self.save_error.as_deref()
}
/// Whether the install tab should be offered at all.
///
/// Two conditions, and the first is not a preference: an installed copy
/// never offers to install itself, because a program that does that is
/// telling its user something untrue about what it is. The preference only
/// covers the other case -- a portable copy, run on purpose, by somebody
/// who does not want to be asked again.
pub fn show_install_tab(&self, running_installed: bool) -> bool {
!running_installed && !self.prefs.hide_install_tab
}
/// Whether the install tab is hidden by preference.
pub fn hide_install_tab(&self) -> bool {
self.prefs.hide_install_tab
}
/// Record whether to hide the install tab on a portable copy.
pub fn set_hide_install_tab(&mut self, hide: bool) {
if self.prefs.hide_install_tab == hide {
return;
}
self.prefs.hide_install_tab = hide;
self.persist();
}
/// Whether the window asks the platform for a hardware-drawn context.
///
/// **Roadmap item 137.** Read once, before the window is made, because it decides
/// how the window is made. Changing it therefore takes effect at the next
/// launch, and the panel says so rather than appearing to do nothing.
pub fn acceleration(&self) -> bool {
self.prefs.acceleration
}
/// Record whether to ask for acceleration.
pub fn set_acceleration(&mut self, on: bool) {
if self.prefs.acceleration == on {
return;
}
self.prefs.acceleration = on;
self.persist();
}
/// How often the window draws while something in it is moving.
pub fn frame_target(&self) -> crate::pace::Target {
crate::pace::Target::from_setting(self.prefs.frame_rate)
}
/// Record a new frame-rate target.
pub fn set_frame_target(&mut self, target: crate::pace::Target) {
let value = target.to_setting();
if self.prefs.frame_rate == value {
return;
}
self.prefs.frame_rate = value;
self.persist();
}
/// Whether the header carries a live frame-rate readout.
pub fn show_frame_rate(&self) -> bool {
self.prefs.show_frame_rate
}
/// Whether the app should open in group mode.
pub fn always_group(&self) -> bool {
self.prefs.always_group
}
/// How the autolock is configured, brought into range.
pub fn autolock(&self) -> crate::autolock::Autolock {
crate::autolock::Autolock {
enabled: self.prefs.autolock,
after_secs: self.prefs.autolock_after,
floor_secs: self.prefs.autolock_floor,
ceiling_secs: self.prefs.autolock_ceiling,
}
.sane()
}
/// Remember an autolock setting.
pub fn set_autolock(&mut self, auto: crate::autolock::Autolock) {
let auto = auto.sane();
if self.autolock() == auto {
return;
}
self.prefs.autolock = auto.enabled;
self.prefs.autolock_after = auto.after_secs;
self.prefs.autolock_floor = auto.floor_secs;
self.prefs.autolock_ceiling = auto.ceiling_secs;
self.persist();
}
/// Whether every recording is sealed with the app-lock passphrase.
pub fn seal_with_app_lock(&self) -> bool {
self.prefs.seal_with_app_lock
}
/// The remembered encrypted destination, rebuilt from the settings file.
pub fn destination(&self) -> crate::storage::Destination {
crate::storage::Destination::from_prefs(
&self.prefs.vault_dir,
&self.prefs.vault_tool,
&self.prefs.vault_hidden,
)
}
/// Remember a destination, including the answer to the hidden question.
pub fn set_destination(&mut self, destination: &crate::storage::Destination) {
let (dir, tool, hidden) = destination.to_prefs();
if (
&self.prefs.vault_dir,
&self.prefs.vault_tool,
&self.prefs.vault_hidden,
) == (&dir, &tool, &hidden)
{
return;
}
self.prefs.vault_dir = dir;
self.prefs.vault_tool = tool;
self.prefs.vault_hidden = hidden;
self.persist();
}
/// The Failsafe posture in force.
pub fn failsafe(&self) -> veilvoice_guard::failsafe::Posture {
veilvoice_guard::failsafe::Posture::from_key(&self.prefs.failsafe)
}
/// Record the Failsafe posture.
pub fn set_failsafe(&mut self, posture: veilvoice_guard::failsafe::Posture) {
if self.failsafe() == posture {
return;
}
self.prefs.failsafe = posture.key().to_string();
self.persist();
}
/// How notifications should be shown.
pub fn notify_style(&self) -> crate::notify::Style {
crate::notify::Style::from_key(&self.prefs.notify_style)
}
/// Record how notifications should be shown.
pub fn set_notify_style(&mut self, style: crate::notify::Style) {
if self.notify_style() == style {
return;
}
self.prefs.notify_style = style.key().to_string();
self.persist();
}
/// Where the live monitor sits, or whether it is shown.
pub fn live_monitor(&self) -> crate::monitor::Style {
crate::monitor::Style::from_key(&self.prefs.live_monitor)
}
/// Record where the live monitor sits.
pub fn set_live_monitor(&mut self, style: crate::monitor::Style) {
if self.live_monitor() == style {
return;
}
self.prefs.live_monitor = style.key().to_string();
self.persist();
}
/// Record whether the app should open in group mode.
pub fn set_always_group(&mut self, always: bool) {
if self.prefs.always_group == always {
return;
}
self.prefs.always_group = always;
self.persist();
}
/// Remember, or stop remembering, that recordings are sealed with the
/// app-lock passphrase.
///
/// Roadmap item 86. Same pair-of-methods shape as `always_group` and for the same
/// reason: the security tab asks for a choice to be kept without knowing
/// where preferences live or what happens when the platform will not say.
pub fn set_seal_with_app_lock(&mut self, seal: bool) {
if self.prefs.seal_with_app_lock == seal {
return;
}
self.prefs.seal_with_app_lock = seal;
self.persist();
}
/// Which tabs the window offers.
fn interface_page(&mut self, ui: &mut Ui) {
section(ui, "Failsafe", "The safety catch. On by default.");
let current = self.failsafe();
ui.horizontal(|ui| {
for posture in veilvoice_guard::failsafe::Posture::ALL {
if ui
.selectable_label(current == *posture, posture.label())
.clicked()
&& current != *posture
{
self.set_failsafe(*posture);
}
}
});
ui.label(
RichText::new(format!(" {}", self.failsafe().note()))
.small()
.color(if self.failsafe().is_on() {
p::muted()
} else {
// Off is a real choice and it is not a neutral one. The
// colour says so without the words having to shout.
p::yellow()
}),
);
ui.add_space(4.0);
for note in [
veilvoice_guard::failsafe::CANNOT_PREVENT,
veilvoice_guard::failsafe::NEVER_CLOSES,
] {
ui.label(RichText::new(format!(" {note}")).small().color(p::muted()));
}
ui.add_space(12.0);
section(
ui,
"The live monitor",
"What is going in and what is coming out, while the Studio is veiling.",
);
let current_monitor = self.live_monitor();
ui.horizontal(|ui| {
for style in crate::monitor::Style::ALL {
if ui
.selectable_label(current_monitor == *style, style.label())
.clicked()
&& current_monitor != *style
{
self.set_live_monitor(*style);
}
}
});
ui.label(
RichText::new(format!(" {}", self.live_monitor().note()))
.small()
.color(p::muted()),
);
// The limit, printed beside the setting rather than left to be
// discovered. A level is not proof that the voice is being changed: a
// working meter and a bypassed engine draw the same bar.
ui.label(
RichText::new(
" It shows levels, which tells you sound is arriving and sound is leaving. \
It cannot tell you the disguise is working; listening to the preview in \
the Studio is what does that.",
)
.small()
.color(p::muted()),
);
ui.add_space(12.0);
section(
ui,
"Notifications",
"How VeilVoice tells you something while it is running.",
);
let current = self.notify_style();
ui.horizontal(|ui| {
for style in crate::notify::Style::ALL {
if ui
.selectable_label(current == *style, style.label())
.clicked()
&& current != *style
{
self.set_notify_style(*style);
}
}
});
ui.label(
RichText::new(format!(" {}", self.notify_style().note()))
.small()
.color(p::muted()),
);
// The measured contrast, shown rather than asserted. A translucent card
// is a colour laid over the panel behind it, so whether its text is
// legible depends on the palette in force -- and a reader choosing a
// palette should be able to see the number rather than take it on
// trust, exactly as the palette editor already shows its own ratios.
if self.notify_style() != crate::notify::Style::Off {
let card = crate::notify::Card::for_level(crate::notify::Level::Warn, p::bg());
let measured = format!(
" warning text measures {:.1}:1 on this palette, against the {:.1}:1 \
a notification needs",
card.ratio,
crate::notify::LEAST_CONTRAST
);
ui.label(RichText::new(measured).small().color(p::muted()));
if card.opaque {
ui.label(
RichText::new(
" drawn solid rather than translucent here: nothing in this \
palette reads well enough through it, and a warning you \
cannot read is not a warning",
)
.small()
.color(p::muted()),
);
}
}
ui.label(
RichText::new(format!(" {}", crate::notify::SCOPE))
.small()
.color(p::muted()),
);
ui.add_space(12.0);
section(
ui,
"Drawing",
"How the window reaches the screen. The About tab shows what was \
asked for beside what the driver actually gave.",
);
let mut accelerated = self.prefs.acceleration;
if ui
.checkbox(
&mut accelerated,
"Ask the graphics driver to draw the window",
)
.on_hover_text("Takes effect the next time VeilVoice starts")
.changed()
{
self.set_acceleration(accelerated);
}
ui.label(
RichText::new(
" On, and asking is already the safe direction: a machine that \
cannot give a hardware context is given a software one and the \
window still opens. This is for the other case, where the driver \
accepts and then draws badly. A hybrid-graphics laptop handing \
over the wrong adapter, or a black window on a driver whose \
OpenGL path is broken, looks like success from in here and \
nothing can detect it, so it is a switch rather than a \
measurement. Turning it off is slower and it works.",
)
.small()
.color(p::muted()),
);
ui.label(
RichText::new(
" It takes effect at the next launch, because the choice is \
made before the window exists.",
)
.small()
.color(p::yellow()),
);
ui.add_space(12.0);
section(
ui,
"Tabs",
"What the row along the top of the window shows.",
);
let mut hide = self.prefs.hide_install_tab;
if ui
.checkbox(&mut hide, "Hide the install tab")
.on_hover_text("Only affects a portable copy; an installed one never shows it")
.changed()
{
self.set_hide_install_tab(hide);
}
ui.label(
RichText::new(
" The install tab already disappears by itself once VeilVoice is \
installed: a program offering to install itself when it already is \
tells you something untrue about what you are running. This covers the \
other case: a portable copy, run on purpose, by somebody who does not \
want to be asked again. Nothing else changes: `veilvoice install` still \
works from the command line.",
)
.small()
.color(p::muted()),
);
if let Some(error) = &self.save_error {
ui.add_space(10.0);
ui.label(RichText::new(error).color(p::yellow()).small());
}
}
/// The tabs the tour has already covered.
pub fn toured_tabs(&self) -> Vec<String> {
self.prefs
.toured_tabs
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(str::to_string)
.collect()
}
/// Record that the tour has covered these tabs, and save.
pub fn mark_toured(&mut self, keys: &[String]) {
self.prefs.toured_tabs = keys.join(",");
self.persist();
}
/// The first-run panel: offered once, with animation already on.
///
/// Shown as a page rather than a modal because it is not urgent and does
/// not gate anything -- the legal notice is the thing that gates, and two
/// blocking dialogues before a user has seen the app is one too many.
/// The two appearance choices, for the first-run setup to place.
///
/// Placed by `crate::firstrun`, which owns the first-run flow, so there is
/// one copy of this wording rather than one per caller.
pub fn first_run_appearance(&mut self, ui: &mut Ui) {
let mut changed = false;
changed |= ui
.checkbox(&mut self.prefs.animations, "Animate the interface")
.changed();
ui.label(
RichText::new(" Transitions and easing. Turning this off makes every change instant.")
.small()
.color(p::muted()),
);
ui.add_space(8.0);
ui.add_enabled_ui(self.prefs.animations, |ui| {
changed |= ui
.checkbox(&mut self.prefs.animated_icon, "Animate the mark")
.changed();
});
ui.label(
RichText::new(" The soundbar in the header, as on the website.")
.small()
.color(p::muted()),
);
if changed {
self.persist();
}
}
/// The autolock switch and delay, for the first-run setup to place.
pub fn first_run_autolock(&mut self, ui: &mut Ui) {
let mut changed = ui
.checkbox(&mut self.prefs.autolock, "Lock the window when unused")
.changed();
ui.add_enabled_ui(self.prefs.autolock, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("after").color(p::muted()));
egui::ComboBox::from_id_salt("first-run-autolock")
.selected_text(crate::autolock::describe_secs(self.prefs.autolock_after))
.show_ui(ui, |ui| {
for choice in crate::autolock::CHOICES {
changed |= ui
.selectable_value(
&mut self.prefs.autolock_after,
*choice,
crate::autolock::describe_secs(*choice),
)
.changed();
}
});
});
});
if changed {
self.persist();
}
}
/// Mark the first run answered.
pub fn finish_first_run(&mut self) {
self.prefs.configured = true;
self.first_run = false;
self.persist();
}
/// The settings tab.
pub fn tab(&mut self, ui: &mut Ui, ctx: &egui::Context) {
ui.add_space(6.0);
ui.label(RichText::new("Settings").size(16.0).color(p::fg()).strong());
ui.add_space(2.0);
ui.label(
RichText::new("Applies as you change it, and is remembered.")
.small()
.color(p::muted()),
);
ui.add_space(10.0);
// The menu, then the page. A row rather than a sidebar: there are three
// pages, and a sidebar for three items is furniture.
ui.horizontal(|ui| {
for (page, label, _) in Page::ALL {
let selected = self.page == *page;
let text =
RichText::new(*label).color(if selected { p::blue() } else { p::muted() });
if ui.selectable_label(selected, text).clicked() {
self.page = *page;
}
}
});
ui.add_space(4.0);
ui.separator();
ui.add_space(10.0);
match self.page {
Page::Appearance => self.appearance_page(ui, ctx),
Page::Motion => self.motion_page(ui, ctx),
Page::Interface => self.interface_page(ui),
Page::Security => self.security_page(ui),
Page::Storage => self.storage_page(ui),
}
if let Some(error) = &self.save_error {
ui.add_space(14.0);
ui.label(
RichText::new(format!(
"This choice applies now but could not be saved: {error}"
))
.small()
.color(p::yellow()),
);
}
}
/// Explain where custom palettes go, and say what was refused and why.
///
/// The refusals are the important half. A user who writes a palette file,
/// puts it in the right place and sees nothing happen has no way to tell
/// whether the application never looked, or looked and disliked it. Every
/// complaint from the loader is shown verbatim, including the measured
/// contrast ratio, because "muted on bg is 2.4:1 and needs 3.0:1" tells
/// somebody exactly what to change and by roughly how much.
fn custom_palette_help(&mut self, ui: &mut Ui) {
ui.add_space(14.0);
ui.label(
egui::RichText::new("Your own palettes")
.color(crate::theme::palette::fg())
.strong(),
);
match crate::palettes::default_dir() {
Some(dir) => {
ui.label(
egui::RichText::new(format!(
"Drop a .palette file in {} and it appears above.",
dir.display()
))
.color(crate::theme::palette::muted())
.size(12.0),
);
}
None => {
ui.label(
egui::RichText::new(
"No writable configuration directory was found, so custom \
palettes are unavailable on this machine.",
)
.color(crate::theme::palette::muted())
.size(12.0),
);
}
}
ui.label(
egui::RichText::new(
"Every one of the twelve tokens must be present, and the colours \
must be readable against each other. A palette whose text \
fails the contrast check is refused rather than applied.",
)
.color(crate::theme::palette::muted())
.size(12.0),
);
if self.palette_problems.is_empty() {
return;
}
ui.add_space(8.0);
ui.label(
egui::RichText::new(format!(
"{} palette problem(s):",
self.palette_problems.len()
))
.color(crate::theme::palette::yellow())
.size(12.0),
);
for problem in &self.palette_problems {
ui.label(
egui::RichText::new(format!(" {problem}"))
.color(crate::theme::palette::yellow())
.size(12.0),
);
}
}
/// The colour scheme, as a compact control for the window header.
///
/// The same list the appearance page offers and the same effect; what it
/// does not carry is the swatches and the custom-palette help, which need
/// room and belong on the page. Two controls for one setting is worth it
/// here: the header is where the website puts this, and a reader who has
/// used the site looks in the same place.
///
/// Returns the picker's own rectangle. The controls beside it in the
/// header take their height from it rather than working one out
/// separately, which is what left them a pixel apart (finding F-178).
///
/// Its width is [`crate::layout::LOCK_WIDTH`], which the lock button and
/// the unlock button are also drawn at, so the picker and the button
/// beside it are one box repeated rather than two boxes that nearly agree
/// (finding F-196).
pub fn theme_picker(&mut self, ui: &mut Ui, ctx: &egui::Context) -> egui::Rect {
let current = crate::theme::active();
let mut chosen = None;
let picker = egui::ComboBox::from_id_salt("header-theme")
.selected_text(RichText::new(current.name).small())
.width(crate::layout::LOCK_WIDTH)
.show_ui(ui, |ui| {
for theme in themes() {
if ui
.selectable_label(theme.id == current.id, theme.name)
.clicked()
{
chosen = Some(theme.id);
}
}
});
if let Some(id) = chosen {
if crate::theme::set_by_id(ctx, id) {
self.prefs.theme = id.to_string();
self.persist();
}
}
picker.response.rect
}
/// The appearance page: palette, and what each one changes.
fn appearance_page(&mut self, ui: &mut Ui, ctx: &egui::Context) {
section(
ui,
"Colour scheme",
"The same schemes the website offers, plus any of your own.",
);
let current = crate::theme::active();
let mut chosen: Option<&'static str> = None;
egui::ComboBox::from_id_salt("theme-picker")
.selected_text(current.name)
.width(220.0)
.show_ui(ui, |ui| {
for theme in themes() {
if ui
.selectable_label(theme.id == current.id, theme.name)
.clicked()
{
chosen = Some(theme.id);
}
}
});
ui.add_space(10.0);
swatches(ui);
self.custom_palette_help(ui);
if let Some(id) = chosen {
if crate::theme::set_by_id(ctx, id) {
self.prefs.theme = id.to_string();
self.persist();
}
}
}
/// The motion page, including honouring the system's reduced-motion
/// setting.
fn motion_page(&mut self, ui: &mut Ui, ctx: &egui::Context) {
let motion = self.motion(ctx);
section(
ui,
"Animation",
"Transitions, easing, and the moving mark. On by default.",
);
let mut changed = false;
changed |= ui
.checkbox(&mut self.prefs.animations, "Animate the interface")
.changed();
ui.label(
RichText::new(
" Off makes every change instant. Nothing is hidden either way; \
animation only affects how a change is shown, never whether it happens.",
)
.small()
.color(p::muted()),
);
ui.add_space(10.0);
ui.add_enabled_ui(self.prefs.animations, |ui| {
changed |= ui
.checkbox(&mut self.prefs.animated_icon, "Animate the mark")
.changed();
});
ui.label(
RichText::new(" The soundbar in the header, as on the website.")
.small()
.color(p::muted()),
);
ui.add_space(14.0);
ui.horizontal(|ui| {
ui.label(RichText::new("preview").small().color(p::muted()));
crate::soundbar::draw(
ui,
egui::vec2(120.0, 26.0),
motion,
ui.input(|i| i.time) as f32,
);
});
ui.add_space(18.0);
section(
ui,
"How often it draws",
"While something is moving. An idle window still draws nothing at all.",
);
let mut target = self.frame_target();
egui::ComboBox::from_id_salt("frame-rate")
.selected_text(target.label())
.width(200.0)
.show_ui(ui, |ui| {
if ui
.selectable_label(
target == crate::pace::Target::Display,
crate::pace::Target::Display.label(),
)
.clicked()
{
target = crate::pace::Target::Display;
}
for hz in crate::pace::TARGETS {
let choice = crate::pace::Target::Fixed(*hz);
if ui
.selectable_label(target == choice, choice.label())
.clicked()
{
target = choice;
}
}
});
if target != self.frame_target() {
self.set_frame_target(target);
}
ui.label(
RichText::new(
" Matching the display is the default and is what looks right: the \
window waits for the screen either way, so asking for fewer frames \
than it shows is a choice to make for a battery rather than for \
smoothness.",
)
.small()
.color(p::muted()),
);
ui.add_space(10.0);
changed |= ui
.checkbox(
&mut self.prefs.show_frame_rate,
"Show the frame rate in the header",
)
.changed();
ui.label(
RichText::new(
" The rate as drawn, and a count of frames that arrived late. The \
About tab carries the same numbers without this being on.",
)
.small()
.color(p::muted()),
);
// If the system has asked for reduced motion, say so. Otherwise the
// toggle looks broken: it is ticked and nothing moves.
if motion.system_reduced {
ui.add_space(12.0);
ui.label(
RichText::new(
"Your system is set to reduce motion, so animation stays off whatever \
is ticked here. That setting wins on purpose.",
)
.small()
.color(p::yellow()),
);
}
if std::env::var_os("VEILVOICE_NO_ANIMATION").is_some() {
ui.add_space(8.0);
ui.label(
RichText::new(
"VEILVOICE_NO_ANIMATION is set in the environment, which also \
keeps animation off.",
)
.small()
.color(p::yellow()),
);
}
if changed {
self.persist();
}
}
/// Roadmap item 92. The autolock, and the range it offers.
fn security_page(&mut self, ui: &mut Ui) {
use crate::autolock::{describe_secs, parse, Autolock, CHOICES};
let mut auto = self.autolock();
ui.label(RichText::new("Lock the window when it is not used").color(p::fg()));
ui.label(
RichText::new(
"Off unless you turn it on. Starting a long job does not count as using \
the window: if you walk away while something is rendering, that is \
exactly when you would want it locked.",
)
.color(p::muted())
.small(),
);
ui.add_space(6.0);
let mut changed = ui
.checkbox(&mut auto.enabled, "lock after a period of no use")
.changed();
if auto.enabled {
ui.add_space(6.0);
let current = auto.after_secs;
egui::ComboBox::from_id_salt("autolock-after")
.selected_text(describe_secs(auto.after_secs))
.show_ui(ui, |ui| {
for choice in CHOICES {
// Only the ones inside the range this user has set, so
// a shortened range does not offer a delay it would
// then clamp away behind their back.
if *choice < auto.floor_secs || *choice > auto.ceiling_secs {
continue;
}
ui.selectable_value(&mut auto.after_secs, *choice, describe_secs(*choice));
}
});
changed |= auto.after_secs != current;
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(RichText::new("or type one").color(p::muted()).small());
let box_ = ui.add(
egui::TextEdit::singleline(&mut self.autolock_typed)
.hint_text("90m, 2h, 1d")
.desired_width(120.0),
);
if box_.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
match parse(&self.autolock_typed) {
Some(secs) => {
auto.after_secs = secs;
// Typing a value outside the range widens the range
// to hold it, which is what somebody typing it
// meant. Clamping it away silently would look like
// the box being ignored.
auto.floor_secs = auto.floor_secs.min(secs);
auto.ceiling_secs = auto.ceiling_secs.max(secs);
self.autolock_typed.clear();
self.autolock_error = None;
changed = true;
}
None => {
self.autolock_error = Some(
"that is not a length of time. Try 90m, 2h or 1d.".to_string(),
);
}
}
}
});
if let Some(error) = &self.autolock_error {
ui.label(RichText::new(error).color(p::yellow()).small());
}
ui.add_space(8.0);
ui.label(
RichText::new(format!(
"the list offers {} to {}",
describe_secs(auto.floor_secs),
describe_secs(auto.ceiling_secs)
))
.color(p::muted())
.small(),
);
if auto != Autolock::default().sane()
&& ui
.button("put the list back to five minutes to two days")
.clicked()
{
let default = Autolock::default();
auto.floor_secs = default.floor_secs;
auto.ceiling_secs = default.ceiling_secs;
changed = true;
}
}
if changed {
self.set_autolock(auto);
}
}
/// The storage page: where files go, and the portable or installed choice.
fn storage_page(&mut self, ui: &mut Ui) {
section(
ui,
"Where this is kept",
"Plain text. Edit it or delete it; nothing here is secret.",
);
match &self.path {
Some(path) => {
ui.label(
RichText::new(path.display().to_string())
.small()
.color(p::cyan()),
);
}
None => {
ui.label(
RichText::new(
"Nowhere. This platform did not say where configuration belongs \
(no APPDATA, XDG_CONFIG_HOME or HOME), so choices apply for this \
session only.",
)
.small()
.color(p::yellow()),
);
}
}
if self.prefs.recovered_from_corrupt_file {
ui.add_space(10.0);
ui.label(
RichText::new(
"The settings file could not be understood, so the defaults are in \
force. Changing anything here will rewrite it.",
)
.small()
.color(p::yellow()),
);
}
ui.add_space(16.0);
if ui.button("reset to defaults").clicked() {
let configured = self.prefs.configured;
self.prefs = Prefs {
configured,
..Prefs::default()
};
self.persist();
}
ui.label(
RichText::new(
" Colour scheme and animation only. This does not touch the app lock, \
your passphrase, or any recording.",
)
.small()
.color(p::muted()),
);
}
}
/// A titled group with a one-line explanation under it.
fn section(ui: &mut Ui, title: &str, blurb: &str) {
ui.label(RichText::new(title).color(p::fg()).strong());
ui.label(RichText::new(blurb).small().color(p::muted()));
ui.add_space(10.0);
}
/// The active palette, as a row of swatches, so the choice can be seen rather
/// than only read.
fn swatches(ui: &mut Ui) {
let theme = crate::theme::active();
ui.horizontal(|ui| {
for (name, colour) in [
("accent", theme.accent),
("veiled", theme.accent_2),
("ok", theme.ok),
("warn", theme.warn),
("error", theme.err),
("text", theme.fg),
("muted", theme.muted),
] {
let (rect, response) =
ui.allocate_exact_size(egui::vec2(26.0, 18.0), egui::Sense::hover());
if ui.is_rect_visible(rect) {
ui.painter()
.rect_filled(rect, egui::CornerRadius::same(3), colour);
ui.painter().rect_stroke(
rect,
egui::CornerRadius::same(3),
egui::Stroke::new(1.0, p::border()),
egui::StrokeKind::Inside,
);
}
response.on_hover_text(name);
}
});
}
#[cfg(test)]
mod tests {
use super::*;
/// Drive the settings tab once, with no window.
fn render(settings: &mut Settings) {
let ctx = egui::Context::default();
let _ = crate::headless_frame(&ctx, Default::default(), |ui| {
egui::CentralPanel::default().show(ui, |ui| {
let ctx = ui.ctx().clone();
settings.tab(ui, &ctx)
});
});
}
#[test]
fn every_page_renders_without_a_window() {
let mut settings = Settings::default();
for (page, _, _) in Page::ALL {
settings.page = *page;
render(&mut settings);
}
}
#[test]
fn the_first_run_appearance_choices_render() {
let mut settings = Settings::default();
let ctx = egui::Context::default();
let _ = crate::headless_frame(&ctx, Default::default(), |ui| {
egui::CentralPanel::default().show(ui, |ui| settings.first_run_appearance(ui));
});
}
/// The menu has to cover the pages and the pages have to cover the menu, or
/// a page becomes unreachable.
#[test]
fn the_menu_lists_every_page_exactly_once() {
let mut seen: Vec<Page> = Page::ALL.iter().map(|(p, _, _)| *p).collect();
let count = seen.len();
seen.dedup();
assert_eq!(seen.len(), count, "a page is listed twice");
assert_eq!(count, 5, "a page was added without a menu entry");
for (_, label, blurb) in Page::ALL {
assert!(!label.is_empty() && !blurb.is_empty());
}
}
/// A first run has not been configured; answering it must stick.
#[test]
fn the_first_run_is_offered_once() {
let mut settings = Settings {
prefs: Prefs::default(),
..Default::default()
};
settings.first_run = !settings.prefs.configured;
assert!(settings.needs_first_run());
settings.prefs.configured = true;
settings.first_run = false;
assert!(!settings.needs_first_run());
}
/// Defaults are what the request asked for: animation on, offered at the
/// start, switchable afterwards.
#[test]
fn animation_is_on_by_default_and_can_be_turned_off() {
let ctx = egui::Context::default();
let mut settings = Settings::default();
assert!(settings.motion(&ctx).enabled);
assert!(settings.motion(&ctx).icon);
settings.prefs.animated_icon = false;
assert!(settings.motion(&ctx).enabled, "only the mark was stilled");
assert!(!settings.motion(&ctx).icon);
settings.prefs.animations = false;
assert!(!settings.motion(&ctx).enabled);
assert!(!settings.motion(&ctx).icon);
}
/// Choosing a theme must apply it and record it.
#[test]
fn choosing_a_theme_applies_and_records_it() {
let ctx = egui::Context::default();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.conf");
let mut settings = Settings {
path: Some(path.clone()),
..Default::default()
};
assert!(crate::theme::set_by_id(&ctx, "nord"));
settings.prefs.theme = "nord".into();
settings.persist();
assert_eq!(Prefs::load(&path).theme, "nord");
assert!(settings.save_error.is_none(), "{:?}", settings.save_error);
// And it comes back on the next launch.
let reloaded = Settings {
prefs: Prefs::load(&path),
path: Some(path),
..Default::default()
};
assert_eq!(reloaded.prefs.theme, "nord");
crate::theme::set_by_id(&ctx, "tokyo-night");
}
/// A save that cannot happen must not be silent, and must not lose the
/// choice for this session either.
#[test]
fn a_failed_save_is_reported_rather_than_swallowed() {
let mut settings = Settings {
path: None,
..Default::default()
};
settings.prefs.animations = false;
settings.persist();
assert!(settings.save_error.is_some(), "the failure was swallowed");
assert!(!settings.prefs.animations, "the choice was lost as well");
// And the panel shows it.
render(&mut settings);
}
/// Reset must not touch anything that is not a presentation choice.
/// An installed copy never offers to install itself, whatever the
/// preference says. The preference is only about the portable case.
#[test]
fn the_install_tab_is_never_offered_by_an_installed_copy() {
let mut settings = Settings::default();
assert!(
settings.show_install_tab(false),
"a portable copy offers it by default"
);
assert!(
!settings.show_install_tab(true),
"an installed copy must never offer to install itself"
);
settings.prefs.hide_install_tab = true;
assert!(
!settings.show_install_tab(false),
"the preference hides it on a portable copy"
);
assert!(
!settings.show_install_tab(true),
"and an installed copy is still never offered it"
);
}
/// The tick is remembered. A preference that has to be set on every launch
/// is not a preference.
#[test]
fn hiding_the_install_tab_survives_a_reload() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.conf");
let mut settings = Settings {
path: Some(path.clone()),
..Default::default()
};
settings.set_hide_install_tab(true);
assert!(
settings.save_error().is_none(),
"{:?}",
settings.save_error()
);
assert!(Prefs::load(&path).hide_install_tab);
}
#[test]
fn reset_leaves_the_first_run_answered() {
let dir = tempfile::tempdir().unwrap();
let mut settings = Settings {
path: Some(dir.path().join("settings.conf")),
prefs: Prefs {
theme: "dracula".into(),
animations: false,
animated_icon: false,
configured: true,
toured_tabs: String::new(),
notify_style: "overlay".into(),
failsafe: "close".into(),
live_monitor: "toolbar".into(),
acceleration: true,
hide_install_tab: false,
always_group: false,
seal_with_app_lock: false,
vault_dir: String::new(),
vault_tool: String::new(),
vault_hidden: String::new(),
autolock: false,
autolock_after: 15 * 60,
autolock_floor: crate::autolock::FLOOR_SECS,
autolock_ceiling: crate::autolock::CEILING_SECS,
frame_rate: 0,
show_frame_rate: false,
recovered_from_corrupt_file: false,
},
page: Page::Storage,
..Default::default()
};
let configured = settings.prefs.configured;
settings.prefs = Prefs {
configured,
..Prefs::default()
};
settings.persist();
assert_eq!(settings.prefs.theme, "tokyo-night");
assert!(settings.prefs.animations);
assert!(
settings.prefs.configured,
"reset must not ask the first-run question again"
);
}
/// Loading must survive a settings file full of nonsense.
#[test]
fn a_corrupt_file_leaves_a_usable_panel_that_says_so() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.conf");
std::fs::write(&path, "this is not a settings file\n@@@@\n").unwrap();
let mut settings = Settings {
prefs: Prefs::load(&path),
path: Some(path),
page: Page::Storage,
..Default::default()
};
assert!(settings.prefs.recovered_from_corrupt_file);
assert_eq!(settings.prefs.theme, "tokyo-night");
render(&mut settings);
}
}