1285 lines
46 KiB
Markdown
1285 lines
46 KiB
Markdown
# 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` `#007aff` black white; widths 2/4/8 image px (default 4); highlighter = colour at 40 % opacity (alpha 102), width × 4; arrow head length `max(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
|
||
|
||
1. **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).
|
||
2. **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).
|
||
3. **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.
|
||
4. **Undo/redo after save:** dirty must track content, not a counter. Test: `editor::doc::tests::undo_past_a_save_is_dirty` (Task 1).
|
||
5. **The editor closes while the clipboard must live on:** Ctrl+C then Esc must keep the copied image available. Covered by the `__clipboard` child (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):
|
||
|
||
```rust
|
||
#[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):
|
||
|
||
```rust
|
||
#[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)
|
||
|
||
```rust
|
||
//! 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)
|
||
|
||
```rust
|
||
//! 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: Commit** `feat: 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:
|
||
|
||
```rust
|
||
#[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:
|
||
|
||
```rust
|
||
#[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`**
|
||
|
||
```rust
|
||
//! 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`**
|
||
|
||
```rust
|
||
//! 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"`):
|
||
|
||
```rust
|
||
#[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:
|
||
|
||
```rust
|
||
#[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
|
||
|
||
```rust
|
||
#[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
|
||
|
||
```rust
|
||
/// 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.rs` `flag_conflicts` gains:
|
||
|
||
```rust
|
||
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`:
|
||
|
||
```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_clipboard` and `__clipboard`**
|
||
|
||
In `src/output.rs`, split `copy_to_clipboard`:
|
||
|
||
```rust
|
||
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
|
||
|
||
```rust
|
||
/// 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
|
||
|
||
```rust
|
||
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 four `pub mod` lines)
|
||
|
||
```rust
|
||
//! `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 under `timeout -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`:
|
||
|
||
```markdown
|
||
|
||
## 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`.
|