crates/veilvoice-gui/src/notify.rs
what this file is for · veilvoice-gui · 460 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
//! How the application tells you something, and the three ways to be told.
//!
//! # Three modes, and none of them is the obviously right one
//!
//! [`Style::Overlay`] draws a rounded, translucent card in the corner of
//! VeilVoice's own window. It is the quiet option: it does not steal focus, it
//! does not interrupt what you are typing, and it fades on its own.
//!
//! [`Style::Alert`] is the loud one. It stops the panel it is on until it is
//! dismissed, so it cannot be missed and it cannot be missed *quietly* --
//! which is the point when the thing being reported is that something started
//! recording your screen.
//!
//! [`Style::Off`] shows nothing. It is offered because a monitor that
//! interrupts somebody every thirty seconds is a monitor they switch off at the
//! operating system, and then it is not watching for anything at all. Better a
//! reader who chose silence knowingly than one who disabled the whole feature
//! to get it.
//!
//! There is no default that suits everybody, so the default is the middle one
//! and the choice is a preference rather than a guess.
//!
//! # The contrast is computed, never assumed
//!
//! A translucent card is a colour laid over whatever is behind it, so the text
//! on it is legible only if the *composited* result has enough contrast. Two
//! things follow, and both were got wrong in the first version of this file:
//!
//! * The background to measure against is the blend, not the card's own tint.
//! [`blend`] does that arithmetic, and [`Card::readable_text`] measures the
//! result with the same WCAG ratio [`crate::palettes`] already uses on user
//! palettes.
//! * If no candidate reaches the threshold, the card is drawn **opaque**
//! rather than shipped illegible. Translucency is a nicety; being able to
//! read a warning is not.
//!
//! # What this does not do
//!
//! It does not raise a system notification, put anything in a tray, or reach
//! outside VeilVoice's own window. Those need per-platform APIs and, on two of
//! the three, a registered application identity -- and this project is
//! published under a pseudonym on purpose. A notification that only appears
//! while the window is open is a real limit, and [`SCOPE`] says so rather than
//! letting somebody rely on being told while VeilVoice is closed.
//!
//! # In plain words
//!
//! When VeilVoice has something to tell you, it can do it three ways: a small
//! rounded box in the corner of its own window that fades away by itself, a
//! message that stops what you are doing until you dismiss it, or nothing at
//! all.
//!
//! The quiet box is see-through, so the colours behind it change how readable
//! the writing is. Rather than guessing, VeilVoice measures the actual
//! contrast of the result and picks the text colour that comes out clearest --
//! and if none of them is clear enough, it makes the box solid instead. A
//! warning you cannot read is not a warning.
//!
//! One honest limit: these only appear while the VeilVoice window is open. It
//! does not put messages into your desktop's own notification area.
use eframe::egui::{self, Color32, RichText, Ui};
use crate::palettes::contrast;
use crate::theme::palette as p;
/// The smallest contrast ratio a notification's text may have.
///
/// WCAG 2.1's threshold for body text. Not 3.0, which is the large-text
/// allowance: a notification is read once, quickly, often out of the corner of
/// an eye, and it is the one piece of text in the application most likely to be
/// read badly.
pub const LEAST_CONTRAST: f32 = 4.5;
/// How much of the card's own colour shows over what is behind it.
///
/// Not a free parameter. Below about this the card stops reading as a surface
/// and the text appears to float on the panel; far above it there is no point
/// calling it translucent.
pub const CARD_ALPHA: u8 = 216;
/// How the application shows a notification.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Style {
/// A rounded, translucent card in the corner. Fades by itself.
#[default]
Overlay,
/// A message that stops the panel until it is dismissed.
Alert,
/// Nothing at all.
Off,
}
impl Style {
/// A short name, for a picker.
pub fn label(self) -> &'static str {
match self {
Self::Overlay => "a card in the corner",
Self::Alert => "a message that stops you",
Self::Off => "nothing",
}
}
/// What this choice costs and buys, in the words a front end should show.
pub fn note(self) -> &'static str {
match self {
Self::Overlay => {
"A rounded card in the corner of this window that fades on its own. \
It will not take focus or interrupt what you are typing, which \
also means it can be missed."
}
Self::Alert => {
"Stops the panel until you dismiss it. Cannot be missed, and cannot \
be missed quietly, which is what you want when the thing being \
reported is that something started recording."
}
Self::Off => {
"Nothing is shown. Offered because a monitor that interrupts you \
every thirty seconds is one you switch off entirely, and then it \
is not watching for anything. Choosing silence here is better \
than disabling the feature."
}
}
}
/// Every style, in the order a picker should offer them.
pub const ALL: &'static [Style] = &[Style::Overlay, Style::Alert, Style::Off];
/// The identifier written to the settings file.
pub fn key(self) -> &'static str {
match self {
Self::Overlay => "overlay",
Self::Alert => "alert",
Self::Off => "off",
}
}
/// Read a style back. An unrecognised value is the default rather than an
/// error: a settings file from a newer version should not stop the
/// application, and of the two ways to be wrong, showing a notification is
/// the one that cannot hide a warning.
pub fn from_key(key: &str) -> Style {
Self::ALL
.iter()
.copied()
.find(|style| style.key() == key)
.unwrap_or_default()
}
}
/// How serious a notification is.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Level {
/// Something happened and nothing is wrong.
#[default]
Note,
/// Something worth acting on.
Warn,
}
/// One thing to tell the reader.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Notice {
/// The message.
pub text: String,
/// How serious it is.
pub level: Level,
}
impl Notice {
/// A plain note.
pub fn note(text: impl Into<String>) -> Self {
Self {
text: text.into(),
level: Level::Note,
}
}
/// Something worth acting on.
pub fn warn(text: impl Into<String>) -> Self {
Self {
text: text.into(),
level: Level::Warn,
}
}
}
/// Lay `over` on top of `under` at `alpha`, giving the colour actually seen.
///
/// The whole reason the contrast here is computed rather than assumed. A card
/// drawn at 85% opacity over a dark panel is neither of those two colours, and
/// measuring against either one gives an answer that is wrong in a direction
/// nobody notices until they are reading a warning they cannot read.
pub fn blend(over: Color32, under: Color32, alpha: u8) -> Color32 {
let a = alpha as f32 / 255.0;
let mix = |o: u8, u: u8| ((o as f32 * a) + (u as f32 * (1.0 - a))).round() as u8;
Color32::from_rgb(
mix(over.r(), under.r()),
mix(over.g(), under.g()),
mix(over.b(), under.b()),
)
}
/// A card's measured colours: what it is drawn in, and what its text is drawn
/// in, chosen so the result is legible.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Card {
/// The card's fill, already blended with the panel behind it.
pub fill: Color32,
/// The text colour that measured best against that fill.
pub text: Color32,
/// The measured ratio of [`Card::text`] on [`Card::fill`].
pub ratio: f32,
/// True when translucency had to be given up to stay legible.
///
/// Reported rather than hidden: a card that is quietly opaque looks like a
/// design choice, and the preferences panel says which it was.
pub opaque: bool,
}
impl Card {
/// Work out how to draw a card of this level on this panel.
///
/// Tries the translucent card first, and only if nothing on it reaches
/// [`LEAST_CONTRAST`] does it fall back to an opaque one. Translucency is
/// a nicety; reading the warning is not.
pub fn for_level(level: Level, panel: Color32) -> Card {
let tint = match level {
Level::Note => p::bg_dark(),
Level::Warn => p::yellow(),
};
let translucent = blend(tint, panel, CARD_ALPHA);
let best = Self::readable_text(translucent);
if best.1 >= LEAST_CONTRAST {
return Card {
fill: translucent,
text: best.0,
ratio: best.1,
opaque: false,
};
}
let solid = Self::readable_text(tint);
Card {
fill: tint,
text: solid.0,
ratio: solid.1,
opaque: true,
}
}
/// The palette colour that reads best on `fill`, and its ratio.
///
/// Measured across the palette's own text colours rather than assuming
/// black or white. A user palette can be anything, and picking the
/// contrasting extreme would put a colour on screen that is in no theme.
pub fn readable_text(fill: Color32) -> (Color32, f32) {
let candidates = [p::fg(), p::bg(), p::muted()];
let mut best = (candidates[0], contrast(candidates[0], fill));
for candidate in candidates.iter().skip(1) {
let ratio = contrast(*candidate, fill);
if ratio > best.1 {
best = (*candidate, ratio);
}
}
best
}
}
/// Draw a notice, in whichever way was chosen.
///
/// Returns true when the reader dismissed it. [`Style::Off`] returns true
/// immediately: nothing was shown, so nothing is waiting to be acknowledged,
/// and leaving it queued would build a backlog nobody can ever clear.
pub fn show(ui: &mut Ui, style: Style, notice: &Notice) -> bool {
match style {
Style::Off => true,
Style::Alert => alert(ui, notice),
Style::Overlay => overlay(ui, notice),
}
}
/// The quiet one: a rounded translucent card.
fn overlay(ui: &mut Ui, notice: &Notice) -> bool {
let card = Card::for_level(notice.level, p::bg());
let mut dismissed = false;
egui::Frame::new()
.fill(card.fill)
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::symmetric(12, 9))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(¬ice.text).color(card.text));
if ui
.small_button(RichText::new("×").color(card.text))
.clicked()
{
dismissed = true;
}
});
});
dismissed
}
/// The loud one: it stops the panel until acknowledged.
fn alert(ui: &mut Ui, notice: &Notice) -> bool {
let card = Card::for_level(notice.level, p::bg());
let mut dismissed = false;
egui::Frame::new()
.fill(card.fill)
.corner_radius(egui::CornerRadius::same(4))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.label(RichText::new(¬ice.text).color(card.text).strong());
ui.add_space(8.0);
if ui.button("dismiss").clicked() {
dismissed = true;
}
});
dismissed
}
/// What a reader has to be told about these notifications.
pub const SCOPE: &str = "\
These appear inside VeilVoice's own window and nowhere else. VeilVoice does not \
put messages into your desktop's notification area, so nothing here reaches you \
while the window is closed. That is a real limit rather than an oversight: a \
system notification needs a registered application identity on two of the three \
platforms, and this project is published under a pseudonym on purpose.";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_style_is_named_explained_and_round_trips() {
let mut keys: Vec<&str> = Style::ALL.iter().map(|s| s.key()).collect();
let count = keys.len();
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), count, "two styles share a key");
for style in Style::ALL {
assert!(!style.label().is_empty(), "{style:?}");
assert!(style.note().len() > 60, "{style:?}: say what it costs");
assert_eq!(Style::from_key(style.key()), *style);
}
}
/// An unreadable settings file must not stop the application, and of the
/// two ways to be wrong the one that still shows a warning is chosen.
#[test]
fn an_unknown_setting_falls_back_to_showing_something() {
assert_eq!(Style::from_key("something-new"), Style::Overlay);
assert_eq!(Style::from_key(""), Style::Overlay);
assert_ne!(
Style::from_key("something-new"),
Style::Off,
"a file this build cannot read must never silence the warnings"
);
}
/// The blend is the colour actually on screen, and it is between the two.
#[test]
fn blending_lands_between_the_two_colours() {
let over = Color32::from_rgb(255, 255, 255);
let under = Color32::from_rgb(0, 0, 0);
assert_eq!(blend(over, under, 255), over, "fully opaque is the card");
assert_eq!(blend(over, under, 0), under, "fully clear is the panel");
let half = blend(over, under, 128);
assert!(half.r() > 120 && half.r() < 136, "{half:?}");
}
/// **The point of the module.** Whatever the palette, the text on a card
/// is legible -- and where translucency cannot manage it, translucency is
/// what gets given up.
#[test]
fn a_card_is_always_legible_even_if_it_has_to_stop_being_translucent() {
for level in [Level::Note, Level::Warn] {
for panel in [
Color32::from_rgb(0, 0, 0),
Color32::from_rgb(255, 255, 255),
Color32::from_rgb(26, 27, 38),
Color32::from_rgb(122, 162, 247),
] {
let card = Card::for_level(level, panel);
assert!(
card.ratio >= LEAST_CONTRAST || card.opaque,
"{level:?} on {panel:?} gave {:.2}:1 while still translucent",
card.ratio
);
// And the ratio reported is the ratio of what is drawn.
let measured = contrast(card.text, card.fill);
assert!(
(measured - card.ratio).abs() < 0.01,
"reported {:.3}, measured {measured:.3}",
card.ratio
);
}
}
}
/// The text colour is chosen by measurement, not by assuming black or
/// white -- a user palette can be anything, and an assumed extreme puts a
/// colour on screen that is in no theme.
#[test]
fn the_text_colour_is_the_best_measured_candidate() {
for fill in [
Color32::from_rgb(0, 0, 0),
Color32::from_rgb(255, 255, 255),
Color32::from_rgb(128, 128, 128),
] {
let (chosen, ratio) = Card::readable_text(fill);
for other in [p::fg(), p::bg(), p::muted()] {
assert!(
ratio >= contrast(other, fill) - 0.001,
"{chosen:?} at {ratio:.2} lost to {other:?} at {:.2}",
contrast(other, fill)
);
}
}
}
/// Nothing queued behind a style that shows nothing. A notice that is never
/// displayed and never dismissed is a backlog nobody can clear.
#[test]
fn switching_notifications_off_does_not_build_a_queue() {
// `show` needs a Ui, so the contract is asserted where it is decided.
let source = include_str!("notify.rs").replace("\r\n", "\n");
let start = source.find("pub fn show(").expect("the function");
let end = source[start..].find("\n}\n").expect("its end") + start;
let body = &source[start..end];
assert!(
body.contains("Style::Off => true"),
"Off has to report the notice as dealt with:\n{body}"
);
}
/// The limit is stated, because somebody will otherwise rely on being told
/// while the window is closed.
#[test]
fn the_scope_note_says_these_do_not_leave_the_window() {
let scope = SCOPE.to_lowercase();
assert!(scope.contains("own window and nowhere else"), "{scope}");
assert!(
scope.contains("while the window is closed"),
"the case somebody will assume works: {scope}"
);
assert!(scope.contains("pseudonym"), "and why: {scope}");
}
#[test]
fn a_notice_carries_its_level() {
assert_eq!(Notice::note("x").level, Level::Note);
assert_eq!(Notice::warn("x").level, Level::Warn);
assert_eq!(Level::default(), Level::Note);
}
}