#![allow(clippy::manual_midpoint)]
use kurbo::Rect;
use crate::ap::emit::{Content, Float, PaintOp, color_op, color_op_via};
use crate::color::Color;
use crate::geom;
const BEZIER: f32 = 0.552_284_8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckStyle {
Check,
Circle,
Cross,
Diamond,
Square,
Star,
}
#[must_use]
pub fn style_from_caption(caption: &str) -> Option<CheckStyle> {
match caption.chars().next()? {
'4' => Some(CheckStyle::Check),
'8' => Some(CheckStyle::Cross),
'H' => Some(CheckStyle::Star),
'l' => Some(CheckStyle::Circle),
'n' => Some(CheckStyle::Square),
'u' => Some(CheckStyle::Diamond),
_ => None,
}
}
#[must_use]
pub fn check_box(bbox: Rect, style: CheckStyle, color: Color) -> String {
let centre = geom::center_square(bbox);
let fitted = match style {
CheckStyle::Check | CheckStyle::Cross => centre,
_ => geom::scale_from_center(centre, 2.0 / 3.0),
};
shape(fitted, style, color)
}
#[must_use]
pub fn radio_button(bbox: Rect, style: CheckStyle, color: Color) -> String {
let centre = geom::center_square(bbox);
let fitted = match style {
CheckStyle::Check | CheckStyle::Cross => centre,
CheckStyle::Circle => geom::scale_from_center(centre, 0.5),
_ => geom::scale_from_center(centre, 2.0 / 3.0),
};
shape(fitted, style, color)
}
const DROP_BUTTON_GREY: f32 = 220.0 / 255.0;
const DROP_BUTTON_BORDER: crate::ap::border::BorderStyleInfo = crate::ap::border::BorderStyleInfo {
width: 2.0,
style: crate::ap::border::BorderStyle::Beveled,
dash: crate::ap::border::Dash {
on: 3,
gap: 0,
phase: 0,
},
};
#[must_use]
pub fn drop_button(bbox: Rect) -> String {
if geom::is_empty(bbox) {
return String::new();
}
let grey = color_op_via(Color::Gray(DROP_BUTTON_GREY), PaintOp::Fill, Float::G6);
let mut out = Content::new();
out.raw("q\n");
out.raw(&grey);
out.rect(bbox, Float::Shortest);
out.raw("re f\n");
out.raw("Q\n");
let border = crate::ap::border::border_path(bbox, DROP_BUTTON_BORDER, Color::Gray(0.0));
if !border.is_empty() {
out.raw("q\n");
out.raw(&border);
out.raw("Q\n");
}
let (cx, cy) = (
(geom::left(bbox) + geom::right(bbox)) / 2.0,
(geom::top(bbox) + geom::bottom(bbox)) / 2.0,
);
if geom::is_float_bigger(geom::width(bbox), 6.0)
&& geom::is_float_bigger(geom::height(bbox), 6.0)
{
out.raw("q\n0 g\n");
for (x, y, op) in [
(cx - 3.0, cy + 1.5, "m\n"),
(cx + 3.0, cy + 1.5, "l\n"),
(cx, cy - 1.5, "l\n"),
(cx - 3.0, cy + 1.5, "l f\n"),
] {
out.point(x, y, Float::Shortest);
out.raw(op);
}
out.raw(&grey);
out.raw("Q\n");
}
out.as_str().to_owned()
}
fn shape(bbox: Rect, style: CheckStyle, color: Color) -> String {
let (paint, operator) = match style {
CheckStyle::Cross | CheckStyle::Star => (PaintOp::Stroke, "S\n"),
_ => (PaintOp::Fill, "f\n"),
};
let mut out = Content::new();
out.raw("q\n");
out.raw(&color_op(color, paint));
out.raw(&path(bbox, style));
out.raw(operator);
out.raw("Q\n");
out.as_str().to_owned()
}
#[must_use]
pub fn path(bbox: Rect, style: CheckStyle) -> String {
match style {
CheckStyle::Check => check_path(bbox),
CheckStyle::Circle => circle_path(bbox),
CheckStyle::Cross => cross_path(bbox),
CheckStyle::Diamond => diamond_path(bbox),
CheckStyle::Square => square_path(bbox),
CheckStyle::Star => star_path(bbox),
}
}
fn check_path(bbox: Rect) -> String {
const ROWS: [[(f32, f32); 3]; 8] = [
[(0.28, 0.52), (0.27, 0.48), (0.29, 0.40)],
[(0.30, 0.33), (0.31, 0.29), (0.31, 0.28)],
[(0.39, 0.28), (0.49, 0.29), (0.77, 0.67)],
[(0.76, 0.68), (0.78, 0.69), (0.76, 0.75)],
[(0.76, 0.75), (0.73, 0.80), (0.68, 0.75)],
[(0.68, 0.74), (0.68, 0.74), (0.44, 0.47)],
[(0.43, 0.47), (0.40, 0.47), (0.41, 0.58)],
[(0.40, 0.60), (0.28, 0.66), (0.30, 0.56)],
];
let (w, h) = (geom::width(bbox), geom::height(bbox));
let (left, bottom) = (geom::left(bbox), geom::bottom(bbox));
let place = |(x, y): (f32, f32)| (x * w + left, y * h + bottom);
let rows: Vec<[(f32, f32); 3]> = ROWS
.iter()
.map(|row| [place(row[0]), place(row[1]), place(row[2])])
.collect();
let mut out = Content::new();
let Some(first) = rows.first() else {
return String::new();
};
move_to(&mut out, first[0]);
for (index, row) in rows.iter().enumerate() {
let next = rows.get(index + 1).unwrap_or(first)[0];
let (px1, py1) = (row[1].0 - row[0].0, row[1].1 - row[0].1);
let (px2, py2) = (row[2].0 - next.0, row[2].1 - next.1);
curve_to(
&mut out,
(row[0].0 + px1 * BEZIER, row[0].1 + py1 * BEZIER),
(next.0 + px2 * BEZIER, next.1 + py2 * BEZIER),
next,
);
}
out.as_str().to_owned()
}
fn circle_path(bbox: Rect) -> String {
let (w, h) = (geom::width(bbox), geom::height(bbox));
let (left, bottom, right, top) = (
geom::left(bbox),
geom::bottom(bbox),
geom::right(bbox),
geom::top(bbox),
);
let p1 = (left, bottom + h / 2.0);
let p2 = (left + w / 2.0, top);
let p3 = (right, bottom + h / 2.0);
let p4 = (left + w / 2.0, bottom);
let mut out = Content::new();
move_to(&mut out, p1);
for (from, to, flip) in [
(p1, p2, false),
(p2, p3, true),
(p3, p4, true),
(p4, p1, false),
] {
let (px, py) = if flip {
(to.0 - from.0, from.1 - to.1)
} else {
(to.0 - from.0, to.1 - from.1)
};
let (h1, h2) = if flip {
((from.0 + px * BEZIER, from.1), (to.0, to.1 + py * BEZIER))
} else {
((from.0, from.1 + py * BEZIER), (to.0 - px * BEZIER, to.1))
};
curve_to(&mut out, h1, h2, to);
}
out.as_str().to_owned()
}
fn cross_path(bbox: Rect) -> String {
let mut out = Content::new();
move_to(&mut out, (geom::left(bbox), geom::top(bbox)));
line_to(&mut out, (geom::right(bbox), geom::bottom(bbox)));
move_to(&mut out, (geom::left(bbox), geom::bottom(bbox)));
line_to(&mut out, (geom::right(bbox), geom::top(bbox)));
out.as_str().to_owned()
}
fn diamond_path(bbox: Rect) -> String {
let (w, h) = (geom::width(bbox), geom::height(bbox));
let (left, bottom, right, top) = (
geom::left(bbox),
geom::bottom(bbox),
geom::right(bbox),
geom::top(bbox),
);
closed_loop(&[
(left, bottom + h / 2.0),
(left + w / 2.0, top),
(right, bottom + h / 2.0),
(left + w / 2.0, bottom),
])
}
fn square_path(bbox: Rect) -> String {
let (left, bottom, right, top) = (
geom::left(bbox),
geom::bottom(bbox),
geom::right(bbox),
geom::top(bbox),
);
closed_loop(&[(left, top), (right, top), (right, bottom), (left, bottom)])
}
fn star_path(bbox: Rect) -> String {
let radius =
(geom::top(bbox) - geom::bottom(bbox)) / (1.0 + (std::f32::consts::PI / 5.0).cos());
let centre = (
(geom::left(bbox) + geom::right(bbox)) / 2.0,
(geom::top(bbox) + geom::bottom(bbox)) / 2.0,
);
let mut points = [(0.0_f32, 0.0_f32); 5];
let mut angle = std::f32::consts::PI / 10.0;
for point in &mut points {
*point = (
centre.0 + radius * angle.cos(),
centre.1 + radius * angle.sin(),
);
angle += std::f32::consts::PI * 2.0 / 5.0;
}
let mut out = Content::new();
let Some(first) = points.first().copied() else {
return String::new();
};
move_to(&mut out, first);
for index in [2, 4, 1, 3, 0] {
if let Some(point) = points.get(index) {
line_to(&mut out, *point);
}
}
out.as_str().to_owned()
}
fn closed_loop(points: &[(f32, f32)]) -> String {
let mut out = Content::new();
let Some(first) = points.first() else {
return String::new();
};
move_to(&mut out, *first);
for point in points.iter().skip(1) {
line_to(&mut out, *point);
}
line_to(&mut out, *first);
out.as_str().to_owned()
}
fn move_to(out: &mut Content, (x, y): (f32, f32)) {
out.point(x, y, Float::Shortest);
out.raw("m\n");
}
fn line_to(out: &mut Content, (x, y): (f32, f32)) {
out.point(x, y, Float::Shortest);
out.raw("l\n");
}
fn curve_to(out: &mut Content, h1: (f32, f32), h2: (f32, f32), to: (f32, f32)) {
out.point(h1.0, h1.1, Float::Shortest);
out.point(h2.0, h2.1, Float::Shortest);
out.point(to.0, to.1, Float::Shortest);
out.raw("c\n");
}
#[cfg(test)]
mod tests {
use super::{CheckStyle, check_box, path, radio_button, style_from_caption};
use crate::color::Color;
use crate::geom;
#[test]
fn the_caption_table_maps_six_dingbat_code_points() {
assert_eq!(style_from_caption("4"), Some(CheckStyle::Check));
assert_eq!(style_from_caption("8"), Some(CheckStyle::Cross));
assert_eq!(style_from_caption("H"), Some(CheckStyle::Star));
assert_eq!(style_from_caption("l"), Some(CheckStyle::Circle));
assert_eq!(style_from_caption("n"), Some(CheckStyle::Square));
assert_eq!(style_from_caption("u"), Some(CheckStyle::Diamond));
assert_eq!(style_from_caption("4abc"), Some(CheckStyle::Check));
assert_eq!(style_from_caption("x"), None);
assert_eq!(style_from_caption(""), None);
}
#[test]
fn a_transparent_colour_still_leaves_a_path_behind() {
let box_ = geom::rect(0.0, 0.0, 12.0, 12.0);
let drawn = check_box(box_, CheckStyle::Circle, Color::Transparent);
assert!(!drawn.contains(" rg\n"), "{drawn}");
assert!(drawn.starts_with("q\n"), "{drawn}");
assert!(drawn.ends_with("f\nQ\n"), "{drawn}");
assert!(drawn.contains(" c\n"), "{drawn}");
}
#[test]
fn the_cross_and_star_stroke_where_the_rest_fill() {
let box_ = geom::rect(0.0, 0.0, 12.0, 12.0);
for style in [CheckStyle::Cross, CheckStyle::Star] {
assert!(
check_box(box_, style, Color::Gray(0.0)).ends_with("S\nQ\n"),
"{style:?}"
);
}
for style in [
CheckStyle::Check,
CheckStyle::Circle,
CheckStyle::Diamond,
CheckStyle::Square,
] {
assert!(
check_box(box_, style, Color::Gray(0.0)).ends_with("f\nQ\n"),
"{style:?}"
);
}
}
#[test]
fn a_radio_circle_shrinks_further_than_a_checkbox_one() {
let box_ = geom::rect(0.0, 0.0, 12.0, 12.0);
let as_check = check_box(box_, CheckStyle::Circle, Color::Gray(0.0));
let as_radio = radio_button(box_, CheckStyle::Circle, Color::Gray(0.0));
assert_ne!(as_check, as_radio);
assert!(as_check.contains("\n1.9999999 6 m\n"), "{as_check}");
assert!(as_radio.contains("\n3 6 m\n"), "{as_radio}");
}
#[test]
fn the_check_mark_closes_its_outline() {
let drawn = path(geom::rect(0.0, 0.0, 100.0, 100.0), CheckStyle::Check);
assert_eq!(drawn.matches(" c\n").count(), 8);
assert_eq!(drawn.matches(" m\n").count(), 1);
assert!(drawn.starts_with("28 52 m\n"), "{drawn}");
assert!(drawn.ends_with("28 52 c\n"), "{drawn}");
}
#[test]
fn the_polygons_return_to_their_first_point() {
for style in [CheckStyle::Diamond, CheckStyle::Square] {
let drawn = path(geom::rect(0.0, 0.0, 10.0, 10.0), style);
assert_eq!(drawn.matches(" m\n").count(), 1, "{style:?}");
assert_eq!(drawn.matches(" l\n").count(), 4, "{style:?}");
}
}
#[test]
fn the_cross_lifts_the_pen_between_its_two_strokes() {
let drawn = path(geom::rect(0.0, 0.0, 10.0, 10.0), CheckStyle::Cross);
assert_eq!(drawn, "0 10 m\n10 0 l\n0 0 m\n10 10 l\n");
}
#[test]
fn the_star_skips_a_vertex_each_step() {
let drawn = path(geom::rect(0.0, 0.0, 10.0, 10.0), CheckStyle::Star);
assert_eq!(drawn.matches(" m\n").count(), 1);
assert_eq!(drawn.matches(" l\n").count(), 5);
}
}