46 KiB
valw Phase 5a: built-in editor basics 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: valw edit <file>: a Markup-like window with arrow/rect/ellipse/line/pen/highlighter, colours, widths, undo/redo, save/copy; the preview opens it instead of Satty.
Architecture: Pure modules first (editor/shape.rs tools, constraints and shared geometry; editor/doc.rs history; editor/view.rs fitting; editor/export.rs tiny-skia rendering), then the eframe (glow) app in editor/mod.rs. Copy goes through a hidden valw __clipboard child (no fork inside a multithreaded GUI process). Runtime libraries come from the binary's RUNPATH instead of LD_LIBRARY_PATH.
Tech Stack: Rust 2024, eframe/egui 0.36.2 (glow, wayland, default_fonts; no wgpu, no x11), tiny-skia 0.12.
Spec: docs/superpowers/specs/2026-10-02-valw-phase-5a-editor-design.md
Global Constraints
- jj only;
cargo clippy -- -D warnings,cargo fmt --check,cargo nextest run,nix flake check. - No Vulkan, no wgpu. App id
valw-editor; title<file name> — valw. - Colours
#ff3b30(default)#ff9500#ffcc00#34c759#007affblack white; widths 2/4/8 image px (default 4); highlighter = colour at 40 % opacity (alpha 102), width × 4; arrow head lengthmax(12, 4 × width), 30° half-angle; clicks under 2 image px are discarded except Pen (dot). - Status messages last 2 s. Ctrl+S overwrites; Ctrl+Shift+S writes
<stem> edited.png(unique). - Tell the user before opening editor windows on the live session.
Review Focus
- Export ≠ screen: shapes must land at the same image pixels in the saved PNG as on the canvas, at any window size. Tests:
editor::view::tests::round_trip,editor::export::tests::line_pixels(Tasks 2–3). - Images with alpha (window shots from Phase 3): export must keep transparency, not turn it black or premultiply twice. Test:
editor::export::tests::keeps_transparency(Task 2). - Unsaved changes on close (Esc or the compositor's close): never lose edits silently. Test:
editor::doc::tests::*dirty*(Task 1); real check in the checklist. - Undo/redo after save: dirty must track content, not a counter. Test:
editor::doc::tests::undo_past_a_save_is_dirty(Task 1). - The editor closes while the clipboard must live on: Ctrl+C then Esc must keep the copied image available. Covered by the
__clipboardchild (Task 4), checked in the checklist.
File Structure
| File | Change | Task |
|---|---|---|
src/editor/shape.rs |
Tool, Style, Shape, Drag, Prim, constrain, geometry, palette/widths |
1 |
src/editor/doc.rs |
Doc |
1 |
src/editor/view.rs |
Fit |
2 |
src/editor/export.rs |
render, edited_path |
2 |
src/config.rs |
[editor] backend |
3 |
src/host.rs |
editor_command, open_editor |
3 |
src/output.rs, src/main.rs |
serve_clipboard, __clipboard |
4 |
src/editor/mod.rs |
eframe app | 4 |
Cargo.toml, nix/package.ulu.nix, nix/devshell.ulu.nix |
deps, RUNPATH, dev LD_LIBRARY_PATH |
4 |
docs/test-checklist.md |
Phase 5a section | 5 |
Task 1: Shapes, geometry and history
Files: create src/editor/mod.rs (pub mod doc; pub mod shape; only), src/editor/shape.rs, src/editor/doc.rs; src/main.rs: mod editor; + // The editor lands in pieces; Task 4 removes this. #![allow(dead_code)].
Produces: shape::{Tool, Style, Shape, Drag, Prim, PALETTE, WIDTHS, constrain, geometry}, Shape::is_click, doc::Doc::{new, add, undo, redo, can_undo, can_redo, is_dirty, mark_saved, shapes}.
- Step 1: Failing tests —
src/editor/shape.rs(tests only):
#[cfg(test)]
mod tests {
use super::*;
const RED: Style = Style { color: [255, 59, 48], width: 4.0 };
fn shape(tool: Tool, points: &[(f32, f32)]) -> Shape {
Shape { tool, style: RED, points: points.to_vec() }
}
fn close(a: (f32, f32), b: (f32, f32)) -> bool {
(a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3
}
#[test]
fn shift_snaps_lines_to_45_degrees() {
let end = constrain(Tool::Line, (0.0, 0.0), (100.0, 10.0), true);
assert!(close(end, ((100f32.hypot(10.0)), 0.0)), "{end:?}");
let end = constrain(Tool::Arrow, (0.0, 0.0), (50.0, 45.0), true);
let d = 50f32.hypot(45.0) / 2f32.sqrt();
assert!(close(end, (d, d)), "{end:?}");
assert_eq!(constrain(Tool::Line, (0.0, 0.0), (100.0, 10.0), false), (100.0, 10.0));
}
#[test]
fn shift_makes_squares_and_circles() {
assert_eq!(constrain(Tool::Rectangle, (10.0, 10.0), (40.0, 20.0), true), (40.0, 40.0));
assert_eq!(constrain(Tool::Ellipse, (10.0, 10.0), (0.0, 60.0), true), (-40.0, 60.0));
}
#[test]
fn clicks_are_discarded_but_a_pen_dot_is_kept() {
assert!(shape(Tool::Arrow, &[(5.0, 5.0), (6.0, 6.0)]).is_click());
assert!(!shape(Tool::Arrow, &[(5.0, 5.0), (8.0, 5.0)]).is_click());
assert!(shape(Tool::Highlighter, &[(5.0, 5.0), (5.5, 5.0)]).is_click());
assert!(!shape(Tool::Pen, &[(5.0, 5.0)]).is_click());
}
#[test]
fn arrow_has_a_line_and_a_head_at_the_end() {
let g = geometry(&shape(Tool::Arrow, &[(0.0, 0.0), (100.0, 0.0)]));
assert_eq!(g.len(), 2);
let Prim::Stroke { points, width, .. } = &g[0] else { panic!("{g:?}") };
assert_eq!(*width, 4.0);
assert!(close(points[0], (0.0, 0.0)) && close(points[1], (84.0, 0.0)), "{points:?}");
let Prim::Fill { points, .. } = &g[1] else { panic!("{g:?}") };
// Head length max(12, 4 × 4) = 16, half-width 16 × tan 30°.
let half = 16.0 * (30f32).to_radians().tan();
assert!(close(points[0], (100.0, 0.0)), "{points:?}");
assert!(close(points[1], (84.0, half)) && close(points[2], (84.0, -half)), "{points:?}");
}
#[test]
fn highlighter_is_wide_and_translucent() {
let g = geometry(&shape(Tool::Highlighter, &[(0.0, 0.0), (50.0, 0.0)]));
let Prim::Stroke { width, color, round, .. } = &g[0] else { panic!("{g:?}") };
assert_eq!(*width, 16.0);
assert_eq!(*color, [255, 59, 48, 102]);
assert!(!round, "butt caps: overlapping caps would darken");
}
#[test]
fn rectangle_and_ellipse_are_closed_outlines() {
let g = geometry(&shape(Tool::Rectangle, &[(10.0, 10.0), (0.0, 20.0)]));
let Prim::Stroke { points, closed, .. } = &g[0] else { panic!("{g:?}") };
assert!(*closed);
assert_eq!(points, &[(10.0, 10.0), (0.0, 10.0), (0.0, 20.0), (10.0, 20.0)]);
let g = geometry(&shape(Tool::Ellipse, &[(0.0, 0.0), (20.0, 10.0)]));
let Prim::Stroke { points, closed, .. } = &g[0] else { panic!("{g:?}") };
assert!(*closed && points.len() == 72);
assert!(points.iter().all(|p| ((p.0 - 10.0) / 10.0).powi(2) + ((p.1 - 5.0) / 5.0).powi(2) - 1.0 < 1e-3));
}
#[test]
fn a_pen_dot_is_a_filled_circle() {
let g = geometry(&shape(Tool::Pen, &[(5.0, 5.0)]));
let Prim::Fill { points, color } = &g[0] else { panic!("{g:?}") };
assert_eq!(*color, [255, 59, 48, 255]);
assert!(points.iter().all(|p| ((p.0 - 5.0).hypot(p.1 - 5.0) - 2.0).abs() < 1e-3));
}
#[test]
fn drag_collects_points_per_tool() {
let mut d = Drag::new(Tool::Pen, RED, (0.0, 0.0));
d.move_to((1.0, 1.0));
d.move_to((2.0, 3.0));
assert_eq!(d.shape(false).points, vec![(0.0, 0.0), (1.0, 1.0), (2.0, 3.0)]);
let mut d = Drag::new(Tool::Rectangle, RED, (0.0, 0.0));
d.move_to((5.0, 9.0));
d.move_to((30.0, 10.0));
assert_eq!(d.shape(false).points, vec![(0.0, 0.0), (30.0, 10.0)]);
assert_eq!(d.shape(true).points, vec![(0.0, 0.0), (30.0, 30.0)]);
}
}
src/editor/doc.rs (tests only):
#[cfg(test)]
mod tests {
use super::*;
use crate::editor::shape::{Style, Tool};
fn line(x: f32) -> Shape {
Shape { tool: Tool::Line, style: Style { color: [0, 0, 0], width: 2.0 }, points: vec![(0.0, 0.0), (x, 0.0)] }
}
#[test]
fn undo_and_redo_move_shapes() {
let mut d = Doc::new();
assert!(!d.can_undo() && !d.can_redo());
d.add(line(1.0));
d.add(line(2.0));
assert!(d.undo());
assert_eq!(d.shapes(), &[line(1.0)]);
assert!(d.redo());
assert_eq!(d.shapes(), &[line(1.0), line(2.0)]);
assert!(!d.redo());
}
#[test]
fn a_new_shape_clears_redo() {
let mut d = Doc::new();
d.add(line(1.0));
d.undo();
d.add(line(3.0));
assert!(!d.can_redo());
assert_eq!(d.shapes(), &[line(3.0)]);
}
#[test]
fn dirty_until_saved() {
let mut d = Doc::new();
assert!(!d.is_dirty());
d.add(line(1.0));
assert!(d.is_dirty());
d.mark_saved();
assert!(!d.is_dirty());
}
#[test]
fn undo_past_a_save_is_dirty() {
let mut d = Doc::new();
d.add(line(1.0));
d.mark_saved();
d.undo();
assert!(d.is_dirty());
d.redo();
assert!(!d.is_dirty(), "back to the saved content");
}
}
Run: cargo nextest run editor:: → fails to compile (types missing).
- Step 2: Implement
shape.rs(above its tests)
//! Markup tools and the geometry both painters share: the canvas (egui)
//! and the export (tiny-skia) draw the same primitives, in image pixels.
pub type P = (f32, f32);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tool {
Arrow,
Rectangle,
Ellipse,
Line,
Pen,
Highlighter,
}
impl Tool {
pub const ALL: [Tool; 6] = [
Tool::Arrow,
Tool::Rectangle,
Tool::Ellipse,
Tool::Line,
Tool::Pen,
Tool::Highlighter,
];
pub fn label(self) -> &'static str {
match self {
Tool::Arrow => "Arrow",
Tool::Rectangle => "Rectangle",
Tool::Ellipse => "Ellipse",
Tool::Line => "Line",
Tool::Pen => "Pen",
Tool::Highlighter => "Highlighter",
}
}
fn freehand(self) -> bool {
matches!(self, Tool::Pen | Tool::Highlighter)
}
}
/// The toolbar's colours; the first is the default.
pub const PALETTE: [[u8; 3]; 7] = [
[0xff, 0x3b, 0x30],
[0xff, 0x95, 0x00],
[0xff, 0xcc, 0x00],
[0x34, 0xc7, 0x59],
[0x00, 0x7a, 0xff],
[0x00, 0x00, 0x00],
[0xff, 0xff, 0xff],
];
/// S, M, L in image pixels; M is the default.
pub const WIDTHS: [(&str, f32); 3] = [("S", 2.0), ("M", 4.0), ("L", 8.0)];
const HIGHLIGHT_ALPHA: u8 = 102;
const CLICK: f32 = 2.0;
const ELLIPSE_POINTS: usize = 72;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Style {
pub color: [u8; 3],
pub width: f32,
}
/// One finished mark. Two-point tools hold [start, end]; Pen and
/// Highlighter hold every point.
#[derive(Debug, Clone, PartialEq)]
pub struct Shape {
pub tool: Tool,
pub style: Style,
pub points: Vec<P>,
}
impl Shape {
/// A press without a real drag. A Pen click still leaves a dot.
pub fn is_click(&self) -> bool {
if self.tool == Tool::Pen {
return false;
}
let first = self.points[0];
self.points
.iter()
.all(|p| (p.0 - first.0).hypot(p.1 - first.1) < CLICK)
}
}
/// A shape being drawn.
#[derive(Debug, Clone)]
pub struct Drag {
tool: Tool,
style: Style,
points: Vec<P>,
}
impl Drag {
pub fn new(tool: Tool, style: Style, start: P) -> Drag {
Drag { tool, style, points: vec![start] }
}
pub fn move_to(&mut self, p: P) {
if self.tool.freehand() || self.points.len() == 1 {
self.points.push(p);
} else {
self.points[1] = p;
}
}
/// The shape so far; `shift` applies the tool's constraint.
pub fn shape(&self, shift: bool) -> Shape {
let mut points = self.points.clone();
if !self.tool.freehand() && points.len() == 2 {
points[1] = constrain(self.tool, points[0], points[1], shift);
}
Shape { tool: self.tool, style: self.style, points }
}
}
/// Shift: lines and arrows snap to 45° steps, rectangles and ellipses
/// become squares and circles.
pub fn constrain(tool: Tool, start: P, end: P, shift: bool) -> P {
if !shift {
return end;
}
let (dx, dy) = (end.0 - start.0, end.1 - start.1);
match tool {
Tool::Line | Tool::Arrow => {
let step = std::f32::consts::FRAC_PI_4;
let angle = (dy.atan2(dx) / step).round() * step;
let len = dx.hypot(dy);
(start.0 + len * angle.cos(), start.1 + len * angle.sin())
}
Tool::Rectangle | Tool::Ellipse => {
let side = dx.abs().max(dy.abs());
(start.0 + side.copysign(dx), start.1 + side.copysign(dy))
}
Tool::Pen | Tool::Highlighter => end,
}
}
/// A primitive in image pixels. Colours are straight (not premultiplied) RGBA.
#[derive(Debug, Clone, PartialEq)]
pub enum Prim {
Stroke {
points: Vec<P>,
width: f32,
color: [u8; 4],
closed: bool,
/// Round caps and joins; false means butt caps.
round: bool,
},
/// A convex polygon.
Fill { points: Vec<P>, color: [u8; 4] },
}
pub fn geometry(shape: &Shape) -> Vec<Prim> {
let [r, g, b] = shape.style.color;
let opaque = [r, g, b, 255];
let w = shape.style.width;
let pts = &shape.points;
let stroke = |points: Vec<P>, closed: bool| Prim::Stroke {
points,
width: w,
color: opaque,
closed,
round: true,
};
match shape.tool {
Tool::Line => vec![stroke(vec![pts[0], pts[1]], false)],
Tool::Arrow => arrow(pts[0], pts[1], w, opaque),
Tool::Rectangle => {
let ((x0, y0), (x1, y1)) = (pts[0], pts[1]);
vec![stroke(vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1)], true)]
}
Tool::Ellipse => {
let ((x0, y0), (x1, y1)) = (pts[0], pts[1]);
let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, (x1 - x0).abs() / 2.0, (y1 - y0).abs() / 2.0);
vec![stroke(polygon(cx, cy, rx, ry, ELLIPSE_POINTS), true)]
}
Tool::Pen if pts.len() == 1 => {
let (x, y) = pts[0];
vec![Prim::Fill { points: polygon(x, y, w / 2.0, w / 2.0, 24), color: opaque }]
}
Tool::Pen => vec![stroke(pts.clone(), false)],
Tool::Highlighter => vec![Prim::Stroke {
points: pts.clone(),
width: w * 4.0,
color: [r, g, b, HIGHLIGHT_ALPHA],
closed: false,
round: false,
}],
}
}
/// A line to the base of a filled head whose tip is at `end`.
fn arrow(start: P, end: P, width: f32, color: [u8; 4]) -> Vec<Prim> {
let (dx, dy) = (end.0 - start.0, end.1 - start.1);
let len = dx.hypot(dy).max(f32::EPSILON);
let (ux, uy) = (dx / len, dy / len);
let head = (4.0 * width).max(12.0).min(len);
let half = head * 30f32.to_radians().tan();
let base = (end.0 - ux * head, end.1 - uy * head);
vec![
Prim::Stroke { points: vec![start, base], width, color, closed: false, round: true },
Prim::Fill {
points: vec![end, (base.0 - uy * half, base.1 + ux * half), (base.0 + uy * half, base.1 - ux * half)],
color,
},
]
}
/// `n` points around an ellipse, starting at angle 0.
fn polygon(cx: f32, cy: f32, rx: f32, ry: f32, n: usize) -> Vec<P> {
(0..n)
.map(|i| {
let a = i as f32 / n as f32 * std::f32::consts::TAU;
(cx + rx * a.cos(), cy + ry * a.sin())
})
.collect()
}
(Check arrow_has_a_line_and_a_head_at_the_end: direction (1,0) → base (84,0); head points (84, +half) and (84, −half) — the second point is base − u⊥·half with u⊥ = (−uy, ux) = (0, 1)… compute: (base.0 - uy*half, base.1 + ux*half) = (84, +half) ✓, third = (84, −half) ✓.)
- Step 3: Implement
doc.rs(above its tests)
//! The edit history: the shapes drawn so far, what undo took away, and
//! what was last saved.
use crate::editor::shape::Shape;
#[derive(Debug, Default)]
pub struct Doc {
shapes: Vec<Shape>,
redo: Vec<Shape>,
saved: Vec<Shape>,
}
impl Doc {
pub fn new() -> Doc {
Doc::default()
}
pub fn shapes(&self) -> &[Shape] {
&self.shapes
}
pub fn add(&mut self, shape: Shape) {
self.shapes.push(shape);
self.redo.clear();
}
pub fn undo(&mut self) -> bool {
let Some(shape) = self.shapes.pop() else { return false };
self.redo.push(shape);
true
}
pub fn redo(&mut self) -> bool {
let Some(shape) = self.redo.pop() else { return false };
self.shapes.push(shape);
true
}
pub fn can_undo(&self) -> bool {
!self.shapes.is_empty()
}
pub fn can_redo(&self) -> bool {
!self.redo.is_empty()
}
/// Whether the shapes differ from what was last saved.
pub fn is_dirty(&self) -> bool {
self.shapes != self.saved
}
pub fn mark_saved(&mut self) {
self.saved = self.shapes.clone();
}
}
- Step 4: Run —
cargo nextest run(+12), clippy, fmt. Step 5: Commitfeat: editor shapes, geometry and history.
Task 2: Fitting and export
Files: src/editor/view.rs, src/editor/export.rs; Cargo.toml: tiny-skia = "0.12.0"; src/editor/mod.rs: pub mod export; pub mod view;.
Produces: view::Fit { scale: f32, offset: P }, Fit::new(image: (u32, u32), area: (f32, f32, f32, f32) /* x, y, w, h */, pixels_per_point: f32), to_screen(P) -> P, to_image(P) -> P, clamp(P, image) -> P; export::render(base: &RgbaImage, shapes: &[Shape]) -> RgbaImage, export::edited_path(&Path) -> PathBuf.
- Step 1: Failing tests
src/editor/view.rs tests:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn small_images_are_not_upscaled() {
let f = Fit::new((400, 300), (0.0, 50.0, 1000.0, 800.0), 1.0);
assert_eq!(f.scale, 1.0);
assert_eq!(f.offset, (300.0, 300.0), "centred");
}
#[test]
fn large_images_fit_the_area() {
let f = Fit::new((1920, 1080), (0.0, 40.0, 960.0, 1000.0), 1.0);
assert_eq!(f.scale, 0.5);
assert_eq!(f.offset, (0.0, 40.0 + (1000.0 - 540.0) / 2.0));
}
#[test]
fn hundred_percent_means_one_image_pixel_per_physical_pixel() {
let f = Fit::new((400, 300), (0.0, 0.0, 1000.0, 800.0), 2.0);
assert_eq!(f.scale, 0.5, "0.5 points per image px at 2× = 1 physical px");
}
#[test]
fn round_trip() {
let f = Fit::new((1920, 1080), (10.0, 40.0, 1000.0, 700.0), 1.25);
for p in [(0.0, 0.0), (1919.0, 1079.0), (333.5, 777.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:?}");
}
}
#[test]
fn clamp_keeps_points_on_the_image() {
assert_eq!(clamp((-5.0, 50.0), (100, 80)), (0.0, 50.0));
assert_eq!(clamp((150.0, 90.0), (100, 80)), (100.0, 80.0));
}
}
src/editor/export.rs tests:
#[cfg(test)]
mod tests {
use super::*;
use crate::editor::shape::{Style, Tool};
use image::Rgba;
fn line(tool: Tool, color: [u8; 3]) -> Shape {
Shape { tool, style: Style { color, width: 4.0 }, points: vec![(10.0, 20.0), (90.0, 20.0)] }
}
#[test]
fn line_pixels() {
let base = RgbaImage::from_pixel(100, 40, Rgba([255, 255, 255, 255]));
let out = render(&base, &[line(Tool::Line, [255, 0, 0])]);
assert_eq!(out.dimensions(), (100, 40));
assert_eq!(out.get_pixel(50, 20).0, [255, 0, 0, 255], "on the line");
assert_eq!(out.get_pixel(50, 5).0, [255, 255, 255, 255], "away from it");
assert_eq!(out.get_pixel(5, 20).0, [255, 255, 255, 255], "before its start cap");
}
#[test]
fn highlighter_blends_at_40_percent() {
let base = RgbaImage::from_pixel(100, 40, Rgba([0, 0, 255, 255]));
let out = render(&base, &[line(Tool::Highlighter, [255, 255, 0])]);
let [r, g, b, a] = out.get_pixel(50, 20).0;
assert_eq!(a, 255);
for (got, want) in [(r, 102), (g, 102), (b, 153)] {
assert!((got as i32 - want).abs() <= 2, "{:?}", out.get_pixel(50, 20));
}
}
#[test]
fn keeps_transparency() {
let base = RgbaImage::from_pixel(100, 40, Rgba([10, 20, 30, 0]));
let mut half = base.clone();
half.put_pixel(50, 5, Rgba([200, 100, 50, 128]));
let out = render(&half, &[line(Tool::Line, [255, 0, 0])]);
assert_eq!(out.get_pixel(0, 0).0[3], 0, "transparent stays transparent");
let [r, g, b, a] = out.get_pixel(50, 5).0;
assert_eq!(a, 128);
assert!((r as i32 - 200).abs() <= 2 && (g as i32 - 100).abs() <= 2 && (b as i32 - 50).abs() <= 2);
assert_eq!(out.get_pixel(50, 20).0, [255, 0, 0, 255]);
}
#[test]
fn edited_path_sits_next_to_the_file() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("Screenshot at 10.00.png");
assert_eq!(edited_path(&file), dir.path().join("Screenshot at 10.00 edited.png"));
std::fs::write(dir.path().join("Screenshot at 10.00 edited.png"), b"x").unwrap();
assert_ne!(edited_path(&file), dir.path().join("Screenshot at 10.00 edited.png"));
}
}
Run: cargo nextest run editor:: → compile errors (missing items).
- Step 2: Implement
view.rs
//! Fitting the image into the canvas: image pixels ↔ egui points.
use crate::editor::shape::P;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Fit {
/// Points per image pixel.
pub scale: f32,
/// Where the image's top-left corner is, in points.
pub offset: P,
}
impl Fit {
/// Fits `image` into `area` (x, y, w, h in points), centred, never
/// above 100 % (one image pixel per physical pixel).
pub fn new(image: (u32, u32), area: (f32, f32, f32, f32), pixels_per_point: f32) -> Fit {
let (iw, ih) = (image.0 as f32, image.1 as f32);
let (x, y, w, h) = area;
let scale = (w / iw).min(h / ih).min(1.0 / pixels_per_point);
Fit { scale, offset: (x + (w - iw * scale) / 2.0, y + (h - ih * scale) / 2.0) }
}
pub fn to_screen(&self, p: P) -> P {
(self.offset.0 + p.0 * self.scale, self.offset.1 + p.1 * self.scale)
}
pub fn to_image(&self, p: P) -> P {
((p.0 - self.offset.0) / self.scale, (p.1 - self.offset.1) / self.scale)
}
}
/// `p` moved onto the image's area.
pub fn clamp(p: P, image: (u32, u32)) -> P {
(p.0.clamp(0.0, image.0 as f32), p.1.clamp(0.0, image.1 as f32))
}
(large_images_fit_the_area: scale = min(0.5, 0.926, 1) = 0.5; offset x = 0 + (960 − 960)/2 = 0 ✓.)
- Step 3: Implement
export.rs
//! The edited image at full resolution: the base plus every shape,
//! drawn with tiny-skia from the same geometry as the canvas.
use std::path::{Path, PathBuf};
use image::RgbaImage;
use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform};
use crate::editor::shape::{Prim, Shape, geometry};
pub fn render(base: &RgbaImage, shapes: &[Shape]) -> RgbaImage {
let (w, h) = base.dimensions();
let mut pixmap = Pixmap::new(w, h).expect("the image has a size");
for (dst, src) in pixmap.pixels_mut().iter_mut().zip(base.pixels()) {
let [r, g, b, a] = src.0;
*dst = tiny_skia::ColorU8::from_rgba(r, g, b, a).premultiply();
}
for prim in shapes.iter().flat_map(geometry) {
draw(&mut pixmap, &prim);
}
let mut out = RgbaImage::new(w, h);
for (dst, src) in out.pixels_mut().zip(pixmap.pixels()) {
let c = src.demultiply();
dst.0 = [c.red(), c.green(), c.blue(), c.alpha()];
}
out
}
fn draw(pixmap: &mut Pixmap, prim: &Prim) {
let (points, color) = match prim {
Prim::Stroke { points, color, .. } | Prim::Fill { points, color } => (points, color),
};
let mut pb = PathBuilder::new();
pb.move_to(points[0].0, points[0].1);
for p in &points[1..] {
pb.line_to(p.0, p.1);
}
let closed = matches!(prim, Prim::Fill { .. } | Prim::Stroke { closed: true, .. });
if closed {
pb.close();
}
let Some(path) = pb.finish() else { return };
let mut paint = Paint::default();
paint.set_color_rgba8(color[0], color[1], color[2], color[3]);
paint.anti_alias = true;
match prim {
Prim::Fill { .. } => pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None),
Prim::Stroke { width, round, .. } => {
let stroke = Stroke {
width: *width,
line_cap: if *round { LineCap::Round } else { LineCap::Butt },
line_join: if *round { LineJoin::Round } else { LineJoin::Miter },
..Stroke::default()
};
pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
}
}
}
/// `<stem> edited.png` next to `path`, numbered if taken.
pub fn edited_path(path: &Path) -> PathBuf {
let dir = path.parent().unwrap_or(Path::new("."));
let stem = path.file_stem().map(|s| s.to_string_lossy()).unwrap_or_default();
crate::output::unique_path(dir, &format!("{stem} edited.png"))
}
(If pixels_mut, premultiply, demultiply or PathBuilder::finish differ in tiny-skia 0.12, adapt to its API.)
- Step 4: Run (+9), clippy, fmt. Step 5: Commit
feat: editor fitting and export.
Task 3: Editor backend config and the preview's command
Files: src/config.rs, src/host.rs.
Produces: config::{Editor, Backend} (Backend::{Builtin, Satty}, serde lowercase, default Builtin), Config::editor; host::editor_command(backend: Backend, path: &Path, exe: &Path) -> std::process::Command.
- Step 1: Failing tests
src/config.rs tests (the unknown-keys test uses [editor]: change its table to [toolbar]\nposition = "top" so it stays unknown → "toolbar"):
#[test]
fn editor_backend_default_and_values() {
assert_eq!(Config::default().editor.backend, Backend::Builtin);
let (config, _) = parse("[editor]\nbackend = \"satty\"\n").unwrap();
assert_eq!(config.editor.backend, Backend::Satty);
assert!(parse("[editor]\nbackend = \"gimp\"\n").is_err());
}
src/host.rs tests:
#[test]
fn editor_command_per_backend() {
let path = Path::new("/p/Shot 1.png");
let exe = Path::new("/nix/store/x/bin/valw");
let builtin = editor_command(Backend::Builtin, path, exe);
assert_eq!(builtin.get_program(), exe);
assert_eq!(builtin.get_args().collect::<Vec<_>>(), ["edit", "/p/Shot 1.png"]);
let satty = editor_command(Backend::Satty, path, exe);
assert_eq!(satty.get_program(), "satty");
assert_eq!(
satty.get_args().collect::<Vec<_>>(),
["--filename", "/p/Shot 1.png", "--output-filename", "/p/Shot 1.png"]
);
}
Run → compile errors.
- Step 2: Implement
config.rs: pub editor: Editor, in Config, and
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
#[serde(default)]
pub struct Editor {
pub backend: Backend,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Backend {
/// `valw edit`.
#[default]
Builtin,
Satty,
}
host.rs: replace open_editor with
/// The command that opens `path` in the configured editor; both save over
/// the file.
fn editor_command(backend: Backend, path: &Path, exe: &Path) -> std::process::Command {
let mut command = match backend {
Backend::Builtin => {
let mut c = std::process::Command::new(exe);
c.arg("edit").arg(path);
c
}
Backend::Satty => {
let mut c = std::process::Command::new("satty");
c.arg("--filename").arg(path).arg("--output-filename").arg(path);
c
}
};
command
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
command
}
/// Opens the screenshot in the configured editor.
fn open_editor(path: &Path) {
use std::os::unix::process::CommandExt;
let backend = match config::load(&config::default_path()) {
Ok(c) => c.editor.backend,
Err(e) => {
tracing::warn!("using the built-in editor: {e:#}");
Backend::Builtin
}
};
let exe = std::env::current_exe().unwrap_or_else(|_| "valw".into());
let mut command = editor_command(backend, path, &exe);
// SAFETY: setsid is async-signal-safe.
unsafe {
command.pre_exec(|| rustix::process::setsid().map(drop).map_err(Into::into));
}
if let Err(e) = command.spawn() {
tracing::warn!("could not start the editor ({backend:?}): {e}");
}
}
with use crate::config::{self, Backend}; (merge with existing imports). get_args yields &OsStr; compare via .collect::<Vec<_>>() against &str arrays works through PartialEq<str> for OsStr; if not, map with to_str().unwrap().
- Step 3: Run (+2), clippy, fmt. Step 4: Commit
feat: preview opens the configured editor.
Task 4: The editor window, __clipboard, packaging
Files: src/editor/mod.rs, src/output.rs, src/main.rs, Cargo.toml, nix/package.ulu.nix, nix/devshell.ulu.nix.
Consumes: Tasks 1–2. Produces: editor::run(path: &Path) -> Result<()>; output::serve_clipboard(png: Vec<u8>) -> Result<()> (serves in the foreground until replaced); Command::Edit { file: PathBuf }; hidden Command::Clipboard (__clipboard, PNG on stdin).
- Step 1: Failing tests —
src/main.rsflag_conflictsgains:
assert!(parses(&["edit", "a.png"]));
assert!(!parses(&["edit"]));
assert!(parses(&["__clipboard"]));
and preview_host_is_hidden_from_help gains assert!(!help.contains("__clipboard"), "{help}");. Run → fails.
- Step 2: Dependencies and packaging
Cargo.toml: eframe = { version = "0.36.2", default-features = false, features = ["default_fonts", "glow", "wayland"] }.
winit (through eframe) loads libwayland-client, libxkbcommon and libEGL with dlopen, and Cargo's feature unification switches valw's own libwayland to dlopen too. Give the binary a RUNPATH instead of an inherited LD_LIBRARY_PATH (this also fixes the Phase 4 minor about the variable leaking into Satty and the clipboard child). nix/package.ulu.nix:
# Clicking a preview opens Satty if configured. --suffix keeps a user's own Satty first.
postInstall = ''
wrapProgram $out/bin/valw --suffix PATH : ${lib.makeBinPath [ pkgs.satty ]}
'';
# dlopen()ed at run time: libwayland (winit, valw), libxkbcommon (winit),
# libEGL (zoom, editor; Mesa's drivers come from the system).
postFixup = ''
patchelf --add-rpath ${lib.makeLibraryPath [ pkgs.wayland pkgs.libxkbcommon pkgs.libglvnd ]} $out/bin/.valw-wrapped
'';
(If the wrapped file is named differently, ls $out/bin in the build log and adjust.) nix/devshell.ulu.nix: inside mkShell, LD_LIBRARY_PATH = lib.makeLibraryPath [ pkgs.wayland pkgs.libxkbcommon pkgs.libglvnd ]; (take lib from the module arguments).
- Step 3:
output::serve_clipboardand__clipboard
In src/output.rs, split copy_to_clipboard:
fn prepare_clipboard(png: Vec<u8>) -> Result<PreparedCopy> {
let mut options = Options::new();
options.foreground(true);
options
.prepare_copy(Source::Bytes(png.into()), MimeType::Specific("image/png".into()))
.context("could not copy to the clipboard")
.hint("the compositor needs ext-data-control or wlr-data-control; see `valw doctor`")
}
pub fn copy_to_clipboard(png: Vec<u8>, lock: Lock) -> Result<()> {
let prepared = prepare_clipboard(png)?;
crate::detach::spawn(lock, move || {
let _ = prepared.serve();
})
}
/// Serves `png` as the clipboard from this process until something else
/// takes the clipboard.
pub fn serve_clipboard(png: Vec<u8>) -> Result<()> {
prepare_clipboard(png)?.serve().context("the clipboard server failed")
}
(PreparedCopy is wl_clipboard_rs::copy::PreparedCopy; import it.)
In src/main.rs: Command::Edit { /// The image to edit. file: PathBuf } documented "Mark up an image (opened by clicking a preview).", and
/// Internal: serve a PNG from stdin as the clipboard (the editor's copy).
#[command(name = "__clipboard", hide = true)]
Clipboard,
run arms: Command::Edit { file } => editor::run(&file), and
Command::Clipboard => {
let mut png = Vec::new();
std::io::Read::read_to_end(&mut std::io::stdin(), &mut png).context("could not read the image")?;
output::serve_clipboard(png)
}
- Step 4:
src/editor/mod.rs(replace the Task 1/2 module list; keep the fourpub modlines)
//! `valw edit`: a Markup-like editor window (eframe, OpenGL ES).
pub mod doc;
pub mod export;
pub mod shape;
pub mod view;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow};
use eframe::egui::{self, Color32, Key, Modifiers, Pos2, Rect, Sense, Stroke, StrokeKind, ViewportCommand};
use image::RgbaImage;
use self::doc::Doc;
use self::shape::{Drag, PALETTE, Prim, Style, Tool, WIDTHS, geometry};
use self::view::Fit;
const APP_ID: &str = "valw-editor";
const STATUS_FOR: Duration = Duration::from_secs(2);
pub fn run(path: &Path) -> Result<()> {
let image = crate::thumbnail::load(path)?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let (w, h) = image.dimensions();
let viewport = egui::ViewportBuilder::default()
.with_title(format!("{name} — valw"))
.with_app_id(APP_ID)
.with_inner_size([(w as f32).clamp(480.0, 1600.0), (h as f32 + 48.0).clamp(320.0, 1000.0)]);
let options = eframe::NativeOptions { viewport, ..Default::default() };
let path = path.to_path_buf();
eframe::run_native(
APP_ID,
options,
Box::new(move |cc| Ok(Box::new(Editor::new(&cc.egui_ctx, path, image)))),
)
.map_err(|e| anyhow!("the editor window failed: {e}"))
}
struct Editor {
path: PathBuf,
base: RgbaImage,
texture: egui::TextureHandle,
doc: Doc,
tool: Tool,
color: usize,
width: usize,
drag: Option<Drag>,
status: Option<(String, Instant)>,
/// The unsaved-changes bar is showing.
confirm_close: bool,
/// The user chose to close; let the close through.
closing: bool,
}
impl Editor {
fn new(ctx: &egui::Context, path: PathBuf, base: RgbaImage) -> Editor {
let size = [base.width() as usize, base.height() as usize];
let pixels = egui::ColorImage::from_rgba_unmultiplied(size, base.as_raw());
let texture = ctx.load_texture("image", pixels, egui::TextureOptions::LINEAR);
Editor {
path,
base,
texture,
doc: Doc::new(),
tool: Tool::Arrow,
color: 0,
width: 1,
drag: None,
status: None,
confirm_close: false,
closing: false,
}
}
fn style(&self) -> Style {
Style { color: PALETTE[self.color], width: WIDTHS[self.width].1 }
}
fn say(&mut self, text: impl Into<String>) {
self.status = Some((text.into(), Instant::now()));
}
fn png(&self) -> Result<Vec<u8>> {
crate::output::encode_png(&export::render(&self.base, self.doc.shapes()))
}
fn save(&mut self, copy: bool) -> bool {
let target = if copy { export::edited_path(&self.path) } else { self.path.clone() };
match self.png().and_then(|png| crate::output::write_atomic(&target, &png)) {
Ok(()) => {
if !copy {
self.doc.mark_saved();
}
tracing::info!("saved {}", target.display());
self.say(if copy { "Saved a copy" } else { "Saved" });
true
}
Err(e) => {
tracing::error!("save failed: {e:#}");
self.say(format!("Could not save: {e}"));
false
}
}
}
fn copy(&mut self) {
let result = self.png().and_then(|png| {
use std::os::unix::process::CommandExt;
let exe = std::env::current_exe().context("no path to valw")?;
let mut command = std::process::Command::new(exe);
command
.arg("__clipboard")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
// SAFETY: setsid is async-signal-safe.
unsafe {
command.pre_exec(|| rustix::process::setsid().map(drop).map_err(Into::into));
}
let mut child = command.spawn().context("could not start the clipboard server")?;
child.stdin.take().context("no stdin")?.write_all(&png).context("could not hand over the image")?;
Ok(())
});
match result {
Ok(()) => self.say("Copied"),
Err(e) => {
tracing::error!("copy failed: {e:#}");
self.say(format!("Could not copy: {e}"));
}
}
}
fn close(&mut self, ctx: &egui::Context) {
self.closing = true;
ctx.send_viewport_cmd(ViewportCommand::Close);
}
fn keys(&mut self, ctx: &egui::Context) {
let shift_cmd = Modifiers::COMMAND | Modifiers::SHIFT;
let pressed = |m: Modifiers, k: Key| ctx.input_mut(|i| i.consume_key(m, k));
// Most specific first: consume_key ignores extra Shift.
if pressed(shift_cmd, Key::S) {
self.save(true);
} else if pressed(Modifiers::COMMAND, Key::S) {
if self.save(false) && self.confirm_close {
self.close(ctx);
}
}
if pressed(shift_cmd, Key::Z) || pressed(Modifiers::COMMAND, Key::Y) {
self.doc.redo();
} else if pressed(Modifiers::COMMAND, Key::Z) {
self.doc.undo();
}
if pressed(Modifiers::COMMAND, Key::C) {
self.copy();
}
if pressed(Modifiers::NONE, Key::Escape) {
if self.doc.is_dirty() && !self.confirm_close {
self.confirm_close = true;
} else {
self.close(ctx);
}
}
for (key, tool) in [
(Key::A, Tool::Arrow),
(Key::R, Tool::Rectangle),
(Key::O, Tool::Ellipse),
(Key::L, Tool::Line),
(Key::P, Tool::Pen),
(Key::H, Tool::Highlighter),
] {
if pressed(Modifiers::NONE, key) {
self.tool = tool;
}
}
}
/// The compositor asked to close: with unsaved changes, show the bar instead.
fn guard_close(&mut self, ctx: &egui::Context) {
if ctx.input(|i| i.viewport().close_requested()) && self.doc.is_dirty() && !self.closing {
ctx.send_viewport_cmd(ViewportCommand::CancelClose);
self.confirm_close = true;
}
}
fn toolbar(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
for tool in Tool::ALL {
if ui.selectable_label(self.tool == tool, tool.label()).clicked() {
self.tool = tool;
}
}
ui.separator();
for (i, [r, g, b]) in PALETTE.into_iter().enumerate() {
let (rect, response) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), Sense::click());
ui.painter().rect_filled(rect, 4.0, Color32::from_rgb(r, g, b));
let ring = if i == self.color { ui.visuals().strong_text_color() } else { ui.visuals().weak_text_color() };
ui.painter().rect_stroke(rect, 4.0, Stroke::new(if i == self.color { 2.0 } else { 1.0 }, ring), StrokeKind::Outside);
if response.clicked() {
self.color = i;
}
}
ui.separator();
for (i, (label, _)) in WIDTHS.into_iter().enumerate() {
if ui.selectable_label(self.width == i, label).clicked() {
self.width = i;
}
}
ui.separator();
if ui.add_enabled(self.doc.can_undo(), egui::Button::new("Undo")).clicked() {
self.doc.undo();
}
if ui.add_enabled(self.doc.can_redo(), egui::Button::new("Redo")).clicked() {
self.doc.redo();
}
ui.separator();
if ui.button("Copy").clicked() {
self.copy();
}
if ui.button("Save").clicked() {
self.save(false);
}
if let Some((text, at)) = &self.status {
if at.elapsed() < STATUS_FOR {
ui.label(text.as_str());
ui.ctx().request_repaint_after(STATUS_FOR);
}
}
});
}
fn canvas(&mut self, ui: &mut egui::Ui) {
let area = ui.max_rect();
let (response, painter) = ui.allocate_painter(area.size(), Sense::drag());
let size = self.base.dimensions();
let fit = Fit::new(size, (area.min.x, area.min.y, area.width(), area.height()), ui.ctx().pixels_per_point());
let to_pos = |p: shape::P| {
let (x, y) = fit.to_screen(p);
Pos2::new(x, y)
};
let image_rect = Rect::from_min_max(to_pos((0.0, 0.0)), to_pos((size.0 as f32, size.1 as f32)));
painter.image(self.texture.id(), image_rect, Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), Color32::WHITE);
let shift = ui.input(|i| i.modifiers.shift);
let pointer = response.interact_pointer_pos().map(|p| fit.to_image((p.x, p.y)));
if response.drag_started()
&& let Some(p) = pointer
&& image_rect.contains(response.interact_pointer_pos().unwrap_or_default())
{
self.drag = Some(Drag::new(self.tool, self.style(), p));
}
if let (Some(drag), Some(p)) = (&mut self.drag, pointer) {
drag.move_to(view::clamp(p, size));
}
if response.drag_stopped()
&& let Some(drag) = self.drag.take()
{
let shape = drag.shape(shift);
if !shape.is_click() {
self.doc.add(shape);
}
}
let live = self.drag.as_ref().map(|d| d.shape(shift));
for shape in self.doc.shapes().iter().chain(live.as_ref()) {
for prim in geometry(shape) {
paint(&painter, &prim, fit.scale, to_pos);
}
}
}
}
/// Draws one primitive on the canvas.
fn paint(painter: &egui::Painter, prim: &Prim, scale: f32, to_pos: impl Fn(shape::P) -> Pos2) {
let color = |c: &[u8; 4]| Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]);
match prim {
Prim::Fill { points, color: c } => {
let points = points.iter().map(|&p| to_pos(p)).collect();
painter.add(egui::Shape::convex_polygon(points, color(c), Stroke::NONE));
}
Prim::Stroke { points, width, color: c, closed, round } => {
let points: Vec<Pos2> = points.iter().map(|&p| to_pos(p)).collect();
let stroke = Stroke::new(width * scale, color(c));
if *closed {
painter.add(egui::Shape::closed_line(points, stroke));
} else {
// egui lines have butt caps; add the round caps the export draws.
if *round {
for end in [points[0], points[points.len() - 1]] {
painter.circle_filled(end, width * scale / 2.0, color(c));
}
}
painter.add(egui::Shape::line(points, stroke));
}
}
}
}
impl eframe::App for Editor {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
self.keys(&ctx);
self.guard_close(&ctx);
egui::Panel::top("toolbar").show(ui, |ui| self.toolbar(ui));
if self.confirm_close && self.doc.is_dirty() {
egui::Panel::bottom("unsaved").show(ui, |ui| {
ui.label("Unsaved changes — Esc to discard, Ctrl+S to save");
});
}
egui::CentralPanel::no_frame().show(ui, |ui| self.canvas(ui));
}
}
Remove the Task 1 dead-code allowance from src/main.rs. API drift (egui 0.36: selectable_label, rect_stroke's StrokeKind, Panel::top/bottom(..).show(ui, ..), CentralPanel::no_frame().show(ui, ..)): follow the compiler, keep the behaviour, and ledger any change.
-
Step 5: Run —
cargo nextest run(all pass), clippy, fmt. -
Step 6: Smoke run (tell the user first: a window opens for a few seconds) —
cargo run -q -- edit <a recent screenshot>in the dev shell undertimeout -s INT 4; read the newest log: no errors. Optionally a niri screenshot shows the window. -
Step 7: Commit —
jj commit -m "feat: valw edit"
Task 5: Checks and checklist
nix flake check -L --keep-going: all pass (the headless sway check exercises the dlopen'ed libwayland through RUNPATH).- Append to
docs/test-checklist.md:
## Editor (Phase 5a)
- [ ] Clicking a preview opens `valw edit` (title `<name> — valw`); `[editor] backend = "satty"` still opens Satty.
- [ ] Each tool draws (arrow head at the release point, outline rectangle/ellipse, line, pen dot on a click, translucent highlighter); Shift snaps lines to 45° and makes squares/circles.
- [ ] Colours and S/M/L change new shapes only; A/R/O/L/P/H switch tools.
- [ ] Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y and the toolbar buttons undo and redo.
- [ ] Ctrl+S overwrites the file (the preview's file shows the edits); Ctrl+Shift+S writes `… edited.png`; "Saved" appears for 2 s.
- [ ] The saved PNG matches the canvas (same positions and widths), also for a window shot with transparent corners.
- [ ] Ctrl+C then Esc: the edited image is still pasteable afterwards.
- [ ] Esc with unsaved changes shows the bar; Esc again discards; Ctrl+S in the bar saves and closes. Closing the window with Mod+Q behaves the same.
- [ ] A large (1920×1080) shot fits the window; a small one isn't blown up; the window can be resized.
- Commit
docs: editor checklist.