#![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)]
pub enum Fill {
Page,
Raised,
Overlay,
Well,
}
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",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Depth {
Flat,
Raised,
Well,
}
impl Depth {
#[must_use]
pub const fn bevel(self) -> Option<Bevel> {
match self {
Self::Flat => 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),
}
}
#[must_use]
pub const fn pressed(self) -> Self {
match self {
Self::Raised => Self::Well,
other => other,
}
}
}
#[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-muted",
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)]
pub enum RowPart {
Primary,
Secondary,
Meta,
Actions,
}
impl RowPart {
#[must_use]
pub const fn revealed_on_hover(self) -> bool {
matches!(self, Self::Actions)
}
#[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",
}
}
}
#[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 abutting(self) -> bool {
matches!(self, Self::Segmented | Self::Tabs)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Readiness {
Ready,
Pending,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Region<'a> {
Band,
Sidebar,
Pane,
Split,
TabGroup,
Modal,
Bespoke {
name: &'a str,
},
}
impl Region<'_> {
#[must_use]
pub const fn depth(self) -> Depth {
match self {
Self::Band | Self::Sidebar | Self::Split | Self::TabGroup => Depth::Flat,
Self::Pane => Depth::Well,
Self::Modal => Depth::Raised,
Self::Bespoke { .. } => Depth::Flat,
}
}
#[must_use]
pub const fn described(self) -> bool {
!matches!(self, Self::Bespoke { .. })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Arrangement {
ListDetail {
tabbed: bool,
},
SidebarContent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FieldKind {
Text,
Secret,
Number,
Textarea,
Select,
Checkbox,
Hidden,
}
impl FieldKind {
#[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)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Field<'a> {
pub kind: FieldKind,
pub name: &'a str,
pub label: &'a str,
pub hint: Option<&'a str>,
pub error: Option<&'a str>,
pub required: bool,
pub extended: bool,
}
impl<'a> Field<'a> {
#[must_use]
pub const fn new(kind: FieldKind, name: &'a str, label: &'a str) -> Self {
Self {
kind,
name,
label,
hint: None,
error: None,
required: false,
extended: false,
}
}
#[must_use]
pub const fn invalid(&self) -> bool {
self.error.is_some()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Width {
Content,
Fixed,
Fill,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Priority {
Optional,
Secondary,
Essential,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Column<'a> {
pub name: &'a str,
pub width: Width,
pub priority: Priority,
}
impl<'a> Column<'a> {
#[must_use]
pub const fn new(name: &'a str) -> Self {
Self {
name,
width: Width::Fill,
priority: Priority::Secondary,
}
}
#[must_use]
pub const fn kept_at(&self, cutoff: Priority) -> bool {
(self.priority as u8) >= (cutoff as u8)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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 flat_has_neither_edge_nor_fill() {
assert_eq!(Depth::Flat.bevel(), None);
assert_eq!(Depth::Flat.fill(), None);
}
#[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);
}
#[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(),
] {
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 only_the_actions_part_hides_until_hovered() {
for p in [RowPart::Primary, RowPart::Secondary, RowPart::Meta] {
assert!(!p.revealed_on_hover(), "{p:?} should always be visible");
}
assert!(RowPart::Actions.revealed_on_hover());
assert_eq!(RowPart::Primary.intent(), "content");
assert_eq!(RowPart::Secondary.intent(), "content-secondary");
assert_eq!(RowPart::Meta.intent(), "content-muted");
}
#[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 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::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::Split,
Region::TabGroup,
Region::Modal,
] {
assert!(r.described(), "{r:?} should be describable");
}
assert!(!Region::Bespoke { name: "day-plan" }.described());
}
#[test]
fn a_bespoke_region_inherits_its_depth_rather_than_choosing_one() {
assert_eq!(Region::Bespoke { name: "day-plan" }.depth(), Depth::Flat);
assert_eq!(Region::Bespoke { name: "kanban" }.depth(), Depth::Flat);
}
#[test]
fn a_screen_with_a_bespoke_region_is_still_a_whole_screen() {
let day_plan = [
Region::Band,
Region::Bespoke { 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::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_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 {
name: "Title",
width: Width::Fill,
priority: Priority::Essential,
},
Column {
name: "Due",
width: Width::Fixed,
priority: Priority::Secondary,
},
Column {
name: "Estimate",
width: Width::Fixed,
priority: Priority::Optional,
},
];
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 {
name: "Estimate",
width: Width::Fixed,
priority: Priority::Optional,
},
];
let after = [
Column::new("Title"),
Column::new("Project"), Column {
name: "Estimate",
width: Width::Fixed,
priority: Priority::Optional,
},
];
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::ListDetail { tabbed: true };
let plain = Arrangement::ListDetail { tabbed: false };
assert_ne!(go, plain);
assert_ne!(go, Arrangement::SidebarContent);
}
#[test]
fn readiness_names_the_state_and_not_the_shimmer() {
assert_ne!(Readiness::Ready, Readiness::Pending);
}
}