pub const TEXT_CONTRAST: f32 = 4.5;
pub const NON_TEXT_CONTRAST: f32 = 3.0;
pub const ENHANCED_CONTRAST: f32 = 7.0;
pub const MIN_TARGET_PX: f32 = 24.0;
pub const COMFORT_TARGET_PX: f32 = 44.0;
pub fn relative_luminance(srgb: [f32; 3]) -> f32 {
let linear = srgb.map(|c| {
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
});
0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
}
pub fn contrast_ratio(a: [f32; 3], b: [f32; 3]) -> f32 {
let (la, lb) = (relative_luminance(a), relative_luminance(b));
(la.max(lb) + 0.05) / (la.min(lb) + 0.05)
}
pub const ELEVATION_STEP: f32 = 2.0;
pub const STATE_STEP: f32 = 4.0;
pub fn cie_lightness(srgb: [f32; 3]) -> f32 {
const EPSILON: f32 = 216.0 / 24389.0;
const KAPPA: f32 = 24389.0 / 27.0;
let y = relative_luminance(srgb);
if y <= EPSILON {
KAPPA * y
} else {
116.0 * y.cbrt() - 16.0
}
}
pub fn composite(fg: [f32; 3], alpha: f32, bg: [f32; 3]) -> [f32; 3] {
let a = alpha.clamp(0.0, 1.0);
[
fg[0] * a + bg[0] * (1.0 - a),
fg[1] * a + bg[1] * (1.0 - a),
fg[2] * a + bg[2] * (1.0 - a),
]
}
pub fn meter_fraction(value: f32, max: f32) -> f32 {
if max > 0.0 && max.is_finite() && value.is_finite() {
(value / max).clamp(0.0, 1.0)
} else {
0.0
}
}
pub const METER_GAIN_TAU_SECONDS: f32 = 0.08;
pub const METER_TRAIL_HOLD_SECONDS: f32 = 0.45;
pub const METER_TRAIL_RATE: f32 = 1.5;
pub const METER_SNAP: f32 = 0.002;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct MeterShown {
pub fill: f32,
pub trail: f32,
pub hold: f32,
}
pub fn meter_motion(shown: MeterShown, target: f32, dt: f32, duration_scale: f32) -> MeterShown {
let target = if target.is_finite() {
target.clamp(0.0, 1.0)
} else {
0.0
};
if !(duration_scale.is_finite() && duration_scale > 0.0) {
return MeterShown {
fill: target,
trail: target,
hold: 0.0,
};
}
let dt = if dt.is_finite() { dt.max(0.0) } else { 0.0 } / duration_scale;
let mut next = shown;
let mut fresh = false;
if target < shown.fill {
next.fill = target;
if shown.trail <= shown.fill {
next.trail = shown.fill;
next.hold = METER_TRAIL_HOLD_SECONDS;
fresh = true;
}
} else if target > shown.fill {
let step = 1.0 - (-dt / METER_GAIN_TAU_SECONDS).exp();
next.fill = shown.fill + (target - shown.fill) * step;
if target - next.fill < METER_SNAP {
next.fill = target;
}
}
next.trail = next.trail.max(next.fill);
if next.trail > next.fill {
if !fresh {
let over = dt - next.hold;
next.hold = (next.hold - dt).max(0.0);
if over > 0.0 {
next.trail = (next.trail - METER_TRAIL_RATE * over).max(next.fill);
}
}
if next.trail - next.fill < METER_SNAP {
next.trail = next.fill;
}
}
if next.trail <= next.fill {
next.hold = 0.0;
}
next
}
pub fn window_ui_scale(
width: f32,
height: f32,
reference_short_side: f32,
scale: f32,
) -> Option<f32> {
let ui = width.min(height) / reference_short_side * scale;
(reference_short_side > 0.0 && scale > 0.0 && ui > 0.0 && ui.is_finite()).then_some(ui)
}
const SNAP_TOLERANCE: f32 = 1.0e-3;
pub fn snap_line_width(length: f32, scale: f32) -> f32 {
let usable = |x: f32| x.is_finite() && x > 0.0;
if !usable(length) || !usable(scale) {
return length;
}
let device = (length * scale + SNAP_TOLERANCE).floor().max(1.0);
let mut logical = device / scale;
for _ in 0..4 {
let drawn = logical * scale;
if drawn < device {
logical = logical.next_up();
} else if drawn > device && logical.next_down() * scale >= device {
logical = logical.next_down();
} else {
break;
}
}
logical
}
pub const PAD_REPEAT_DELAY: f32 = 0.4;
pub const PAD_REPEAT_RATE: f32 = 12.0;
pub const STICK_PUSH: f32 = 0.5;
pub const RADIAL_STICK_PUSH: f32 = 0.5;
pub const RADIAL_DEAD_ZONE: f32 = 0.25;
pub fn radial_pick(x: f32, y: f32, count: usize) -> Option<usize> {
if count == 0 {
return None;
}
if !(x.is_finite() && y.is_finite()) || (x == 0.0 && y == 0.0) {
return Some(0);
}
let turns = (x.atan2(y) / std::f32::consts::TAU).rem_euclid(1.0);
Some(((turns * count as f32 + 0.5 + 1e-4).floor() as usize) % count)
}
pub fn radial_place(index: usize, count: usize) -> (f32, f32) {
if count == 0 {
return (0.0, 1.0);
}
let angle = (index % count) as f32 / count as f32 * std::f32::consts::TAU;
(angle.sin(), angle.cos())
}
pub fn radial_step(value: usize, count: usize, delta: i32) -> usize {
if count == 0 {
return 0;
}
let at = value.min(count - 1) as i64;
(at + i64::from(delta)).rem_euclid(count as i64) as usize
}
pub fn slot_edge(index: usize, columns: usize, len: usize, end: bool, whole: bool) -> usize {
let Some(last) = len.checked_sub(1) else {
return 0;
};
let columns = columns.max(1);
match (end, whole) {
(false, true) => 0,
(true, true) => last,
(false, false) => index.min(last) / columns * columns,
(true, false) => (index.min(last) / columns * columns + columns - 1).min(last),
}
}
pub fn slider_fraction(value: f32, min: f32, max: f32) -> f32 {
if min.is_finite() && max.is_finite() && value.is_finite() && max > min {
((value - min) / (max - min)).clamp(0.0, 1.0)
} else {
0.0
}
}
pub fn slider_text(value: f32, min: f32, max: f32) -> String {
let value = if value.is_finite() { value } else { 0.0 };
let value = if min.is_finite() && max.is_finite() && min <= max {
value.clamp(min, max)
} else {
value
};
if min == 0.0 && max == 1.0 {
return format!("{}%", (value * 100.0).round());
}
let text = format!("{value:.2}");
let text = text.trim_end_matches('0').trim_end_matches('.');
if text == "-0" || text.is_empty() {
"0".into()
} else {
text.into()
}
}
pub fn choice_step(selected: usize, len: usize, delta: i32) -> usize {
let last = len.saturating_sub(1);
let from = selected.min(last);
if delta < 0 {
from.saturating_sub(delta.unsigned_abs() as usize)
} else {
from.saturating_add(delta as usize).min(last)
}
}
pub fn choice_next(selected: usize, len: usize) -> usize {
if len == 0 {
0
} else {
(selected.min(len - 1) + 1) % len
}
}
pub const TEXT_SIZE_STEPS: [f32; 4] = [1.0, 1.25, 1.5, 2.0];
pub const INTERFACE_SIZE_STEPS: [f32; 4] = [0.75, 1.0, 1.25, 1.5];
pub const PAD_STYLES: [PadStyle; 3] = [PadStyle::Xbox, PadStyle::PlayStation, PadStyle::Switch];
pub fn nearest_step(steps: &[f32], value: f32) -> usize {
if value.is_nan() || steps.is_empty() {
return 0;
}
let mut lowest = 0;
let mut highest = 0;
for (index, step) in steps.iter().enumerate() {
if *step < steps[lowest] {
lowest = index;
}
if *step > steps[highest] {
highest = index;
}
}
if value <= steps[lowest] {
return lowest;
}
if value >= steps[highest] {
return highest;
}
let mut nearest = lowest;
let mut distance = f32::INFINITY;
for (index, step) in steps.iter().enumerate() {
let away = (step - value).abs();
if away < distance {
nearest = index;
distance = away;
}
}
nearest
}
pub fn tab_step(selected: usize, len: usize, delta: i32) -> usize {
if len == 0 {
return 0;
}
let from = selected.min(len - 1) as i64;
(from + i64::from(delta)).rem_euclid(len as i64) as usize
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum PadStyle {
#[default]
Xbox,
PlayStation,
Switch,
}
impl PadStyle {
pub fn name(self) -> &'static str {
match self {
Self::Xbox => "Xbox",
Self::PlayStation => "PlayStation",
Self::Switch => "Switch",
}
}
}
pub fn key_name(code: &str) -> String {
let fixed = match code {
"Escape" => "Esc",
"Delete" => "Del",
"Insert" => "Ins",
"ContextMenu" => "Menu",
"Backquote" => "`",
"Backslash" | "IntlBackslash" => "\\",
"BracketLeft" => "[",
"BracketRight" => "]",
"Comma" => ",",
"Equal" => "=",
"Minus" => "-",
"Period" => ".",
"Quote" => "'",
"Semicolon" => ";",
"Slash" => "/",
"NumpadAdd" => "Num +",
"NumpadSubtract" => "Num -",
"NumpadMultiply" => "Num *",
"NumpadDivide" => "Num /",
"NumpadDecimal" => "Num .",
"NumpadComma" => "Num ,",
"NumpadEqual" => "Num =",
"NumpadEnter" => "Num Enter",
_ => "",
};
if !fixed.is_empty() {
return fixed.to_owned();
}
if code.starts_with("Unidentified") {
return "Unknown key".to_owned();
}
for side in ["Left", "Right"] {
if let Some(modifier) = code.strip_suffix(side) {
let name = match modifier {
"Shift" => "Shift",
"Control" => "Ctrl",
"Alt" => "Alt",
"Super" => "Super",
"Meta" => "Meta",
"Hyper" => "Hyper",
_ => continue,
};
return format!("{side} {name}");
}
}
if let Some(direction) = code.strip_prefix("Arrow") {
return format!("{direction} Arrow");
}
for prefix in ["Key", "Digit"] {
if let Some(rest) = code.strip_prefix(prefix)
&& rest.chars().count() == 1
{
return rest.to_owned();
}
}
if let Some(rest) = code.strip_prefix("Numpad")
&& rest.chars().all(|c| c.is_ascii_digit())
&& !rest.is_empty()
{
return format!("Num {rest}");
}
words(code)
}
pub fn mouse_name(button: &str) -> String {
match button {
"Left" => "Left Click".to_owned(),
"Right" => "Right Click".to_owned(),
"Middle" => "Middle Click".to_owned(),
"Back" => "Mouse 4".to_owned(),
"Forward" => "Mouse 5".to_owned(),
other => match numbered(other) {
Some(n) => format!("Mouse {n}"),
None => words(other),
},
}
}
pub fn pad_name(button: &str, style: PadStyle) -> String {
let (xbox, playstation, switch) = match button {
"South" => ("A", "Cross", "B"),
"East" => ("B", "Circle", "A"),
"North" => ("Y", "Triangle", "X"),
"West" => ("X", "Square", "Y"),
"C" => ("C", "C", "C"),
"Z" => ("Z", "Z", "Z"),
"LeftTrigger" => ("LB", "L1", "L"),
"LeftTrigger2" => ("LT", "L2", "ZL"),
"RightTrigger" => ("RB", "R1", "R"),
"RightTrigger2" => ("RT", "R2", "ZR"),
"Select" => ("View", "Create", "-"),
"Start" => ("Menu", "Options", "+"),
"Mode" => ("Guide", "PS", "Home"),
"LeftThumb" => ("LS", "L3", "L3"),
"RightThumb" => ("RS", "R3", "R3"),
"DPadUp" => ("D-pad Up", "D-pad Up", "D-pad Up"),
"DPadDown" => ("D-pad Down", "D-pad Down", "D-pad Down"),
"DPadLeft" => ("D-pad Left", "D-pad Left", "D-pad Left"),
"DPadRight" => ("D-pad Right", "D-pad Right", "D-pad Right"),
other => {
return match numbered(other) {
Some(n) => format!("Button {n}"),
None => words(other),
};
}
};
match style {
PadStyle::Xbox => xbox,
PadStyle::PlayStation => playstation,
PadStyle::Switch => switch,
}
.to_owned()
}
pub fn is_modifier_key(code: &str) -> bool {
["Shift", "Control", "Alt", "Super", "Meta", "Hyper"]
.iter()
.any(|name| {
code.strip_prefix(name)
.is_some_and(|side| matches!(side, "" | "Left" | "Right"))
})
}
pub fn shortcut_label(code: &str, [ctrl, shift, alt, meta]: [bool; 4], mac: bool) -> String {
let mut out = String::new();
if mac {
for (held, glyph) in [(ctrl, '⌃'), (alt, '⌥'), (shift, '⇧'), (meta, '⌘')] {
if held {
out.push(glyph);
}
}
out.push_str(match code {
"Enter" => "↩",
"Backspace" => "⌫",
"Delete" => "⌦",
"Escape" => "⎋",
"Tab" => "⇥",
"ArrowUp" => "↑",
"ArrowDown" => "↓",
"ArrowLeft" => "←",
"ArrowRight" => "→",
other => return out + &key_name(other),
});
return out;
}
for (held, name) in [
(meta, "Super"),
(ctrl, "Ctrl"),
(alt, "Alt"),
(shift, "Shift"),
] {
if held {
out.push_str(name);
out.push('+');
}
}
out + &key_name(code)
}
pub fn shortcut_conflicts<S: PartialEq>(keymap: &[(&str, S)]) -> Vec<(String, String)> {
let mut clashes = Vec::new();
for (index, (first, shortcut)) in keymap.iter().enumerate() {
for (second, other) in &keymap[index + 1..] {
if shortcut == other {
clashes.push(((*first).to_owned(), (*second).to_owned()));
}
}
}
clashes
}
pub fn fuzzy_score(query: &str, text: &str) -> Option<u32> {
const MATCH: i32 = 16;
const WORD_START: i32 = 12;
const CONSECUTIVE: i32 = 10;
const GAP: i32 = 2;
const LEAD_CAP: i32 = 4;
const NO_MATCH: i32 = i32::MIN / 2;
let chars: Vec<char> = text.chars().collect();
let lower: Vec<char> = chars
.iter()
.map(|c| c.to_lowercase().next().unwrap_or(*c))
.collect();
let starts: Vec<bool> = chars
.iter()
.enumerate()
.map(|(i, c)| {
i == 0
|| !chars[i - 1].is_alphanumeric()
|| (c.is_uppercase() && chars[i - 1].is_lowercase())
})
.collect();
let mut total: i32 = 0;
for term in query.split_whitespace() {
let term: Vec<char> = term
.chars()
.map(|c| c.to_lowercase().next().unwrap_or(c))
.collect();
let mut prev = vec![NO_MATCH; chars.len()];
for (i, wanted) in term.iter().enumerate() {
let mut cur = vec![NO_MATCH; chars.len()];
let mut gapped = NO_MATCH;
for j in 0..chars.len() {
if j >= 2 {
gapped = (gapped - GAP).max(prev[j - 2].saturating_sub(GAP));
}
if lower[j] != *wanted {
continue;
}
let here = MATCH + if starts[j] { WORD_START } else { 0 };
let before = if i == 0 {
-(j as i32).min(LEAD_CAP)
} else {
let next_to = if j >= 1 && prev[j - 1] > NO_MATCH {
prev[j - 1] + CONSECUTIVE
} else {
NO_MATCH
};
next_to.max(gapped)
};
if before > NO_MATCH {
cur[j] = here + before;
}
}
prev = cur;
}
let best = prev.into_iter().max().filter(|&score| score > NO_MATCH)?;
total += best.max(1);
}
let short = 255 - chars.len().min(255) as i32;
Some(total.max(0) as u32 * 256 + short as u32)
}
fn numbered(name: &str) -> Option<&str> {
name.strip_prefix("Other(")?.strip_suffix(')')
}
fn words(name: &str) -> String {
let mut out = String::with_capacity(name.len() + 2);
let mut before: Option<char> = None;
for c in name.chars() {
if c.is_uppercase() && before.is_some_and(|b| b.is_lowercase() || b.is_ascii_digit()) {
out.push(' ');
}
out.push(c);
before = Some(c);
}
out
}
pub fn repeat_steps(held_before: f32, held_now: f32, delay: f32, per_second: f32) -> u32 {
let due = |held: f32| {
if held >= delay && held.is_finite() {
((held - delay) * per_second).floor() as u32 + 1
} else {
0
}
};
let usable = |x: f32| x.is_finite() && x >= 0.0;
if usable(delay) && per_second.is_finite() && per_second > 0.0 && held_now > held_before {
due(held_now).saturating_sub(due(held_before))
} else {
0
}
}
pub fn toast_hold(characters: usize, base_seconds: f32, chars_per_second: f32, scale: f32) -> f32 {
let usable = |x: f32| x.is_finite() && x > 0.0;
let base = if usable(base_seconds) {
base_seconds
} else {
0.0
};
let reading = if usable(chars_per_second) {
characters as f32 / chars_per_second
} else {
0.0
};
if usable(scale) {
(base + reading) * scale
} else {
0.0
}
}
pub fn toast_life(hold: f32, fade: f32) -> f32 {
let fade = if fade.is_finite() && fade > 0.0 {
fade
} else {
0.0
};
let hold = if hold.is_finite() && hold > 0.0 {
hold
} else {
0.0
};
hold + 2.0 * fade
}
pub fn toast_alpha(age: f32, hold: f32, fade: f32) -> f32 {
if !age.is_finite() || age < 0.0 {
return 0.0;
}
let hold = if hold.is_finite() && hold > 0.0 {
hold
} else {
0.0
};
if !(fade.is_finite() && fade > 0.0) {
return if age < hold { 1.0 } else { 0.0 };
}
let rising = age / fade;
let falling = (hold + 2.0 * fade - age) / fade;
rising.min(falling).clamp(0.0, 1.0)
}
pub fn toast_grow(age: f32, hold: f32, fade: f32) -> f32 {
let left = 1.0 - toast_alpha(age, hold, fade);
1.0 - left * left * left
}
pub fn clock_text(seconds: f32) -> String {
let total = if seconds.is_finite() && seconds > 0.0 {
seconds as u64
} else {
0
};
let (hours, minutes, secs) = (total / 3600, total / 60 % 60, total % 60);
if hours > 0 {
format!("{hours}:{minutes:02}:{secs:02}")
} else {
format!("{minutes}:{secs:02}")
}
}
pub fn list_height(rows: f32, row: f32, gap: f32) -> f32 {
if rows.is_finite() && rows > 0.0 && row.is_finite() && gap.is_finite() {
(rows * (row + gap)).max(0.0)
} else {
0.0
}
}
pub fn scroll_to_reveal(
top: f32,
bottom: f32,
view: f32,
scroll: f32,
max: f32,
margin: f32,
) -> f32 {
let usable = [top, bottom, view, scroll, max, margin]
.iter()
.all(|x| x.is_finite());
let max = max.max(0.0);
if !usable || view <= 0.0 || bottom < top {
return if scroll.is_finite() {
scroll.clamp(0.0, max)
} else {
0.0
};
}
let height = bottom - top;
if height > view {
return top.clamp(0.0, max);
}
let margin = margin.max(0.0).min((view - height) * 0.5);
let want = if top - margin < scroll {
top - margin
} else if bottom + margin > scroll + view {
bottom + margin - view
} else {
scroll
};
let want = if (want - scroll).abs() > view {
(top + bottom) * 0.5 - view * 0.5
} else {
want
};
want.clamp(0.0, max)
}
pub fn snap_to_row(want: f32, scroll: f32, tops: &[f32]) -> Option<f32> {
if !(want.is_finite() && scroll.is_finite()) || (want - scroll).abs() < f32::EPSILON {
return None;
}
let usable = tops.iter().copied().filter(|t| t.is_finite());
if want > scroll {
usable
.filter(|&t| t >= want - 0.5)
.min_by(|a, b| a.total_cmp(b))
} else {
usable
.filter(|&t| t <= want + 0.5)
.max_by(|a, b| a.total_cmp(b))
}
}
pub fn page_target(from: f32, centres: &[f32], page: f32) -> Option<usize> {
if !(from.is_finite() && page.is_finite() && page != 0.0) {
return None;
}
let ahead = |centre: f32| (centre - from) * page.signum();
let mut nearest: Option<(usize, f32)> = None;
let mut farthest: Option<(usize, f32)> = None;
for (i, ¢re) in centres.iter().enumerate() {
let d = ahead(centre);
if d.is_nan() || d < 0.5 {
continue;
}
if nearest.is_none_or(|(_, n)| d < n) {
nearest = Some((i, d));
}
if d <= page.abs() && farthest.is_none_or(|(_, f)| d > f) {
farthest = Some((i, d));
}
}
farthest.or(nearest).map(|(i, _)| i)
}
pub const SPLIT_KEY_STEP_PX: f32 = 16.0;
pub fn split_mins(extent: f32, min_first: f32, min_second: f32) -> (f32, f32) {
let usable = |x: f32| if x.is_finite() { x.max(0.0) } else { 0.0 };
let extent = usable(extent);
let first = usable(min_first).min(extent);
let second = usable(min_second).min(extent - first);
(first, second)
}
pub fn split_clamp(fraction: f32, extent: f32, min_first: f32, min_second: f32) -> f32 {
let fraction = if fraction.is_finite() {
fraction.clamp(0.0, 1.0)
} else {
0.0
};
if !(extent.is_finite() && extent > 0.0) {
return fraction;
}
let (first, second) = split_mins(extent, min_first, min_second);
fraction.clamp(first / extent, 1.0 - second / extent)
}
pub fn split_drag(start: f32, distance: f32, extent: f32, min_first: f32, min_second: f32) -> f32 {
if extent.is_finite() && extent > 0.0 && distance.is_finite() {
split_clamp(start + distance / extent, extent, min_first, min_second)
} else {
split_clamp(start, extent, min_first, min_second)
}
}
pub const DOCK_PANE_MIN_PX: f32 = 96.0;
pub const DOCK_EDGE_FRACTION: f32 = 0.25;
pub const DOCK_EDGE_SHARE: f32 = 0.25;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum DockZone {
Into,
Left,
Right,
Top,
Bottom,
}
pub fn dock_zone(width: f32, height: f32, x: f32, y: f32) -> DockZone {
if !(width > 0.0 && height > 0.0 && x.is_finite() && y.is_finite()) {
return DockZone::Into;
}
let (nx, ny) = ((x / width).clamp(0.0, 1.0), (y / height).clamp(0.0, 1.0));
let edge = DOCK_EDGE_FRACTION;
if (edge..=1.0 - edge).contains(&nx) && (edge..=1.0 - edge).contains(&ny) {
return DockZone::Into;
}
let nearest = [
(nx, DockZone::Left),
(1.0 - nx, DockZone::Right),
(ny, DockZone::Top),
(1.0 - ny, DockZone::Bottom),
];
nearest
.into_iter()
.fold((f32::MAX, DockZone::Into), |best, now| {
if now.0 < best.0 { now } else { best }
})
.1
}
pub fn dock_edge_zone(width: f32, height: f32, x: f32, y: f32, band: f32) -> Option<DockZone> {
if !(x >= 0.0 && y >= 0.0 && x <= width && y <= height) {
return None;
}
[
(x, DockZone::Left),
(width - x, DockZone::Right),
(y, DockZone::Top),
(height - y, DockZone::Bottom),
]
.into_iter()
.filter(|(distance, _)| *distance < band)
.min_by(|a, b| a.0.total_cmp(&b.0))
.map(|(_, zone)| zone)
}
pub fn dock_drop_rect(
zone: DockZone,
width: f32,
height: f32,
whole: bool,
) -> (f32, f32, f32, f32) {
let share = if whole { DOCK_EDGE_SHARE } else { 0.5 };
match zone {
DockZone::Into => (0.0, 0.0, width, height),
DockZone::Left => (0.0, 0.0, width * share, height),
DockZone::Right => (width * (1.0 - share), 0.0, width * share, height),
DockZone::Top => (0.0, 0.0, width, height * share),
DockZone::Bottom => (0.0, height * (1.0 - share), width, height * share),
}
}
pub fn dock_tab_slot(tabs: &[(f32, f32, f32)], x: f32, y: f32) -> usize {
tabs.iter()
.filter(|&&(centre, _, bottom)| bottom <= y || (centre < x && y < bottom))
.count()
}
pub fn dock_neighbour(from: (f32, f32), others: &[(f32, f32)], toward: DockZone) -> Option<usize> {
let (dx, dy) = match toward {
DockZone::Into => return None,
DockZone::Left => (-1.0, 0.0),
DockZone::Right => (1.0, 0.0),
DockZone::Top => (0.0, -1.0),
DockZone::Bottom => (0.0, 1.0),
};
others
.iter()
.enumerate()
.filter_map(|(index, &(x, y))| {
let (vx, vy) = (x - from.0, y - from.1);
let ahead = vx * dx + vy * dy;
let aside = vx * dy - vy * dx;
(ahead > 1.0).then_some((index, ahead * ahead + 4.0 * aside * aside))
})
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(index, _)| index)
}
pub fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {
use std::cmp::Ordering;
fn digits(s: &str) -> (&str, usize) {
let run = s.bytes().take_while(u8::is_ascii_digit).count();
(s[..run].trim_start_matches('0'), run)
}
let (mut x, mut y) = (a, b);
loop {
match (x.chars().next(), y.chars().next()) {
(None, None) => return a.cmp(b),
(None, Some(_)) => return Ordering::Less,
(Some(_), None) => return Ordering::Greater,
(Some(p), Some(q)) if p.is_ascii_digit() && q.is_ascii_digit() => {
let ((m, taken_x), (n, taken_y)) = (digits(x), digits(y));
let order = m.len().cmp(&n.len()).then_with(|| m.cmp(n));
if order != Ordering::Equal {
return order;
}
(x, y) = (&x[taken_x..], &y[taken_y..]);
}
(Some(p), Some(q)) => {
let order = p.to_lowercase().cmp(q.to_lowercase());
if order != Ordering::Equal {
return order;
}
(x, y) = (&x[p.len_utf8()..], &y[q.len_utf8()..]);
}
}
}
}
pub fn table_window(scroll: f32, pitch: f32, pool: usize, len: usize) -> usize {
let last = len.saturating_sub(pool);
if !(scroll.is_finite() && pitch.is_finite()) || pitch <= 0.0 || scroll <= 0.0 {
return 0;
}
((scroll / pitch).floor() as usize).min(last)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TableStep {
Up,
Down,
PageUp,
PageDown,
First,
Last,
}
pub fn table_step(from: Option<usize>, step: TableStep, len: usize, page: usize) -> Option<usize> {
let last = len.checked_sub(1)?;
let page = page.max(1);
let Some(from) = from.map(|f| f.min(last)) else {
return Some(match step {
TableStep::Up | TableStep::PageUp | TableStep::Last => last,
TableStep::Down | TableStep::PageDown | TableStep::First => 0,
});
};
Some(match step {
TableStep::Up => from.saturating_sub(1),
TableStep::Down => (from + 1).min(last),
TableStep::PageUp => from.saturating_sub(page),
TableStep::PageDown => (from + page).min(last),
TableStep::First => 0,
TableStep::Last => last,
})
}
pub fn table_anchor(
scroll: f32,
pitch: f32,
old_first: usize,
new_index: Option<usize>,
max: f32,
) -> f32 {
let moved = new_index.map_or(scroll, |new| {
scroll + (new as f32 - old_first as f32) * pitch
});
if moved.is_finite() {
moved.clamp(0.0, max.max(0.0))
} else {
0.0
}
}
pub const TABLE_LEAST_COLUMN: f32 = 3.0;
pub fn column_resize(start: f32, by: f32, least: f32) -> f32 {
let wide = start + by;
if wide.is_finite() {
wide.max(least)
} else {
start.max(least)
}
}
pub const TYPE_AHEAD_SECS: f32 = 1.0;
pub fn tree_indent(text_px: f32) -> f32 {
(text_px * 1.5).max(MIN_TARGET_PX)
}
pub fn tree_ends(depths: &[u32]) -> Vec<usize> {
let mut ends = vec![depths.len(); depths.len()];
let mut open: Vec<usize> = Vec::new();
for (i, &depth) in depths.iter().enumerate() {
while let Some(&top) = open.last() {
if depths[top] < depth {
break;
}
ends[top] = i;
open.pop();
}
open.push(i);
}
ends
}
pub fn tree_visible(ends: &[usize], open: &[bool]) -> Vec<usize> {
let mut shown = Vec::new();
let mut i = 0;
while i < ends.len() {
shown.push(i);
i = if open.get(i).copied().unwrap_or(false) {
i + 1
} else {
ends[i]
};
}
shown
}
pub fn tree_parent(depths: &[u32], index: usize) -> Option<usize> {
let depth = *depths.get(index)?;
(0..index).rev().find(|&j| depths[j] < depth)
}
pub fn tree_heir(depths: &[u32], ends: &[usize], index: usize) -> Option<usize> {
let depth = *depths.get(index)?;
let next = *ends.get(index)?;
if depths.get(next) == Some(&depth) {
return Some(next);
}
(0..index).rev().find(|&j| depths[j] <= depth)
}
pub fn type_ahead(
len: usize,
from: Option<usize>,
prefix_len: usize,
hit: impl Fn(usize) -> bool,
) -> Option<usize> {
let start = match from {
None => 0,
Some(f) if prefix_len <= 1 => f + 1,
Some(f) => f,
};
(0..len).map(|k| (start + k) % len.max(1)).find(|&i| hit(i))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum NumberError {
Empty,
Expression,
NotANumber,
NotFinite,
}
impl std::fmt::Display for NumberError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Empty => "type a number",
Self::Expression => "type a number, not an expression",
Self::NotANumber => "that is not a number",
Self::NotFinite => "that number is too large",
})
}
}
pub fn parse_number(text: &str) -> Result<f32, NumberError> {
let text = text.trim();
if text.is_empty() {
return Err(NumberError::Empty);
}
let text = text.replace('\u{2212}', "-");
let mut previous = None;
for (i, c) in text.char_indices() {
let sign = matches!(c, '+' | '-');
let exponent_sign = sign && matches!(previous, Some('e' | 'E'));
if matches!(c, '*' | '/' | '(' | ')' | '^' | '%' | '\u{d7}' | '\u{f7}')
|| (sign && i > 0 && !exponent_sign)
{
return Err(NumberError::Expression);
}
previous = Some(c);
}
match text.parse::<f32>() {
Ok(n) if n.is_finite() => Ok(n),
Ok(_) => Err(NumberError::NotFinite),
Err(_) => Err(NumberError::NotANumber),
}
}
pub fn number_decimals(step: f32) -> usize {
if !(step.is_finite() && step > 0.0) {
return 2;
}
(0..=6)
.find(|&d| {
let scale = 10f32.powi(d);
((step * scale).round() / scale - step).abs() <= step * 1e-4
})
.map_or(6, |d| d as usize)
}
pub fn format_number(value: f32, step: f32) -> String {
let base = number_decimals(step);
if !value.is_finite() {
return format!("{value}");
}
let value = if value == 0.0 { 0.0 } else { value };
(base..=6)
.map(|d| format!("{value:.d$}"))
.find(|s| s.parse::<f32>().is_ok_and(|n| n == value))
.unwrap_or_else(|| format!("{value:.6}"))
}
pub fn scrub_value(
start: f32,
distance: f32,
step: f32,
min: f32,
max: f32,
fine: bool,
coarse: bool,
) -> f32 {
let rate = step
* if fine {
0.1
} else if coarse {
10.0
} else {
1.0
};
let raw = start + distance * rate;
if !raw.is_finite() {
return start;
}
let scale = 10f32.powi(number_decimals(rate).max(number_decimals(step)) as i32);
hold((raw * scale).round() / scale, min, max)
}
pub fn step_value(value: f32, steps: f32, step: f32, min: f32, max: f32) -> f32 {
let scale = 10f32.powi(number_decimals(step) as i32);
hold(((value + steps * step) * scale).round() / scale, min, max)
}
pub(crate) fn hold(value: f32, min: f32, max: f32) -> f32 {
value.clamp(min.min(max), max.max(min))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum HexError {
Empty,
Length,
NotHex,
}
impl std::fmt::Display for HexError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Empty => "type a colour, like #ff8800",
Self::Length => "a colour is 3, 4, 6 or 8 hex digits",
Self::NotHex => "that is not a hex colour",
})
}
}
pub fn parse_hex(text: &str) -> Result<[f32; 4], HexError> {
let text = text.trim();
let digits = text.strip_prefix('#').unwrap_or(text).trim();
if digits.is_empty() {
return Err(HexError::Empty);
}
if !digits.is_ascii() {
return Err(HexError::NotHex);
}
if !matches!(digits.len(), 3 | 4 | 6 | 8) {
return if digits.bytes().all(|b| b.is_ascii_hexdigit()) {
Err(HexError::Length)
} else {
Err(HexError::NotHex)
};
}
let mut bytes = [255u8; 4];
let short = digits.len() <= 4;
for (i, slot) in bytes
.iter_mut()
.take(digits.len() / if short { 1 } else { 2 })
.enumerate()
{
let part = if short {
&digits[i..=i]
} else {
&digits[2 * i..2 * i + 2]
};
let n = u8::from_str_radix(part, 16).map_err(|_| HexError::NotHex)?;
*slot = if short { n * 17 } else { n };
}
Ok(bytes.map(|b| f32::from(b) / 255.0))
}
pub fn hex_text(rgba: [f32; 4]) -> String {
let byte = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u8;
let [r, g, b, a] = rgba.map(byte);
if a == 255 {
format!("#{r:02X}{g:02X}{b:02X}")
} else {
format!("#{r:02X}{g:02X}{b:02X}{a:02X}")
}
}
pub fn hsv_to_rgb(hue: f32, saturation: f32, value: f32) -> [f32; 3] {
let h = if hue.is_finite() {
hue.rem_euclid(360.0) / 60.0
} else {
0.0
};
let s = saturation.clamp(0.0, 1.0);
let v = value.clamp(0.0, 1.0);
let chroma = v * s;
let x = chroma * (1.0 - (h % 2.0 - 1.0).abs());
let m = v - chroma;
let (r, g, b) = match h as u8 {
0 => (chroma, x, 0.0),
1 => (x, chroma, 0.0),
2 => (0.0, chroma, x),
3 => (0.0, x, chroma),
4 => (x, 0.0, chroma),
_ => (chroma, 0.0, x),
};
[r + m, g + m, b + m]
}
pub fn rgb_to_hsv(rgb: [f32; 3], previous: [f32; 3]) -> [f32; 3] {
const EPSILON: f32 = 1e-6;
let [r, g, b] = rgb;
let max = r.max(g).max(b);
let delta = max - r.min(g).min(b);
if max <= EPSILON {
return [previous[0], previous[1], 0.0];
}
if delta <= EPSILON {
return [previous[0], 0.0, max];
}
let hue = if max == r {
((g - b) / delta).rem_euclid(6.0)
} else if max == g {
(b - r) / delta + 2.0
} else {
(r - g) / delta + 4.0
};
[hue * 60.0, delta / max, max]
}
pub fn area_values(offset: [f32; 2], inner_origin: [f32; 2], inner_size: [f32; 2]) -> [f32; 2] {
let along = |p: f32, origin: f32, size: f32| {
if size > 0.0 && p.is_finite() {
((p - origin) / size).clamp(0.0, 1.0)
} else {
0.5
}
};
[
along(offset[0], inner_origin[0], inner_size[0]),
1.0 - along(offset[1], inner_origin[1], inner_size[1]),
]
}
pub const SPINNER_PERIOD_SECS: f32 = 0.9;
pub fn spinner_angle(elapsed: f32) -> f32 {
if !elapsed.is_finite() {
return 0.0;
}
let turns = (elapsed / SPINNER_PERIOD_SECS).rem_euclid(1.0);
turns * std::f32::consts::TAU
}
pub const COOLDOWN_WHOLE_SECONDS_FROM: f32 = 3.0;
const COOLDOWN_ROUND_SLACK: f32 = 1e-3;
pub fn cooldown_text(remaining: f32) -> String {
if !remaining.is_finite() || remaining <= 0.0 {
return String::new();
}
if remaining < COOLDOWN_WHOLE_SECONDS_FROM {
let tenths = ((remaining * 10.0 - COOLDOWN_ROUND_SLACK).ceil() as u32).max(1);
return format!("{}.{}", tenths / 10, tenths % 10);
}
let seconds = (remaining - COOLDOWN_ROUND_SLACK).ceil() as u32;
if seconds >= 60 {
format!("{}:{:02}", seconds / 60, seconds % 60)
} else {
seconds.to_string()
}
}
pub fn cooldown_words(remaining: f32) -> String {
if !remaining.is_finite() || remaining <= 0.0 {
return String::new();
}
let seconds = ((remaining - COOLDOWN_ROUND_SLACK).ceil() as u32).max(1);
let (minutes, rest) = (seconds / 60, seconds % 60);
let plural = |n: u32, unit: &str| format!("{n} {unit}{}", if n == 1 { "" } else { "s" });
match (minutes, rest) {
(0, s) => format!("{} remaining", plural(s, "second")),
(m, 0) => format!("{} remaining", plural(m, "minute")),
(m, s) => format!("{} {} remaining", plural(m, "minute"), plural(s, "second")),
}
}
pub fn cooldown_sweep(remaining: f32, duration: f32) -> f32 {
if !remaining.is_finite() || remaining <= 0.0 {
return std::f32::consts::TAU;
}
if !duration.is_finite() || duration <= 0.0 {
return 0.0;
}
(1.0 - (remaining / duration).clamp(0.0, 1.0)) * std::f32::consts::TAU
}
pub const HOLD_UNWIND_RATE: f32 = 4.0;
pub const HOLD_SCALE_MAX: f32 = 4.0;
pub fn hold_seconds(seconds: f32, scale: f32) -> f32 {
if !seconds.is_finite() || seconds <= 0.0 {
return 0.0;
}
let scale = if scale.is_nan() {
1.0
} else {
scale.clamp(0.0, HOLD_SCALE_MAX)
};
seconds * scale
}
pub fn hold_step(progress: f32, dt: f32, seconds: f32, held: bool, instant: bool) -> f32 {
let progress = if progress.is_finite() {
progress.clamp(0.0, 1.0)
} else {
0.0
};
let dt = if dt.is_finite() { dt.max(0.0) } else { 0.0 };
if held {
if seconds <= 0.0 {
return 1.0;
}
return (progress + dt / seconds).min(1.0);
}
if instant {
return 0.0;
}
let unwind = seconds.clamp(0.4, 2.0) / HOLD_UNWIND_RATE;
(progress - dt / unwind).max(0.0)
}
pub const DIALOG_SLIDE_PX: f32 = 12.0;
pub const DIALOG_SLIDE_SECONDS: f32 = 0.18;
pub const POPOVER_SLIDE_PX: f32 = 6.0;
pub const POPOVER_SLIDE_SECONDS: f32 = 0.12;
pub fn ease_out(age: f32, seconds: f32) -> f32 {
if seconds.is_nan() || seconds <= 0.0 || seconds.is_infinite() || age.is_nan() {
return 1.0;
}
let left = 1.0 - (age / seconds).clamp(0.0, 1.0);
1.0 - left * left * left
}
pub fn eta_words(seconds: f32) -> String {
if !seconds.is_finite() || seconds < 0.0 {
return String::new();
}
if seconds < 1.0 {
return "less than a second left".to_string();
}
let whole = seconds.round() as u64;
if whole < 10 {
return format!("about {whole} s left");
}
if whole < 58 {
return format!("about {} s left", (whole + 2) / 5 * 5);
}
if whole < 3600 {
let minutes = (whole + 30) / 60;
if minutes < 60 {
return format!("about {minutes} min left");
}
}
let total = (whole + 150) / 300 * 5;
let (hours, minutes) = (total / 60, total % 60);
if minutes == 0 {
format!("about {hours} h left")
} else {
format!("about {hours} h {minutes} min left")
}
}
pub fn progress_text(step: Option<(u32, u32, &str)>, eta_seconds: Option<f32>) -> String {
let step = step.map(|(at, of, name)| {
if name.is_empty() {
format!("{at} of {of}")
} else {
format!("{at} of {of}: {name}")
}
});
let left = eta_seconds.map(eta_words).filter(|words| !words.is_empty());
match (step, left) {
(Some(step), Some(left)) => format!("{step}, {left}"),
(Some(only), None) | (None, Some(only)) => only,
(None, None) => String::new(),
}
}
pub fn bar_fit(widths: &[f32], ranks: &[u32], gap: f32, room: f32, reserve: f32) -> Vec<bool> {
let width = |w: f32| if w.is_finite() { w.max(0.0) } else { 0.0 };
let needed = |shown: &[bool]| {
let (sum, count) = widths
.iter()
.zip(shown)
.filter(|(_, shown)| **shown)
.fold((0.0, 0usize), |(sum, count), (w, _)| {
(sum + width(*w), count + 1)
});
sum + gap * count.saturating_sub(1) as f32
};
let mut shown = vec![true; widths.len()];
if needed(&shown) <= room {
return shown;
}
let room = room - reserve;
while needed(&shown) > room {
let next = (0..widths.len())
.filter(|&i| shown[i])
.min_by_key(|&i| (ranks.get(i).copied().unwrap_or(0), std::cmp::Reverse(i)));
match next {
Some(i) => shown[i] = false,
None => break,
}
}
shown
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Toward {
Up,
Down,
Left,
Right,
}
const TOWARD_EPSILON: f32 = 0.5;
pub fn target_toward(from: [f32; 4], rects: &[[f32; 4]], toward: Toward) -> Option<usize> {
let centre = |r: &[f32; 4]| [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0];
let origin = centre(&from);
let mut best: Option<(usize, f32)> = None;
for (index, rect) in rects.iter().enumerate() {
let at = centre(rect);
let (dx, dy) = (at[0] - origin[0], at[1] - origin[1]);
let (along, across) = match toward {
Toward::Right => (dx, dy.abs()),
Toward::Left => (-dx, dy.abs()),
Toward::Down => (dy, dx.abs()),
Toward::Up => (-dy, dx.abs()),
};
if along.is_nan() || along <= TOWARD_EPSILON || !across.is_finite() {
continue;
}
let score = along + 2.0 * across;
if best.is_none_or(|(_, lowest)| score < lowest) {
best = Some((index, score));
}
}
best.map(|(index, _)| index)
}
pub fn target_step(rects: &[[f32; 4]], current: Option<usize>, forward: bool) -> Option<usize> {
let mut order: Vec<usize> = (0..rects.len()).collect();
order.sort_by(|&a, &b| {
rects[a][1]
.total_cmp(&rects[b][1])
.then(rects[a][0].total_cmp(&rects[b][0]))
.then(a.cmp(&b))
});
let last = order.len().checked_sub(1)?;
let at = current.and_then(|c| order.iter().position(|&i| i == c));
let next = match (at, forward) {
(None, true) => 0,
(None, false) => last,
(Some(at), true) => (at + 1) % order.len(),
(Some(at), false) => (at + last) % order.len(),
};
Some(order[next])
}
pub fn box_outside(
min: (f32, f32),
max: (f32, f32),
view_min: (f32, f32),
view_max: (f32, f32),
) -> bool {
max.0 < view_min.0 || min.0 > view_max.0 || max.1 < view_min.1 || min.1 > view_max.1
}
pub fn pin_to_edge(
point: (f32, f32),
centre: (f32, f32),
min: (f32, f32),
max: (f32, f32),
) -> (f32, f32) {
if min.0 > max.0 || min.1 > max.1 || !point.0.is_finite() || !point.1.is_finite() {
return centre;
}
if point.0 >= min.0 && point.0 <= max.0 && point.1 >= min.1 && point.1 <= max.1 {
return point;
}
let (dx, dy) = (point.0 - centre.0, point.1 - centre.1);
let reach = |d: f32, from: f32, lo: f32, hi: f32| {
if d > 0.0 {
(hi - from) / d
} else if d < 0.0 {
(lo - from) / d
} else {
f32::INFINITY
}
};
let s = reach(dx, centre.0, min.0, max.0).min(reach(dy, centre.1, min.1, max.1));
let s = if s.is_finite() { s.max(0.0) } else { 0.0 };
(
(centre.0 + dx * s).clamp(min.0, max.0),
(centre.1 + dy * s).clamp(min.1, max.1),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_stick_points_into_the_wedge_of_the_item_it_faces() {
for count in 1..=12usize {
for item in 0..count {
let (x, y) = radial_place(item, count);
assert_eq!(radial_pick(x, y, count), Some(item), "{item} of {count}");
let half = std::f32::consts::TAU / count as f32 * 0.45;
let angle = item as f32 / count as f32 * std::f32::consts::TAU;
for off in [-half, half] {
let (a, b) = ((angle + off).sin(), (angle + off).cos());
assert_eq!(radial_pick(a, b, count), Some(item), "{item} of {count}");
}
}
}
}
#[test]
fn the_edge_between_two_wedges_belongs_to_the_clockwise_one() {
assert_eq!(radial_pick(1.0, 1.0, 4), Some(1));
assert_eq!(radial_pick(1.0, -1.0, 4), Some(2));
assert_eq!(radial_pick(-1.0, -1.0, 4), Some(3));
assert_eq!(radial_pick(-1.0, 1.0, 4), Some(0));
assert_eq!(radial_pick(0.01, 0.0, 4), Some(1));
assert_eq!(radial_pick(500.0, 0.0, 4), Some(1));
}
#[test]
fn a_ring_of_nothing_picks_nothing_and_a_blank_direction_picks_the_first() {
assert_eq!(radial_pick(1.0, 0.0, 0), None);
assert_eq!(radial_pick(0.0, 0.0, 5), Some(0));
assert_eq!(radial_pick(f32::NAN, 1.0, 5), Some(0));
assert_eq!(radial_pick(1.0, 0.0, 1), Some(0));
assert_eq!(radial_step(0, 0, 1), 0);
}
#[test]
fn a_step_wraps_both_ways_and_a_value_past_the_ring_is_the_last_item() {
assert_eq!(radial_step(7, 8, 1), 0);
assert_eq!(radial_step(0, 8, -1), 7);
assert_eq!(radial_step(2, 8, -19), 7);
assert_eq!(radial_step(99, 8, 1), 0);
assert_eq!(radial_step(3, 8, 0), 3);
}
#[test]
fn a_carried_payload_steers_to_the_nearest_target_that_way() {
let from = [0., 0., 10., 10.];
let rects = [
[30., 0., 40., 10.],
[12., 30., 22., 40.],
[-30., 0., -20., 10.],
];
assert_eq!(target_toward(from, &rects, Toward::Right), Some(0));
assert_eq!(target_toward(from, &rects, Toward::Left), Some(2));
assert_eq!(target_toward(from, &rects, Toward::Down), Some(1));
assert_eq!(target_toward(from, &rects, Toward::Up), None);
assert_eq!(target_toward(from, &[from], Toward::Right), None);
assert_eq!(target_toward(from, &[], Toward::Right), None);
}
#[test]
fn stepping_through_targets_goes_in_reading_order_and_wraps() {
let rects = [[0., 50., 10., 60.], [0., 0., 10., 10.], [20., 0., 30., 10.]];
assert_eq!(target_step(&rects, Some(2), true), Some(0));
assert_eq!(target_step(&rects, Some(0), true), Some(1));
assert_eq!(target_step(&rects, Some(1), false), Some(0));
assert_eq!(target_step(&rects, None, false), Some(0));
assert_eq!(target_step(&rects, Some(7), true), Some(1));
assert_eq!(target_step(&[], None, true), None);
}
#[test]
fn white_on_black_is_21_to_1() {
assert!((contrast_ratio([1., 1., 1.], [0., 0., 0.]) - 21.0).abs() < 0.01);
let x = [0.3, 0.6, 0.2];
assert_eq!(contrast_ratio(x, x), 1.0);
}
#[test]
fn the_meter_fraction_is_clamped_and_never_nan() {
assert_eq!(meter_fraction(25.0, 100.0), 0.25);
assert_eq!(meter_fraction(150.0, 100.0), 1.0);
assert_eq!(meter_fraction(-5.0, 100.0), 0.0);
assert_eq!(meter_fraction(5.0, 0.0), 0.0);
assert_eq!(meter_fraction(f32::NAN, 100.0), 0.0);
assert_eq!(meter_fraction(5.0, f32::INFINITY), 0.0);
}
fn settled(at: f32) -> MeterShown {
MeterShown {
fill: at,
trail: at,
hold: 0.0,
}
}
#[test]
fn a_loss_is_exact_at_once_and_leaves_a_trail_that_holds_then_drains_to_the_fill() {
let mut s = meter_motion(settled(1.0), 0.4, 0.016, 1.0);
assert_eq!((s.fill, s.trail), (0.4, 1.0));
for _ in 0..25 {
s = meter_motion(s, 0.4, 0.016, 1.0);
}
assert_eq!(s.trail, 1.0);
let mut last = s.trail;
let mut seconds = 0.0;
while s.trail > s.fill {
s = meter_motion(s, 0.4, 0.016, 1.0);
assert!(s.trail <= last && s.trail >= s.fill, "{s:?}");
last = s.trail;
seconds += 0.016;
assert!(seconds < 2.0, "never drained: {s:?}");
}
assert!(seconds < 0.6, "{seconds}");
assert_eq!(s, settled(0.4));
}
#[test]
fn a_second_loss_in_the_hold_does_not_restart_it() {
let mut s = meter_motion(settled(1.0), 0.8, 0.016, 1.0);
for _ in 0..20 {
s = meter_motion(s, 0.8, 0.016, 1.0);
}
let hold_before = s.hold;
let s = meter_motion(s, 0.5, 0.016, 1.0);
assert_eq!(s.fill, 0.5);
assert_eq!(s.trail, 1.0, "the trail is still the first loss's start");
assert!((s.hold - (hold_before - 0.016)).abs() < 1e-5, "{s:?}");
}
#[test]
fn a_gain_eases_up_never_overshoots_and_leaves_no_trail() {
let mut s = settled(0.2);
let mut last = s.fill;
let mut first = true;
let mut seconds = 0.0;
while s.fill < 0.9 {
s = meter_motion(s, 0.9, 0.016, 1.0);
if first {
assert!(s.fill > 0.2 && s.fill < 0.9, "partway: {s:?}");
first = false;
}
assert!(s.fill > last && s.fill <= 0.9, "{s:?}");
assert_eq!(s.trail, s.fill, "a gain draws no trail");
last = s.fill;
seconds += 0.016;
assert!(seconds < 0.6, "too slow: {s:?}");
}
assert_eq!(s, settled(0.9));
}
#[test]
fn a_gain_during_a_trail_lifts_the_trail_with_the_fill() {
let mut s = meter_motion(settled(0.5), 0.2, 0.016, 1.0);
assert_eq!((s.fill, s.trail), (0.2, 0.5));
for _ in 0..200 {
s = meter_motion(s, 0.9, 0.016, 1.0);
assert!(s.trail >= s.fill);
}
assert_eq!(s, settled(0.9));
}
#[test]
fn the_scale_stretches_every_length_and_zero_is_the_target_at_once() {
let mut s = meter_motion(settled(1.0), 0.0, 0.0, 2.0);
for _ in 0..40 {
s = meter_motion(s, 0.0, 0.016, 2.0);
}
assert_eq!(s.trail, 1.0);
for scale in [0.0, -1.0, f32::NAN, f32::INFINITY] {
assert_eq!(meter_motion(settled(1.0), 0.3, 0.016, scale), settled(0.3));
assert_eq!(meter_motion(settled(0.1), 0.7, 0.016, scale), settled(0.7));
}
let s = meter_motion(settled(0.5), 0.9, f32::NAN, 1.0);
assert_eq!(s.fill, 0.5);
assert_eq!(
meter_motion(settled(0.5), f32::NAN, 0.016, 0.0),
settled(0.0)
);
}
#[test]
fn the_shorter_side_over_the_reference_sets_the_scale() {
assert_eq!(window_ui_scale(1280.0, 720.0, 720.0, 1.0), Some(1.0));
assert_eq!(window_ui_scale(1920.0, 1080.0, 720.0, 1.0), Some(1.5));
assert_eq!(window_ui_scale(3840.0, 2160.0, 720.0, 1.0), Some(3.0));
assert_eq!(window_ui_scale(720.0, 1280.0, 720.0, 1.0), Some(1.0));
assert_eq!(window_ui_scale(3440.0, 1440.0, 720.0, 1.0), Some(2.0));
assert_eq!(window_ui_scale(1920.0, 1080.0, 720.0, 1.25), Some(1.875));
assert_eq!(window_ui_scale(640.0, 360.0, 720.0, 1.0), Some(0.5));
}
#[test]
fn no_window_or_an_invalid_setting_gives_no_scale() {
assert_eq!(window_ui_scale(0.0, 0.0, 720.0, 1.0), None);
assert_eq!(window_ui_scale(1280.0, 0.0, 720.0, 1.0), None);
assert_eq!(window_ui_scale(1280.0, 720.0, 0.0, 1.0), None);
assert_eq!(window_ui_scale(1280.0, 720.0, -720.0, -1.0), None);
assert_eq!(window_ui_scale(1280.0, 720.0, 720.0, 0.0), None);
assert_eq!(window_ui_scale(1280.0, 720.0, f32::NAN, 1.0), None);
assert_eq!(window_ui_scale(1280.0, 720.0, 720.0, f32::INFINITY), None);
}
#[test]
fn a_line_rounds_down_to_whole_device_pixels_and_never_below_one() {
let device = |length: f32, scale: f32| snap_line_width(length, scale) * scale;
assert_eq!(device(3.0, 1.0), 3.0);
assert_eq!(device(3.0, 800.0 / 720.0), 3.0);
assert_eq!(device(3.0, 1.25), 3.0);
assert_eq!(device(3.0, 1.5), 4.0);
assert_eq!(device(3.0, 2.0), 6.0);
assert_eq!(device(3.0, 3.0), 9.0);
assert_eq!(device(0.25, 1.0), 1.0);
assert_eq!(device(1.0, 0.5), 1.0);
assert_eq!(snap_line_width(0.0, 1.5), 0.0);
assert_eq!(device(4.0 / 1.5, 1.5), 4.0);
}
#[test]
fn a_snapped_line_is_never_a_hair_short_of_its_pixel() {
for height in 360..=4320 {
let scale = height as f32 / 720.0;
for length in [1.0, 2.0, 3.0, 5.0, 8.0] {
let drawn = snap_line_width(length, scale) * scale;
let whole = (length * scale + 1.0e-3).floor().max(1.0);
assert!(
drawn >= whole && drawn - whole < 1.0e-5,
"{length} px at {height} px tall draws {drawn}"
);
}
}
for height in [720, 768, 800, 900, 1050, 1080, 1200, 1440, 1600, 2160] {
let scale = height as f32 / 720.0;
let drawn = snap_line_width(3.0, scale) * scale;
assert_eq!(
drawn,
drawn.floor(),
"3 px at {height} px tall draws {drawn}"
);
}
}
#[test]
fn an_unusable_length_or_scale_is_left_alone() {
assert_eq!(snap_line_width(-3.0, 1.5), -3.0);
assert!(snap_line_width(f32::NAN, 1.5).is_nan());
assert_eq!(snap_line_width(3.0, 0.0), 3.0);
assert_eq!(snap_line_width(3.0, f32::INFINITY), 3.0);
}
#[test]
fn a_sliders_fraction_is_clamped_and_never_nan() {
assert_eq!(slider_fraction(25.0, 0.0, 100.0), 0.25);
assert_eq!(slider_fraction(1.25, 0.5, 2.0), 0.5);
assert_eq!(slider_fraction(-3.0, 0.0, 1.0), 0.0);
assert_eq!(slider_fraction(9.0, 0.0, 1.0), 1.0);
assert_eq!(slider_fraction(5.0, 5.0, 5.0), 0.0);
assert_eq!(slider_fraction(5.0, 9.0, 1.0), 0.0);
assert_eq!(slider_fraction(f32::NAN, 0.0, 1.0), 0.0);
assert_eq!(slider_fraction(0.5, 0.0, f32::INFINITY), 0.0);
}
#[test]
fn a_sliders_text_is_a_percentage_for_zero_to_one_and_the_number_otherwise() {
assert_eq!(slider_text(0.7, 0.0, 1.0), "70%");
assert_eq!(slider_text(0.0, 0.0, 1.0), "0%");
assert_eq!(slider_text(1.0, 0.0, 1.0), "100%");
assert_eq!(slider_text(0.1 + 0.6, 0.0, 1.0), "70%");
assert_eq!(slider_text(40.0, 0.0, 100.0), "40");
assert_eq!(slider_text(1.25, 0.5, 2.0), "1.25");
assert_eq!(slider_text(1.5, 0.5, 2.0), "1.5");
assert_eq!(slider_text(0.333, 0.0, 2.0), "0.33");
assert_eq!(slider_text(-0.001, -1.0, 1.0), "0");
assert_eq!(slider_text(-2.5, -5.0, 5.0), "-2.5");
assert_eq!(slider_text(7.0, 0.0, 1.0), "100%");
assert_eq!(slider_text(300.0, 0.0, 100.0), "100");
assert_eq!(slider_text(f32::NAN, 0.0, 100.0), "0");
}
#[test]
fn a_held_direction_repeats_after_the_delay_at_the_same_pace_at_any_frame_rate() {
assert_eq!(repeat_steps(0.0, 0.3, 0.5, 10.0), 0);
assert_eq!(repeat_steps(0.3, 0.5, 0.5, 10.0), 1);
let total = |frame: f32| {
let (mut held, mut steps) = (0.0_f32, 0);
while held < 2.0 {
let next = held + frame;
steps += repeat_steps(held, next, 0.5, 10.0);
held = next;
}
(steps, held)
};
for frame in [1.0 / 144.0, 1.0 / 60.0, 1.0 / 20.0, 0.25, 1.0] {
let (steps, held) = total(frame);
let expected = repeat_steps(0.0, held, 0.5, 10.0);
assert_eq!(steps, expected, "{frame} s frames");
}
assert_eq!(repeat_steps(1.0, 1.0, 0.5, 10.0), 0);
assert_eq!(repeat_steps(1.0, 0.5, 0.5, 10.0), 0);
}
#[test]
fn an_unusable_repeat_setting_repeats_nothing() {
assert_eq!(repeat_steps(0.0, 5.0, 0.5, 0.0), 0);
assert_eq!(repeat_steps(0.0, 5.0, 0.5, -10.0), 0);
assert_eq!(repeat_steps(0.0, 5.0, f32::NAN, 10.0), 0);
assert_eq!(repeat_steps(0.0, 5.0, 0.5, f32::INFINITY), 0);
assert_eq!(repeat_steps(0.0, f32::INFINITY, 0.5, 10.0), 0);
}
#[test]
fn a_toast_stands_as_long_as_its_message_takes_to_read_times_the_players_scale() {
assert_eq!(toast_hold(10, 2.0, 20.0, 1.0), 2.5);
assert_eq!(toast_hold(100, 2.0, 20.0, 1.0), 7.0);
assert_eq!(toast_hold(0, 2.0, 20.0, 1.0), 2.0);
assert_eq!(toast_hold(100, 2.0, 20.0, 10.0), 70.0);
assert!(toast_hold(40, 2.0, 20.0, 1.0) < toast_hold(41, 2.0, 20.0, 1.0));
}
#[test]
fn an_unusable_hold_setting_never_gives_a_negative_infinite_or_nan_time() {
for scale in [-1.0, 0.0, f32::NAN, f32::INFINITY] {
assert_eq!(toast_hold(10, 2.0, 20.0, scale), 0.0);
}
for speed in [0.0, -5.0, f32::NAN, f32::INFINITY] {
assert_eq!(toast_hold(10, 2.0, speed, 1.0), 2.0);
}
for base in [-2.0, f32::NAN, f32::NEG_INFINITY] {
assert_eq!(toast_hold(10, base, 20.0, 1.0), 0.5);
}
}
#[test]
fn a_toast_fades_in_holds_and_fades_out_over_its_life() {
let (hold, fade) = (2.0, 0.5);
assert_eq!(toast_life(hold, fade), 3.0);
assert_eq!(toast_alpha(0.0, hold, fade), 0.0);
assert_eq!(toast_alpha(0.25, hold, fade), 0.5);
assert_eq!(toast_alpha(0.5, hold, fade), 1.0);
assert_eq!(toast_alpha(2.5, hold, fade), 1.0);
assert_eq!(toast_alpha(2.75, hold, fade), 0.5);
assert_eq!(toast_alpha(3.0, hold, fade), 0.0);
assert_eq!(toast_alpha(10.0, hold, fade), 0.0);
}
#[test]
fn a_toast_with_no_fade_appears_whole_and_goes_at_once() {
for fade in [0.0, -1.0, f32::NAN] {
assert_eq!(toast_alpha(0.0, 2.0, fade), 1.0);
assert_eq!(toast_alpha(1.99, 2.0, fade), 1.0);
assert_eq!(toast_alpha(2.0, 2.0, fade), 0.0);
assert_eq!(toast_life(2.0, fade), 2.0);
assert_eq!(toast_grow(0.0, 2.0, fade), 1.0);
assert_eq!(toast_grow(2.0, 2.0, fade), 0.0);
}
}
#[test]
fn a_toast_shortened_while_fading_in_still_ends_and_is_never_out_of_range() {
for step in 0..=40 {
let age = step as f32 * 0.025;
let alpha = toast_alpha(age, 0.0, 0.5);
assert!((0.0..=1.0).contains(&alpha), "age {age}: {alpha}");
}
assert_eq!(toast_alpha(1.0, 0.0, 0.5), 0.0);
assert_eq!(toast_alpha(f32::NAN, 2.0, 0.5), 0.0);
assert_eq!(toast_alpha(-1.0, 2.0, 0.5), 0.0);
}
#[test]
fn a_toast_opens_its_room_early_holds_it_and_closes_it_at_the_end() {
let (hold, fade) = (2.0, 0.5);
assert_eq!(toast_grow(0.0, hold, fade), 0.0);
assert_eq!(toast_grow(1.0, hold, fade), 1.0);
assert_eq!(toast_grow(3.0, hold, fade), 0.0);
assert!(toast_grow(0.125, hold, fade) > 0.5);
assert!(toast_grow(2.75, hold, fade) > 0.8);
assert!(toast_grow(2.95, hold, fade) < 0.5);
let mut last = 0.0;
for step in 0..=20 {
let grow = toast_grow(step as f32 * 0.025, hold, fade);
assert!((0.0..=1.0).contains(&grow));
assert!(
grow >= last,
"the room closed while entering at step {step}"
);
last = grow;
}
for age in [f32::NAN, -1.0, 100.0] {
assert_eq!(toast_grow(age, hold, fade), 0.0);
}
}
#[test]
fn a_choice_steps_to_its_ends_and_stops_there() {
assert_eq!(choice_step(1, 4, 1), 2);
assert_eq!(choice_step(1, 4, -1), 0);
assert_eq!(choice_step(3, 4, 1), 3);
assert_eq!(choice_step(0, 4, -1), 0);
assert_eq!(choice_step(1, 4, i32::MAX), 3);
assert_eq!(choice_step(2, 4, i32::MIN), 0);
assert_eq!(choice_step(2, 4, 0), 2);
assert_eq!(choice_step(9, 4, 0), 3);
assert_eq!(choice_step(9, 4, -1), 2);
assert_eq!(choice_step(0, 0, 1), 0);
assert_eq!(choice_step(5, 0, -1), 0);
}
#[test]
fn a_choice_cycles_round_when_activated() {
assert_eq!(choice_next(0, 3), 1);
assert_eq!(choice_next(2, 3), 0);
assert_eq!(choice_next(0, 1), 0);
assert_eq!(
choice_next(9, 3),
0,
"past the end reads as the last, then wraps"
);
assert_eq!(choice_next(0, 0), 0);
}
#[test]
fn tabs_step_round_the_strip_in_both_directions() {
assert_eq!(tab_step(1, 3, 1), 2);
assert_eq!(tab_step(1, 3, -1), 0);
assert_eq!(tab_step(2, 3, 1), 0);
assert_eq!(tab_step(0, 3, -1), 2);
assert_eq!(tab_step(1, 3, 0), 1);
assert_eq!(tab_step(1, 3, 3), 1);
assert_eq!(tab_step(1, 3, -7), 0);
assert_eq!(tab_step(0, 3, i32::MIN), 1);
assert_eq!(tab_step(0, 1, 1), 0);
assert_eq!(tab_step(0, 1, -1), 0);
assert_eq!(tab_step(9, 3, 0), 2);
assert_eq!(tab_step(9, 3, 1), 0);
assert_eq!(tab_step(0, 0, 1), 0);
assert_eq!(tab_step(5, 0, -1), 0);
}
#[test]
fn a_list_stands_a_pitch_tall_for_each_row() {
assert_eq!(list_height(1.0, 48.0, 10.0), 58.0);
assert_eq!(list_height(2.0, 48.0, 10.0), 116.0);
assert_eq!(list_height(4.5, 48.0, 10.0), 261.0);
assert_eq!(list_height(0.5, 48.0, 10.0), 29.0);
for rows in [0.0, -2.0, f32::NAN, f32::INFINITY] {
assert_eq!(list_height(rows, 48.0, 10.0), 0.0, "rows {rows}");
}
}
#[test]
fn a_reveal_keeps_the_next_row_in_sight_and_stays_put_when_it_can() {
let reveal =
|top: f32, scroll: f32| scroll_to_reveal(top, top + 40.0, 160.0, scroll, 280.0, 40.0);
assert_eq!(reveal(0.0, 0.0), 0.0);
assert_eq!(reveal(40.0, 0.0), 0.0, "a row below it in view: stay");
assert_eq!(
reveal(80.0, 0.0),
0.0,
"the third row still has the fourth in view"
);
assert_eq!(
reveal(120.0, 0.0),
40.0,
"the fourth brings its next row in"
);
assert_eq!(reveal(160.0, 40.0), 80.0);
assert_eq!(reveal(120.0, 200.0), 80.0);
assert_eq!(
reveal(0.0, 80.0),
0.0,
"the first row stops at the top, not a margin above"
);
assert_eq!(reveal(400.0, 200.0), 280.0);
}
#[test]
fn a_reveal_of_a_far_row_centres_it_and_a_tall_row_shows_its_top() {
assert_eq!(
scroll_to_reveal(300.0, 340.0, 100.0, 0.0, 400.0, 0.0),
270.0
);
assert_eq!(
scroll_to_reveal(100.0, 140.0, 100.0, 400.0, 400.0, 0.0),
70.0
);
assert_eq!(
scroll_to_reveal(150.0, 300.0, 100.0, 0.0, 300.0, 40.0),
150.0
);
assert_eq!(
scroll_to_reveal(150.0, 300.0, 100.0, 250.0, 300.0, 40.0),
150.0
);
assert_eq!(scroll_to_reveal(80.0, 120.0, 60.0, 0.0, 300.0, 40.0), 70.0);
}
#[test]
fn a_reveal_never_leaves_the_list_and_ignores_what_it_cannot_read() {
assert_eq!(scroll_to_reveal(0.0, 40.0, 100.0, 50.0, 300.0, 40.0), 0.0);
assert_eq!(
scroll_to_reveal(390.0, 430.0, 100.0, 0.0, 300.0, 40.0),
300.0
);
assert_eq!(scroll_to_reveal(60.0, 100.0, 200.0, 30.0, 0.0, 40.0), 0.0);
assert_eq!(scroll_to_reveal(60.0, 100.0, 200.0, 30.0, -5.0, 40.0), 0.0);
assert_eq!(scroll_to_reveal(60.0, 100.0, 0.0, 30.0, 300.0, 40.0), 30.0);
assert_eq!(
scroll_to_reveal(f32::NAN, 100.0, 100.0, 30.0, 300.0, 40.0),
30.0
);
assert_eq!(
scroll_to_reveal(60.0, 100.0, 100.0, f32::NAN, 300.0, 40.0),
0.0
);
assert_eq!(
scroll_to_reveal(100.0, 60.0, 100.0, 30.0, 300.0, 40.0),
30.0
);
}
#[test]
fn a_page_goes_a_viewport_and_stops_at_the_ends() {
let rows = [20.0, 70.0, 120.0, 170.0, 220.0, 270.0];
assert_eq!(page_target(20.0, &rows, 150.0), Some(3));
assert_eq!(
page_target(170.0, &rows, 150.0),
Some(5),
"the last row within a page"
);
assert_eq!(page_target(220.0, &rows, 150.0), Some(5));
assert_eq!(
page_target(270.0, &rows, 150.0),
None,
"nothing below the last row"
);
assert_eq!(page_target(270.0, &rows, -150.0), Some(2));
assert_eq!(
page_target(70.0, &rows, -150.0),
Some(0),
"the first row within a page"
);
assert_eq!(page_target(20.0, &rows, -150.0), None);
assert_eq!(page_target(20.0, &rows, 10.0), Some(1));
assert_eq!(page_target(270.0, &rows, -10.0), Some(4));
let grid = [20.0, 20.0, 70.0, 70.0];
assert_eq!(page_target(20.0, &grid, 100.0), Some(2));
assert_eq!(page_target(20.0, &[], 100.0), None);
assert_eq!(page_target(20.0, &rows, 0.0), None);
assert_eq!(page_target(f32::NAN, &rows, 100.0), None);
}
#[test]
fn a_scroll_moves_on_to_a_row_top_and_never_short_of_what_was_asked() {
let tops = [0.0, 57.0, 114.0, 171.0, 228.0];
assert_eq!(snap_to_row(1.0, 0.0, &tops), Some(57.0));
assert_eq!(
snap_to_row(57.0, 0.0, &tops),
Some(57.0),
"already on a top"
);
assert_eq!(
snap_to_row(56.6, 0.0, &tops),
Some(57.0),
"rounded layout, half a unit of slack"
);
assert_eq!(snap_to_row(58.0, 0.0, &tops), Some(114.0));
assert_eq!(snap_to_row(120.0, 228.0, &tops), Some(114.0));
assert_eq!(snap_to_row(114.0, 228.0, &tops), Some(114.0));
assert_eq!(snap_to_row(1.0, 228.0, &tops), Some(0.0));
assert_eq!(snap_to_row(229.0, 171.0, &tops), None);
assert_eq!(snap_to_row(-5.0, 57.0, &tops), None);
assert_eq!(snap_to_row(57.0, 57.0, &tops), None);
assert_eq!(snap_to_row(60.0, 0.0, &[]), None);
assert_eq!(snap_to_row(f32::NAN, 0.0, &tops), None);
assert_eq!(snap_to_row(60.0, 0.0, &[f32::NAN, 114.0]), Some(114.0));
}
#[test]
fn a_key_is_named_as_its_cap_is_printed() {
assert_eq!(key_name("KeyQ"), "Q");
assert_eq!(key_name("Digit0"), "0");
assert_eq!(key_name("F5"), "F5");
assert_eq!(key_name("F13"), "F13");
assert_eq!(key_name("ShiftLeft"), "Left Shift");
assert_eq!(key_name("ControlRight"), "Right Ctrl");
assert_eq!(key_name("AltLeft"), "Left Alt");
assert_eq!(key_name("SuperRight"), "Right Super");
assert_eq!(key_name("ArrowLeft"), "Left Arrow");
assert_eq!(key_name("ArrowDown"), "Down Arrow");
assert_eq!(key_name("Numpad7"), "Num 7");
assert_eq!(key_name("NumpadAdd"), "Num +");
assert_eq!(key_name("NumpadEnter"), "Num Enter");
assert_eq!(key_name("Semicolon"), ";");
assert_eq!(key_name("Backquote"), "`");
assert_eq!(key_name("Escape"), "Esc");
assert_eq!(key_name("Delete"), "Del");
assert_eq!(key_name("Space"), "Space");
assert_eq!(key_name("Enter"), "Enter");
assert_eq!(key_name("Tab"), "Tab");
assert_eq!(key_name("PageUp"), "Page Up");
assert_eq!(key_name("CapsLock"), "Caps Lock");
assert_eq!(key_name("PrintScreen"), "Print Screen");
assert_eq!(key_name("MediaPlayPause"), "Media Play Pause");
assert_eq!(key_name("Unidentified(Windows(255))"), "Unknown key");
}
#[test]
fn a_mouse_button_is_named_by_what_a_player_calls_it() {
assert_eq!(mouse_name("Left"), "Left Click");
assert_eq!(mouse_name("Right"), "Right Click");
assert_eq!(mouse_name("Middle"), "Middle Click");
assert_eq!(mouse_name("Back"), "Mouse 4");
assert_eq!(mouse_name("Forward"), "Mouse 5");
assert_eq!(mouse_name("Other(9)"), "Mouse 9");
}
#[test]
fn a_pad_button_is_named_in_the_vocabulary_of_its_make() {
for (button, xbox, playstation, switch) in [
("South", "A", "Cross", "B"),
("East", "B", "Circle", "A"),
("North", "Y", "Triangle", "X"),
("West", "X", "Square", "Y"),
("LeftTrigger", "LB", "L1", "L"),
("RightTrigger2", "RT", "R2", "ZR"),
("Select", "View", "Create", "-"),
("Start", "Menu", "Options", "+"),
] {
assert_eq!(pad_name(button, PadStyle::Xbox), xbox, "{button}");
assert_eq!(
pad_name(button, PadStyle::PlayStation),
playstation,
"{button}"
);
assert_eq!(pad_name(button, PadStyle::Switch), switch, "{button}");
}
for style in [PadStyle::Xbox, PadStyle::PlayStation, PadStyle::Switch] {
assert_eq!(pad_name("DPadLeft", style), "D-pad Left");
assert_eq!(pad_name("Other(14)", style), "Button 14");
}
assert_eq!(PadStyle::default(), PadStyle::Xbox);
}
#[test]
fn a_value_shows_the_step_nearest_it() {
let steps = TEXT_SIZE_STEPS;
for (index, step) in steps.iter().enumerate() {
assert_eq!(nearest_step(&steps, *step), index);
}
assert_eq!(nearest_step(&steps, 1.2), 1);
assert_eq!(nearest_step(&steps, 1.7), 2);
assert_eq!(nearest_step(&steps, 1.8), 3);
assert_eq!(nearest_step(&steps, 1.125), 0);
assert_eq!(nearest_step(&steps, 1.75), 2);
assert_eq!(nearest_step(&steps, 0.0), 0);
assert_eq!(nearest_step(&steps, -3.0), 0);
assert_eq!(nearest_step(&steps, 40.0), 3);
assert_eq!(nearest_step(&steps, f32::INFINITY), 3);
assert_eq!(nearest_step(&steps, f32::NEG_INFINITY), 0);
assert_eq!(nearest_step(&steps, f32::NAN), 0);
assert_eq!(nearest_step(&[], 1.0), 0);
assert_eq!(nearest_step(&INTERFACE_SIZE_STEPS, 0.9), 1);
}
#[test]
fn a_pad_family_is_named_as_its_maker_writes_it() {
let names: Vec<_> = PAD_STYLES.iter().map(|style| style.name()).collect();
assert_eq!(names, ["Xbox", "PlayStation", "Switch"]);
}
#[test]
fn a_fuzzy_query_is_a_subsequence_of_every_term_in_any_order() {
assert!(fuzzy_score("opn prj", "File: Open Project").is_some());
assert!(fuzzy_score("prj opn", "File: Open Project").is_some());
assert!(fuzzy_score("OPEN", "open project").is_some());
assert_eq!(fuzzy_score("opz", "Open Project"), None);
assert_eq!(fuzzy_score("ooo", "Open"), None);
assert_eq!(fuzzy_score("a", ""), None);
assert!(fuzzy_score(" ", "Save").unwrap() > fuzzy_score("", "Save As").unwrap());
}
#[test]
fn a_word_start_and_a_run_beat_letters_picked_from_the_middle() {
let score = |text| fuzzy_score("opn prj", text).unwrap();
let open = score("Open Project");
assert!(open > score("Reopen Project"));
assert!(open > score("Open Prefabs, then Jump"));
assert!(open > score("Open Project Settings"));
assert!(fuzzy_score("op", "OpenProject").unwrap() > fuzzy_score("op", "stopper").unwrap());
assert!(fuzzy_score("ab", "a b").unwrap() > fuzzy_score("ab", "a xxxx b").unwrap());
}
#[test]
fn a_split_is_never_nan_and_holds_its_minimums_in_every_room() {
for fraction in [-1.0, 0.0, 0.3, 1.0, 7.0, f32::NAN, f32::INFINITY] {
for extent in [-5.0, 0.0, 40.0, 500.0, f32::NAN] {
let held = split_clamp(fraction, extent, 100.0, 80.0);
assert!(
(0.0..=1.0).contains(&held),
"{fraction} in {extent}: {held}"
);
}
}
for fraction in [0.0, 0.1, 0.5, 0.9, 1.0] {
let held = split_clamp(fraction, 500.0, 100.0, 80.0);
assert!(held * 500.0 >= 100.0 - 1e-3 && (1.0 - held) * 500.0 >= 80.0 - 1e-3);
}
assert_eq!(split_mins(30.0, 100.0, 80.0), (30.0, 0.0));
assert_eq!(split_mins(-3.0, 100.0, 80.0), (0.0, 0.0));
assert_eq!(split_drag(0.4, 0.0, 500.0, 0.0, 0.0), 0.4);
assert_eq!(split_drag(0.4, f32::NAN, 500.0, 0.0, 0.0), 0.4);
}
#[test]
fn a_dock_zone_is_the_middle_half_or_the_nearest_edge() {
assert_eq!(dock_zone(400.0, 200.0, 100.0, 50.0), DockZone::Into);
assert_eq!(dock_zone(400.0, 200.0, 300.0, 150.0), DockZone::Into);
assert_eq!(dock_zone(400.0, 200.0, 99.0, 100.0), DockZone::Left);
assert_eq!(dock_zone(400.0, 200.0, 301.0, 100.0), DockZone::Right);
assert_eq!(dock_zone(400.0, 200.0, 200.0, 49.0), DockZone::Top);
assert_eq!(dock_zone(400.0, 200.0, 200.0, 151.0), DockZone::Bottom);
assert_eq!(dock_zone(400.0, 200.0, 40.0, 40.0), DockZone::Left);
assert_eq!(dock_zone(400.0, 200.0, 80.0, 10.0), DockZone::Top);
assert_eq!(dock_zone(0.0, 200.0, 1.0, 1.0), DockZone::Into);
assert_eq!(dock_zone(400.0, 200.0, f32::NAN, 1.0), DockZone::Into);
assert_eq!(dock_zone(400.0, 200.0, -50.0, 100.0), DockZone::Left);
}
#[test]
fn the_dock_edge_band_picks_the_nearest_edge_and_nothing_inside() {
assert_eq!(
dock_edge_zone(800.0, 400.0, 790.0, 200.0, 24.0),
Some(DockZone::Right)
);
assert_eq!(
dock_edge_zone(800.0, 400.0, 5.0, 395.0, 24.0),
Some(DockZone::Left)
);
assert_eq!(dock_edge_zone(800.0, 400.0, 30.0, 200.0, 24.0), None);
assert_eq!(dock_edge_zone(800.0, 400.0, -1.0, 200.0, 24.0), None);
}
#[test]
fn the_drop_rect_is_the_part_the_layout_will_give_the_panel() {
assert_eq!(
dock_drop_rect(DockZone::Into, 400.0, 200.0, false),
(0.0, 0.0, 400.0, 200.0)
);
assert_eq!(
dock_drop_rect(DockZone::Right, 400.0, 200.0, false),
(200.0, 0.0, 200.0, 200.0)
);
assert_eq!(
dock_drop_rect(DockZone::Bottom, 400.0, 200.0, true),
(0.0, 150.0, 400.0, 50.0)
);
}
#[test]
fn a_neighbour_is_the_nearest_group_that_way() {
let others = [(100.0, 50.0), (400.0, 60.0), (210.0, 300.0), (200.0, 50.0)];
let from = (200.0, 50.0);
assert_eq!(dock_neighbour(from, &others, DockZone::Left), Some(0));
assert_eq!(dock_neighbour(from, &others, DockZone::Right), Some(1));
assert_eq!(dock_neighbour(from, &others, DockZone::Bottom), Some(2));
assert_eq!(dock_neighbour(from, &others, DockZone::Top), None);
assert_eq!(dock_neighbour(from, &others, DockZone::Into), None);
assert_eq!(
dock_tab_slot(&[(50.0, 0.0, 40.0), (150.0, 0.0, 40.0)], 100.0, 20.0),
1
);
}
#[test]
fn a_typed_number_is_read_and_a_sum_a_word_or_an_overflow_is_refused_for_its_own_reason() {
assert_eq!(parse_number("+5"), Ok(5.0));
assert_eq!(parse_number("-.5"), Ok(-0.5));
assert_eq!(parse_number("1e-3"), Ok(0.001));
assert_eq!(parse_number("2E+2"), Ok(200.0));
for sum in [
"1-2", "3+4", "--3", "2*3", "6/2", "(2)", "2^3", "50%", "1e3-1",
] {
assert_eq!(parse_number(sum), Err(NumberError::Expression), "{sum}");
}
for word in [".", "1.2.3", "1,5", "ten", "1e", "0x10"] {
assert_eq!(parse_number(word), Err(NumberError::NotANumber), "{word}");
}
for big in ["inf", "NaN", "1e99"] {
assert_eq!(parse_number(big), Err(NumberError::NotFinite), "{big}");
}
assert_eq!(parse_number(" "), Err(NumberError::Empty));
}
#[test]
fn a_number_shows_the_digits_of_its_step_and_more_only_when_it_needs_them() {
assert_eq!(format_number(-2.0, 0.1), "-2.0");
assert_eq!(format_number(0.1 + 0.2, 0.1), "0.3");
assert_eq!(format_number(99999.9, 0.1), "99999.9");
assert_eq!(format_number(1.0 / 3.0, 0.1), "0.333333");
assert_eq!(format_number(7.0, 0.25), "7.00");
assert_eq!(format_number(-0.0, 1.0), "0");
assert_eq!(format_number(1.5, 0.0), "1.50");
assert_eq!(number_decimals(f32::NAN), 2);
assert_eq!(number_decimals(0.001), 3);
}
#[test]
fn a_scrub_and_a_step_are_rounded_to_their_digits_and_held_to_the_range() {
let up = scrub_value(0.3, 3.0, 0.1, 0.0, 1.0, false, false);
assert_eq!(up, 0.6);
assert_eq!(scrub_value(up, -3.0, 0.1, 0.0, 1.0, false, false), 0.3);
assert_eq!(scrub_value(0.0, 3.0, 0.1, 0.0, 1.0, true, false), 0.03);
assert_eq!(scrub_value(0.5, 5.0, 0.1, 0.0, 100.0, false, true), 5.5);
assert_eq!(scrub_value(1.0, 5.0, 0.1, 3.0, 0.0, false, false), 1.5);
assert_eq!(scrub_value(1.0, f32::NAN, 0.1, 0.0, 9.0, false, false), 1.0);
assert_eq!(step_value(0.1, 2.0, 0.1, 0.0, 1.0), 0.3);
assert_eq!(step_value(0.1, -5.0, 0.1, 0.0, 1.0), 0.0);
}
#[test]
fn hex_is_read_in_every_form_and_shown_the_shortest_true_way() {
let orange = [1.0, 136.0 / 255.0, 0.0, 1.0];
assert_eq!(parse_hex("#ff8800"), Ok(orange));
assert_eq!(parse_hex(" FF8800 "), Ok(orange));
assert_eq!(parse_hex("#f80"), Ok([1.0, 136.0 / 255.0, 0.0, 1.0]));
assert_eq!(parse_hex("#f80c").map(|c| c[3]), Ok(204.0 / 255.0));
assert_eq!(parse_hex("#ff880080").map(|c| c[3]), Ok(128.0 / 255.0));
assert_eq!(parse_hex(""), Err(HexError::Empty));
assert_eq!(parse_hex("#"), Err(HexError::Empty));
assert_eq!(parse_hex("#ff88"), Ok(parse_hex("#ffff8888").unwrap()));
assert_eq!(parse_hex("#ff880"), Err(HexError::Length));
assert_eq!(parse_hex("#ff88000"), Err(HexError::Length));
assert_eq!(parse_hex("#gg8800"), Err(HexError::NotHex));
assert_eq!(parse_hex("#ff\u{e9}800"), Err(HexError::NotHex));
assert_eq!(hex_text(orange), "#FF8800");
assert_eq!(hex_text([1.0, 1.0, 1.0, 0.0]), "#FFFFFF00");
for text in ["#000000", "#FFFFFF", "#12AB9C", "#12AB9C7F"] {
assert_eq!(hex_text(parse_hex(text).unwrap()), text);
}
}
#[test]
fn hsv_round_trips_and_a_grey_or_a_black_keeps_what_it_cannot_say() {
for (h, s, v) in [
(0.0, 1.0, 1.0),
(45.0, 0.5, 0.75),
(200.0, 0.3, 0.2),
(300.0, 1.0, 0.5),
] {
let back = rgb_to_hsv(hsv_to_rgb(h, s, v), [0.0; 3]);
assert!((back[0] - h).abs() < 1e-3, "{h} came back {}", back[0]);
assert!((back[1] - s).abs() < 1e-5 && (back[2] - v).abs() < 1e-5);
}
assert_eq!(hsv_to_rgb(-60.0, 1.0, 1.0), hsv_to_rgb(300.0, 1.0, 1.0));
assert_eq!(hsv_to_rgb(f32::NAN, 1.0, 1.0), [1.0, 0.0, 0.0]);
assert_eq!(
rgb_to_hsv([1.0, 1.0, 1.0], [10.0, 0.7, 0.3]),
[10.0, 0.0, 1.0]
);
assert_eq!(
rgb_to_hsv([0.0, 0.0, 0.0], [10.0, 0.7, 0.3]),
[10.0, 0.7, 0.0]
);
}
#[test]
fn a_pointer_in_the_area_is_saturation_across_and_value_up_held_at_the_edge() {
let at = |x, y| area_values([x, y], [2.0, 2.0], [96.0, 46.0]);
assert_eq!(at(2.0, 48.0), [0.0, 0.0]);
assert_eq!(at(98.0, 2.0), [1.0, 1.0]);
assert_eq!(at(50.0, 25.0), [0.5, 0.5]);
assert_eq!(at(-500.0, 900.0), [0.0, 0.0]);
assert_eq!(area_values([5.0, 5.0], [0.0, 0.0], [0.0, 0.0]), [0.5, 0.5]);
}
#[test]
fn time_left_is_in_words_with_the_precision_an_estimate_has() {
assert_eq!(eta_words(0.4), "less than a second left");
assert_eq!(eta_words(4.0), "about 4 s left");
assert_eq!(eta_words(21.0), "about 20 s left");
assert_eq!(eta_words(57.0), "about 55 s left");
assert_eq!(eta_words(58.0), "about 1 min left");
assert_eq!(eta_words(150.0), "about 3 min left");
assert_eq!(eta_words(3570.0), "about 1 h left");
assert_eq!(eta_words(3900.0), "about 1 h 5 min left");
assert_eq!(eta_words(f32::NAN), "");
assert_eq!(eta_words(-3.0), "");
}
#[test]
fn an_ease_out_rises_monotonically_and_is_clamped() {
let mut last = -1.0;
for step in 0..=20 {
let now = ease_out(step as f32 * 0.01, 0.2);
assert!(now > last, "step {step}: {now} after {last}");
last = now;
}
assert_eq!(ease_out(-1.0, 0.2), 0.0);
assert_eq!(ease_out(9.0, 0.2), 1.0);
assert_eq!(ease_out(0.1, f32::NAN), 1.0);
assert_eq!(ease_out(f32::NAN, 0.2), 1.0);
}
#[test]
fn the_progress_line_joins_a_step_and_the_time_left_and_drops_what_is_missing() {
assert_eq!(
progress_text(Some((3, 7, "Measuring meshes")), Some(20.0)),
"3 of 7: Measuring meshes, about 20 s left"
);
assert_eq!(progress_text(Some((3, 7, "")), None), "3 of 7");
assert_eq!(progress_text(None, Some(f32::INFINITY)), "");
assert_eq!(progress_text(None, None), "");
}
#[test]
fn a_spinner_turns_once_a_period_on_the_clock_and_never_leaves_its_range() {
assert_eq!(spinner_angle(0.0), 0.0);
let half = spinner_angle(SPINNER_PERIOD_SECS / 2.0);
assert!((half - std::f32::consts::PI).abs() < 1e-5);
assert!(spinner_angle(SPINNER_PERIOD_SECS * 7.25) < std::f32::consts::TAU);
assert!(spinner_angle(-0.1) >= 0.0);
assert_eq!(spinner_angle(f32::NAN), 0.0);
}
#[test]
fn a_bar_hides_the_lowest_rank_first_and_the_later_of_equals() {
let widths = [100.0; 4];
assert_eq!(bar_fit(&widths, &[0; 4], 10.0, 430.0, 0.0), [true; 4]);
assert_eq!(
bar_fit(&widths, &[0; 4], 10.0, 429.0, 0.0),
[true, true, true, false]
);
assert_eq!(
bar_fit(&widths, &[3, 1, 3, 2], 10.0, 429.0, 0.0),
[true, false, true, true]
);
assert_eq!(
bar_fit(&widths, &[3, 1, 3, 2], 10.0, 300.0, 0.0),
[true, false, true, false]
);
assert_eq!(
bar_fit(&widths, &[0; 4], 10.0, 320.0, 0.0),
[true, true, true, false]
);
assert_eq!(
bar_fit(&widths, &[0; 4], 10.0, 210.0, 0.0),
[true, true, false, false]
);
}
#[test]
fn a_bar_keeps_the_room_of_its_more_button_only_when_something_hides() {
let widths = [100.0; 3];
assert_eq!(bar_fit(&widths, &[0; 3], 8.0, 320.0, 60.0), [true; 3]);
assert_eq!(
bar_fit(&widths, &[0; 3], 8.0, 300.0, 60.0),
[true, true, false]
);
assert_eq!(
bar_fit(&widths, &[0; 3], 8.0, 250.0, 60.0),
[true, false, false]
);
}
#[test]
fn a_bar_with_no_room_or_odd_widths_hides_all_and_never_loops() {
assert_eq!(
bar_fit(&[10.0, 10.0], &[0, 0], 4.0, -5.0, 0.0),
[false, false]
);
assert_eq!(
bar_fit(&[f32::NAN, 10.0], &[0, 0], 4.0, 14.0, 0.0),
[true, true]
);
assert_eq!(bar_fit(&[], &[], 4.0, 0.0, 0.0), Vec::<bool>::new());
assert_eq!(
bar_fit(&[50.0; 3], &[1], 0.0, 100.0, 0.0),
[true, true, false]
);
}
#[test]
fn a_pinned_point_keeps_its_direction_and_never_leaves_the_rectangle() {
let (centre, min, max) = ((640.0, 360.0), (64.0, 36.0), (1216.0, 684.0));
let (x, y) = pin_to_edge((740.0, -3000.0), centre, min, max);
assert!((y - 36.0).abs() < 1e-3, "{y}");
let (rx, ry) = (x - centre.0, y - centre.1);
let (ox, oy) = (740.0 - centre.0, -3000.0 - centre.1);
assert!((rx * oy - ry * ox).abs() < 1.0, "off the ray: {rx} {ry}");
for i in 0..360 {
let a = (i as f32).to_radians();
let (px, py) = pin_to_edge(
(centre.0 + a.cos() * 9000.0, centre.1 + a.sin() * 9000.0),
centre,
min,
max,
);
assert!(
(min.0..=max.0).contains(&px) && (min.1..=max.1).contains(&py),
"{i}"
);
}
assert_eq!(pin_to_edge(centre, centre, min, max), centre);
assert_eq!(pin_to_edge((9.0, 9.0), centre, max, min), centre);
assert_eq!(pin_to_edge((f32::NAN, 0.0), centre, min, max), centre);
}
#[test]
fn a_box_is_outside_only_when_none_of_it_overlaps_the_view() {
let view = ((0.0, 0.0), (100.0, 100.0));
assert!(box_outside((101.0, 0.0), (120.0, 10.0), view.0, view.1));
assert!(box_outside((0.0, -30.0), (10.0, -1.0), view.0, view.1));
assert!(!box_outside((90.0, 90.0), (130.0, 130.0), view.0, view.1));
assert!(!box_outside((100.0, 0.0), (120.0, 10.0), view.0, view.1));
}
}