diff --git a/docs/superpowers/plans/2026-10-03-valw-phase-5b-editor.md b/docs/superpowers/plans/2026-10-03-valw-phase-5b-editor.md new file mode 100644 index 0000000..8a8c3ce --- /dev/null +++ b/docs/superpowers/plans/2026-10-03-valw-phase-5b-editor.md @@ -0,0 +1,844 @@ +# 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 /* width of each line */ }`; `text::path(text: &str, size: f32, origin: P) -> Option` (origin = top-left of the first line box); `text::line_height(size) -> f32`. + +- [ ] **Step 1: Failing tests** — `src/editor/text.rs`: + +```rust +#[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) + +```rust +//! 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> = + 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, +} + +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 { + 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 = 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) -> RgbaImage`; `export::crop(image: RgbaImage, rect: Option) -> 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: + +```rust + #[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: + +```rust + 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: + +```rust +/// 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 + +```rust + 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` and draws each shape with: + +```rust +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) -> 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, shapes() -> impl Iterator, 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::>()`): + +```rust + 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`: + +```rust + #[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` fields with `Vec`: + +```rust +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, + redo: Vec, + saved: Vec, + 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 `Item`s; `can_undo` = items non-empty; `is_dirty` = `items != saved`; `mark_saved` clones items; and + +```rust + pub fn shapes(&self) -> impl Iterator { + 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 { + 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`** + +```rust +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 { left, right, top, bottom: bool }`, `fn crop_edges(rect_screen: (f32, f32, f32, f32), p: P, tolerance: f32) -> Option`. + +- [ ] **Step 1: Failing test** — in `mod.rs` tests: + +```rust + #[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** + +```rust +/// 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 { + 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`, `cropping: Option` (rect in image px as x0, y0, x1, y1, unnormalised while dragging), plus the composite key becomes `shown: (u64, Option)` — the revision and the typed text — so the texture re-renders while typing. + +Behaviour, in `keys()` before everything else: + +```rust + 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. + +```rust + /// 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`: + +```markdown + +## 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`.