diff --git a/src/editor/doc.rs b/src/editor/doc.rs index 31293fb..86a8654 100644 --- a/src/editor/doc.rs +++ b/src/editor/doc.rs @@ -1,13 +1,22 @@ -//! The edit history: the shapes drawn so far, what undo took away, and -//! what was last saved. +//! The edit history: what was drawn and cropped so far, what undo took +//! away, and what was last saved. -use crate::editor::shape::Shape; +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 { - shapes: Vec, - redo: Vec, - saved: Vec, + items: Vec, + redo: Vec, + saved: Vec, revision: u64, } @@ -16,54 +25,82 @@ impl Doc { Doc::default() } - pub fn shapes(&self) -> &[Shape] { - &self.shapes + 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, + }) + } + + /// The number the next Number shape shows. + // Used by the editor from Task 4 of the 5b plan on; that task removes this. + #[allow(dead_code)] + pub fn next_number(&self) -> u32 { + 1 + self.shapes().filter(|s| s.tool == Tool::Number).count() as u32 } pub fn add(&mut self, shape: Shape) { - self.shapes.push(shape); + self.push(Item::Shape(shape)); + } + + // Used by the editor from Task 4 of the 5b plan on; that task removes this. + #[allow(dead_code)] + pub fn add_crop(&mut self, rect: PixelRect) { + self.push(Item::Crop(rect)); + } + + fn push(&mut self, item: Item) { + self.items.push(item); self.redo.clear(); self.revision += 1; } pub fn undo(&mut self) -> bool { - let Some(shape) = self.shapes.pop() else { + let Some(item) = self.items.pop() else { return false; }; - self.redo.push(shape); + self.redo.push(item); self.revision += 1; true } pub fn redo(&mut self) -> bool { - let Some(shape) = self.redo.pop() else { + let Some(item) = self.redo.pop() else { return false; }; - self.shapes.push(shape); + self.items.push(item); self.revision += 1; true } - /// Changes whenever the drawn shapes do. + /// Changes whenever the drawn shapes or the crop do. pub fn revision(&self) -> u64 { self.revision } pub fn can_undo(&self) -> bool { - !self.shapes.is_empty() + !self.items.is_empty() } pub fn can_redo(&self) -> bool { !self.redo.is_empty() } - /// Whether the shapes differ from what was last saved. + /// Whether the history differs from what was last saved. pub fn is_dirty(&self) -> bool { - self.shapes != self.saved + self.items != self.saved } pub fn mark_saved(&mut self) { - self.saved = self.shapes.clone(); + self.saved = self.items.clone(); } } @@ -71,6 +108,7 @@ impl Doc { mod tests { use super::*; use crate::editor::shape::{Style, Tool}; + use crate::frame::PixelRect; fn line(x: f32) -> Shape { Shape { @@ -91,9 +129,12 @@ mod tests { d.add(line(1.0)); d.add(line(2.0)); assert!(d.undo()); - assert_eq!(d.shapes(), &[line(1.0)]); + assert_eq!(d.shapes().cloned().collect::>(), [line(1.0)]); assert!(d.redo()); - assert_eq!(d.shapes(), &[line(1.0), line(2.0)]); + assert_eq!( + d.shapes().cloned().collect::>(), + [line(1.0), line(2.0)] + ); assert!(!d.redo()); } @@ -122,7 +163,7 @@ mod tests { d.undo(); d.add(line(3.0)); assert!(!d.can_redo()); - assert_eq!(d.shapes(), &[line(3.0)]); + assert_eq!(d.shapes().cloned().collect::>(), [line(3.0)]); } #[test] @@ -145,4 +186,66 @@ mod tests { d.redo(); assert!(!d.is_dirty(), "back to the saved content"); } + + 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); + } } diff --git a/src/editor/export.rs b/src/editor/export.rs index 8e701b4..fa40a2f 100644 --- a/src/editor/export.rs +++ b/src/editor/export.rs @@ -31,8 +31,6 @@ pub fn render<'a>(base: &RgbaImage, shapes: impl IntoIterator) } /// The part of `image` inside the last crop, or all of it. -// The editor calls this from Task 3 of the 5b plan on; that task removes this. -#[allow(dead_code)] 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(), diff --git a/src/editor/mod.rs b/src/editor/mod.rs index 5ae17a9..188d6dc 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -16,6 +16,8 @@ use eframe::egui::{ }; use image::RgbaImage; +use crate::frame::PixelRect; + use self::doc::Doc; use self::shape::{Drag, PALETTE, Prim, Style, Tool, WIDTHS, geometry}; use self::view::Fit; @@ -105,7 +107,8 @@ impl Editor { } fn png(&self) -> Result> { - crate::output::encode_png(&export::render(&self.base, self.doc.shapes())) + let image = export::render(&self.base, self.doc.shapes()); + crate::output::encode_png(&export::crop(image, self.doc.crop())) } fn save(&mut self, copy: bool) -> bool { @@ -293,8 +296,14 @@ impl Editor { 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, + let shown = self.doc.crop().unwrap_or(PixelRect { + x: 0, + y: 0, + width: size.0, + height: size.1, + }); + let fit = Fit::with_crop( + shown, (area.min.x, area.min.y, area.width(), area.height()), ui.ctx().pixels_per_point(), ); @@ -302,12 +311,14 @@ impl Editor { 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))); + let (x0, y0) = (shown.x as f32, shown.y as f32); + let (x1, y1) = (x0 + shown.width as f32, y0 + shown.height as f32); + let image_rect = Rect::from_min_max(to_pos((x0, y0)), to_pos((x1, y1))); + let (w, h) = (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)), + Rect::from_min_max(Pos2::new(x0 / w, y0 / h), Pos2::new(x1 / w, y1 / h)), Color32::WHITE, ); @@ -330,7 +341,7 @@ impl Editor { 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)); + drag.move_to(view::clamp(p, shown)); } if response.drag_stopped() && let Some(drag) = self.drag.take() diff --git a/src/editor/view.rs b/src/editor/view.rs index 688e20c..b7972e9 100644 --- a/src/editor/view.rs +++ b/src/editor/view.rs @@ -1,20 +1,36 @@ -//! Fitting the image into the canvas: image pixels ↔ egui points. +//! Fitting the image (or its crop) into the canvas: image pixels ↔ egui +//! points. use crate::editor::shape::P; +use crate::frame::PixelRect; #[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. + /// 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 { - /// Fits `image` into `area` (x, y, w, h in points), centred, never - /// above 100 % (one image pixel per physical pixel). + /// Fits the whole image; see `with_crop`. + #[cfg(test)] 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 whole = PixelRect { + x: 0, + y: 0, + width: image.0, + height: image.1, + }; + Fit::with_crop(whole, 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 { + let (iw, ih) = (crop.width as f32, crop.height as f32); let (x, y, w, h) = area; let scale = (w / iw).min(h / ih).min(1.0 / pixels_per_point); // Centred, then onto a physical pixel boundary so that at 100 % each @@ -26,29 +42,30 @@ impl Fit { snap(x + (w - iw * scale) / 2.0), snap(y + (h - ih * scale) / 2.0), ), + origin: (crop.x as f32, crop.y as f32), } } pub fn to_screen(self, p: P) -> P { ( - self.offset.0 + p.0 * self.scale, - self.offset.1 + p.1 * self.scale, + 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, - (p.1 - self.offset.1) / self.scale, + (p.0 - self.offset.0) / self.scale + self.origin.0, + (p.1 - self.offset.1) / self.scale + self.origin.1, ) } } -/// `p` moved onto the image's area. -pub fn clamp(p: P, image: (u32, u32)) -> P { +/// `p` moved into `rect`. +pub fn clamp(p: P, rect: PixelRect) -> P { ( - p.0.clamp(0.0, image.0 as f32), - p.1.clamp(0.0, image.1 as f32), + 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), ) } @@ -103,7 +120,48 @@ mod tests { #[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)); + let r = PixelRect { + x: 0, + y: 0, + width: 100, + height: 80, + }; + assert_eq!(clamp((-5.0, 50.0), r), (0.0, 50.0)); + assert_eq!(clamp((150.0, 90.0), r), (100.0, 80.0)); + } + + #[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:?}" + ); + } } }