use crate::form::Markup;
use crate::{Emit, push_class};
use makeover_layout::{CellPart, Column, Priority, RowPart, Width};
use std::fmt::Write as _;
#[derive(Debug, Clone, Copy, Default)]
pub struct Sizing<'a> {
pub lengths: &'a [(&'a str, &'a str)],
pub fallback: &'a str,
}
impl Sizing<'_> {
fn length_for(&self, name: &str) -> &str {
self.lengths
.iter()
.find(|(column, _)| *column == name)
.map_or_else(
|| {
if self.fallback.is_empty() {
"auto"
} else {
self.fallback
}
},
|(_, length)| *length,
)
}
fn track(&self, column: &Column<'_>) -> String {
match column.width {
Width::Content => "max-content".to_owned(),
Width::Fixed => self.length_for(column.name).to_owned(),
Width::Fill => format!("minmax({}, 1fr)", self.length_for(column.name)),
_ => "auto".to_owned(),
}
}
}
#[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 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);
}
#[must_use]
pub fn grid_template_columns(
columns: &[Column<'_>],
sizing: &Sizing<'_>,
cutoff: Priority,
) -> String {
columns
.iter()
.filter(|column| column.kept_at(cutoff))
.map(|column| sizing.track(column))
.collect::<Vec<_>>()
.join(" ")
}
#[must_use]
pub fn narrowing_css(
columns: &[Column<'_>],
selector: &str,
sizing: &Sizing<'_>,
cutoff: Priority,
opts: &Emit,
) -> String {
let parts: Vec<&str> = selector.split(',').map(str::trim).collect();
let mut css = format!(
"{} {{\n grid-template-columns: {};\n}}\n",
parts.join(", "),
grid_template_columns(columns, sizing, cutoff)
);
for column in columns.iter().filter(|c| !c.kept_at(cutoff)) {
let class = column_class(column, opts);
let targets: Vec<String> = parts
.iter()
.map(|part| format!("{part} > .{class}"))
.collect();
let _ = write!(css, "{} {{\n display: none;\n}}\n", targets.join(",\n"));
}
css
}
#[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 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) {
for column in columns {
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("\">");
out.push_str(found.map_or("", |cell| cell.content.0));
out.push_str("</div>");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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(), 2, "{html}");
}
#[test]
fn the_class_and_the_selector_that_hides_it_agree_on_the_name() {
let columns = vec![Column {
priority: Priority::Optional,
..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);
let css = narrowing_css(&columns, ".row", &sizing(), Priority::Essential, &opts);
assert!(html.contains("class=\"cell col-Due-date "), "{html}");
assert!(css.contains(".row > .col-Due-date {"), "{css}");
}
#[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,
..Column::new("description")
},
Column {
width: Width::Fixed,
priority: Priority::Secondary,
..Column::new("due")
},
Column {
width: Width::Fixed,
priority: Priority::Optional,
..Column::new("progress")
},
]
}
fn sizing() -> Sizing<'static> {
Sizing {
lengths: &[
("description", "200px"),
("due", "110px"),
("progress", "100px"),
],
fallback: "",
}
}
#[test]
fn a_fill_column_gets_a_floor_and_the_slack() {
let tracks = grid_template_columns(&columns(), &sizing(), Priority::Optional);
assert_eq!(tracks, "minmax(200px, 1fr) 110px 100px");
}
#[test]
fn a_column_with_no_length_makes_no_claim() {
let sizing = Sizing::default();
let tracks = grid_template_columns(&columns(), &sizing, Priority::Optional);
assert_eq!(tracks, "minmax(auto, 1fr) auto auto");
}
#[test]
fn raising_the_cutoff_drops_columns_and_their_tracks_together() {
let columns = columns();
let wide = grid_template_columns(&columns, &sizing(), Priority::Optional);
assert_eq!(wide.split(' ').count(), 4);
let narrow = grid_template_columns(&columns, &sizing(), Priority::Secondary);
assert_eq!(narrow, "minmax(200px, 1fr) 110px");
let narrowest = grid_template_columns(&columns, &sizing(), Priority::Essential);
assert_eq!(narrowest, "minmax(200px, 1fr)");
}
#[test]
fn narrowing_hides_a_dropped_column_by_its_own_class_not_its_position() {
let css = narrowing_css(
&columns(),
".ui-mode-mobile .task-row",
&sizing(),
Priority::Secondary,
&Emit::default(),
);
assert!(
css.contains("grid-template-columns: minmax(200px, 1fr) 110px;"),
"{css}"
);
assert!(
css.contains(".ui-mode-mobile .task-row > .col-progress {"),
"{css}"
);
assert!(!css.contains("nth-child"), "{css}");
assert!(!css.contains(".col-due {\n display: none"), "{css}");
}
#[test]
fn a_selector_list_distributes_the_hidden_column() {
let css = narrowing_css(
&columns(),
".task-header-row, .task-row",
&sizing(),
Priority::Secondary,
&Emit::default(),
);
assert!(
css.contains(".task-header-row > .col-progress,\n.task-row > .col-progress {"),
"{css}"
);
assert!(
!css.contains(".task-header-row {\n display: none"),
"{css}"
);
assert!(
css.contains(".task-header-row, .task-row {\n grid-template-columns:"),
"{css}"
);
}
#[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">"#),
"{html}"
);
assert!(
html.contains(r#"<div class="cell col-due cell-fixed cell-drops-next cell-value">"#),
"{html}"
);
assert!(
html.contains(r#"<div class="cell col-progress cell-fixed cell-drops-first"></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_and_the_narrowing() {
let opts = Emit {
class_prefix: "mk-",
..Emit::default()
};
let cells = [Cell::new("due", Markup("x"))];
assert!(
cells_html(&columns(), &cells, &opts).contains("mk-cell mk-col-due"),
"prefix missing"
);
assert!(
narrowing_css(&columns(), ".t", &sizing(), Priority::Secondary, &opts)
.contains(".mk-col-progress"),
"prefix missing"
);
}
}