mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-03 17:12:52 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1cd1b2499 |
+21
-128
@@ -78,57 +78,45 @@ pub fn insert(body: &mut TextBody, pos: Pos, text: &str, props: Option<RunProps>
|
||||
if body.paragraphs.is_empty() {
|
||||
body.paragraphs.push(Paragraph::default());
|
||||
}
|
||||
let (pi0, mut ch) = clamp(body, pos);
|
||||
let props = props.unwrap_or_else(|| props_at(body, (pi0, ch)));
|
||||
let (mut pi, mut ch) = clamp(body, pos);
|
||||
let props = props.unwrap_or_else(|| props_at(body, (pi, ch)));
|
||||
let text = text.replace("\r\n", "\n").replace('\r', "\n");
|
||||
// Work on the paragraph being edited apart from the body, so each new paragraph is pushed to a
|
||||
// list and the body's vector is spliced once instead of shifted for every `\n`.
|
||||
let Some(slot) = body.paragraphs.get_mut(pi0) else { return (pi0, ch) };
|
||||
let mut cur = std::mem::take(slot);
|
||||
let mut done: Vec<Paragraph> = Vec::new();
|
||||
let mut first = true;
|
||||
for part in text.split('\n') {
|
||||
if !first {
|
||||
// Split the current paragraph at ch.
|
||||
let idx = split_at(&mut cur, ch);
|
||||
let tail: Vec<Run> = cur.runs.drain(idx..).collect();
|
||||
let newp = Paragraph {
|
||||
level: cur.level,
|
||||
props: cur.props.clone(),
|
||||
runs: tail,
|
||||
def_props: cur.def_props.clone(),
|
||||
end_props: cur.end_props.clone(),
|
||||
};
|
||||
cur.end_props = props.clone();
|
||||
done.push(std::mem::replace(&mut cur, newp));
|
||||
// Split paragraph pi at ch.
|
||||
let Some(p) = body.paragraphs.get_mut(pi) else { break };
|
||||
let idx = split_at(p, ch);
|
||||
let tail: Vec<Run> = p.runs.drain(idx..).collect();
|
||||
let newp =
|
||||
Paragraph { level: p.level, props: p.props.clone(), runs: tail, def_props: p.def_props.clone(), end_props: p.end_props.clone() };
|
||||
p.end_props = props.clone();
|
||||
body.paragraphs.insert(pi + 1, newp);
|
||||
pi += 1;
|
||||
ch = 0;
|
||||
}
|
||||
first = false;
|
||||
// The run index just after the last insertion: the next one lands there with no rescan.
|
||||
let mut next: Option<usize> = None;
|
||||
for (k, seg) in part.split('\u{b}').enumerate() {
|
||||
let Some(p) = body.paragraphs.get_mut(pi) else { break };
|
||||
if k > 0 {
|
||||
let idx = next.unwrap_or_else(|| split_at(&mut cur, ch));
|
||||
cur.runs.insert(idx, Run { text: String::new(), props: props.clone(), kind: RunKind::Break });
|
||||
let idx = split_at(p, ch);
|
||||
p.runs.insert(idx, Run { text: String::new(), props: props.clone(), kind: RunKind::Break });
|
||||
ch += 1;
|
||||
next = Some(idx + 1);
|
||||
}
|
||||
if seg.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let idx = next.unwrap_or_else(|| split_at(&mut cur, ch));
|
||||
cur.runs.insert(idx, Run::with(seg, props.clone()));
|
||||
let idx = split_at(p, ch);
|
||||
p.runs.insert(idx, Run::with(seg, props.clone()));
|
||||
ch += seg.chars().count();
|
||||
next = Some(idx + 1);
|
||||
}
|
||||
cur.normalize();
|
||||
if cur.runs.is_empty() {
|
||||
cur.end_props = props.clone();
|
||||
if let Some(p) = body.paragraphs.get_mut(pi) {
|
||||
p.normalize();
|
||||
if p.runs.is_empty() {
|
||||
p.end_props = props.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
let pi = pi0 + done.len();
|
||||
done.push(cur);
|
||||
body.paragraphs.splice(pi0..=pi0, done);
|
||||
(pi, ch)
|
||||
}
|
||||
|
||||
@@ -264,16 +252,8 @@ pub fn slice(body: &TextBody, a: Pos, b: Pos) -> TextBody {
|
||||
/// Insert a formatted body at `pos` (paste): first paragraph merges into the current one.
|
||||
pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
|
||||
let mut caret = clamp(body, pos);
|
||||
// Plain text runs go straight in at the run index after the previous one and the paragraph is
|
||||
// normalized once, not rescanned and merged for every source run.
|
||||
let mut next: Option<usize> = None;
|
||||
let mut dirty = false;
|
||||
for (k, sp) in src.paragraphs.iter().enumerate() {
|
||||
if k > 0 {
|
||||
if let (true, Some(p)) = (dirty, body.paragraphs.get_mut(caret.0)) {
|
||||
p.normalize();
|
||||
}
|
||||
(next, dirty) = (None, false);
|
||||
caret = insert(body, caret, "\n", None);
|
||||
if let Some(p) = body.paragraphs.get_mut(caret.0) {
|
||||
p.level = sp.level;
|
||||
@@ -281,22 +261,6 @@ pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
|
||||
}
|
||||
}
|
||||
for r in &sp.runs {
|
||||
if r.kind == RunKind::Text
|
||||
&& !r.text.is_empty()
|
||||
&& !r.text.contains(['\n', '\r', '\u{b}'])
|
||||
&& let Some(p) = body.paragraphs.get_mut(caret.0)
|
||||
{
|
||||
let idx = next.unwrap_or_else(|| split_at(p, caret.1));
|
||||
p.runs.insert(idx, Run::with(&r.text, r.props.clone()));
|
||||
caret.1 += r.text.chars().count();
|
||||
(next, dirty) = (Some(idx + 1), true);
|
||||
continue;
|
||||
}
|
||||
// Anything else takes the general path, after the pending runs are merged.
|
||||
if let (true, Some(p)) = (dirty, body.paragraphs.get_mut(caret.0)) {
|
||||
p.normalize();
|
||||
}
|
||||
(next, dirty) = (None, false);
|
||||
match r.kind {
|
||||
RunKind::Text => caret = insert(body, caret, &r.text, Some(r.props.clone())),
|
||||
RunKind::Break => caret = insert(body, caret, "\u{b}", Some(r.props.clone())),
|
||||
@@ -310,9 +274,6 @@ pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (true, Some(p)) = (dirty, body.paragraphs.get_mut(caret.0)) {
|
||||
p.normalize();
|
||||
}
|
||||
caret
|
||||
}
|
||||
|
||||
@@ -607,74 +568,6 @@ mod tests {
|
||||
assert_eq!(caret, (0, 5));
|
||||
}
|
||||
|
||||
/// The paste as it ran before the fast path: one `insert` (and normalize) per source run.
|
||||
fn insert_body_by_runs(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
|
||||
let mut caret = clamp(body, pos);
|
||||
for (k, sp) in src.paragraphs.iter().enumerate() {
|
||||
if k > 0 {
|
||||
caret = insert(body, caret, "\n", None);
|
||||
if let Some(p) = body.paragraphs.get_mut(caret.0) {
|
||||
p.level = sp.level;
|
||||
p.props = sp.props.clone();
|
||||
}
|
||||
}
|
||||
for r in &sp.runs {
|
||||
match r.kind {
|
||||
RunKind::Text => caret = insert(body, caret, &r.text, Some(r.props.clone())),
|
||||
RunKind::Break => caret = insert(body, caret, "\u{b}", Some(r.props.clone())),
|
||||
_ => {
|
||||
if let Some(p) = body.paragraphs.get_mut(caret.0) {
|
||||
let idx = split_at(p, caret.1);
|
||||
p.runs.insert(idx, r.clone());
|
||||
caret.1 += r.char_len();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
caret
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_matches_inserting_run_by_run() {
|
||||
let bold = |t: &str| Run::with(t, RunProps { bold: Some(true), ..Default::default() });
|
||||
let mut src = body("");
|
||||
src.paragraphs = vec![
|
||||
Paragraph {
|
||||
runs: vec![Run::with("ab", RunProps::default()), Run::with("cd", RunProps::default()), bold("ef"), bold("g")],
|
||||
..Default::default()
|
||||
},
|
||||
Paragraph {
|
||||
runs: vec![
|
||||
bold("h"),
|
||||
Run { text: String::new(), props: RunProps::default(), kind: RunKind::Break },
|
||||
Run::with("i", RunProps::default()),
|
||||
],
|
||||
level: 1,
|
||||
..Default::default()
|
||||
},
|
||||
Paragraph { runs: vec![Run::with("jk", RunProps::default())], ..Default::default() },
|
||||
];
|
||||
for at in [0, 1, 3, 6] {
|
||||
let mut dest = body("XYZabc");
|
||||
format(&mut dest, (0, 2), (0, 4), &|p| p.italic = Some(true));
|
||||
let mut want = dest.clone();
|
||||
let want_caret = insert_body_by_runs(&mut want, (0, at), &src);
|
||||
let caret = insert_body(&mut dest, (0, at), &src);
|
||||
assert_eq!(caret, want_caret, "caret at {at}");
|
||||
assert_eq!(format!("{:?}", dest.paragraphs), format!("{:?}", want.paragraphs), "paste at {at}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_insert_splits_into_paragraphs() {
|
||||
let mut b = body("abcd");
|
||||
let c = insert(&mut b, (0, 2), "x\ny\u{b}z\n\nw", None);
|
||||
assert_eq!(c, (3, 1));
|
||||
let texts: Vec<String> = b.paragraphs.iter().map(Paragraph::text).collect();
|
||||
assert_eq!(texts, ["abx", "y\u{b}z", "", "wcd"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_and_moves() {
|
||||
let b = body("Hello brave world");
|
||||
|
||||
@@ -125,7 +125,9 @@ pub fn draw(ctx: &mut RenderContext, bevel: &Bevel, fill: Option<Rgba>, path: &B
|
||||
let longest = bounds.width().max(bounds.height()) * k;
|
||||
let s = (MAX_SIDE / longest).min(1.0) * k;
|
||||
let (pw, ph) = (((bounds.width() * s).ceil() as u32).clamp(1, 2048), ((bounds.height() * s).ceil() as u32).clamp(1, 2048));
|
||||
let polys = crate::pathgrad::polygons(path);
|
||||
// Each pixel tests every edge, so a dense outline is simplified to the raster's edge budget.
|
||||
let pixel = (bounds.width() / f64::from(pw)).min(bounds.height() / f64::from(ph));
|
||||
let polys = crate::pathgrad::simplify(crate::pathgrad::polygons(path), pixel, crate::pathgrad::edge_budget(pw, ph));
|
||||
if polys.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ use vello_cpu::{RenderContext, peniko};
|
||||
|
||||
/// Nesting depth of clips and layers past which a glyph opens no more (hostile fonts).
|
||||
const MAX_DEPTH: usize = 64;
|
||||
/// Fills and gradient stops one colour glyph may paint; the rest of a hostile glyph is skipped.
|
||||
const MAX_WORK: usize = 100_000;
|
||||
|
||||
/// Paint `gid` of `face` as a colour glyph, its origin mapped by `at` (y down, points) at `scale`
|
||||
/// points per font unit. `fg` is the text colour (palette index 0xFFFF) and `alpha` fades it all.
|
||||
@@ -26,7 +28,7 @@ pub fn paint(ctx: &mut RenderContext, face: &FontFace, gid: u32, at: Affine, sca
|
||||
.unwrap_or_default();
|
||||
// Font units are y up; the slide is y down.
|
||||
let base = at * Affine::scale(scale) * Affine::scale_non_uniform(1.0, -1.0);
|
||||
let mut p = Painter { ctx, face, base, stack: vec![Affine::IDENTITY], palette, fg, opened: Vec::new() };
|
||||
let mut p = Painter { ctx, face, base, stack: vec![Affine::IDENTITY], palette, fg, opened: Vec::new(), work: 0 };
|
||||
p.ctx.push_layer(None, None, Some(alpha.clamp(0.0, 1.0) as f32), None, None);
|
||||
let ok = glyph.paint(face.location(), &mut p).is_ok();
|
||||
// A malformed glyph may leave layers open: close them all.
|
||||
@@ -50,6 +52,8 @@ struct Painter<'a> {
|
||||
/// Clips and composite groups pushed by the glyph, innermost last: true when a layer was
|
||||
/// opened on the context for it (false past `MAX_DEPTH`, so its pop is skipped too).
|
||||
opened: Vec<bool>,
|
||||
/// Fills plus gradient stops painted so far, against [`MAX_WORK`].
|
||||
work: usize,
|
||||
}
|
||||
|
||||
fn affine(t: &Transform) -> Affine {
|
||||
@@ -174,6 +178,16 @@ impl ColorPainter for Painter<'_> {
|
||||
}
|
||||
|
||||
fn fill(&mut self, brush: Brush<'_>) {
|
||||
let stops = match &brush {
|
||||
Brush::Solid { .. } => 0,
|
||||
Brush::LinearGradient { color_stops, .. } | Brush::RadialGradient { color_stops, .. } | Brush::SweepGradient { color_stops, .. } => {
|
||||
color_stops.len()
|
||||
}
|
||||
};
|
||||
self.work = self.work.saturating_add(1 + stops);
|
||||
if self.work > MAX_WORK {
|
||||
return;
|
||||
}
|
||||
match brush {
|
||||
Brush::Solid { palette_index, alpha } => {
|
||||
let c = self.color(palette_index, alpha);
|
||||
|
||||
@@ -25,6 +25,16 @@ const MAX_EDGE_WORK: u64 = 64_000_000;
|
||||
/// Fewest edges a simplified polygon keeps, so a coarse outline still has a shape.
|
||||
const MIN_POLY_EDGES: usize = 8;
|
||||
|
||||
/// Most points `polygons` keeps from one path; a path flattening past this (a huge finite curve) is
|
||||
/// not drawn rather than held in memory first.
|
||||
const MAX_POINTS: usize = 2_000_000;
|
||||
|
||||
/// The edge budget for a `pw` x `ph` raster where each pixel tests every edge.
|
||||
pub(crate) fn edge_budget(pw: u32, ph: u32) -> usize {
|
||||
let pixels = u64::from(pw).saturating_mul(u64::from(ph)).max(1);
|
||||
usize::try_from(MAX_EDGE_WORK / pixels).unwrap_or(usize::MAX).max(MIN_POLY_EDGES)
|
||||
}
|
||||
|
||||
/// Colour position (0 at the focus, 1 at the edge) of `p` for path `kind` in `bounds` with the
|
||||
/// focus rect `focus` (fractions inset from left, top, right, bottom), `outline` being the
|
||||
/// shape's flattened polygon(s) for `shape`.
|
||||
@@ -114,21 +124,31 @@ fn shape_position(outline: &[Vec<Point>], c: Point, p: Point) -> f64 {
|
||||
pub fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
|
||||
let mut out: Vec<Vec<Point>> = Vec::new();
|
||||
let mut cur: Vec<Point> = Vec::new();
|
||||
kurbo::flatten(path.iter(), 0.25, |el| match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
if cur.len() > 1 {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
cur = vec![p];
|
||||
let mut total = 0usize;
|
||||
kurbo::flatten(path.iter(), 0.25, |el| {
|
||||
total += 1;
|
||||
if total > MAX_POINTS {
|
||||
return;
|
||||
}
|
||||
PathEl::LineTo(p) => cur.push(p),
|
||||
PathEl::ClosePath => {
|
||||
if let Some(f) = cur.first().copied() {
|
||||
cur.push(f);
|
||||
match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
if cur.len() > 1 {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
cur = vec![p];
|
||||
}
|
||||
PathEl::LineTo(p) => cur.push(p),
|
||||
PathEl::ClosePath => {
|
||||
if let Some(f) = cur.first().copied() {
|
||||
cur.push(f);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
if total > MAX_POINTS {
|
||||
return Vec::new();
|
||||
}
|
||||
if cur.len() > 1 {
|
||||
out.push(cur);
|
||||
}
|
||||
@@ -146,7 +166,7 @@ pub fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
|
||||
/// `outline` cut down to at most about `max_edges` edges in total: first by dropping points
|
||||
/// closer than `tol` to the last kept one (detail finer than a raster pixel), then, if still too
|
||||
/// many, by keeping every k-th point. Each polygon stays closed. Returns it unchanged when it fits.
|
||||
fn simplify(outline: Vec<Vec<Point>>, tol: f64, max_edges: usize) -> Vec<Vec<Point>> {
|
||||
pub(crate) fn simplify(outline: Vec<Vec<Point>>, tol: f64, max_edges: usize) -> Vec<Vec<Point>> {
|
||||
let edges = |o: &[Vec<Point>]| o.iter().map(|p| p.len().saturating_sub(1)).sum::<usize>();
|
||||
if edges(&outline) <= max_edges {
|
||||
return outline;
|
||||
@@ -233,8 +253,7 @@ pub fn fill(ctx: &mut RenderContext, kind: &str, focus: [f64; 4], stops: &[(f64,
|
||||
let s = (MAX_SIDE / longest).min(1.0) * k;
|
||||
let (pw, ph) = (((bounds.width() * s).ceil() as u32).clamp(1, 4096), ((bounds.height() * s).ceil() as u32).clamp(1, 4096));
|
||||
let outline = if kind == "shape" {
|
||||
let pixels = u64::from(pw).saturating_mul(u64::from(ph)).max(1);
|
||||
let max_edges = usize::try_from(MAX_EDGE_WORK / pixels).unwrap_or(usize::MAX).max(MIN_POLY_EDGES);
|
||||
let max_edges = edge_budget(pw, ph);
|
||||
let pixel = (bounds.width() / f64::from(pw)).min(bounds.height() / f64::from(ph));
|
||||
simplify(polygons(path), pixel, max_edges)
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user