use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::RenderCtx;
use crate::motion::{clamp01, interpolate};
use crate::ui::text;
pub const BACKGROUND: &str = "#ffffff";
pub const REFERENCE_EDGE: f32 = 1080.0;
pub const FONT_SIZE: f32 = 80.0;
pub const FONT_WEIGHT: u16 = 700;
pub const LINE_HEIGHT: f32 = 1.1;
pub const TEXT_INK: &str = "#171717";
pub const STRIKE_INK: &str = "#f11515";
pub const STRIKE_WIDTH: f32 = 14.0;
pub const DRAW_START: f32 = 10.0;
pub const DRAW_END: f32 = 25.0;
pub const MAX_RANDOMNESS_OFFSET: f32 = 5.0;
pub const ROUGHNESS: f32 = 1.5;
pub const BOWING: f32 = 1.0;
pub const SEED: i32 = 1;
#[must_use]
pub fn progress(frame: f32) -> f32 {
clamp01(interpolate(frame, [DRAW_START, DRAW_END], [0.0, 1.0]))
}
#[derive(Debug, Clone, Copy)]
pub struct RoughRandom {
seed: i32,
}
impl RoughRandom {
#[must_use]
pub fn new(seed: i32) -> Self {
Self { seed }
}
pub fn next_value(&mut self) -> f32 {
self.seed = self.seed.wrapping_mul(48271);
((self.seed & 0x7fff_ffff) as f32) / 2_147_483_648.0
}
}
pub type Cubic = [(f32, f32); 4];
#[must_use]
pub fn rough_line(x1: f32, y1: f32, x2: f32, y2: f32, seed: i32) -> Cubic {
let mut rng = RoughRandom::new(seed);
let length_sq = (x1 - x2).powi(2) + (y1 - y2).powi(2);
let length = length_sq.sqrt();
let gain = if length < 200.0 {
1.0
} else if length > 500.0 {
0.4
} else {
-0.001_666_8 * length + 1.233_334
};
let mut offset = MAX_RANDOMNESS_OFFSET;
if offset * offset * 100.0 > length_sq {
offset = length / 10.0;
}
let off = |x: f32, rng: &mut RoughRandom| ROUGHNESS * gain * (rng.next_value() * 2.0 * x - x);
let diverge = 0.2 + rng.next_value() * 0.2;
let mid_x = off(BOWING * MAX_RANDOMNESS_OFFSET * (y2 - y1) / 200.0, &mut rng);
let mid_y = off(BOWING * MAX_RANDOMNESS_OFFSET * (x1 - x2) / 200.0, &mut rng);
let sx = x1 + off(offset, &mut rng);
let sy = y1 + off(offset, &mut rng);
let c1x = mid_x + x1 + (x2 - x1) * diverge + off(offset, &mut rng);
let c1y = mid_y + y1 + (y2 - y1) * diverge + off(offset, &mut rng);
let c2x = mid_x + x1 + 2.0 * (x2 - x1) * diverge + off(offset, &mut rng);
let c2y = mid_y + y1 + 2.0 * (y2 - y1) * diverge + off(offset, &mut rng);
let ex = x2 + off(offset, &mut rng);
let ey = y2 + off(offset, &mut rng);
[(sx, sy), (c1x, c1y), (c2x, c2y), (ex, ey)]
}
fn point(curve: &Cubic, t: f32) -> (f32, f32) {
let u = 1.0 - t;
let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t];
let mut p = (0.0, 0.0);
for (weight, c) in w.iter().zip(curve) {
p.0 += weight * c.0;
p.1 += weight * c.1;
}
p
}
const STEPS: usize = 256;
#[must_use]
pub fn curve_length(curve: &Cubic) -> f32 {
let mut previous = point(curve, 0.0);
let mut total = 0.0;
for i in 1..=STEPS {
let p = point(curve, i as f32 / STEPS as f32);
total += ((p.0 - previous.0).powi(2) + (p.1 - previous.1).powi(2)).sqrt();
previous = p;
}
total
}
#[must_use]
pub fn partial_curve(curve: &Cubic, fraction: f32) -> Cubic {
let fraction = clamp01(fraction);
let target = curve_length(curve) * fraction;
let mut previous = point(curve, 0.0);
let mut run = 0.0;
let mut t = fraction;
for i in 1..=STEPS {
let p = point(curve, i as f32 / STEPS as f32);
let seg = ((p.0 - previous.0).powi(2) + (p.1 - previous.1).powi(2)).sqrt();
if run + seg >= target {
let within = if seg > 0.0 { (target - run) / seg } else { 0.0 };
t = ((i - 1) as f32 + within) / STEPS as f32;
break;
}
run += seg;
previous = p;
}
let mix = |a: (f32, f32), b: (f32, f32)| (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t);
let ab = mix(curve[0], curve[1]);
let bc = mix(curve[1], curve[2]);
let cd = mix(curve[2], curve[3]);
let abc = mix(ab, bc);
let bcd = mix(bc, cd);
let abcd = mix(abc, bcd);
[curve[0], ab, abc, abcd]
}
fn curve_d(c: &Cubic) -> String {
format!(
"M{} {} C{} {}, {} {}, {} {}",
c[0].0, c[0].1, c[1].0, c[1].1, c[2].0, c[2].1, c[3].0, c[3].1
)
}
#[must_use]
pub fn scene_scale(canvas_width: f32, canvas_height: f32) -> f32 {
canvas_width.min(canvas_height) / REFERENCE_EDGE
}
#[derive(Debug, Clone, PartialEq)]
pub struct StrikeThroughConfig {
pub before: String,
pub struck: String,
pub after: String,
pub text_color: String,
pub strike_color: String,
pub stroke_width: f32,
pub font_family: Option<String>,
pub duration: usize,
}
impl Default for StrikeThroughConfig {
fn default() -> Self {
Self {
before: "The".to_owned(),
struck: "forbidden".to_owned(),
after: "fruit".to_owned(),
text_color: TEXT_INK.to_owned(),
strike_color: STRIKE_INK.to_owned(),
stroke_width: STRIKE_WIDTH,
font_family: None,
duration: 90,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct StrikeThrough {
pub config: StrikeThroughConfig,
}
impl StrikeThrough {
#[must_use]
pub fn new(config: StrikeThroughConfig) -> Self {
Self { config }
}
#[must_use]
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "strike-through",
name: "Strike Through",
summary: "A hand-drawn red strike-through draws across one word of a sentence",
feature: "strike-through",
}
}
}
impl Component for StrikeThrough {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
self.config.duration
}
fn background(&self) -> Option<&'static str> {
Some(BACKGROUND)
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let config = &self.config;
let family = config
.font_family
.clone()
.unwrap_or_else(|| crate::INTER_FAMILY.to_owned());
let frame = ctx.frame();
let scale = scene_scale(ctx.width(), ctx.height());
let (w, h) = (ctx.width() / scale, ctx.height() / scale);
let width_of = |s: &str| ctx.text_width_at(&family, FONT_WEIGHT, FONT_SIZE, s);
let space = ctx.space_width_at(&family, FONT_WEIGHT, FONT_SIZE);
let before_w = width_of(&config.before) + space;
let struck_w = width_of(&config.struck);
let after_w = width_of(&config.after);
let total = before_w + struck_w + space + after_w;
let line_h = FONT_SIZE * LINE_HEIGHT;
let x0 = (w - total) / 2.0;
let top = (h - line_h) / 2.0;
let baseline = top + ctx.baseline_offset_at(&family, FONT_WEIGHT, FONT_SIZE, line_h);
let struck_x = x0 + before_w;
let run = |s: &str, x: f32| {
text::text_node_left(
s,
x,
baseline,
family.clone(),
FONT_SIZE,
FONT_WEIGHT,
0.0,
config.text_color.clone(),
1.0,
)
};
let y = top + line_h / 2.0;
let full = rough_line(struck_x, y, struck_x + struck_w, y, SEED);
let p = progress(frame);
let strike = if p > 0.0 {
let d = curve_d(&partial_curve(&full, p));
fframes::svgr!(
<path d={d} fill="none" stroke={config.strike_color.clone()}
stroke-width={config.stroke_width} />
)
} else {
fframes::svgr!(<g></g>)
};
fframes::svgr!(
<g transform={format!("scale({scale})")}>
{run(&config.before, x0)}
{run(&config.struck, struck_x)}
{run(&config.after, struck_x + struck_w + space)}
{strike}
</g>
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_defaults_are_the_documented_config() {
let config = StrikeThroughConfig::default();
assert_eq!(config.struck, "forbidden");
assert_eq!(config.text_color, "#171717");
assert_eq!(config.strike_color, "#f11515");
assert_eq!(config.stroke_width, 14.0);
assert_eq!(config.font_family, None);
}
#[test]
fn the_meta_names_the_component_and_its_feature() {
let component = StrikeThrough::default();
assert_eq!(component.meta().id, "strike-through");
assert_eq!(StrikeThrough::meta_static().feature, "strike-through");
assert_eq!(component.duration(), 90);
assert_eq!(component.background(), Some(BACKGROUND));
}
#[test]
fn the_strike_draws_between_frames_ten_and_twenty_five() {
assert_eq!(progress(0.0), 0.0);
assert_eq!(progress(10.0), 0.0);
assert!((progress(17.5) - 0.5).abs() < 1e-6);
assert_eq!(progress(25.0), 1.0);
assert_eq!(progress(80.0), 1.0);
}
#[test]
fn the_rough_generator_matches_roughjs() {
let mut rng = RoughRandom::new(1);
assert!((rng.next_value() - 48271.0 / 2_147_483_648.0).abs() < 1e-9);
let second = rng.next_value();
assert!((0.0..1.0).contains(&second));
}
#[test]
fn the_rough_line_stays_near_its_straight_line() {
let c = rough_line(0.0, 50.0, 300.0, 50.0, SEED);
assert!((c[0].0).abs() < 8.0 && (c[0].1 - 50.0).abs() < 8.0);
assert!((c[3].0 - 300.0).abs() < 8.0 && (c[3].1 - 50.0).abs() < 8.0);
assert_eq!(c, rough_line(0.0, 50.0, 300.0, 50.0, SEED));
}
#[test]
fn a_partial_curve_is_that_fraction_of_the_length() {
let c = rough_line(0.0, 50.0, 300.0, 50.0, SEED);
let full = curve_length(&c);
let half = curve_length(&partial_curve(&c, 0.5));
assert!((half - full / 2.0).abs() < 1.0, "{half} vs {full}");
let whole = partial_curve(&c, 1.0);
assert!((whole[3].0 - c[3].0).abs() < 0.5);
}
}