use crate::form::Markup;
use crate::{Emit, push_class};
use makeover_layout::{CellPart, Column, ColumnKind, Flow, Priority, RowPart, Width};
use std::fmt::Write as _;
#[must_use]
pub fn column_class(column: &Column<'_>, opts: &Emit) -> String {
let mut out = String::new();
push_column_class(&mut out, column, opts);
out
}
pub fn push_column_class(out: &mut String, column: &Column<'_>, opts: &Emit) {
out.push_str(opts.class_prefix);
out.push_str("col-");
push_column_name(out, column.name);
}
pub fn push_column_name(out: &mut String, name: &str) {
for ch in name.chars() {
if ch.is_alphanumeric() || ch == '_' || ch == '-' {
out.push(ch);
} else {
out.push('-');
}
}
}
fn width_class(width: Width) -> &'static str {
match width {
Width::Content => "cell-content",
Width::Fixed => "cell-fixed",
_ => "cell-fill",
}
}
fn drop_class(priority: Priority) -> &'static str {
match priority {
Priority::Optional => "cell-drops-first",
Priority::Secondary => "cell-drops-next",
_ => "cell-keeps",
}
}
#[must_use]
pub fn kind_class(kind: ColumnKind) -> Option<&'static str> {
match kind {
ColumnKind::Identifier => Some("kind-identifier"),
ColumnKind::Date => Some("kind-date"),
ColumnKind::Number => Some("kind-number"),
ColumnKind::Code => Some("kind-code"),
ColumnKind::Status => Some("kind-status"),
ColumnKind::Actions => Some("kind-actions"),
_ => None,
}
}
pub const KIND_CLASSES: &[&str] = &[
"kind-identifier",
"kind-date",
"kind-number",
"kind-code",
"kind-status",
"kind-actions",
];
#[must_use]
pub fn column_classes(column: &Column<'_>, opts: &Emit) -> String {
let mut out = String::new();
push_column_classes(&mut out, column, opts);
out
}
pub fn push_column_classes(out: &mut String, column: &Column<'_>, opts: &Emit) {
push_column_class(out, column, opts);
out.push(' ');
push_class(out, width_class(column.width), opts);
out.push(' ');
push_class(out, drop_class(column.priority), opts);
if let Some(kind) = kind_class(column.kind) {
out.push(' ');
push_class(out, kind, opts);
}
out.push(' ');
push_class(out, "min-", opts);
let _ = write!(out, "{}", column.floor());
}
pub const CELL_IN: &str = "cell-in";
#[derive(Debug, Clone, Copy)]
pub struct Cell<'a> {
pub column: &'a str,
pub part: Option<CellPart>,
pub content: Markup<'a>,
}
impl<'a> Cell<'a> {
#[must_use]
pub const fn new(column: &'a str, content: Markup<'a>) -> Self {
Self {
column,
part: None,
content,
}
}
}
pub const ROW_PART_CLASSES: &[&str] = &[
"row-primary",
"row-secondary",
"row-meta",
"row-actions",
"row-tokens",
"row-proportion",
"row-part",
];
pub const FLOW_CLASSES: &[&str] = &["row-relaxed"];
pub const NESTING_CLASSES: &[&str] = &["row-nested", "row-branch", "row-disclose"];
#[must_use]
pub fn flow_class(flow: Flow) -> Option<&'static str> {
match flow {
Flow::Relaxed => Some("row-relaxed"),
_ => None,
}
}
pub const CELL_WIDTH_CLASSES: &[&str] = &["cell-content", "cell-fixed", "cell-fill"];
pub const CELL_DROP_CLASSES: &[&str] = &["cell-drops-first", "cell-drops-next", "cell-keeps"];
pub const CELL_PART_CLASSES: &[&str] = &[
"cell-value",
"cell-tokens",
"cell-actions",
"cell-link",
"cell-part",
];
#[must_use]
pub fn part_class(part: RowPart) -> &'static str {
match part {
RowPart::Primary => "row-primary",
RowPart::Secondary => "row-secondary",
RowPart::Meta => "row-meta",
RowPart::Actions => "row-actions",
RowPart::Tokens => "row-tokens",
RowPart::Proportion => "row-proportion",
_ => "row-part",
}
}
#[must_use]
pub fn cell_part_class(part: CellPart) -> &'static str {
match part {
CellPart::Value => "cell-value",
CellPart::Tokens => "cell-tokens",
CellPart::Actions => "cell-actions",
CellPart::Link => "cell-link",
_ => "cell-part",
}
}
#[must_use]
pub fn cells_html(columns: &[Column<'_>], cells: &[Cell<'_>], opts: &Emit) -> String {
let mut html = String::new();
cells_html_into(columns, cells, opts, &mut html);
html
}
pub fn cells_html_into(columns: &[Column<'_>], cells: &[Cell<'_>], opts: &Emit, out: &mut String) {
emit_cells(columns, cells, opts, out, None);
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Placed {
pub block: core::ops::Range<usize>,
pub content: core::ops::Range<usize>,
}
pub fn cells_html_placed(
columns: &[Column<'_>],
cells: &[Cell<'_>],
opts: &Emit,
out: &mut String,
placed: &mut Vec<Placed>,
) {
emit_cells(columns, cells, opts, out, Some(placed));
}
fn emit_cells(
columns: &[Column<'_>],
cells: &[Cell<'_>],
opts: &Emit,
out: &mut String,
mut placed: Option<&mut Vec<Placed>>,
) {
for column in columns {
let at = out.len();
let found = cells.iter().find(|cell| cell.column == column.name);
out.push_str("<div class=\"");
push_class(out, "cell", opts);
out.push(' ');
push_column_classes(out, column, opts);
if let Some(part) = found.and_then(|cell| cell.part) {
out.push(' ');
push_class(out, cell_part_class(part), opts);
}
out.push_str("\"><span class=\"");
push_class(out, CELL_IN, opts);
out.push_str("\">");
let content = out.len();
out.push_str(found.map_or("", |cell| cell.content.0));
let wrote = content..out.len();
out.push_str("</span></div>");
if let Some(placed) = placed.as_deref_mut() {
placed.push(Placed {
block: at..out.len(),
content: wrote,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn saying_where_a_cell_landed_does_not_change_what_is_written() {
let columns = [
Column {
name: "Name",
..Column::new("Name")
},
Column {
name: "Price",
..Column::new("Price")
},
];
let cells = [
Cell {
column: "Name",
part: Some(CellPart::Value),
content: Markup("Kick"),
},
Cell {
column: "Price",
part: None,
content: Markup("<b>Free</b>"),
},
];
let opts = Emit::default();
let mut plain = String::new();
cells_html_into(&columns, &cells, &opts, &mut plain);
let mut said = String::from("before:");
let mut placed = Vec::new();
cells_html_placed(&columns, &cells, &opts, &mut said, &mut placed);
assert_eq!(said.strip_prefix("before:").unwrap(), plain);
assert_eq!(placed.len(), columns.len());
assert_eq!(placed[0].block.start, "before:".len());
assert_eq!(placed[1].block.end, said.len());
for one in &placed {
let block = &said[one.block.clone()];
assert!(block.starts_with("<div class=\""), "{block}");
assert!(block.ends_with("</div>"), "{block}");
}
assert_eq!(&said[placed[0].content.clone()], "Kick");
assert_eq!(&said[placed[1].content.clone()], "<b>Free</b>");
for one in &placed {
assert!(one.block.start < one.content.start);
assert!(one.content.end < one.block.end);
}
let mut none = String::new();
let mut empty = Vec::new();
cells_html_placed(&columns, &[], &opts, &mut none, &mut empty);
assert_eq!(empty.len(), columns.len());
for one in &empty {
assert!(one.content.is_empty(), "{one:?}");
assert!(!none[one.block.clone()].is_empty());
}
}
#[test]
fn every_width_and_drop_class_is_one_the_vocabulary_wrote_down() {
for width in [Width::Content, Width::Fixed, Width::Fill] {
assert!(
CELL_WIDTH_CLASSES.contains(&width_class(width)),
"{width:?} is missing from CELL_WIDTH_CLASSES"
);
}
for priority in [Priority::Optional, Priority::Secondary, Priority::Essential] {
assert!(
CELL_DROP_CLASSES.contains(&drop_class(priority)),
"{priority:?} is missing from CELL_DROP_CLASSES"
);
}
let names = crate::vocabulary::names(&Emit::default());
for name in CELL_WIDTH_CLASSES.iter().chain(CELL_DROP_CLASSES) {
assert!(names.contains(*name), "{name} is not in the vocabulary");
}
}
#[test]
fn a_column_name_cannot_break_out_of_the_class_attribute() {
let name = "a\" onclick=\"steal()";
let columns = vec![Column::new(name)];
let cells = vec![Cell {
column: name,
part: None,
content: Markup("x"),
}];
let html = cells_html(&columns, &cells, &Emit::default());
assert!(!html.contains("onclick=\"steal()"), "{html}");
assert!(html.contains("col-a--onclick--steal--"), "{html}");
assert_eq!(html.matches('"').count(), 4, "{html}");
}
#[test]
fn the_class_and_the_selector_that_names_it_agree_on_the_name() {
let columns = vec![Column {
priority: Priority::Optional,
kind: ColumnKind::Text,
..Column::new("Due date")
}];
let cells = vec![Cell {
column: "Due date",
part: None,
content: Markup("x"),
}];
let opts = Emit::default();
let html = cells_html(&columns, &cells, &opts);
assert!(html.contains("class=\"cell col-Due-date "), "{html}");
assert_eq!(column_class(&columns[0], &opts), "col-Due-date");
}
#[test]
fn a_name_already_made_of_identifier_characters_is_untouched() {
for name in ["description", "due", "progress", "Name", "col_2", "a-b"] {
let mut out = String::new();
push_column_name(&mut out, name);
assert_eq!(out, name);
}
}
#[test]
fn a_name_outside_ascii_keeps_itself() {
let mut out = String::new();
push_column_name(&mut out, "Größe");
assert_eq!(out, "Größe");
}
fn columns() -> Vec<Column<'static>> {
vec![
Column {
width: Width::Fill,
priority: Priority::Essential,
kind: ColumnKind::Text,
..Column::new("description")
},
Column {
width: Width::Fixed,
priority: Priority::Secondary,
kind: ColumnKind::Text,
..Column::new("due")
},
Column {
width: Width::Fixed,
priority: Priority::Optional,
kind: ColumnKind::Text,
..Column::new("progress")
},
]
}
#[test]
fn every_column_names_its_floor_as_a_rung_of_the_ladder() {
let opts = Emit::default();
let declared = Column::new("Buyer").min(15);
assert!(column_classes(&declared, &opts).ends_with(" min-16"));
let derived = Column::new("description");
assert!(column_classes(&derived, &opts).ends_with(" min-16"));
let sheet = crate::stylesheet(&opts);
for n in (2..=makeover_layout::MIN_CEILING).step_by(2) {
assert!(sheet.contains(&format!(".min-{n} {{")), "no rung for {n}");
}
}
#[test]
fn cells_follow_the_columns_and_carry_their_column_class() {
let cells = [
Cell {
column: "due",
part: Some(CellPart::Value),
content: Markup("tomorrow"),
},
Cell::new("description", Markup("<span>Ship it</span>")),
];
let html = cells_html(&columns(), &cells, &Emit::default());
let description = html.find("Ship it").expect("description cell");
let due = html.find("tomorrow").expect("due cell");
assert!(description < due, "{html}");
assert!(
html.contains(
r#"<div class="cell col-description cell-fill cell-keeps min-16"><span class="cell-in">"#
),
"{html}"
);
assert!(
html.contains(
r#"<div class="cell col-due cell-fixed cell-drops-next min-8 cell-value"><span class="cell-in">tomorrow</span></div>"#
),
"{html}"
);
assert!(
html.contains(
r#"<div class="cell col-progress cell-fixed cell-drops-first min-10"><span class="cell-in"></span></div>"#
),
"{html}"
);
}
#[test]
fn streamed_cells_are_the_cells_the_other_form_returns() {
let opts = Emit {
class_prefix: "mk-",
..Emit::default()
};
let cells = [
Cell {
column: "due",
part: Some(CellPart::Value),
content: Markup("tomorrow"),
},
Cell::new("description", Markup("<span>Ship it</span>")),
];
for cells in [&cells[..], &[]] {
let mut streamed = String::new();
cells_html_into(&columns(), cells, &opts, &mut streamed);
assert_eq!(streamed, cells_html(&columns(), cells, &opts));
}
for column in &columns() {
let mut streamed = String::new();
push_column_classes(&mut streamed, column, &opts);
assert_eq!(streamed, column_classes(column, &opts));
}
}
#[test]
fn a_cell_naming_no_column_is_dropped() {
let cells = [Cell::new("nonexistent", Markup("nowhere"))];
let html = cells_html(&columns(), &cells, &Emit::default());
assert!(!html.contains("nowhere"), "{html}");
}
#[test]
fn the_class_prefix_reaches_the_cells_the_rung_and_the_wrapper() {
let opts = Emit {
class_prefix: "mk-",
..Emit::default()
};
let cells = [Cell::new("due", Markup("x"))];
let html = cells_html(&columns(), &cells, &opts);
assert!(
html.contains("mk-cell mk-col-due"),
"prefix missing: {html}"
);
assert!(html.contains(" mk-min-8"), "prefix missing: {html}");
assert!(
html.contains("class=\"mk-cell-in\""),
"prefix missing: {html}"
);
}
}