Files
valw/docs/superpowers/plans/2026-10-03-valw-phase-5b-editor.md
T

35 KiB
Raw Blame History

valw Phase 5b: editor text, pixelate, numbers, crop Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Text (T), Pixelate (B), Number (N) and Crop (C) in valw edit, exported exactly as shown.

Architecture: Shapes gain a text payload; Doc history entries become Item::{Shape, Crop}. A new editor/text.rs lays out the vendored DejaVu Sans Bold with ab_glyph and turns glyph outlines into tiny-skia paths. export::render draws text, numbers and pixelation itself (they have no egui-paintable geometry) and export::crop cuts the last crop out. The canvas keeps showing the export renderer's output (also for the text being typed); egui only paints in-progress drags, the caret and the crop overlay. Fit learns to fit a crop rectangle.

Tech Stack: Rust 2024, eframe/egui 0.36.2, tiny-skia 0.12, ab_glyph 0.2.32.

Spec: docs/superpowers/specs/2026-10-03-valw-phase-5b-editor-design.md

Global Constraints

  • jj only; cargo clippy --all-targets -- -D warnings, cargo fmt --check, cargo nextest run, nix flake check.
  • Sizes per S/M/L (index of WIDTHS): text 16/24/36 px, pixel block 8/12/20 px, number diameter 24/32/44 px (number glyphs 0.55 × diameter); line height 1.2 × size; text outline #1c1c1e alpha 204, width size/8; crop overlay 50 % black, 1 px white border, 8 screen px edge grab.
  • Keys: T text, B pixelate, N number, C crop (Ctrl+C stays Copy); while typing, letters are text and Ctrl shortcuts are ignored; Esc cancels typing, then a pending crop, then starts the close flow; Enter finishes typing or applies a pending crop.
  • Tell the user before opening editor windows on the live session.

Review Focus

  1. Turkish text and kerning survive export (no notdef boxes). Test: editor::text::tests::turkish_letters_have_glyphs (Task 1).
  2. Typing doesn't trigger shortcuts (T, A, Ctrl+Z, Esc closing the window). Real check in the checklist; key routing in Task 4.
  3. Pixelate covers earlier shapes and stays inside the image at edges. Tests: editor::export::tests::pixelate_* (Task 2).
  4. Crop + undo/redo + dirty and the export size/offset. Tests: editor::doc::tests::crops_*, editor::export::tests::crop_cuts_out_the_rectangle (Tasks 2–3).
  5. Drawing after a crop lands at the right image pixels. Test: editor::view::tests::round_trip_with_a_crop (Task 3).

File Structure

File Change Task
assets/fonts/DejaVuSans-Bold.ttf, assets/fonts/LICENSE-DejaVu vendored font 1
src/editor/text.rs layout, path, FONT 1
src/editor/shape.rs new tools, text field, size tables 2
src/editor/export.rs text, numbers, pixelate, crop 2
src/editor/doc.rs Item, crops, next_number 3
src/editor/view.rs Fit::with_crop 3
src/editor/mod.rs typing, numbers, crop UI, keys, toolbar 4
docs/test-checklist.md Phase 5b section 5

Task 1: Font and text layout

Files: create assets/fonts/DejaVuSans-Bold.ttf (from nixpkgs#dejavu_fonts, share/fonts/truetype/DejaVuSans-Bold.ttf) and assets/fonts/LICENSE-DejaVu (upstream LICENSE); Cargo.toml: ab_glyph = "0.2.32"; src/editor/mod.rs: pub mod text;; create src/editor/text.rs.

Produces: text::layout(text: &str, size: f32) -> Layout { glyphs: Vec<(GlyphId, P)>, width: f32, height: f32, lines: Vec<f32> /* width of each line */ }; text::path(text: &str, size: f32, origin: P) -> Option<tiny_skia::Path> (origin = top-left of the first line box); text::line_height(size) -> f32.

  • Step 1: Failing tests — src/editor/text.rs:
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_text_has_no_size() {
        let l = layout("", 24.0);
        assert_eq!((l.width, l.height), (0.0, 0.0));
        assert!(path("", 24.0, (0.0, 0.0)).is_none());
    }

    #[test]
    fn lines_and_widths() {
        let one = layout("Hello", 24.0);
        let longer = layout("Hello!", 24.0);
        assert!(longer.width > one.width && one.width > 0.0);
        assert_eq!(one.height, line_height(24.0));
        let two = layout("Hi\nthere", 24.0);
        assert_eq!(two.height, 2.0 * line_height(24.0));
        assert_eq!(two.lines.len(), 2);
        assert!(two.lines[1] > two.lines[0]);
        assert_eq!(two.width, two.lines[1]);
    }

    #[test]
    fn kerning_is_applied() {
        let font = FONT.as_scaled(PxScale::from(48.0));
        let (a, v) = (FONT.glyph_id('A'), FONT.glyph_id('V'));
        let plain = font.h_advance(a) + font.h_advance(v);
        assert!(font.kern(a, v) < 0.0, "DejaVu kerns AV");
        assert!(layout("AV", 48.0).width < plain);
    }

    #[test]
    fn turkish_letters_have_glyphs() {
        for c in "ğüşıöçĞÜŞİÖÇ".chars() {
            assert_ne!(FONT.glyph_id(c).0, 0, "{c} has no glyph");
        }
    }

    #[test]
    fn the_path_sits_in_the_line_box() {
        let p = path("Hg", 24.0, (100.0, 50.0)).unwrap();
        let b = p.bounds();
        assert!(b.left() >= 100.0 && b.top() >= 50.0, "{b:?}");
        assert!(b.bottom() <= 50.0 + line_height(24.0), "{b:?}");
    }
}

Run cargo nextest run editor::text → compile errors.

  • Step 2: Implement (above the tests)
//! Text for the editor: the bundled DejaVu Sans Bold, laid out with
//! ab_glyph and turned into tiny-skia paths, so canvas and export draw the
//! same glyphs.

use std::sync::LazyLock;

use ab_glyph::{Font, FontRef, GlyphId, OutlineCurve, PxScale, ScaleFont};
use tiny_skia::{Path, PathBuilder};

use crate::editor::shape::P;

static FONT_DATA: &[u8] = include_bytes!("../../assets/fonts/DejaVuSans-Bold.ttf");

pub static FONT: LazyLock<FontRef<'static>> =
    LazyLock::new(|| FontRef::try_from_slice(FONT_DATA).expect("the bundled font is valid"));

pub fn line_height(size: f32) -> f32 {
    size * 1.2
}

#[derive(Debug, Clone, PartialEq)]
pub struct Layout {
    /// Each glyph and its pen position (baseline) relative to the box's top-left.
    pub glyphs: Vec<(GlyphId, P)>,
    pub width: f32,
    pub height: f32,
    /// The width of every line.
    pub lines: Vec<f32>,
}

pub fn layout(text: &str, size: f32) -> Layout {
    if text.is_empty() {
        return Layout { glyphs: Vec::new(), width: 0.0, height: 0.0, lines: Vec::new() };
    }
    let font = FONT.as_scaled(PxScale::from(size));
    let mut glyphs = Vec::new();
    let mut lines = Vec::new();
    for (i, line) in text.split('\n').enumerate() {
        let baseline = i as f32 * line_height(size) + font.ascent();
        let mut x = 0.0;
        let mut previous = None;
        for c in line.chars() {
            let id = font.glyph_id(c);
            if let Some(p) = previous {
                x += font.kern(p, id);
            }
            glyphs.push((id, (x, baseline)));
            x += font.h_advance(id);
            previous = Some(id);
        }
        lines.push(x);
    }
    Layout {
        glyphs,
        width: lines.iter().copied().fold(0.0, f32::max),
        height: lines.len() as f32 * line_height(size),
        lines,
    }
}

/// The outlines of `text` with its line box's top-left at `origin`.
pub fn path(text: &str, size: f32, origin: P) -> Option<Path> {
    let layout = layout(text, size);
    let scale = FONT.as_scaled(PxScale::from(size)).scale_factor();
    let mut pb = PathBuilder::new();
    for (id, (gx, gy)) in layout.glyphs {
        let Some(outline) = FONT.outline(id) else { continue };
        // Font units, y up; the pen sits on the baseline.
        let at = |p: ab_glyph::Point| {
            (origin.0 + gx + p.x * scale.horizontal, origin.1 + gy - p.y * scale.vertical)
        };
        let mut end: Option<ab_glyph::Point> = None;
        for curve in &outline.curves {
            let start = match curve {
                OutlineCurve::Line(a, _) | OutlineCurve::Quad(a, _, _) | OutlineCurve::Cubic(a, _, _, _) => *a,
            };
            if end != Some(start) {
                if end.is_some() {
                    pb.close();
                }
                let (x, y) = at(start);
                pb.move_to(x, y);
            }
            match curve {
                OutlineCurve::Line(_, b) => {
                    let (x, y) = at(*b);
                    pb.line_to(x, y);
                    end = Some(*b);
                }
                OutlineCurve::Quad(_, c, b) => {
                    let ((cx, cy), (x, y)) = (at(*c), at(*b));
                    pb.quad_to(cx, cy, x, y);
                    end = Some(*b);
                }
                OutlineCurve::Cubic(_, c1, c2, b) => {
                    let ((ax, ay), (bx, by), (x, y)) = (at(*c1), at(*c2), at(*b));
                    pb.cubic_to(ax, ay, bx, by, x, y);
                    end = Some(*b);
                }
            }
        }
        if end.is_some() {
            pb.close();
        }
    }
    pb.finish()
}

(If ab_glyph's outline y axis turns out to point down, the_path_sits_in_the_line_box fails: flip the sign and ledger it.)

  • Step 3: Run (+5), clippy, fmt. Step 4: Commit feat: bundled font and text layout for the editor.

Task 2: New tools in shapes and export

Files: src/editor/shape.rs, src/editor/export.rs.

Produces: Tool::{Text, Pixelate, Number, Crop} (in Tool::ALL after Highlighter; labels "Text", "Pixelate", "Number", "Crop"); Shape { tool, style, points, text: String }; shape::{size_index, text_size, block_size, number_diameter} (all from Style::width); export::render<'a>(base, shapes: impl IntoIterator<Item = &'a Shape>) -> RgbaImage; export::crop(image: RgbaImage, rect: Option<PixelRect>) -> RgbaImage.

Shape semantics: Text: points = [top-left], text = the text. Number: points = [centre], text = its number. Pixelate: points = [start, end] (Shift: square, like Rectangle). Crop never becomes a Shape (it is a Doc item, Task 3); Drag is never created for Text, Number or Crop. geometry() returns nothing for Text and Number, and for Pixelate a 2 px closed white outline (alpha 200, butt) used only while dragging. is_click() is false for Text and Number.

  • Step 1: Failing tests

Add text: String::new() to every Shape { .. } literal in existing tests (shape, doc, export tests). Then in shape.rs tests:

    #[test]
    fn sizes_follow_s_m_l() {
        let at = |w: f32| Style { color: [0, 0, 0], width: w };
        assert_eq!([2.0, 4.0, 8.0].map(|w| text_size(at(w).width)), [16.0, 24.0, 36.0]);
        assert_eq!([2.0, 4.0, 8.0].map(|w| block_size(at(w).width)), [8, 12, 20]);
        assert_eq!([2.0, 4.0, 8.0].map(|w| number_diameter(at(w).width)), [24.0, 32.0, 44.0]);
    }

    #[test]
    fn pixelate_drags_like_a_rectangle() {
        let mut d = Drag::new(Tool::Pixelate, RED, (0.0, 0.0));
        d.move_to((30.0, 10.0));
        assert_eq!(d.shape(true).points, vec![(0.0, 0.0), (30.0, 30.0)]);
        let g = geometry(&d.shape(false));
        let Prim::Stroke { color, closed, .. } = &g[0] else { panic!("{g:?}") };
        assert!(*closed && *color == [255, 255, 255, 200]);
    }

    #[test]
    fn text_and_numbers_are_never_clicks_and_have_no_egui_geometry() {
        for tool in [Tool::Text, Tool::Number] {
            let s = Shape { tool, style: RED, points: vec![(5.0, 5.0)], text: "1".into() };
            assert!(!s.is_click());
            assert!(geometry(&s).is_empty());
        }
    }

export.rs tests:

    fn white(w: u32, h: u32) -> RgbaImage {
        RgbaImage::from_pixel(w, h, Rgba([255, 255, 255, 255]))
    }

    #[test]
    fn text_is_filled_with_the_colour_and_outlined() {
        let t = Shape {
            tool: Tool::Text,
            style: Style { color: [0, 122, 255], width: 8.0 },
            points: vec![(10.0, 10.0)],
            text: "I".into(),
        };
        let out = render(&white(80, 60), &[t]);
        // The middle of the I's stem: x ≈ 10 + stem centre, y in the cap height.
        let blue = out.pixels().filter(|p| p.0 == [0, 122, 255, 255]).count();
        assert!(blue > 40, "{blue} fully blue pixels");
        let dark = out.pixels().filter(|p| p.0[0] < 80 && p.0[2] < 80).count();
        assert!(dark > 10, "the outline: {dark}");
        assert_eq!(out.get_pixel(70, 55).0, [255, 255, 255, 255]);
    }

    #[test]
    fn a_number_is_a_filled_circle_with_a_white_digit() {
        let n = Shape {
            tool: Tool::Number,
            style: Style { color: [255, 0, 0], width: 8.0 },
            points: vec![(30.0, 30.0)],
            text: "1".into(),
        };
        let out = render(&white(60, 60), &[n]);
        assert_eq!(out.get_pixel(30, 10).0, [255, 0, 0, 255], "inside the 44 px circle, above the digit");
        assert_eq!(out.get_pixel(3, 3).0, [255, 255, 255, 255], "outside it");
        let whites_inside = (20..40).flat_map(|x| (20..40).map(move |y| (x, y)))
            .filter(|&(x, y)| out.get_pixel(x, y).0 == [255, 255, 255, 255])
            .count();
        assert!(whites_inside > 10, "the digit: {whites_inside}");
    }

    #[test]
    fn pixelate_averages_blocks_including_earlier_shapes() {
        let base = RgbaImage::from_fn(24, 12, |x, _| if x % 2 == 0 { Rgba([0, 0, 0, 255]) } else { Rgba([200, 100, 50, 255]) });
        let px = Shape {
            tool: Tool::Pixelate,
            style: Style { color: [0, 0, 0], width: 4.0 },
            points: vec![(0.0, 0.0), (24.0, 12.0)],
            text: String::new(),
        };
        let out = render(&base, &[px.clone()]);
        let first = out.get_pixel(0, 0).0;
        for x in 0..12 {
            for y in 0..12 {
                assert_eq!(out.get_pixel(x, y).0, first);
            }
        }
        for (got, want) in first.iter().zip([100, 50, 25, 255]) {
            assert!((*got as i32 - want).abs() <= 1, "{first:?}");
        }
        let red = Shape {
            tool: Tool::Line,
            style: Style { color: [255, 0, 0], width: 8.0 },
            points: vec![(0.0, 6.0), (24.0, 6.0)],
            text: String::new(),
        };
        let both = render(&base, &[red, px]);
        assert!(both.get_pixel(0, 0).0[0] > first[0], "the line is pixelated in");
    }

    #[test]
    fn pixelate_stays_inside_the_image() {
        let px = Shape {
            tool: Tool::Pixelate,
            style: Style { color: [0, 0, 0], width: 4.0 },
            points: vec![(-10.0, -10.0), (500.0, 500.0)],
            text: String::new(),
        };
        let out = render(&white(30, 20), &[px]);
        assert_eq!(out.dimensions(), (30, 20));
        assert_eq!(out.get_pixel(29, 19).0, [255, 255, 255, 255]);
    }

    #[test]
    fn crop_cuts_out_the_rectangle() {
        let img = RgbaImage::from_fn(10, 8, |x, y| Rgba([x as u8, y as u8, 0, 255]));
        let rect = PixelRect { x: 2, y: 3, width: 4, height: 2 };
        let out = crop(img.clone(), Some(rect));
        assert_eq!(out.dimensions(), (4, 2));
        assert_eq!(out.get_pixel(0, 0).0, [2, 3, 0, 255]);
        assert_eq!(crop(img.clone(), None), img);
    }

(Add use crate::frame::PixelRect; in the test module if not covered by super::*.)

Run → compile errors.

  • Step 2: Implement shape.rs changes

  • Tool gains Text, Pixelate, Number, Crop; ALL becomes 10 entries; labels as above.

  • Shape gains pub text: String; Drag::shape sets text: String::new().

  • constrain: Tool::Rectangle | Tool::Ellipse | Tool::Pixelate share the square branch; Tool::Pen | Tool::Highlighter | Tool::Text | Tool::Number | Tool::Crop => end.

  • is_click: if matches!(self.tool, Tool::Pen | Tool::Text | Tool::Number) { return false; }.

  • Size tables:

/// 0, 1, 2 for S, M, L.
pub fn size_index(width: f32) -> usize {
    WIDTHS.iter().position(|w| w.1 == width).unwrap_or(1)
}

pub fn text_size(width: f32) -> f32 {
    [16.0, 24.0, 36.0][size_index(width)]
}

pub fn block_size(width: f32) -> u32 {
    [8, 12, 20][size_index(width)]
}

pub fn number_diameter(width: f32) -> f32 {
    [24.0, 32.0, 44.0][size_index(width)]
}
  • geometry: Tool::Text | Tool::Number | Tool::Crop => Vec::new(), and
        Tool::Pixelate => {
            let ((x0, y0), (x1, y1)) = (pts[0], pts[1]);
            vec![Prim::Stroke {
                points: vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1)],
                width: 2.0,
                color: [255, 255, 255, 200],
                closed: true,
                round: false,
            }]
        }
  • Step 3: Implement export.rs changes

render takes shapes: impl IntoIterator<Item = &'a Shape> and draws each shape with:

fn draw_shape(pixmap: &mut Pixmap, shape: &Shape) {
    let [r, g, b] = shape.style.color;
    match shape.tool {
        Tool::Text => draw_text(pixmap, &shape.text, text_size(shape.style.width), shape.points[0], [r, g, b, 255], true),
        Tool::Number => {
            let d = number_diameter(shape.style.width);
            let (cx, cy) = shape.points[0];
            if let Some(circle) = PathBuilder::from_circle(cx, cy, d / 2.0) {
                pixmap.fill_path(&circle, &paint([r, g, b, 255]), FillRule::Winding, Transform::identity(), None);
            }
            let size = d * 0.55;
            if let Some(glyphs) = text::path(&shape.text, size, (0.0, 0.0)) {
                // Centre the digits' ink, not their line box.
                let b = glyphs.bounds();
                let shift = Transform::from_translate(cx - (b.left() + b.right()) / 2.0, cy - (b.top() + b.bottom()) / 2.0);
                if let Some(glyphs) = glyphs.transform(shift) {
                    pixmap.fill_path(&glyphs, &paint([255, 255, 255, 255]), FillRule::Winding, Transform::identity(), None);
                }
            }
        }
        Tool::Pixelate => pixelate(pixmap, shape.points[0], shape.points[1], block_size(shape.style.width)),
        _ => {
            for prim in geometry(shape) {
                draw(pixmap, &prim);
            }
        }
    }
}

fn paint(c: [u8; 4]) -> Paint<'static> {
    let mut paint = Paint::default();
    paint.set_color_rgba8(c[0], c[1], c[2], c[3]);
    paint.anti_alias = true;
    paint
}

const OUTLINE: [u8; 4] = [0x1c, 0x1c, 0x1e, 204];

/// `text` with its line box at `origin`; `outlined` adds the dark outline
/// that keeps it readable on any background.
fn draw_text(pixmap: &mut Pixmap, text: &str, size: f32, origin: P, color: [u8; 4], outlined: bool) {
    let Some(path) = text::path(text, size, origin) else { return };
    if outlined {
        let stroke = Stroke { width: size / 8.0, line_join: LineJoin::Round, ..Stroke::default() };
        pixmap.stroke_path(&path, &paint(OUTLINE), &stroke, Transform::identity(), None);
    }
    pixmap.fill_path(&path, &paint(color), FillRule::Winding, Transform::identity(), None);
}

/// Replaces each `block`-sized square of the rectangle (aligned to its
/// top-left, clipped to the image) with its average colour.
fn pixelate(pixmap: &mut Pixmap, a: P, b: P, block: u32) {
    let (w, h) = (pixmap.width(), pixmap.height());
    let clamp = |v: f32, max: u32| v.round().clamp(0.0, max as f32) as u32;
    let (x0, x1) = (clamp(a.0.min(b.0), w), clamp(a.0.max(b.0), w));
    let (y0, y1) = (clamp(a.1.min(b.1), h), clamp(a.1.max(b.1), h));
    let pixels = pixmap.pixels_mut();
    for by in (y0..y1).step_by(block as usize) {
        for bx in (x0..x1).step_by(block as usize) {
            let (ex, ey) = ((bx + block).min(x1), (by + block).min(y1));
            let mut sum = [0u32; 4];
            for y in by..ey {
                for x in bx..ex {
                    let p = pixels[(y * w + x) as usize];
                    for (s, v) in sum.iter_mut().zip([p.red(), p.green(), p.blue(), p.alpha()]) {
                        *s += v as u32;
                    }
                }
            }
            let n = (ex - bx) * (ey - by);
            let [r, g, bl, al] = sum.map(|s| ((s + n / 2) / n) as u8);
            // Premultiplied channels average to valid premultiplied values.
            let avg = tiny_skia::PremultipliedColorU8::from_rgba(r.min(al), g.min(al), bl.min(al), al)
                .expect("channels never exceed alpha");
            for y in by..ey {
                for x in bx..ex {
                    pixels[(y * w + x) as usize] = avg;
                }
            }
        }
    }
}

/// The part of `image` inside the last crop, or all of it.
pub fn crop(image: RgbaImage, rect: Option<PixelRect>) -> RgbaImage {
    match rect {
        Some(r) => image::imageops::crop_imm(&image, r.x, r.y, r.width, r.height).to_image(),
        None => image,
    }
}

draw (the existing primitive painter) keeps using its own Paint; switch it to paint(*color) to share code. Imports: crate::editor::shape::{P, Tool, block_size, number_diameter, text_size}, crate::editor::text, crate::frame::PixelRect.

  • Step 4: Run (all pass, +8), clippy, fmt. Step 5: Commit feat: text, numbers and pixelate in the editor export.

Task 3: History items, crops and fitting

Files: src/editor/doc.rs, src/editor/view.rs, src/editor/mod.rs (call sites only).

Produces: doc::Item::{Shape(Shape), Crop(PixelRect)}; Doc::{add_crop(PixelRect), crop() -> Option<PixelRect>, shapes() -> impl Iterator<Item = &Shape>, next_number() -> u32} (the rest unchanged); view::Fit::with_crop(crop: PixelRect, area, pixels_per_point) -> Fit (and Fit::new = with_crop over the whole image); Fit gains origin: P (the crop's top-left in image px), used by to_screen/to_image.

  • Step 1: Failing tests

doc.rs (the existing tests use d.shapes() as a slice: change those asserts to d.shapes().cloned().collect::<Vec<_>>()):

    fn number(n: &str) -> Shape {
        Shape { tool: Tool::Number, style: Style { color: [0, 0, 0], width: 4.0 }, points: vec![(1.0, 1.0)], text: n.into() }
    }

    const R: PixelRect = PixelRect { x: 1, y: 2, width: 30, height: 20 };

    #[test]
    fn numbers_count_only_numbers_and_follow_undo() {
        let mut d = Doc::new();
        assert_eq!(d.next_number(), 1);
        d.add(number("1"));
        d.add(line(5.0));
        d.add(number("2"));
        assert_eq!(d.next_number(), 3);
        d.undo();
        assert_eq!(d.next_number(), 2);
    }

    #[test]
    fn crops_are_undoable_and_nest() {
        let mut d = Doc::new();
        assert_eq!(d.crop(), None);
        d.add_crop(R);
        let inner = PixelRect { x: 5, y: 5, width: 10, height: 10 };
        d.add_crop(inner);
        assert_eq!(d.crop(), Some(inner));
        d.undo();
        assert_eq!(d.crop(), Some(R));
        d.undo();
        assert_eq!(d.crop(), None);
        d.redo();
        assert_eq!(d.crop(), Some(R));
    }

    #[test]
    fn crops_make_the_doc_dirty_and_skip_shapes() {
        let mut d = Doc::new();
        d.add(line(1.0));
        d.mark_saved();
        d.add_crop(R);
        assert!(d.is_dirty());
        assert_eq!(d.shapes().count(), 1);
    }

view.rs:

    #[test]
    fn a_crop_fills_the_area() {
        let crop = PixelRect { x: 100, y: 50, width: 200, height: 100 };
        let f = Fit::with_crop(crop, (0.0, 0.0, 400.0, 400.0), 1.0);
        assert_eq!(f.scale, 1.0);
        assert_eq!(f.to_screen((100.0, 50.0)), (100.0, 150.0), "the crop's corner is centred");
    }

    #[test]
    fn round_trip_with_a_crop() {
        let crop = PixelRect { x: 640, y: 360, width: 640, height: 360 };
        let f = Fit::with_crop(crop, (0.0, 30.0, 500.0, 400.0), 1.0);
        for p in [(640.0, 360.0), (1279.0, 719.0), (800.5, 400.25)] {
            let q = f.to_image(f.to_screen(p));
            assert!((q.0 - p.0).abs() < 1e-3 && (q.1 - p.1).abs() < 1e-3, "{p:?} {q:?}");
        }
    }

Run → compile errors.

  • Step 2: Implement doc.rs

Replace the three Vec<Shape> fields with Vec<Item>:

use crate::editor::shape::{Shape, Tool};
use crate::frame::PixelRect;

/// One step of the history.
#[derive(Debug, Clone, PartialEq)]
pub enum Item {
    Shape(Shape),
    /// Show and export only this part of the image (image pixels).
    Crop(PixelRect),
}

#[derive(Debug, Default)]
pub struct Doc {
    items: Vec<Item>,
    redo: Vec<Item>,
    saved: Vec<Item>,
    revision: u64,
}

add(shape) pushes Item::Shape; add_crop(rect) pushes Item::Crop (both through a private push(item) that clears redo and bumps the revision); undo/redo move Items; can_undo = items non-empty; is_dirty = items != saved; mark_saved clones items; and

    pub fn shapes(&self) -> impl Iterator<Item = &Shape> {
        self.items.iter().filter_map(|i| match i {
            Item::Shape(s) => Some(s),
            Item::Crop(_) => None,
        })
    }

    /// The last crop, if any.
    pub fn crop(&self) -> Option<PixelRect> {
        self.items.iter().rev().find_map(|i| match i {
            Item::Crop(r) => Some(*r),
            Item::Shape(_) => None,
        })
    }

    pub fn next_number(&self) -> u32 {
        1 + self.shapes().filter(|s| s.tool == Tool::Number).count() as u32
    }
  • Step 3: Implement view.rs
use crate::frame::PixelRect;

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Fit {
    /// Points per image pixel.
    pub scale: f32,
    /// Where `origin` is drawn, in points.
    pub offset: P,
    /// The image pixel at the top-left of what is shown (the crop's corner).
    pub origin: P,
}

impl Fit {
    pub fn new(image: (u32, u32), area: (f32, f32, f32, f32), pixels_per_point: f32) -> Fit {
        Fit::with_crop(PixelRect { x: 0, y: 0, width: image.0, height: image.1 }, area, pixels_per_point)
    }

    /// Fits `crop` into `area` (x, y, w, h in points), centred, never above
    /// 100 % (one image pixel per physical pixel).
    pub fn with_crop(crop: PixelRect, area: (f32, f32, f32, f32), pixels_per_point: f32) -> Fit {
        // ... the existing body with iw/ih = crop.width/height ...
        Fit { scale, offset: (snap(..), snap(..)), origin: (crop.x as f32, crop.y as f32) }
    }

    pub fn to_screen(self, p: P) -> P {
        (self.offset.0 + (p.0 - self.origin.0) * self.scale, self.offset.1 + (p.1 - self.origin.1) * self.scale)
    }

    pub fn to_image(self, p: P) -> P {
        ((p.0 - self.offset.0) / self.scale + self.origin.0, (p.1 - self.offset.1) / self.scale + self.origin.1)
    }
}

/// `p` moved into `rect`.
pub fn clamp(p: P, rect: PixelRect) -> P {
    (
        p.0.clamp(rect.x as f32, (rect.x + rect.width) as f32),
        p.1.clamp(rect.y as f32, (rect.y + rect.height) as f32),
    )
}

Update clamp_keeps_points_on_the_image to pass a PixelRect { x: 0, y: 0, width: 100, height: 80 }.

  • Step 4: Call sites in mod.rs (minimal, so it compiles; the UI comes in Task 4): self.doc.shapes() is now an iterator (export::render takes iterators); png() = export::crop(export::render(&self.base, self.doc.shapes()), self.doc.crop()); the canvas uses let shown = self.doc.crop().unwrap_or(full), Fit::with_crop(shown, …), the image rect from to_pos of the crop's corners with UVs crop / image size, and clamps the drag to shown.

  • Step 5: Run (all pass, +5), clippy, fmt. Step 6: Commit feat: crop history and fitting in the editor.


Task 4: The editor UI for the new tools

Files: src/editor/mod.rs.

Produces: private Typing { at: P, text: String }, CropEdit { rect: (f32, f32, f32, f32), grab: Option<Edges> }, Edges { left, right, top, bottom: bool }, fn crop_edges(rect_screen: (f32, f32, f32, f32), p: P, tolerance: f32) -> Option<Edges>.

  • Step 1: Failing test — in mod.rs tests:
    #[test]
    fn crop_edges_near_edges_and_corners() {
        let r = (100.0, 100.0, 300.0, 200.0);
        let e = |l, r_, t, b| Some(Edges { left: l, right: r_, top: t, bottom: b });
        assert_eq!(crop_edges(r, (102.0, 150.0), 8.0), e(true, false, false, false));
        assert_eq!(crop_edges(r, (295.0, 150.0), 8.0), e(false, true, false, false));
        assert_eq!(crop_edges(r, (200.0, 205.0), 8.0), e(false, false, false, true));
        assert_eq!(crop_edges(r, (99.0, 101.0), 8.0), e(true, false, true, false));
        assert_eq!(crop_edges(r, (200.0, 150.0), 8.0), None, "inside");
        assert_eq!(crop_edges(r, (50.0, 150.0), 8.0), None, "outside");
    }

Run → compile error.

  • Step 2: Implement
/// Which edges of the crop rectangle a press grabs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Edges {
    left: bool,
    right: bool,
    top: bool,
    bottom: bool,
}

/// The edges of `r` (x0, y0, x1, y1 on screen) within `tolerance` of `p`;
/// `None` inside, outside, or far from every edge.
fn crop_edges(r: (f32, f32, f32, f32), p: P, tolerance: f32) -> Option<Edges> {
    let (x0, y0, x1, y1) = r;
    let within_x = p.0 > x0 - tolerance && p.0 < x1 + tolerance;
    let within_y = p.1 > y0 - tolerance && p.1 < y1 + tolerance;
    let e = Edges {
        left: within_y && (p.0 - x0).abs() <= tolerance,
        right: within_y && (p.0 - x1).abs() <= tolerance,
        top: within_x && (p.1 - y0).abs() <= tolerance,
        bottom: within_x && (p.1 - y1).abs() <= tolerance,
    };
    (e.left || e.right || e.top || e.bottom).then_some(e)
}

Editor fields: typing: Option<Typing>, cropping: Option<CropEdit> (rect in image px as x0, y0, x1, y1, unnormalised while dragging), plus the composite key becomes shown: (u64, Option<String>) — the revision and the typed text — so the texture re-renders while typing.

Behaviour, in keys() before everything else:

        if self.typing.is_some() {
            self.type_keys(ctx);
            return;
        }
        if pressed(Modifiers::NONE, Key::Enter) && self.cropping.is_some() {
            self.apply_crop();
        }
        // Esc: a pending crop first, then the close flow.
        if self.cropping.is_some() && pressed(Modifiers::NONE, Key::Escape) {
            self.cropping = None;
        }

and the tool-key table gains (Key::T, Tool::Text), (Key::B, Tool::Pixelate), (Key::N, Tool::Number), (Key::C, Tool::Crop) (after the Ctrl+C check, which consumes Ctrl+C first). Changing tool (keys or toolbar) goes through fn set_tool(&mut self, tool) which finishes typing and drops a pending crop.

    /// While typing every key edits the text; nothing else reacts.
    fn type_keys(&mut self, ctx: &egui::Context) {
        let events = ctx.input_mut(|i| std::mem::take(&mut i.events));
        for event in events {
            match event {
                egui::Event::Text(s) => {
                    if let Some(t) = &mut self.typing {
                        t.text.extend(s.chars().filter(|c| !c.is_control()));
                    }
                }
                egui::Event::Key { key, pressed: true, modifiers, .. } => match key {
                    Key::Backspace => {
                        if let Some(t) = &mut self.typing {
                            t.text.pop();
                        }
                    }
                    Key::Enter if modifiers.shift => {
                        if let Some(t) = &mut self.typing {
                            t.text.push('\n');
                        }
                    }
                    Key::Enter => self.finish_typing(),
                    Key::Escape => self.typing = None,
                    _ => {}
                },
                _ => {}
            }
        }
    }

    fn finish_typing(&mut self) {
        if let Some(t) = self.typing.take()
            && !t.text.trim().is_empty()
        {
            self.doc.add(Shape { tool: Tool::Text, style: self.style(), points: vec![t.at], text: t.text });
        }
    }

    fn apply_crop(&mut self) {
        if let Some(c) = self.cropping.take() {
            let (x0, y0, x1, y1) = c.rect;
            let (x0, x1, y0, y1) = (x0.min(x1).floor(), x0.max(x1).ceil(), y0.min(y1).floor(), y0.max(y1).ceil());
            if x1 - x0 >= 2.0 && y1 - y0 >= 2.0 {
                self.doc.add_crop(PixelRect { x: x0 as u32, y: y0 as u32, width: (x1 - x0) as u32, height: (y1 - y0) as u32 });
            }
        }
    }

In canvas(), after the pointer is mapped (p = image point clamped to the shown rect):

  • On drag_started: if typing is some → finish_typing() and stop (the press only finishes the text). Otherwise by tool: Text → typing = Some(Typing { at: p, text: String::new() }); Number → doc.add(Shape { tool: Number, style, points: vec![p], text: doc.next_number().to_string() }); Crop → if cropping is some and crop_edges(screen rect, pointer, 8.0) hits → set its grab; else cropping = Some(CropEdit { rect: (p.0, p.1, p.0, p.1), grab: None }) (a new rectangle, its far corner follows the pointer); everything else → Drag::new as in 5a.

  • While dragging with Crop: with grab move the grabbed edges to p; without, move the far corner (x1, y1). On drag_stopped with Crop: clear grab; a rectangle under 2 px is dropped.

  • The Esc-during-drag minor from 5a stays deferred.

  • Texture key: (revision, typing text); when it changes, render doc.shapes() plus the typed text as a temporary Tool::Text shape into the texture.

  • Overlays painted with egui on top of the texture: the caret while typing (a 1.5 px line in the text colour at at + (last line width, (lines − 1) × line height), line height tall; visible when (time × 2) as i64 % 2 == 0, request_repaint_after(500 ms)), and with a pending crop the darkened outside (four rect_filled with Color32::from_black_alpha(128)) plus a 1 px white rect_stroke. Hovering a crop edge sets the cursor icon (ResizeHorizontal, ResizeVertical, ResizeNwSe, ResizeNeSw).

  • Step 3: Run (all pass, +1), clippy, fmt.

  • Step 4: Smoke run (tell the user first): open valw edit on a generated image for a few seconds; the toolbar shows the 10 tools; no errors in the log.

  • Step 5: Commit feat: text, numbers, pixelate and crop in the editor.


Task 5: Checks and checklist

  • nix flake check -L --keep-going passes.
  • Append to docs/test-checklist.md:

## Editor tools (Phase 5b)

- [ ] T: click, type (Turkish: ğüşıöçİ), Shift+Enter for a new line, Enter finishes; a click elsewhere also finishes; Esc drops it; while typing, letters don't switch tools and Ctrl+Z/Esc don't undo or close.
- [ ] Text is bold in the chosen colour with a dark outline, readable on light and dark backgrounds; S/M/L sizes; the saved PNG matches the canvas.
- [ ] B: dragging pixelates the area (also earlier arrows inside it); S/M/L block sizes; edges of the image are fine.
- [ ] N: clicks place 1, 2, 3 …; undo then a new click reuses the freed number.
- [ ] C: drag a crop, adjust its edges and corners (the cursor changes), Enter applies, Esc cancels; the canvas then shows only the crop; drawing after a crop lands where you click; undo restores the full image; the saved PNG has the crop's size.
- [ ] The toolbar shows all 10 tools and still wraps in a narrow window.
  • Commit docs: editor tools checklist.