use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::RenderCtx;
use crate::motion::{Spring, clamp01, interpolate};
use crate::ui::text;
pub const BLOCK_W: f32 = 800.0;
pub const FONT_SIZE: f32 = 80.0;
pub const FONT_WEIGHT: u16 = 700;
pub const LINE_HEIGHT: f32 = 1.1;
pub const INK: &str = "#171717";
pub const BACKGROUND: &str = "#f6f5f1";
pub const REFERENCE_HEIGHT: f32 = 1080.0;
pub const HIGHLIGHT_COLOR: &str = "#ffec4f";
pub const HIGHLIGHT_ALPHA: f32 = 0.62;
pub const ROUGHNESS: f32 = 2.3;
pub const MAX_RANDOMNESS_OFFSET: f32 = 10.0;
pub const PADDING_X: f32 = 20.0;
pub const BOWING: f32 = 0.0;
pub const PASSES: usize = 2;
pub const SEED: i32 = 1;
pub const DRAW_FRAMES: f32 = 25.0;
pub const DRAW_SPRING: Spring = Spring {
damping: 200.0,
mass: 1.0,
stiffness: 100.0,
};
pub const REST_THRESHOLD: f32 = 0.02;
#[must_use]
pub fn draw_progress(t: f32) -> f32 {
if t <= 0.0 {
return 0.0;
}
if t >= 1.0 {
return 1.0;
}
let mut natural = 1.0;
for frame in 0..1000 {
if (DRAW_SPRING.value(frame as f32) - 1.0).abs() >= REST_THRESHOLD {
natural = frame as f32 + 1.0;
}
}
let leftover = 1.0 - DRAW_SPRING.value(natural);
let blend = t * t * (3.0 - 2.0 * t);
DRAW_SPRING.value(t * natural) + leftover * blend
}
#[must_use]
pub fn progress_at(frame: f32) -> f32 {
draw_progress(interpolate(frame, [0.0, DRAW_FRAMES], [0.0, 1.0]))
}
#[derive(Debug, Clone, PartialEq)]
pub struct Word {
pub text: String,
pub marked: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Line {
pub text: String,
pub marks: Vec<(f32, f32)>,
}
#[must_use]
pub fn words_of(before: &str, highlight: &str, after: &str) -> Vec<Word> {
let mut out = Vec::new();
for (part, marked) in [(before, false), (highlight, true), (after, false)] {
for word in part.split_whitespace() {
out.push(Word {
text: word.to_owned(),
marked,
});
}
}
out
}
#[must_use]
pub fn layout(words: &[Word], max_width: f32, measure: impl Fn(&str) -> f32) -> Vec<Line> {
let mut rows: Vec<Vec<&Word>> = Vec::new();
let mut current: Vec<&Word> = Vec::new();
for word in words {
let candidate = current
.iter()
.map(|w| w.text.as_str())
.chain(std::iter::once(word.text.as_str()))
.collect::<Vec<_>>()
.join(" ");
if current.is_empty() || measure(&candidate) <= max_width {
current.push(word);
} else {
rows.push(std::mem::take(&mut current));
current.push(word);
}
}
if !current.is_empty() {
rows.push(current);
}
rows.into_iter()
.map(|row| {
let mut spans: Vec<(usize, usize)> = Vec::new();
let mut run: Option<(usize, usize)> = None;
for (i, word) in row.iter().enumerate() {
match (word.marked, run) {
(true, None) => run = Some((i, i)),
(true, Some((a, _))) => run = Some((a, i)),
(false, Some(r)) => {
spans.push(r);
run = None;
}
(false, None) => {}
}
}
if let Some(r) = run {
spans.push(r);
}
let join = |a: usize, b: usize| {
row[a..=b]
.iter()
.map(|w| w.text.as_str())
.collect::<Vec<_>>()
.join(" ")
};
let marks = spans
.into_iter()
.map(|(a, b)| {
let start = if a == 0 {
0.0
} else {
measure(&format!("{} ", join(0, a - 1)))
};
(start, measure(&join(0, b)))
})
.collect();
Line {
text: join(0, row.len() - 1),
marks,
}
})
.collect()
}
#[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);
[curve[0], ab, abc, mix(abc, bcd)]
}
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 highlight_stroke(x0: f32, x1: f32, y: f32, i: usize) -> Cubic {
let seed = SEED + i as i32;
if i % 2 == 1 {
rough_line(x1, y, x0, y, seed)
} else {
rough_line(x0, y, x1, y, seed)
}
}
#[must_use]
pub fn stroke_progress(progress: f32, i: usize) -> f32 {
(progress * PASSES as f32 - i as f32).clamp(0.0, 1.0)
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextMarkerConfig {
pub before: String,
pub highlight: String,
pub after: String,
pub font_family: Option<String>,
pub duration: usize,
}
impl Default for TextMarkerConfig {
fn default() -> Self {
Self {
before: "A truly".to_owned(),
highlight: "remarkable".to_owned(),
after: "end to the World cup".to_owned(),
font_family: None,
duration: 90,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct TextMarker {
pub config: TextMarkerConfig,
}
impl TextMarker {
#[must_use]
pub fn new(config: TextMarkerConfig) -> Self {
Self { config }
}
#[must_use]
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "text-marker",
name: "Text Marker",
summary: "A rough, hand-drawn highlighter stroke is drawn across one phrase",
feature: "text-marker",
}
}
}
impl Component for TextMarker {
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 progress = progress_at(ctx.frame());
let words = words_of(&config.before, &config.highlight, &config.after);
let lines = layout(&words, BLOCK_W, |s| {
text::tracked_width_at(ctx, &family, FONT_WEIGHT, FONT_SIZE, s, 0.0)
});
let line_h = FONT_SIZE * LINE_HEIGHT;
let content_h = ctx
.meter_at(&family, FONT_WEIGHT)
.map_or(FONT_SIZE * 1.2, |m| {
m.ascent(FONT_SIZE) + m.descent(FONT_SIZE)
});
let baseline = ctx.baseline_offset_at(&family, FONT_WEIGHT, FONT_SIZE, line_h);
let block_h = lines.len() as f32 * line_h;
let scale = ctx.height() / REFERENCE_HEIGHT;
let fit = format!(
"translate({} {}) scale({scale})",
(ctx.width() - BLOCK_W * scale) / 2.0,
(ctx.height() - block_h * scale) / 2.0
);
let mut strokes: Vec<Svgr<'static>> = Vec::new();
let mut runs: Vec<Svgr<'static>> = Vec::new();
for (row, line) in lines.iter().enumerate() {
let top = row as f32 * line_h;
runs.push(text::text_node_left(
&line.text,
0.0,
top + baseline,
family.clone(),
FONT_SIZE,
FONT_WEIGHT,
0.0,
INK.to_owned(),
1.0,
));
for &(start, end) in &line.marks {
let x0 = start - PADDING_X;
let x1 = end + PADDING_X;
let cy = top + line_h / 2.0;
for pass in 0..PASSES {
let p = stroke_progress(progress, pass);
if p <= 0.0 {
continue;
}
let d = curve_d(&partial_curve(&highlight_stroke(x0, x1, cy, pass), p));
strokes.push(fframes::svgr!(
<path d={d} fill="none" stroke={HIGHLIGHT_COLOR}
stroke-opacity={HIGHLIGHT_ALPHA} stroke-width={content_h} />
));
}
}
}
fframes::svgr!(
<g transform={fit}>
{Svgr::from(strokes)}
{Svgr::from(runs)}
</g>
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn w(s: &str) -> f32 {
s.chars().count() as f32 * 10.0
}
#[test]
fn the_defaults_are_the_demo() {
let config = TextMarkerConfig::default();
assert_eq!(config.before, "A truly");
assert_eq!(config.highlight, "remarkable");
assert_eq!(config.after, "end to the World cup");
assert_eq!(config.font_family, None);
}
#[test]
fn the_meta_names_the_component_and_its_feature() {
let component = TextMarker::default();
assert_eq!(component.meta().id, "text-marker");
assert_eq!(TextMarker::meta_static().feature, "text-marker");
assert_eq!(component.background(), Some(BACKGROUND));
assert_eq!(component.duration(), 90);
}
#[test]
fn the_draw_on_runs_from_nothing_to_complete_over_25_frames() {
assert_eq!(progress_at(0.0), 0.0);
assert_eq!(progress_at(-5.0), 0.0);
assert!((progress_at(25.0) - 1.0).abs() < 1e-6);
assert_eq!(progress_at(80.0), 1.0);
let mut last = 0.0;
for f in 1..=25 {
let p = progress_at(f as f32);
assert!(p >= last - 1e-4, "frame {f}: {p} < {last}");
last = p;
}
assert!(progress_at(5.0) > 0.0 && progress_at(5.0) < 1.0);
}
#[test]
fn layout_marks_the_highlighted_word() {
let words = words_of("A truly", "remarkable", "end");
let lines = layout(&words, 1000.0, w);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "A truly remarkable end");
assert_eq!(lines[0].marks, vec![(80.0, 180.0)]);
}
#[test]
fn layout_wraps_and_keeps_every_word() {
let words = words_of("aa bb", "cc", "dd");
let lines = layout(&words, 55.0, w);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].text, "aa bb");
assert_eq!(lines[1].text, "cc dd");
assert!(lines[0].marks.is_empty());
assert_eq!(lines[1].marks, vec![(0.0, 20.0)]);
}
#[test]
fn the_strokes_are_deterministic_and_differ_by_seed() {
let a = highlight_stroke(0.0, 100.0, 0.0, 0);
assert_eq!(a, highlight_stroke(0.0, 100.0, 0.0, 0));
assert_ne!(a, highlight_stroke(0.0, 100.0, 0.0, 1));
assert!(highlight_stroke(0.0, 100.0, 0.0, 1)[0].0 > 50.0);
}
#[test]
fn the_strokes_are_drawn_one_after_the_other() {
assert_eq!(stroke_progress(0.25, 0), 0.5);
assert_eq!(stroke_progress(0.25, 1), 0.0);
assert_eq!(stroke_progress(0.75, 0), 1.0);
assert_eq!(stroke_progress(0.75, 1), 0.5);
assert_eq!(stroke_progress(1.0, 1), 1.0);
}
#[test]
fn the_random_generator_matches_roughjs() {
let mut r = RoughRandom::new(1);
assert!((r.next_value() - 48271.0 / 2_147_483_648.0).abs() < 1e-9);
}
}