#![forbid(unsafe_code)]
pub trait Intent {
fn token(self) -> &'static str;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Bevel {
Raised,
Inset,
}
impl Bevel {
#[must_use]
pub const fn edges(self) -> (Edge, Edge) {
match self {
Self::Raised => (Edge::Light, Edge::Dark),
Self::Inset => (Edge::Dark, Edge::Light),
}
}
#[must_use]
pub const fn pressed(self) -> Self {
match self {
Self::Raised => Self::Inset,
Self::Inset => Self::Raised,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Edge {
Light,
Dark,
}
impl Intent for Edge {
fn token(self) -> &'static str {
match self {
Self::Light => "bevel-light",
Self::Dark => "bevel-dark",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Fill {
Page,
Raised,
Overlay,
Well,
Sunken,
}
impl Intent for Fill {
fn token(self) -> &'static str {
match self {
Self::Page => "surface-page",
Self::Raised => "surface-raised",
Self::Overlay => "surface-overlay",
Self::Well => "surface-well",
Self::Sunken => "surface-sunken",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Depth {
Flat,
Raised,
Well,
Sunken,
Overlay,
}
impl Depth {
#[must_use]
pub const fn bevel(self) -> Option<Bevel> {
match self {
Self::Flat | Self::Sunken => None,
Self::Overlay => None,
Self::Raised => Some(Bevel::Raised),
Self::Well => Some(Bevel::Inset),
}
}
#[must_use]
pub const fn fill(self) -> Option<Fill> {
match self {
Self::Flat => None,
Self::Raised => Some(Fill::Raised),
Self::Well => Some(Fill::Well),
Self::Sunken => Some(Fill::Sunken),
Self::Overlay => Some(Fill::Overlay),
}
}
#[must_use]
pub const fn pressed(self) -> Self {
match self {
Self::Raised => Self::Well,
other => other,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum State {
Disabled,
}
impl State {
#[must_use]
pub const fn suppresses_interaction(self) -> bool {
match self {
Self::Disabled => true,
}
}
}
impl Intent for State {
fn token(self) -> &'static str {
match self {
Self::Disabled => "content-muted",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Tone {
Neutral,
Info,
Success,
Warning,
Danger,
}
impl Intent for Tone {
fn token(self) -> &'static str {
match self {
Self::Neutral => "content",
Self::Info => "info",
Self::Success => "success",
Self::Warning => "warning",
Self::Danger => "danger",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Token {
Badge,
Chip {
removable: bool,
},
}
impl Token {
#[must_use]
pub const fn interactive(self) -> bool {
matches!(self, Self::Chip { .. })
}
#[must_use]
pub const fn depth(self, latched: bool) -> Depth {
match self {
Self::Badge => Depth::Flat,
Self::Chip { .. } if latched => Depth::Well,
Self::Chip { .. } => Depth::Raised,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Notice {
Toast,
Banner,
}
impl Notice {
#[must_use]
pub const fn transient(self) -> bool {
matches!(self, Self::Toast)
}
#[must_use]
pub const fn fill(self) -> Fill {
match self {
Self::Toast => Fill::Overlay,
Self::Banner => Fill::Raised,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
pub enum Flow {
#[default]
Tight,
Relaxed,
}
impl Flow {
#[must_use]
pub const fn lines(self) -> u8 {
match self {
Self::Relaxed => 2,
_ => 1,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, PartialOrd, Ord)]
#[non_exhaustive]
pub struct Nesting {
pub level: u8,
}
impl Nesting {
#[must_use]
pub const fn top() -> Self {
Self { level: 0 }
}
#[must_use]
pub const fn at(level: u8) -> Self {
Self { level }
}
#[must_use]
pub const fn is_nested(self) -> bool {
self.level > 0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum RowPart {
Primary,
Secondary,
Meta,
Actions,
Tokens,
Proportion,
}
impl RowPart {
#[must_use]
pub const fn priority(self) -> Priority {
match self {
Self::Primary | Self::Actions => Priority::Essential,
Self::Meta | Self::Proportion => Priority::Optional,
_ => Priority::Secondary,
}
}
#[must_use]
pub const fn intent(self) -> &'static str {
match self {
Self::Primary => "content",
Self::Secondary => "content-secondary",
Self::Meta => "content-muted",
Self::Actions => "content",
Self::Tokens => "content",
Self::Proportion => "content",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Heading {
Page,
Section,
Subsection,
}
impl Heading {
#[must_use]
pub const fn separated(self) -> bool {
matches!(self, Self::Section)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Selector {
Segmented,
Toggle,
Tabs,
}
impl Selector {
#[must_use]
pub const fn chosen(self) -> Depth {
match self {
Self::Segmented | Self::Toggle => Depth::Well,
Self::Tabs => Depth::Raised,
}
}
#[must_use]
pub const fn unchosen(self) -> Depth {
match self {
Self::Tabs => Depth::Sunken,
Self::Segmented | Self::Toggle => Depth::Raised,
}
}
#[must_use]
pub const fn abutting(self) -> bool {
matches!(self, Self::Segmented | Self::Tabs)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Readiness {
Ready,
Pending,
Empty,
Failed,
}
impl Readiness {
#[must_use]
pub const fn shows_content(self) -> bool {
matches!(self, Self::Ready)
}
#[must_use]
pub const fn tone(self) -> Tone {
match self {
Self::Failed => Tone::Danger,
_ => Tone::Neutral,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
pub struct Awaiting {
pub amount: Option<u64>,
}
impl Awaiting {
#[must_use]
pub const fn unmeasured() -> Self {
Self { amount: None }
}
#[must_use]
pub const fn of(amount: u64) -> Self {
Self {
amount: Some(amount),
}
}
#[must_use]
pub const fn is_determinate(self) -> bool {
self.amount.is_some()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Meter<'a> {
pub done: u32,
pub total: u32,
pub tone: Tone,
pub label: Option<&'a str>,
}
impl<'a> Meter<'a> {
#[must_use]
pub const fn new(done: u32, total: u32) -> Self {
Self {
done,
total,
tone: Tone::Neutral,
label: None,
}
}
#[must_use]
pub const fn tone(mut self, tone: Tone) -> Self {
self.tone = tone;
self
}
#[must_use]
pub const fn label(mut self, label: &'a str) -> Self {
self.label = Some(label);
self
}
#[must_use]
pub const fn percent(&self) -> u8 {
if self.total == 0 {
return 0;
}
let scaled = (self.done as u64 * 100) / self.total as u64;
if scaled > 100 { 100 } else { scaled as u8 }
}
#[must_use]
pub const fn overflowing(&self) -> bool {
self.done > self.total
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.total == 0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Figure<'a> {
pub value: &'a str,
pub caption: &'a str,
pub change: Option<&'a str>,
pub tone: Tone,
}
impl<'a> Figure<'a> {
#[must_use]
pub const fn new(value: &'a str, caption: &'a str) -> Self {
Self {
value,
caption,
change: None,
tone: Tone::Neutral,
}
}
#[must_use]
pub const fn change(mut self, change: &'a str) -> Self {
self.change = Some(change);
self
}
#[must_use]
pub const fn tone(mut self, tone: Tone) -> Self {
self.tone = tone;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
pub enum Fit {
#[default]
Natural,
Cover,
Contain,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Extent {
pub width: u32,
pub height: u32,
}
impl Extent {
#[must_use]
pub const fn new(width: u32, height: u32) -> Self {
Self { width, height }
}
#[must_use]
pub fn ratio(self) -> Option<f32> {
(self.width > 0 && self.height > 0).then(|| self.width as f32 / self.height as f32)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
pub enum Loading {
#[default]
Eager,
Lazy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
pub enum Unit {
#[default]
Minutes,
Days,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Span {
from: u16,
to: u16,
}
impl Span {
pub const DAY: Self = Self { from: 0, to: 1440 };
#[must_use]
pub const fn new(from: u16, to: u16) -> Self {
Self {
from,
to: if to > from { to } else { from + 1 },
}
}
#[must_use]
pub const fn from(self) -> u16 {
self.from
}
#[must_use]
pub const fn to(self) -> u16 {
self.to
}
#[must_use]
pub const fn length(self) -> u16 {
self.to - self.from
}
#[must_use]
pub const fn holds(self, minute: u16) -> bool {
minute >= self.from && minute < self.to
}
}
impl Default for Span {
fn default() -> Self {
Self::DAY
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Placement {
at: u16,
length: u16,
}
impl Placement {
#[must_use]
pub const fn new(at: u16, length: u16) -> Self {
Self {
at,
length: if length == 0 { 1 } else { length },
}
}
#[must_use]
pub const fn at(self) -> u16 {
self.at
}
#[must_use]
pub const fn length(self) -> u16 {
self.length
}
#[must_use]
pub const fn end(self) -> u16 {
self.at + self.length
}
#[must_use]
pub const fn overlaps(self, other: Self) -> bool {
self.at < other.end() && other.at < self.end()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Track {
pub span: Span,
pub slot: u16,
pub tick: u16,
pub unit: Unit,
}
impl Track {
pub const DAY: Self = Self {
span: Span::DAY,
slot: 15,
tick: 60,
unit: Unit::Minutes,
};
#[must_use]
pub const fn over(span: Span) -> Self {
Self {
span,
slot: 15,
tick: 60,
unit: Unit::Minutes,
}
}
#[must_use]
pub const fn days(span: Span) -> Self {
Self {
span,
slot: 1,
tick: 7,
unit: Unit::Days,
}
}
#[must_use]
pub const fn slots(self) -> u16 {
if self.slot == 0 {
1
} else {
self.span.length().div_ceil(self.slot)
}
}
#[must_use]
pub fn fraction(self, minute: u16) -> f32 {
let span = f32::from(self.span.length());
let offset = f32::from(minute.saturating_sub(self.span.from()));
(offset / span).clamp(0.0, 1.0)
}
}
impl Default for Track {
fn default() -> Self {
Self::DAY
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Act<'a> {
pub label: &'a str,
pub key: Option<&'a str>,
pub tone: Tone,
pub state: Option<State>,
pub hint: Option<&'a str>,
}
impl<'a> Act<'a> {
#[must_use]
pub const fn new(label: &'a str) -> Self {
Self {
label,
key: None,
tone: Tone::Neutral,
state: None,
hint: None,
}
}
#[must_use]
pub const fn hinted(mut self, hint: &'a str) -> Self {
self.hint = Some(hint);
self
}
#[must_use]
pub const fn key(mut self, key: &'a str) -> Self {
self.key = Some(key);
self
}
#[must_use]
pub const fn tone(mut self, tone: Tone) -> Self {
self.tone = tone;
self
}
#[must_use]
pub const fn state(mut self, state: State) -> Self {
self.state = Some(state);
self
}
#[must_use]
pub fn disabled(&self) -> bool {
self.state.is_some_and(State::suppresses_interaction)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Region<'a> {
Band,
Sidebar,
Pane,
Group,
Split,
Columns,
TabGroup,
Modal,
Handover {
name: &'a str,
},
Ceded {
name: &'a str,
},
Widget {
name: &'a str,
},
}
impl<'a> Region<'a> {
#[must_use]
pub const fn depth(self) -> Depth {
match self {
Self::Band | Self::Sidebar | Self::Split | Self::TabGroup => Depth::Flat,
Self::Group => Depth::Flat,
Self::Columns => Depth::Flat,
Self::Pane => Depth::Well,
Self::Modal => Depth::Raised,
Self::Handover { .. } | Self::Ceded { .. } | Self::Widget { .. } => Depth::Flat,
}
}
#[must_use]
pub const fn described(self) -> bool {
!matches!(self, Self::Handover { .. } | Self::Ceded { .. })
}
#[must_use]
pub const fn owed(self) -> bool {
matches!(self, Self::Handover { .. })
}
#[must_use]
pub const fn name(self) -> Option<&'a str> {
match self {
Self::Handover { name } | Self::Ceded { name } | Self::Widget { name } => Some(name),
Self::Band
| Self::Sidebar
| Self::Pane
| Self::Group
| Self::Split
| Self::Columns
| Self::TabGroup
| Self::Modal => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
pub enum Showing {
#[default]
All,
One,
AtMostOne,
}
impl Showing {
#[must_use]
pub const fn selective(self) -> bool {
!matches!(self, Self::All)
}
#[must_use]
pub const fn dismissible(self) -> bool {
matches!(self, Self::AtMostOne)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Window {
pub from: usize,
pub count: usize,
pub of: Option<usize>,
}
impl Window {
#[must_use]
pub const fn new(from: usize, count: usize) -> Self {
Self {
from,
count,
of: None,
}
}
#[must_use]
pub const fn of(mut self, of: usize) -> Self {
self.of = Some(of);
self
}
#[must_use]
pub const fn frame(at: usize, of: usize) -> Self {
Self {
from: at,
count: 1,
of: Some(of),
}
}
#[must_use]
pub const fn index(self) -> Option<usize> {
if self.count == 0 {
return None;
}
Some(self.from / self.count)
}
#[must_use]
pub const fn windows(self) -> Option<usize> {
match self.of {
Some(of) if self.count > 0 => Some(of.div_ceil(self.count)),
_ => None,
}
}
#[must_use]
pub const fn has_before(self) -> bool {
self.from > 0
}
#[must_use]
pub const fn after(self) -> Option<usize> {
match self.of {
Some(of) => Some(of.saturating_sub(self.from.saturating_add(self.count))),
None => None,
}
}
#[must_use]
pub const fn has_after(self) -> bool {
match self.of {
Some(of) => self.from.saturating_add(self.count) < of,
None => true,
}
}
#[must_use]
pub const fn clamped(mut self) -> Self {
if let Some(of) = self.of
&& self.from >= of
{
let step = if self.count == 0 { 1 } else { self.count };
self.from = of.saturating_sub(step);
}
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Paging {
pub window: Window,
pub paged: bool,
}
impl Paging {
#[must_use]
pub const fn pages(from: usize, per: usize) -> Self {
Self {
window: Window::new(from, per),
paged: true,
}
}
#[must_use]
pub const fn more(shown: usize) -> Self {
Self {
window: Window::new(0, shown),
paged: false,
}
}
#[must_use]
pub const fn of(mut self, of: usize) -> Self {
self.window = self.window.of(of);
self
}
#[must_use]
pub const fn page(self) -> Option<usize> {
if !self.paged {
return None;
}
match self.window.index() {
Some(index) => Some(index + 1),
None => None,
}
}
#[must_use]
pub const fn pages_total(self) -> Option<usize> {
if !self.paged {
return None;
}
self.window.windows()
}
#[must_use]
pub const fn shown(self) -> usize {
self.window.count
}
#[must_use]
pub const fn total(self) -> Option<usize> {
self.window.of
}
#[must_use]
pub const fn remaining(self) -> Option<usize> {
self.window.after()
}
#[must_use]
pub const fn has_more(self) -> bool {
self.window.has_after()
}
#[must_use]
pub const fn has_previous(self) -> bool {
self.window.has_before()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Share(u8);
impl Share {
pub const SIDEBAR: Self = Self(25);
pub const LIST: Self = Self(40);
#[must_use]
pub const fn percent(percent: u8) -> Self {
Self(if percent < 5 {
5
} else if percent > 95 {
95
} else {
percent
})
}
#[must_use]
pub const fn as_percent(self) -> u8 {
self.0
}
#[must_use]
pub const fn of(self, whole: u16) -> u16 {
let taken = (whole as u32 * self.0 as u32).div_ceil(100);
if taken == 0 { 1 } else { taken as u16 }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Arrangement {
ListDetail {
tabbed: bool,
share: Share,
},
SidebarContent {
share: Share,
},
Single,
}
impl Arrangement {
#[must_use]
pub const fn list_detail(tabbed: bool) -> Self {
Self::ListDetail {
tabbed,
share: Share::LIST,
}
}
#[must_use]
pub const fn sidebar_content() -> Self {
Self::SidebarContent {
share: Share::SIDEBAR,
}
}
#[must_use]
pub const fn share(self) -> Option<Share> {
match self {
Self::ListDetail { share, .. } | Self::SidebarContent { share } => Some(share),
Self::Single => None,
}
}
#[must_use]
pub const fn with_share(self, share: Share) -> Self {
match self {
Self::ListDetail { tabbed, .. } => Self::ListDetail { tabbed, share },
Self::SidebarContent { .. } => Self::SidebarContent { share },
Self::Single => Self::Single,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
pub enum Measure {
#[default]
Wide,
Contained,
Reading,
}
impl Measure {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Wide => "wide",
Self::Contained => "contained",
Self::Reading => "reading",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum FieldKind {
Text,
Secret,
Number,
Range,
Interval,
Email,
Url,
Tel,
Date,
DateTime,
Textarea,
Rich,
Select,
Radio,
Checkbox,
File,
Theme,
Hidden,
}
pub const DATE_FORMAT: &str = "%Y-%m-%d";
pub const DATETIME_FORMAT: &str = "%Y-%m-%dT%H:%M";
impl FieldKind {
#[must_use]
pub const fn temporal(self) -> bool {
matches!(self, Self::Date | Self::DateTime)
}
#[must_use]
pub const fn visible(self) -> bool {
!matches!(self, Self::Hidden)
}
#[must_use]
pub const fn confidential(self) -> bool {
matches!(self, Self::Secret)
}
#[must_use]
pub const fn labels_itself(self) -> bool {
matches!(self, Self::Checkbox)
}
#[must_use]
pub const fn offers_options(self) -> bool {
matches!(self, Self::Select | Self::Radio)
}
#[must_use]
pub const fn offers_themes(self) -> bool {
matches!(self, Self::Theme)
}
#[must_use]
pub const fn multiline(self) -> bool {
matches!(self, Self::Textarea | Self::Rich)
}
#[must_use]
pub const fn takes_files(self) -> bool {
matches!(self, Self::File)
}
#[must_use]
pub const fn measurable(self) -> bool {
matches!(self, Self::Number | Self::Range | Self::Interval)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Family {
Image,
Audio,
Video,
}
impl Family {
#[must_use]
pub const fn wildcard(self) -> &'static str {
match self {
Self::Image => "image/*",
Self::Audio => "audio/*",
Self::Video => "video/*",
}
}
#[must_use]
pub fn of_type(media_type: &str) -> Option<Self> {
let (top, _) = media_type.split_once('/')?;
if top.eq_ignore_ascii_case("image") {
Some(Self::Image)
} else if top.eq_ignore_ascii_case("audio") {
Some(Self::Audio)
} else if top.eq_ignore_ascii_case("video") {
Some(Self::Video)
} else {
None
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Accepted<'a> {
Family(Family),
Type(&'a str),
Suffix(&'a str),
}
impl<'a> Accepted<'a> {
#[must_use]
pub fn family(self) -> Option<Family> {
match self {
Self::Family(family) => Some(family),
Self::Type(media_type) => Family::of_type(media_type),
Self::Suffix(_) => None,
}
}
#[must_use]
pub const fn as_str(self) -> &'a str {
match self {
Self::Family(family) => family.wildcard(),
Self::Type(text) | Self::Suffix(text) => text,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct Choice<'a> {
pub value: &'a str,
pub label: &'a str,
pub unavailable: Option<&'a str>,
pub detail: Option<&'a str>,
}
impl<'a> Choice<'a> {
#[must_use]
pub const fn plain(value: &'a str) -> Self {
Self::new(value, value)
}
#[must_use]
pub const fn new(value: &'a str, label: &'a str) -> Self {
Self {
value,
label,
unavailable: None,
detail: None,
}
}
#[must_use]
pub const fn unless(mut self, reason: &'a str) -> Self {
self.unavailable = Some(reason);
self
}
#[must_use]
pub const fn detailing(mut self, detail: &'a str) -> Self {
self.detail = Some(detail);
self
}
#[must_use]
pub const fn available(&self) -> bool {
self.unavailable.is_none()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct Candidate<'a> {
pub value: &'a str,
pub label: &'a str,
pub detail: Option<&'a str>,
}
impl<'a> Candidate<'a> {
#[must_use]
pub const fn plain(value: &'a str) -> Self {
Self::new(value, value)
}
#[must_use]
pub const fn new(value: &'a str, label: &'a str) -> Self {
Self {
value,
label,
detail: None,
}
}
#[must_use]
pub const fn detailed(mut self, detail: &'a str) -> Self {
self.detail = Some(detail);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub enum ThemeVariant {
Light,
Dark,
HighContrast,
}
impl ThemeVariant {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
ThemeVariant::Light => "light",
ThemeVariant::Dark => "dark",
ThemeVariant::HighContrast => "high-contrast",
}
}
#[must_use]
pub const fn heading(self) -> &'static str {
match self {
ThemeVariant::Light => "Light",
ThemeVariant::Dark => "Dark",
ThemeVariant::HighContrast => "High Contrast",
}
}
}
impl std::fmt::Display for ThemeVariant {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub enum Contrast {
Low,
Standard,
High,
}
impl Contrast {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Contrast::Low => "low",
Contrast::Standard => "standard",
Contrast::High => "high",
}
}
#[must_use]
pub const fn badge(self) -> &'static str {
match self {
Contrast::Low => "low",
Contrast::Standard => "OK",
Contrast::High => "AA",
}
}
}
impl std::fmt::Display for Contrast {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct ThemeChoice<'a> {
pub id: &'a str,
pub name: &'a str,
pub variant: ThemeVariant,
pub contrast: Contrast,
}
impl<'a> ThemeChoice<'a> {
#[must_use]
pub const fn new(
id: &'a str,
name: &'a str,
variant: ThemeVariant,
contrast: Contrast,
) -> Self {
Self {
id,
name,
variant,
contrast,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Curve<'a> {
Linear {
step: Option<&'a str>,
},
Logarithmic {
step: Option<&'a str>,
},
}
impl Default for Curve<'_> {
fn default() -> Self {
Self::Linear { step: None }
}
}
impl<'a> Curve<'a> {
#[must_use]
pub const fn step(self) -> Option<&'a str> {
match self {
Self::Linear { step } | Self::Logarithmic { step } => step,
}
}
#[must_use]
pub fn is_ratio(self, min: f64, max: f64) -> bool {
matches!(self, Self::Logarithmic { .. }) && min > 0.0 && max > min
}
#[must_use]
pub fn value_at(self, position: f64, min: f64, max: f64) -> f64 {
let position = position.clamp(0.0, 1.0);
if max <= min || min.is_nan() || max.is_nan() {
return min;
}
if self.is_ratio(min, max) {
min * (max / min).powf(position)
} else {
position.mul_add(max - min, min)
}
}
#[must_use]
pub fn position_of(self, value: f64, min: f64, max: f64) -> f64 {
if max <= min || min.is_nan() || max.is_nan() {
return 0.0;
}
let value = value.clamp(min, max);
let position = if self.is_ratio(min, max) {
(value / min).ln() / (max / min).ln()
} else {
(value - min) / (max - min)
};
position.clamp(0.0, 1.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Field<'a> {
pub kind: FieldKind,
pub name: &'a str,
pub upper_name: Option<&'a str>,
pub label: &'a str,
pub hint: Option<&'a str>,
pub error: Option<&'a str>,
pub note: Option<(Tone, &'a str)>,
pub placeholder: Option<&'a str>,
pub options: &'a [Choice<'a>],
pub themes: &'a [ThemeChoice<'a>],
pub follows: Option<Choice<'a>>,
pub accept: &'a [Accepted<'a>],
pub multiple: bool,
pub required: bool,
pub max_length: Option<u32>,
pub min: Option<&'a str>,
pub max: Option<&'a str>,
pub step: Option<&'a str>,
pub curve: Curve<'a>,
pub unit: Option<&'a str>,
pub extended: bool,
pub as_instant: bool,
}
impl<'a> Field<'a> {
#[must_use]
pub const fn new(kind: FieldKind, name: &'a str, label: &'a str) -> Self {
Self {
kind,
name,
upper_name: None,
label,
hint: None,
error: None,
note: None,
placeholder: None,
options: &[],
themes: &[],
follows: None,
accept: &[],
multiple: false,
required: false,
max_length: None,
min: None,
max: None,
step: None,
curve: Curve::Linear { step: None },
unit: None,
extended: false,
as_instant: false,
}
}
#[must_use]
pub const fn range(name: &'a str, label: &'a str, min: &'a str, max: &'a str) -> Self {
Self {
min: Some(min),
max: Some(max),
..Self::new(FieldKind::Range, name, label)
}
}
#[must_use]
pub const fn interval(name: &'a str, upper_name: &'a str, label: &'a str) -> Self {
Self {
upper_name: Some(upper_name),
..Self::new(FieldKind::Interval, name, label)
}
}
#[must_use]
pub const fn upload(name: &'a str, label: &'a str, accept: &'a [Accepted<'a>]) -> Self {
Self {
accept,
..Self::new(FieldKind::File, name, label)
}
}
#[must_use]
pub const fn select(name: &'a str, label: &'a str, options: &'a [Choice<'a>]) -> Self {
Self::offering(FieldKind::Select, name, label, options)
}
#[must_use]
pub const fn radio(name: &'a str, label: &'a str, options: &'a [Choice<'a>]) -> Self {
Self::offering(FieldKind::Radio, name, label, options)
}
#[must_use]
pub const fn theme(name: &'a str, label: &'a str, themes: &'a [ThemeChoice<'a>]) -> Self {
Self {
themes,
..Self::new(FieldKind::Theme, name, label)
}
}
#[must_use]
pub const fn following(mut self, follow: Choice<'a>) -> Self {
self.follows = Some(follow);
self
}
const fn offering(
kind: FieldKind,
name: &'a str,
label: &'a str,
options: &'a [Choice<'a>],
) -> Self {
Self {
options,
..Self::new(kind, name, label)
}
}
#[must_use]
pub const fn invalid(&self) -> bool {
self.error.is_some()
}
#[must_use]
pub const fn bounded(&self) -> bool {
self.min.is_some() && self.max.is_some()
}
#[must_use]
pub fn accepts_media(&self) -> bool {
self.accept.iter().any(|one| one.family().is_some())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Width {
Content,
Fixed,
Fill,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum Priority {
Optional,
Secondary,
Essential,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Fallback {
Wrap,
Stack,
Shed,
Menu,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Column<'a> {
pub name: &'a str,
pub width: Width,
pub priority: Priority,
pub sortable: bool,
pub sorted: Option<Sort>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Sort {
Ascending,
Descending,
}
impl Sort {
#[must_use]
pub const fn reversed(self) -> Self {
match self {
Self::Ascending => Self::Descending,
Self::Descending => Self::Ascending,
}
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Ascending => "ascending",
Self::Descending => "descending",
}
}
#[must_use]
pub const fn glyph(self) -> &'static str {
match self {
Self::Ascending => "\u{25B2}",
Self::Descending => "\u{25BC}",
}
}
}
impl<'a> Column<'a> {
#[must_use]
pub const fn new(name: &'a str) -> Self {
Self {
name,
width: Width::Fill,
priority: Priority::Secondary,
sortable: false,
sorted: None,
}
}
#[must_use]
pub const fn kept_at(&self, cutoff: Priority) -> bool {
(self.priority as u8) >= (cutoff as u8)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum CellPart {
Value,
Tokens,
Actions,
Link,
}
impl CellPart {
#[must_use]
pub const fn intent(self) -> &'static str {
match self {
Self::Value => "content",
Self::Tokens => "content",
Self::Actions => "content",
Self::Link => "content",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct Facet<'a> {
pub name: &'a str,
pub mode: Selecting,
pub values: &'a [FacetValue<'a>],
}
impl<'a> Facet<'a> {
#[must_use]
pub const fn new(name: &'a str, mode: Selecting, values: &'a [FacetValue<'a>]) -> Self {
Self { name, mode, values }
}
#[must_use]
pub fn engaged(&self) -> bool {
self.values.iter().any(|value| value.standing.is_picked())
}
#[must_use]
pub fn reach(&self) -> u8 {
self.values
.iter()
.map(|value| value.depth.level)
.max()
.unwrap_or(0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Selecting {
OneOf,
AnyOf,
Range,
Text,
Subtree,
}
impl Selecting {
#[must_use]
pub const fn offers_values(self) -> bool {
matches!(self, Self::OneOf | Self::AnyOf | Self::Subtree)
}
#[must_use]
pub const fn prunes(self) -> bool {
matches!(self, Self::Subtree)
}
#[must_use]
pub const fn accumulates(self) -> bool {
matches!(self, Self::AnyOf | Self::Subtree)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct FacetValue<'a> {
pub value: &'a str,
pub label: &'a str,
pub count: Option<u64>,
pub standing: Standing,
pub depth: Nesting,
pub branching: bool,
}
impl<'a> FacetValue<'a> {
#[must_use]
pub const fn new(value: &'a str, label: &'a str) -> Self {
Self {
value,
label,
count: None,
standing: Standing::Open,
depth: Nesting::top(),
branching: false,
}
}
#[must_use]
pub const fn of(value: &'a str) -> Self {
Self::new(value, value)
}
#[must_use]
pub const fn counted(mut self, count: u64) -> Self {
self.count = Some(count);
self
}
#[must_use]
pub const fn standing(mut self, standing: Standing) -> Self {
self.standing = standing;
self
}
#[must_use]
pub const fn at(mut self, depth: Nesting, branching: bool) -> Self {
self.depth = depth;
self.branching = branching;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
pub enum Standing {
#[default]
Open,
Taken,
Inherited,
Pruned,
}
impl Standing {
#[must_use]
pub const fn is_picked(self) -> bool {
matches!(self, Self::Taken | Self::Pruned)
}
#[must_use]
pub const fn in_force(self) -> bool {
matches!(self, Self::Taken | Self::Inherited)
}
#[must_use]
pub const fn intent(self) -> &'static str {
match self {
Self::Taken | Self::Inherited | Self::Open => "content",
Self::Pruned => "content-secondary",
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn nesting_says_how_deep_and_not_how_wide() {
assert_eq!(Nesting::default(), Nesting::top());
assert_eq!(Nesting::top().level, 0);
assert!(!Nesting::top().is_nested());
let under = Nesting::at(2);
assert_eq!(under.level, 2);
assert!(under.is_nested());
assert!(Nesting::top() < under);
}
use super::*;
#[test]
fn a_markdown_field_is_multiline_and_offers_nothing() {
assert!(FieldKind::Rich.multiline());
assert!(FieldKind::Textarea.multiline());
assert!(!FieldKind::Text.multiline());
assert!(!FieldKind::Rich.offers_options());
assert!(!FieldKind::Rich.temporal());
assert!(FieldKind::Rich.visible());
}
#[test]
fn only_the_numeric_kinds_are_measurable() {
assert!(FieldKind::Number.measurable());
assert!(FieldKind::Range.measurable());
assert!(FieldKind::Interval.measurable());
for kind in [
FieldKind::Text,
FieldKind::Date,
FieldKind::DateTime,
FieldKind::Select,
FieldKind::Checkbox,
FieldKind::File,
] {
assert!(!kind.measurable(), "{kind:?}");
}
}
#[test]
fn a_field_carries_no_unit_until_one_is_given() {
let plain = Field::new(FieldKind::Number, "attack", "Attack");
assert_eq!(plain.unit, None);
let measured = Field {
unit: Some("s"),
..Field::range("attack", "Attack", "0.001", "5")
};
assert_eq!(measured.unit, Some("s"));
assert!(measured.kind.measurable());
}
#[test]
fn an_interval_states_both_ends_names() {
let suffixed = Field::interval("bpm_min", "bpm_max", "BPM");
assert_eq!(suffixed.name, "bpm_min");
assert_eq!(suffixed.upper_name, Some("bpm_max"));
let prefixed = Field::interval("min_price", "max_price", "Price");
assert_eq!(prefixed.name, "min_price");
assert_eq!(prefixed.upper_name, Some("max_price"));
assert_eq!(suffixed.kind, FieldKind::Interval);
}
#[test]
fn every_other_kind_has_no_upper_end() {
for kind in [FieldKind::Text, FieldKind::Number, FieldKind::Range] {
assert_eq!(Field::new(kind, "n", "N").upper_name, None, "{kind:?}");
}
assert_eq!(Field::range("t", "T", "0", "1").upper_name, None);
}
#[test]
fn an_interval_owes_no_bounds_and_takes_the_axis_facts_once() {
let plain = Field::interval("bpm_min", "bpm_max", "BPM");
assert!(!plain.bounded());
let axis = Field {
min: Some("0"),
max: Some("300"),
step: Some("1"),
unit: Some("BPM"),
..Field::interval("bpm_min", "bpm_max", "BPM")
};
assert!(axis.bounded());
assert_eq!(axis.unit, Some("BPM"));
assert!(axis.error.is_none());
}
#[test]
fn only_a_subtree_prunes_and_only_the_listing_modes_offer_values() {
assert!(Selecting::Subtree.prunes());
for mode in [
Selecting::OneOf,
Selecting::AnyOf,
Selecting::Range,
Selecting::Text,
] {
assert!(!mode.prunes(), "{mode:?}");
}
assert!(!Selecting::Text.offers_values());
assert!(!Selecting::Range.offers_values());
assert!(Selecting::OneOf.offers_values());
assert!(Selecting::AnyOf.accumulates());
assert!(!Selecting::OneOf.accumulates());
}
#[test]
fn an_inherited_value_is_in_force_without_having_been_picked() {
assert!(Standing::Inherited.in_force());
assert!(!Standing::Inherited.is_picked());
assert!(Standing::Taken.in_force());
assert!(Standing::Taken.is_picked());
assert!(Standing::Pruned.is_picked());
assert!(!Standing::Pruned.in_force());
assert!(!Standing::Open.is_picked());
assert!(!Standing::Open.in_force());
assert_ne!(Standing::Pruned.intent(), "content-muted");
}
#[test]
fn a_facet_is_engaged_by_a_decision_and_not_by_an_inherited_value() {
let inherited = [
FacetValue::of("music")
.standing(Standing::Taken)
.at(Nesting::at(0), true),
FacetValue::new("music/synths", "synths")
.standing(Standing::Inherited)
.at(Nesting::at(1), false),
];
let facet = Facet::new("Tag", Selecting::Subtree, &inherited);
assert!(facet.engaged());
assert_eq!(facet.reach(), 1);
let untouched = [
FacetValue::of("music").at(Nesting::at(0), true),
FacetValue::new("music/synths", "synths")
.standing(Standing::Inherited)
.at(Nesting::at(1), false),
];
assert!(!Facet::new("Tag", Selecting::Subtree, &untouched).engaged());
let typed = Facet::new("Search", Selecting::Text, &[]);
assert!(!typed.engaged());
assert_eq!(typed.reach(), 0);
}
#[test]
fn a_count_is_absent_rather_than_zero_when_it_was_not_measured() {
assert_eq!(FacetValue::of("Ambient").count, None);
assert_eq!(FacetValue::of("Ambient").counted(0).count, Some(0));
}
#[test]
fn one_kind_takes_files_and_the_two_file_members_are_its_alone() {
assert!(FieldKind::File.takes_files());
for kind in [
FieldKind::Text,
FieldKind::Textarea,
FieldKind::Rich,
FieldKind::Select,
FieldKind::Checkbox,
FieldKind::Hidden,
] {
assert!(!kind.takes_files());
}
let plain = Field::new(FieldKind::File, "cover", "Cover");
assert!(plain.accept.is_empty());
assert!(!plain.multiple);
}
#[test]
fn an_accept_list_says_which_disclosure_and_a_suffix_says_none() {
assert_eq!(
Accepted::Family(Family::Image).family(),
Some(Family::Image)
);
assert_eq!(Accepted::Type("image/jpeg").family(), Some(Family::Image));
assert_eq!(Accepted::Type("audio/flac").family(), Some(Family::Audio));
assert_eq!(
Accepted::Type("video/quicktime").family(),
Some(Family::Video)
);
assert_eq!(Accepted::Type("text/csv").family(), None);
assert_eq!(Accepted::Suffix(".mp3").family(), None);
assert_eq!(Accepted::Suffix(".tar.gz").family(), None);
assert_eq!(Accepted::Type("IMAGE/PNG").family(), Some(Family::Image));
}
#[test]
fn every_accepted_entry_has_one_spelling_a_host_can_write() {
assert_eq!(Accepted::Family(Family::Image).as_str(), "image/*");
assert_eq!(Accepted::Family(Family::Audio).as_str(), "audio/*");
assert_eq!(Accepted::Family(Family::Video).as_str(), "video/*");
assert_eq!(Accepted::Type("text/csv").as_str(), "text/csv");
assert_eq!(Accepted::Suffix(".tar.gz").as_str(), ".tar.gz");
}
#[test]
fn a_list_accepting_two_families_still_has_a_disclosure_to_offer() {
const MEDIA: &[Accepted<'_>] = &[
Accepted::Family(Family::Image),
Accepted::Family(Family::Video),
];
assert!(Field::upload("media", "Media", MEDIA).accepts_media());
const BUILDS: &[Accepted<'_>] = &[Accepted::Suffix(".zip"), Accepted::Suffix(".dmg")];
assert!(!Field::upload("build", "Build", BUILDS).accepts_media());
assert!(!Field::upload("any", "File", &[]).accepts_media());
}
#[test]
fn an_upload_carries_its_list_and_takes_one_file_until_it_says_otherwise() {
const IMAGES: &[Accepted<'_>] = &[
Accepted::Type("image/jpeg"),
Accepted::Type("image/png"),
Accepted::Type("image/webp"),
];
let avatar = Field::upload("avatar", "Avatar", IMAGES);
assert_eq!(avatar.kind, FieldKind::File);
assert_eq!(avatar.accept, IMAGES);
assert!(!avatar.multiple);
let several = Field {
multiple: true,
..avatar
};
assert!(several.multiple);
}
#[test]
fn the_four_readiness_states_are_one_axis_and_only_one_shows_content() {
assert!(Readiness::Ready.shows_content());
for state in [Readiness::Pending, Readiness::Empty, Readiness::Failed] {
assert!(!state.shows_content());
}
}
#[test]
fn a_region_shows_all_of_its_children_unless_it_says_otherwise() {
assert_eq!(Showing::default(), Showing::All);
assert!(!Showing::All.selective());
}
#[test]
fn only_a_disclosure_can_show_nothing() {
assert!(Showing::AtMostOne.dismissible());
assert!(!Showing::One.dismissible());
assert!(!Showing::All.dismissible());
assert!(Showing::One.selective());
assert!(Showing::AtMostOne.selective());
}
#[test]
fn an_empty_region_is_not_a_broken_one() {
assert_eq!(Readiness::Empty.tone(), Tone::Neutral);
assert_eq!(Readiness::Failed.tone(), Tone::Danger);
assert_eq!(Readiness::Pending.tone(), Tone::Neutral);
}
#[test]
fn a_column_can_be_sorted_without_being_sortable() {
let fixed = Column {
sorted: Some(Sort::Descending),
..Column::new("Created")
};
assert!(!fixed.sortable);
assert_eq!(fixed.sorted.map(Sort::as_str), Some("descending"));
let offered = Column {
sortable: true,
..Column::new("Name")
};
assert_eq!(offered.sorted, None);
}
#[test]
fn a_direction_flips_and_says_what_it_is() {
assert_eq!(Sort::Ascending.reversed(), Sort::Descending);
assert_eq!(Sort::Descending.reversed().reversed(), Sort::Descending);
assert_eq!(Sort::Ascending.as_str(), "ascending");
}
#[test]
fn a_direction_carries_its_caret_and_the_two_are_not_the_same_glyph() {
assert_eq!(Sort::Ascending.glyph(), "\u{25B2}");
assert_eq!(Sort::Descending.glyph(), "\u{25BC}");
assert_ne!(Sort::Ascending.glyph(), Sort::Descending.glyph());
for d in [Sort::Ascending, Sort::Descending] {
assert_eq!(d.glyph().trim(), d.glyph());
}
}
#[test]
fn a_figure_carries_its_tone_because_no_renderer_can_derive_it() {
let streak = Figure::new("0", "Current Streak").tone(Tone::Warning);
assert_eq!(streak.tone, Tone::Warning);
assert_eq!(Figure::new("17", "Total").tone, Tone::Neutral);
}
#[test]
fn a_figures_change_is_the_toned_part_and_is_absent_by_default() {
let views = Figure::new("1,204", "Views")
.change("+12.5%")
.tone(Tone::Success);
assert_eq!(views.change, Some("+12.5%"));
assert_eq!(views.tone, Tone::Success);
assert_eq!(Figure::new("3.1%", "Conversion").change, None);
}
#[test]
fn a_figures_value_is_text_because_only_the_app_knows_what_it_is() {
for value in ["84%", "12/30", "3d"] {
assert_eq!(Figure::new(value, "Rate").value, value);
}
}
#[test]
fn a_proportion_is_a_row_part_and_takes_no_intent_of_its_own() {
assert_eq!(RowPart::Proportion.intent(), RowPart::Tokens.intent());
}
#[test]
fn a_file_field_is_drawn_and_offers_no_options() {
assert!(FieldKind::File.visible());
assert!(!FieldKind::File.offers_options());
assert!(!FieldKind::File.confidential());
}
#[test]
fn a_constraint_is_a_fact_about_the_question_and_not_a_verdict() {
let field = Field {
max_length: Some(100),
min: Some("1"),
max: Some("240"),
required: true,
..Field::new(FieldKind::Number, "minutes", "Minutes")
};
assert!(!field.invalid());
let when = Field {
min: Some("2026-08-09T14:30"),
..Field::new(FieldKind::Text, "starts", "Starts")
};
assert_eq!(when.min, Some("2026-08-09T14:30"));
}
#[test]
fn a_meter_keeps_the_over_run_the_percentage_throws_away() {
let over = Meter::new(45, 30);
assert_eq!(over.percent(), 100);
assert!(over.overflowing());
let exact = Meter::new(30, 30);
assert_eq!(exact.percent(), over.percent());
assert!(!exact.overflowing());
}
#[test]
fn an_empty_set_does_not_divide_by_zero() {
let none = Meter::new(0, 0);
assert_eq!(none.percent(), 0);
assert!(none.is_empty());
assert!(!none.overflowing());
}
#[test]
fn the_ratio_survives_where_a_percentage_would_not() {
let m = Meter::new(3, 7).label("subtasks");
assert_eq!(m.percent(), 42);
assert_eq!((m.done, m.total), (3, 7));
assert_eq!(m.label, Some("subtasks"));
}
#[test]
fn tone_is_carried_because_no_renderer_can_derive_it() {
let subtasks = Meter::new(9, 10).tone(Tone::Success);
let estimate = Meter::new(9, 10).tone(Tone::Danger);
assert_eq!(subtasks.percent(), estimate.percent());
assert_ne!(subtasks.tone, estimate.tone);
assert_eq!(Meter::new(9, 10).tone, Tone::Neutral);
}
#[test]
fn an_act_is_reachable_until_it_is_disabled() {
assert!(!Act::new("Save").disabled());
assert!(Act::new("Save").state(State::Disabled).disabled());
}
#[test]
fn an_act_carries_its_key_because_a_terminal_has_nothing_else() {
assert_eq!(Act::new("Delete").key, None);
let quit = Act::new("Quit").key("q").tone(Tone::Danger);
assert_eq!(quit.key, Some("q"));
assert_eq!(quit.tone, Tone::Danger);
}
#[test]
fn a_meter_does_not_overflow_on_large_counts() {
let big = Meter::new(u32::MAX, u32::MAX);
assert_eq!(big.percent(), 100);
assert!(!big.overflowing());
}
#[test]
fn inset_is_raised_with_the_light_moved() {
let (rl, rd) = Bevel::Raised.edges();
let (il, id) = Bevel::Inset.edges();
assert_eq!((rl, rd), (Edge::Light, Edge::Dark));
assert_eq!((il, id), (rd, rl));
}
#[test]
fn pressing_twice_is_a_no_op() {
for b in [Bevel::Raised, Bevel::Inset] {
assert_eq!(b.pressed().pressed(), b);
}
}
#[test]
fn a_raised_region_is_never_filled_with_a_recessed_surface() {
assert_eq!(Depth::Raised.fill(), Some(Fill::Raised));
assert_eq!(Depth::Raised.bevel(), Some(Bevel::Raised));
assert_eq!(Depth::Well.bevel(), Some(Bevel::Inset));
assert_ne!(Depth::Well.fill(), Depth::Raised.fill());
}
#[test]
fn state_is_orthogonal_to_depth() {
assert_eq!(Depth::Raised.fill(), Some(Fill::Raised));
assert_eq!(Depth::Well.fill(), Some(Fill::Well));
assert!(State::Disabled.suppresses_interaction());
}
#[test]
fn only_disabled_stops_answering() {
assert!(State::Disabled.suppresses_interaction());
}
#[test]
fn disabled_resolves_against_an_intent_makeover_already_derives() {
assert_eq!(State::Disabled.token(), "content-muted");
}
#[test]
fn flat_has_neither_edge_nor_fill() {
assert_eq!(Depth::Flat.bevel(), None);
assert_eq!(Depth::Flat.fill(), None);
}
#[test]
fn sunken_is_recessed_by_colour_with_no_edge() {
assert_eq!(Depth::Sunken.fill(), Some(Fill::Sunken));
assert_eq!(Depth::Sunken.bevel(), None);
}
#[test]
fn sunken_and_flat_are_different_claims() {
assert_eq!(Depth::Flat.bevel(), Depth::Sunken.bevel());
assert_ne!(Depth::Flat.fill(), Depth::Sunken.fill());
}
#[test]
fn a_sunken_surface_is_not_a_well() {
assert_ne!(Fill::Sunken, Fill::Well);
assert_eq!(Fill::Sunken.token(), "surface-sunken");
assert_eq!(Fill::Well.token(), "surface-well");
}
#[test]
fn every_selector_describes_both_of_its_states() {
for s in [Selector::Tabs, Selector::Segmented, Selector::Toggle] {
assert_ne!(
s.chosen(),
s.unchosen(),
"{s:?} cannot tell picked from unpicked"
);
}
}
#[test]
fn only_a_tab_inverts_the_other_way() {
assert_eq!(Selector::Tabs.unchosen(), Depth::Sunken);
assert_eq!(Selector::Tabs.chosen(), Depth::Raised);
for s in [Selector::Segmented, Selector::Toggle] {
assert_eq!(s.unchosen(), Depth::Raised);
assert_eq!(s.chosen(), Depth::Well);
assert_eq!(s.unchosen().pressed(), s.chosen());
}
}
#[test]
fn pressing_a_card_makes_a_well() {
assert_eq!(Depth::Raised.pressed(), Depth::Well);
assert_eq!(
Depth::Raised.pressed().bevel(),
Depth::Raised.bevel().map(Bevel::pressed)
);
assert_eq!(Depth::Flat.pressed(), Depth::Flat);
assert_eq!(Depth::Well.pressed(), Depth::Well);
assert_eq!(Depth::Overlay.pressed(), Depth::Overlay);
}
#[test]
fn an_overlay_is_lifted_rather_than_edged() {
assert_eq!(Depth::Overlay.fill(), Some(Fill::Overlay));
assert_eq!(Depth::Overlay.bevel(), None);
assert_ne!(Depth::Overlay.fill(), Depth::Sunken.fill());
assert_ne!(Depth::Overlay.fill(), Depth::Flat.fill());
}
#[test]
fn a_note_is_neither_a_hint_nor_an_error() {
let format = Field {
note: Some((Tone::Warning, "Re-encoding drops embedded BWF and iXML")),
..Field::select("format", "Format", &[])
};
assert!(!format.invalid(), "a note is not a validation failure");
assert!(format.hint.is_none());
assert!(format.error.is_none());
assert_eq!(format.note.unwrap().0, Tone::Warning);
assert!(Field::new(FieldKind::Text, "title", "Title").note.is_none());
}
#[test]
fn neutral_is_content_not_muted_content() {
assert_eq!(Tone::Neutral.token(), "content");
assert!(!Token::Badge.interactive());
assert_eq!(State::Disabled.token(), "content-muted");
}
#[test]
fn intents_name_makeover_tokens_and_nothing_else() {
assert_eq!(Edge::Light.token(), "bevel-light");
assert_eq!(Edge::Dark.token(), "bevel-dark");
assert_eq!(Fill::Raised.token(), "surface-raised");
assert_eq!(Fill::Well.token(), "surface-well");
for t in [
Edge::Light.token(),
Edge::Dark.token(),
Tone::Danger.token(),
Tone::Neutral.token(),
State::Disabled.token(),
] {
assert!(!t.starts_with('#'), "{t} looks like a value");
assert!(
!t.chars().next().unwrap().is_ascii_digit(),
"{t} is a value"
);
}
}
#[test]
fn a_badge_cannot_be_pressed_and_a_chip_latches() {
assert!(!Token::Badge.interactive());
assert!(Token::Chip { removable: false }.interactive());
assert!(Token::Chip { removable: true }.interactive());
assert_eq!(Token::Badge.depth(false), Depth::Flat);
assert_eq!(Token::Badge.depth(true), Depth::Flat);
let chip = Token::Chip { removable: false };
assert_eq!(chip.depth(false), Depth::Raised);
assert_eq!(chip.depth(true), Depth::Raised.pressed());
}
#[test]
fn a_toast_and_a_banner_differ_in_more_than_placement() {
assert!(Notice::Toast.transient());
assert!(!Notice::Banner.transient());
assert_eq!(Notice::Toast.fill(), Fill::Overlay);
assert_eq!(Notice::Banner.fill(), Fill::Raised);
}
#[test]
fn emphasis_falls_off_down_the_row() {
assert_eq!(RowPart::Primary.intent(), "content");
assert_eq!(RowPart::Secondary.intent(), "content-secondary");
assert_eq!(RowPart::Meta.intent(), "content-muted");
}
#[test]
fn a_token_part_carries_no_intent_of_its_own() {
assert_eq!(RowPart::Tokens.intent(), RowPart::Actions.intent());
assert_eq!(RowPart::Tokens.intent(), "content");
}
#[test]
fn the_two_temporal_kinds_are_the_two_that_name_a_moment() {
assert!(FieldKind::Date.temporal());
assert!(FieldKind::DateTime.temporal());
for kind in [
FieldKind::Text,
FieldKind::Secret,
FieldKind::Number,
FieldKind::Email,
FieldKind::Url,
FieldKind::Tel,
FieldKind::Range,
FieldKind::Textarea,
FieldKind::Rich,
FieldKind::Select,
FieldKind::Radio,
FieldKind::Checkbox,
FieldKind::File,
FieldKind::Hidden,
] {
assert!(!kind.temporal(), "{kind:?}");
}
}
#[test]
fn a_date_carries_no_time_and_a_datetime_carries_no_zone() {
assert_eq!(DATE_FORMAT, "%Y-%m-%d");
assert!(!DATE_FORMAT.contains("%H"), "a day carries no hour");
assert_eq!(DATETIME_FORMAT, "%Y-%m-%dT%H:%M");
assert!(
DATETIME_FORMAT.starts_with(DATE_FORMAT),
"a moment starts with the day it is on"
);
assert!(!DATETIME_FORMAT.contains("%Z"), "no zone name");
assert!(!DATETIME_FORMAT.ends_with('Z'), "not UTC-stamped");
assert!(!DATETIME_FORMAT.contains("%S"), "no seconds by default");
}
#[test]
fn a_temporal_kind_takes_a_label_above_it_and_offers_no_options() {
for kind in [FieldKind::Date, FieldKind::DateTime] {
assert!(kind.visible(), "{kind:?}");
assert!(!kind.confidential(), "{kind:?}");
assert!(!kind.labels_itself(), "{kind:?}");
assert!(!kind.offers_options(), "{kind:?}");
}
}
#[test]
fn a_cell_part_names_an_intent_and_only_the_value_is_text() {
assert_eq!(CellPart::Value.intent(), "content");
for part in [CellPart::Tokens, CellPart::Actions, CellPart::Link] {
assert_eq!(part.intent(), CellPart::Value.intent(), "{part:?}");
}
}
#[test]
fn every_cell_part_answers_with_a_token_and_never_a_value() {
for part in [
CellPart::Value,
CellPart::Tokens,
CellPart::Actions,
CellPart::Link,
] {
let intent = part.intent();
assert!(!intent.is_empty(), "{part:?} names nothing");
assert!(!intent.starts_with('#'), "{part:?} looks like a value");
}
}
#[test]
fn a_separator_is_what_tells_a_section_from_a_subsection() {
assert!(Heading::Section.separated());
assert!(!Heading::Subsection.separated());
assert!(!Heading::Page.separated());
}
#[test]
fn a_chosen_segment_is_held_in_and_a_chosen_tab_comes_forward() {
assert_eq!(Selector::Segmented.chosen(), Depth::Well);
assert_eq!(Selector::Toggle.chosen(), Depth::Well);
assert_eq!(Selector::Tabs.chosen(), Depth::Raised);
assert_eq!(Selector::Segmented.chosen(), Depth::Raised.pressed());
assert!(Selector::Segmented.abutting());
assert!(Selector::Tabs.abutting());
assert!(!Selector::Toggle.abutting());
}
#[test]
fn columns_are_peers_and_a_split_is_not() {
assert_eq!(Region::Columns.depth(), Depth::Flat);
assert_eq!(Region::Split.depth(), Depth::Flat);
assert_ne!(Region::Columns, Region::Split);
}
#[test]
fn columns_carry_no_count_and_no_share() {
let columns: Region<'_> = Region::Columns;
assert_eq!(columns.name(), None);
}
#[test]
fn columns_are_described_and_the_escape_hatch_is_still_one_member() {
assert!(Region::Columns.described());
assert!(!Region::Handover { name: "timeline" }.described());
}
#[test]
fn a_span_never_has_zero_minutes_however_it_is_asked_for() {
assert_eq!(Span::new(600, 600).length(), 1);
assert_eq!(Span::new(600, 300).length(), 1);
assert_eq!(Span::DAY.length(), 1440);
}
#[test]
fn a_span_can_run_past_midnight_without_a_second_date() {
let overnight = Span::new(1320, 1560);
assert_eq!(overnight.length(), 240);
assert!(overnight.holds(1500));
assert!(!overnight.holds(1200));
}
#[test]
fn overlap_is_computed_rather_than_declared() {
let morning = Placement::new(540, 60); let overlapping = Placement::new(570, 60); let after = Placement::new(600, 60);
assert!(morning.overlaps(overlapping));
assert!(overlapping.overlaps(morning), "overlap is symmetric");
assert!(!morning.overlaps(after));
assert!(!after.overlaps(morning));
}
#[test]
fn a_placement_is_always_drawable() {
assert_eq!(Placement::new(540, 0).length(), 1);
assert_eq!(Placement::new(540, 30).end(), 570);
}
#[test]
fn a_track_places_the_fraction_every_renderer_would_otherwise_compute() {
let day = Track::DAY;
assert!((day.fraction(0) - 0.0).abs() < f32::EPSILON);
assert!((day.fraction(720) - 0.5).abs() < f32::EPSILON);
assert!((day.fraction(2000) - 1.0).abs() < f32::EPSILON);
}
#[test]
fn a_track_counts_its_slots_and_never_divides_by_zero() {
assert_eq!(Track::DAY.slots(), 96);
assert_eq!(Track::over(Span::new(540, 1020)).slots(), 32);
assert_eq!(Track::over(Span::new(0, 50)).slots(), 4);
let degenerate = Track {
span: Span::DAY,
slot: 0,
tick: 60,
unit: Unit::Minutes,
};
assert_eq!(degenerate.slots(), 1);
}
#[test]
fn a_track_carries_facts_and_no_presentation() {
let day = Track::DAY;
assert_eq!(day.span, Span::DAY);
assert_eq!(day.slot, 15);
assert_eq!(day.tick, 60);
assert_eq!(day.unit, Unit::Minutes);
let Track {
span: _,
slot: _,
tick: _,
unit: _,
} = day;
}
#[test]
fn a_day_strip_is_the_same_arithmetic_under_a_different_unit() {
let march = Track::days(Span::new(0, 31));
assert_eq!(march.slots(), 31);
assert_eq!(march.unit, Unit::Days);
let leave = Placement::new(2, 15);
assert!((march.fraction(leave.at()) - 2.0 / 31.0).abs() < 0.0001);
assert!((march.fraction(leave.end()) - 17.0 / 31.0).abs() < 0.0001);
}
#[test]
fn a_pane_is_looked_into_and_a_band_is_not() {
assert_eq!(Region::Pane.depth(), Depth::Well);
assert_eq!(Region::Modal.depth(), Depth::Raised);
for r in [
Region::Band,
Region::Sidebar,
Region::Group,
Region::Split,
Region::TabGroup,
] {
assert_eq!(r.depth(), Depth::Flat, "{r:?} should carry no edge");
}
}
#[test]
fn exactly_one_region_is_opaque() {
for r in [
Region::Band,
Region::Sidebar,
Region::Pane,
Region::Group,
Region::Split,
Region::TabGroup,
Region::Modal,
Region::Widget { name: "carousel" },
] {
assert!(r.described(), "{r:?} should be describable");
}
assert!(!Region::Handover { name: "day-plan" }.described());
}
#[test]
fn a_region_nobody_converted_yet_is_not_a_region_the_app_gave_up_on() {
assert!(Region::Handover { name: "day-plan" }.owed());
assert!(
!Region::Ceded {
name: "revenue-chart"
}
.owed()
);
assert!(!Region::Pane.owed());
assert!(!Region::Widget { name: "carousel" }.owed());
assert!(!Region::Ceded { name: "waveform" }.described());
assert_eq!(Region::Ceded { name: "waveform" }.name(), Some("waveform"));
assert_eq!(Region::Ceded { name: "waveform" }.depth(), Depth::Flat);
}
#[test]
fn a_group_contains_a_section_without_claiming_to_be_a_pane() {
assert_eq!(Region::Pane.depth(), Depth::Well);
assert_eq!(Region::Group.depth(), Depth::Flat);
assert_ne!(Region::Group, Region::Pane);
assert!(Region::Group.described());
assert_eq!(Region::Group.name(), None);
}
#[test]
fn a_section_heading_names_a_block_that_now_exists() {
assert!(Heading::Section.separated());
assert_eq!(Region::Group.depth(), Depth::Flat);
}
#[test]
fn a_widget_inherits_its_depth_the_way_a_bespoke_does() {
assert_eq!(Region::Widget { name: "carousel" }.depth(), Depth::Flat);
assert_eq!(Region::Widget { name: "pager" }.depth(), Depth::Flat);
}
#[test]
fn a_name_is_readable_without_asking_which_member_carried_it() {
assert_eq!(Region::Widget { name: "carousel" }.name(), Some("carousel"));
assert_eq!(
Region::Handover { name: "day-plan" }.name(),
Some("day-plan")
);
for r in [
Region::Band,
Region::Sidebar,
Region::Pane,
Region::Group,
Region::Split,
Region::TabGroup,
Region::Modal,
] {
assert_eq!(r.name(), None, "{r:?} names nothing an app chose");
}
}
#[test]
fn a_bespoke_region_inherits_its_depth_rather_than_choosing_one() {
assert_eq!(Region::Handover { name: "day-plan" }.depth(), Depth::Flat);
assert_eq!(Region::Handover { name: "kanban" }.depth(), Depth::Flat);
}
#[test]
fn a_screen_with_a_bespoke_region_is_still_a_whole_screen() {
let day_plan = [
Region::Band,
Region::Handover { name: "day-plan" },
Region::Sidebar,
];
assert_eq!(day_plan.iter().filter(|r| r.described()).count(), 2);
assert_eq!(day_plan.iter().filter(|r| !r.described()).count(), 1);
}
#[test]
fn a_secret_field_is_marked_as_one_and_a_hidden_field_is_not_drawn() {
let secret = Field::new(FieldKind::Secret, "password", "Password");
assert!(secret.kind.confidential());
assert!(secret.kind.visible());
assert!(!FieldKind::Hidden.visible());
for k in [
FieldKind::Text,
FieldKind::Number,
FieldKind::Textarea,
FieldKind::Rich,
FieldKind::Select,
FieldKind::Checkbox,
FieldKind::Hidden,
] {
assert!(!k.confidential(), "{k:?} should not be confidential");
}
assert!(FieldKind::Checkbox.labels_itself());
assert!(!FieldKind::Text.labels_itself());
}
#[test]
fn a_plain_field_offers_nothing_and_a_select_offers_its_options() {
let text = Field::new(FieldKind::Text, "title", "Title");
assert!(text.options.is_empty());
assert_eq!(text.placeholder, None);
let sizes = [Choice::plain("small"), Choice::plain("large")];
let select = Field::select("size", "Size", &sizes);
assert_eq!(select.kind, FieldKind::Select);
assert_eq!(select.options.len(), 2);
}
#[test]
fn a_choice_says_what_submits_and_what_is_read_apart() {
let plain = Choice::plain("7");
assert_eq!((plain.value, plain.label), ("7", "7"));
let spelled = Choice::new("7", "One week");
assert_ne!(spelled.value, spelled.label);
assert!(
spelled.available(),
"an option is pickable until it says not"
);
}
#[test]
fn a_candidate_carries_the_line_that_tells_it_from_its_neighbours() {
let audio = Candidate::new("audio/format", "Format").detailed("Audio");
let writing = Candidate::new("writing/format", "Format").detailed("Writing");
assert_eq!(audio.label, writing.label);
assert_ne!(audio.detail, writing.detail);
assert_ne!(
audio, writing,
"two rows a user cannot tell apart are two rows the type can"
);
}
#[test]
fn a_candidate_is_read_differently_from_an_option_and_written_the_same() {
let candidate = Candidate::plain("rust");
assert_eq!((candidate.value, candidate.label), ("rust", "rust"));
assert_eq!(
candidate.detail, None,
"one line unless the route says otherwise"
);
let option = Choice::plain("rust");
assert_eq!(
(candidate.value, candidate.label),
(option.value, option.label)
);
}
#[test]
fn a_radio_asks_the_same_question_as_a_select_and_is_not_the_same_kind() {
let styles = [
Choice::new("copy", "Copy samples in"),
Choice::new("reference", "Reference in place"),
];
let radio = Field::radio("storage", "Storage style", &styles);
let select = Field::select("storage", "Storage style", &styles);
assert_eq!(radio.kind, FieldKind::Radio);
assert_ne!(radio.kind, select.kind);
assert_eq!(radio.options, select.options);
assert_eq!(
Field {
kind: select.kind,
..radio
},
select
);
}
#[test]
fn an_unavailable_option_cannot_be_silent_about_it() {
let multi =
Choice::new("multi", "Multi-sample").unless("Drop a second sample onto the keyboard.");
assert!(!multi.available());
assert_eq!(
multi.unavailable,
Some("Drop a second sample onto the keyboard.")
);
assert_eq!(multi.value, "multi");
assert_eq!(multi.label, "Multi-sample");
}
#[test]
fn an_option_can_say_what_picking_it_means_and_why_it_cannot_be_picked() {
let tier = Choice::new("24", "Small Files")
.detailing("$24/mo. 2GB/file, 100GB total. Fits audio, plugins, binaries.")
.unless("Sold out while the founder window is open.");
assert_eq!(
tier.detail,
Some("$24/mo. 2GB/file, 100GB total. Fits audio, plugins, binaries.")
);
assert_eq!(
tier.unavailable,
Some("Sold out while the founder window is open.")
);
assert!(!tier.available());
let plain = Choice::new("free", "Free").detailing("No charge. Available to everyone.");
assert!(plain.available());
assert_eq!(plain.detail, Some("No charge. Available to everyone."));
assert_eq!(Choice::new("free", "Free").detail, None);
}
#[test]
fn a_range_carries_both_ends_and_a_validated_number_need_not() {
let threshold = Field::range("review", "Review above", "0", "1");
assert_eq!(threshold.kind, FieldKind::Range);
assert!(threshold.bounded());
assert_eq!(threshold.min, Some("0"));
assert_eq!(threshold.max, Some("1"));
assert_eq!(threshold.step, None);
let minutes = Field {
min: Some("1"),
..Field::new(FieldKind::Number, "minutes", "Minutes")
};
assert_ne!(minutes.kind, FieldKind::Range);
assert!(!minutes.bounded(), "one end is a rule, not an extent");
}
#[test]
fn a_range_described_with_one_end_says_so_rather_than_being_refused() {
let half = Field {
max: Some("1"),
..Field::new(FieldKind::Range, "review", "Review above")
};
assert!(!half.bounded());
}
#[test]
fn exactly_the_option_taking_kinds_say_so() {
assert!(FieldKind::Select.offers_options());
assert!(FieldKind::Radio.offers_options());
for kind in [
FieldKind::Text,
FieldKind::Secret,
FieldKind::Number,
FieldKind::Email,
FieldKind::Url,
FieldKind::Tel,
FieldKind::Range,
FieldKind::Textarea,
FieldKind::Rich,
FieldKind::Checkbox,
FieldKind::Hidden,
] {
assert!(!kind.offers_options(), "{kind:?} does not offer options");
}
}
#[test]
fn a_radio_group_takes_a_label_even_though_its_options_carry_their_own() {
assert!(!FieldKind::Radio.labels_itself());
assert!(FieldKind::Checkbox.labels_itself());
}
#[test]
fn a_select_with_no_options_is_sayable() {
let loading = Field::select("project", "Project", &[]);
assert!(loading.options.is_empty());
}
#[test]
fn the_description_carries_the_question_and_never_the_answer() {
let f = Field {
placeholder: Some("yyyy-mm-dd"),
..Field::new(FieldKind::Text, "due", "Due")
};
assert_eq!(f.placeholder, Some("yyyy-mm-dd"));
assert_eq!(f.label, "Due");
}
#[test]
fn a_field_reports_its_own_error_state() {
let mut f = Field::new(FieldKind::Text, "title", "Title");
assert!(!f.invalid());
f.error = Some("Required");
assert!(f.invalid());
}
#[test]
fn columns_drop_by_priority_and_never_by_position() {
let cols = [
Column {
width: Width::Fill,
priority: Priority::Essential,
..Column::new("Title")
},
Column {
width: Width::Fixed,
priority: Priority::Secondary,
..Column::new("Due")
},
Column {
width: Width::Fixed,
priority: Priority::Optional,
..Column::new("Estimate")
},
];
assert_eq!(
cols.iter()
.filter(|c| c.kept_at(Priority::Optional))
.count(),
3
);
let kept: Vec<_> = cols
.iter()
.filter(|c| c.kept_at(Priority::Secondary))
.map(|c| c.name)
.collect();
assert_eq!(kept, ["Title", "Due"]);
let kept: Vec<_> = cols
.iter()
.filter(|c| c.kept_at(Priority::Essential))
.map(|c| c.name)
.collect();
assert_eq!(kept, ["Title"]);
}
#[test]
fn inserting_a_column_does_not_move_what_gets_dropped() {
let before = [
Column::new("Title"),
Column {
width: Width::Fixed,
priority: Priority::Optional,
..Column::new("Estimate")
},
];
let after = [
Column::new("Title"),
Column::new("Project"), Column {
width: Width::Fixed,
priority: Priority::Optional,
..Column::new("Estimate")
},
];
fn dropped<'a>(cols: &[Column<'a>]) -> Vec<&'a str> {
cols.iter()
.filter(|c| !c.kept_at(Priority::Secondary))
.map(|c| c.name)
.collect()
}
assert_eq!(dropped(&before), ["Estimate"]);
assert_eq!(dropped(&after), ["Estimate"]);
}
#[test]
fn an_arrangement_carries_the_tab_group_as_a_modifier() {
let go = Arrangement::list_detail(true);
let plain = Arrangement::list_detail(false);
assert_ne!(go, plain);
assert_ne!(go, Arrangement::sidebar_content());
}
#[test]
fn a_share_is_a_proportion_and_resolves_the_same_way_everywhere() {
assert_eq!(Share::LIST.as_percent(), 40);
assert_eq!(Share::LIST.of(100), 40);
assert_eq!(
Share::SIDEBAR.of(96),
24,
"quasi-tui's 24 columns, said as a quarter"
);
}
#[test]
fn a_region_never_resolves_to_nothing() {
assert_eq!(Share::percent(5).of(1), 1);
assert_eq!(Share::percent(5).of(0), 1);
}
#[test]
fn a_share_outside_the_range_is_clamped_rather_than_refused() {
assert_eq!(Share::percent(0), Share::percent(5));
assert_eq!(Share::percent(200), Share::percent(95));
}
#[test]
fn the_share_rides_on_the_arrangement_that_knows_which_question_it_is() {
assert_eq!(Arrangement::sidebar_content().share(), Some(Share::SIDEBAR));
assert_eq!(Arrangement::list_detail(false).share(), Some(Share::LIST));
assert_eq!(Arrangement::Single.share(), None);
assert_eq!(
Arrangement::Single.with_share(Share::percent(20)),
Arrangement::Single
);
let narrow = Arrangement::sidebar_content().with_share(Share::percent(20));
assert_eq!(narrow.share(), Some(Share::percent(20)));
assert!(matches!(narrow, Arrangement::SidebarContent { .. }));
}
#[test]
fn a_measure_defaults_to_the_one_53_of_69_templates_asked_for() {
assert_eq!(Measure::default(), Measure::Wide);
assert_eq!(Measure::Reading.as_str(), "reading");
}
#[test]
fn readiness_names_the_state_and_not_the_shimmer() {
assert_ne!(Readiness::Ready, Readiness::Pending);
}
#[test]
fn a_window_with_no_length_still_answers_what_it_can() {
let uncounted = Window::new(100, 50);
assert_eq!(uncounted.index(), Some(2));
assert_eq!(uncounted.windows(), None);
assert!(uncounted.has_before());
assert!(uncounted.has_after());
}
#[test]
fn a_counted_window_knows_where_it_ends() {
let last = Window::new(350, 50).of(400);
assert_eq!(last.index(), Some(7));
assert_eq!(last.windows(), Some(8));
assert!(last.has_before());
assert!(!last.has_after());
let first = Window::new(0, 50).of(400);
assert!(!first.has_before());
assert!(first.has_after());
}
#[test]
fn a_window_that_does_not_divide_evenly_rounds_up() {
assert_eq!(Window::new(0, 50).of(401).windows(), Some(9));
}
#[test]
fn a_zero_count_answers_none_rather_than_dividing() {
let empty = Window::new(0, 0).of(400);
assert_eq!(empty.index(), None);
assert_eq!(empty.windows(), None);
assert_eq!(Window::new(900, 0).of(400).clamped().from, 399);
}
#[test]
fn a_window_past_the_end_clamps_inside_rather_than_vanishing() {
assert_eq!(Window::new(900, 50).of(400).clamped().from, 350);
assert_eq!(Window::new(900, 50).clamped().from, 900);
}
#[test]
fn a_carousel_frame_is_a_window_of_one() {
let third = Window::frame(2, 5);
assert_eq!(third.index(), Some(2));
assert_eq!(third.windows(), Some(5));
assert!(third.has_before());
assert!(third.has_after());
let last = Window::frame(4, 5);
assert!(!last.has_after());
}
#[test]
fn numbered_pages_read_from_one_and_load_more_has_no_page() {
let third = Paging::pages(100, 50).of(400);
assert_eq!(third.page(), Some(3));
assert_eq!(third.pages_total(), Some(8));
assert_eq!(third.total(), Some(400));
assert!(third.has_previous());
assert!(third.has_more());
let grown = Paging::more(150).of(400);
assert_eq!(grown.page(), None);
assert_eq!(grown.pages_total(), None);
assert_eq!(grown.shown(), 150);
assert!(!grown.has_previous());
assert!(grown.has_more());
}
#[test]
fn an_uncounted_paging_offers_forward_and_admits_no_total() {
let feed = Paging::more(50);
assert_eq!(feed.total(), None);
assert_eq!(feed.pages_total(), None);
assert_eq!(feed.remaining(), None);
assert!(feed.has_more());
}
#[test]
fn what_is_left_is_derived_and_never_underflows() {
assert_eq!(Paging::more(150).of(400).remaining(), Some(250));
assert_eq!(Paging::pages(350, 50).of(400).remaining(), Some(0));
assert_eq!(Paging::more(500).of(400).remaining(), Some(0));
}
#[test]
fn a_fallback_is_authored_and_a_group_cannot_omit_it() {
let all = [
Fallback::Wrap,
Fallback::Stack,
Fallback::Shed,
Fallback::Menu,
];
for (i, a) in all.iter().enumerate() {
for b in &all[i + 1..] {
assert_ne!(a, b);
}
}
}
#[test]
fn shedding_stops_at_essential_whatever_the_group_holds() {
let members = [
("tabs", Priority::Essential),
("search", Priority::Secondary),
("count", Priority::Optional),
];
let kept: Vec<_> = members
.iter()
.filter(|(_, p)| *p >= Priority::Essential)
.map(|(n, _)| *n)
.collect();
assert_eq!(kept, ["tabs"]);
}
#[test]
fn a_role_says_what_a_part_is_worth_when_the_run_does_not_fit() {
assert_eq!(RowPart::Primary.priority(), Priority::Essential);
assert_eq!(RowPart::Actions.priority(), Priority::Essential);
assert_eq!(RowPart::Meta.priority(), Priority::Optional);
assert_eq!(RowPart::Proportion.priority(), Priority::Optional);
assert_eq!(RowPart::Secondary.priority(), Priority::Secondary);
assert_eq!(RowPart::Tokens.priority(), Priority::Secondary);
}
#[test]
fn a_run_is_one_line_unless_the_description_says_two() {
assert_eq!(Flow::default(), Flow::Tight);
assert_eq!(Flow::Tight.lines(), 1);
assert_eq!(Flow::Relaxed.lines(), 2);
}
#[test]
fn an_unknown_flow_reads_as_one_line() {
for flow in [Flow::Tight, Flow::Relaxed] {
assert!((1..=2).contains(&flow.lines()));
}
}
#[test]
fn an_awaiting_mark_is_indeterminate_until_something_is_measured() {
assert_eq!(Awaiting::default(), Awaiting::unmeasured());
assert!(!Awaiting::unmeasured().is_determinate());
assert!(Awaiting::of(40 * 1024 * 1024).is_determinate());
assert_eq!(Awaiting::of(7).amount, Some(7));
}
const ATTACK: (f64, f64) = (0.001, 5.0);
#[test]
fn a_curve_is_linear_with_no_step_until_a_field_says_otherwise() {
assert_eq!(Curve::default(), Curve::Linear { step: None });
let plain = Field::range("t", "T", "0", "1");
assert_eq!(plain.curve, Curve::Linear { step: None });
assert_eq!(plain.curve.step(), None);
}
#[test]
fn every_curve_carries_its_own_granularity() {
assert_eq!(Curve::Linear { step: Some("0.01") }.step(), Some("0.01"));
assert_eq!(
Curve::Logarithmic {
step: Some("0.001")
}
.step(),
Some("0.001")
);
}
#[test]
fn both_ends_of_the_track_are_the_bounds_under_either_curve() {
let (min, max) = ATTACK;
for curve in [
Curve::Linear { step: None },
Curve::Logarithmic { step: None },
] {
assert!((curve.value_at(0.0, min, max) - min).abs() < 1e-12);
assert!((curve.value_at(1.0, min, max) - max).abs() < 1e-12);
}
}
#[test]
fn a_linear_midpoint_is_the_average_and_a_ratio_midpoint_is_the_geometric_mean() {
let (min, max) = ATTACK;
let linear = Curve::Linear { step: None }.value_at(0.5, min, max);
assert!((linear - 2.5005).abs() < 1e-9);
let ratio = Curve::Logarithmic { step: None }.value_at(0.5, min, max);
assert!((ratio - (min * max).sqrt()).abs() < 1e-12);
assert!(ratio < 0.08);
}
#[test]
fn a_position_and_a_value_round_trip_under_either_curve() {
let (min, max) = ATTACK;
for curve in [
Curve::Linear { step: None },
Curve::Logarithmic { step: None },
] {
for position in [0.0, 0.1, 0.25, 0.5, 0.75, 0.99, 1.0] {
let back = curve.position_of(curve.value_at(position, min, max), min, max);
assert!(
(back - position).abs() < 1e-9,
"{curve:?} lost {position} (got {back})"
);
}
}
}
#[test]
fn a_ratio_curve_across_zero_is_drawn_linearly_rather_than_refused() {
let curve = Curve::Logarithmic { step: None };
assert!(!curve.is_ratio(0.0, 1.0));
assert!((curve.value_at(0.5, 0.0, 1.0) - 0.5).abs() < 1e-12);
assert!(curve.value_at(0.5, -96.0, -20.0).is_finite());
assert!(curve.is_ratio(ATTACK.0, ATTACK.1));
}
#[test]
fn a_track_with_no_extent_has_one_value_on_it() {
for curve in [
Curve::Linear { step: None },
Curve::Logarithmic { step: None },
] {
assert!((curve.value_at(0.7, 4.0, 4.0) - 4.0).abs() < f64::EPSILON);
assert!(curve.position_of(4.0, 4.0, 4.0).abs() < f64::EPSILON);
assert!((curve.value_at(0.7, 9.0, 2.0) - 9.0).abs() < f64::EPSILON);
}
}
#[test]
fn a_position_or_a_value_outside_the_track_is_clamped_to_it() {
let (min, max) = ATTACK;
let curve = Curve::Logarithmic { step: None };
assert!((curve.value_at(-3.0, min, max) - min).abs() < 1e-12);
assert!((curve.value_at(4.0, min, max) - max).abs() < 1e-12);
assert!(curve.position_of(0.0, min, max).abs() < 1e-12);
assert!((curve.position_of(500.0, min, max) - 1.0).abs() < 1e-12);
}
#[test]
fn a_typed_number_keeps_its_own_step_and_a_range_reads_its_curve() {
let typed = Field {
step: Some("5"),
..Field::new(FieldKind::Number, "port", "Port")
};
assert_eq!(typed.step, Some("5"));
let slid = Field {
curve: Curve::Logarithmic {
step: Some("0.001"),
},
..Field::range("attack", "Attack", "0.001", "5")
};
assert_eq!(slid.step, None);
assert_eq!(slid.curve.step(), Some("0.001"));
}
#[test]
fn a_theme_picker_offers_themes_and_no_options() {
let themes = [
ThemeChoice::new("goingson", "GoingsOn", ThemeVariant::Light, Contrast::High),
ThemeChoice::new("dracula", "Dracula", ThemeVariant::Dark, Contrast::Standard),
];
let field = Field::theme("theme", "Theme", &themes);
assert_eq!(field.kind, FieldKind::Theme);
assert!(field.kind.offers_themes());
assert!(!field.kind.offers_options());
assert_eq!(field.themes.len(), 2);
assert!(field.options.is_empty());
assert_eq!(field.follows, None);
}
#[test]
fn following_carries_the_store_s_own_spelling() {
let field =
Field::theme("theme", "Theme", &[]).following(Choice::new("system", "Follow System"));
let follow = field.follows.expect("the row was offered");
assert_eq!(follow.value, "system");
assert_eq!(follow.label, "Follow System");
}
#[test]
fn a_picker_with_nothing_resolved_is_sayable() {
let field = Field::theme("theme", "Theme", &[]);
assert!(field.themes.is_empty());
}
#[test]
fn every_kind_but_theme_offers_no_themes() {
for kind in [
FieldKind::Text,
FieldKind::Select,
FieldKind::Radio,
FieldKind::Checkbox,
FieldKind::File,
FieldKind::Hidden,
] {
assert!(
!kind.offers_themes(),
"{kind:?} does not read Field::themes"
);
}
}
#[test]
fn a_contrast_tier_reads_worst_first() {
assert!(Contrast::Low < Contrast::Standard);
assert!(Contrast::Standard < Contrast::High);
}
#[test]
fn the_groups_and_badges_have_one_spelling_each() {
assert_eq!(ThemeVariant::Light.heading(), "Light");
assert_eq!(ThemeVariant::Dark.heading(), "Dark");
assert_eq!(ThemeVariant::HighContrast.heading(), "High Contrast");
assert_eq!(ThemeVariant::HighContrast.as_str(), "high-contrast");
assert_eq!(Contrast::High.badge(), "AA");
assert_eq!(Contrast::Standard.badge(), "OK");
assert_eq!(Contrast::Low.badge(), "low");
}
#[test]
fn the_variant_spelling_matches_the_theme_file_s_own() {
for (variant, spelling) in [
(ThemeVariant::Light, "light"),
(ThemeVariant::Dark, "dark"),
(ThemeVariant::HighContrast, "high-contrast"),
] {
assert_eq!(variant.as_str(), spelling);
assert_eq!(variant.to_string(), spelling);
}
}
}