feat: crop history and fitting in the editor
This commit is contained in:
1 parent
04f51402b3
commit
8fab01bd35
4 files changed
+215
-45
No files matched your search
+124
-21
@@ -1,13 +1,22 @@
|
|||||||
//! The edit history: the shapes drawn so far, what undo took away, and
|
//! The edit history: what was drawn and cropped so far, what undo took
|
||||||
//! what was last saved.
|
//! 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)]
|
#[derive(Debug, Default)]
|
||||||
pub struct Doc {
|
pub struct Doc {
|
||||||
shapes: Vec<Shape>,
|
items: Vec<Item>,
|
||||||
redo: Vec<Shape>,
|
redo: Vec<Item>,
|
||||||
saved: Vec<Shape>,
|
saved: Vec<Item>,
|
||||||
revision: u64,
|
revision: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,54 +25,82 @@ impl Doc {
|
|||||||
Doc::default()
|
Doc::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn shapes(&self) -> &[Shape] {
|
pub fn shapes(&self) -> impl Iterator<Item = &Shape> {
|
||||||
&self.shapes
|
self.items.iter().filter_map(|i| match i {
|
||||||
|
Item::Shape(s) => Some(s),
|
||||||
|
Item::Crop(_) => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last crop, if any.
|
||||||
|
pub fn crop(&self) -> Option<PixelRect> {
|
||||||
|
self.items.iter().rev().find_map(|i| match i {
|
||||||
|
Item::Crop(r) => Some(*r),
|
||||||
|
Item::Shape(_) => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
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.redo.clear();
|
||||||
self.revision += 1;
|
self.revision += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn undo(&mut self) -> bool {
|
pub fn undo(&mut self) -> bool {
|
||||||
let Some(shape) = self.shapes.pop() else {
|
let Some(item) = self.items.pop() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
self.redo.push(shape);
|
self.redo.push(item);
|
||||||
self.revision += 1;
|
self.revision += 1;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn redo(&mut self) -> bool {
|
pub fn redo(&mut self) -> bool {
|
||||||
let Some(shape) = self.redo.pop() else {
|
let Some(item) = self.redo.pop() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
self.shapes.push(shape);
|
self.items.push(item);
|
||||||
self.revision += 1;
|
self.revision += 1;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Changes whenever the drawn shapes do.
|
/// Changes whenever the drawn shapes or the crop do.
|
||||||
pub fn revision(&self) -> u64 {
|
pub fn revision(&self) -> u64 {
|
||||||
self.revision
|
self.revision
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn can_undo(&self) -> bool {
|
pub fn can_undo(&self) -> bool {
|
||||||
!self.shapes.is_empty()
|
!self.items.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn can_redo(&self) -> bool {
|
pub fn can_redo(&self) -> bool {
|
||||||
!self.redo.is_empty()
|
!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 {
|
pub fn is_dirty(&self) -> bool {
|
||||||
self.shapes != self.saved
|
self.items != self.saved
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mark_saved(&mut self) {
|
pub fn mark_saved(&mut self) {
|
||||||
self.saved = self.shapes.clone();
|
self.saved = self.items.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +108,7 @@ impl Doc {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::editor::shape::{Style, Tool};
|
use crate::editor::shape::{Style, Tool};
|
||||||
|
use crate::frame::PixelRect;
|
||||||
|
|
||||||
fn line(x: f32) -> Shape {
|
fn line(x: f32) -> Shape {
|
||||||
Shape {
|
Shape {
|
||||||
@@ -91,9 +129,12 @@ mod tests {
|
|||||||
d.add(line(1.0));
|
d.add(line(1.0));
|
||||||
d.add(line(2.0));
|
d.add(line(2.0));
|
||||||
assert!(d.undo());
|
assert!(d.undo());
|
||||||
assert_eq!(d.shapes(), &[line(1.0)]);
|
assert_eq!(d.shapes().cloned().collect::<Vec<_>>(), [line(1.0)]);
|
||||||
assert!(d.redo());
|
assert!(d.redo());
|
||||||
assert_eq!(d.shapes(), &[line(1.0), line(2.0)]);
|
assert_eq!(
|
||||||
|
d.shapes().cloned().collect::<Vec<_>>(),
|
||||||
|
[line(1.0), line(2.0)]
|
||||||
|
);
|
||||||
assert!(!d.redo());
|
assert!(!d.redo());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +163,7 @@ mod tests {
|
|||||||
d.undo();
|
d.undo();
|
||||||
d.add(line(3.0));
|
d.add(line(3.0));
|
||||||
assert!(!d.can_redo());
|
assert!(!d.can_redo());
|
||||||
assert_eq!(d.shapes(), &[line(3.0)]);
|
assert_eq!(d.shapes().cloned().collect::<Vec<_>>(), [line(3.0)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -145,4 +186,66 @@ mod tests {
|
|||||||
d.redo();
|
d.redo();
|
||||||
assert!(!d.is_dirty(), "back to the saved content");
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -31,8 +31,6 @@ pub fn render<'a>(base: &RgbaImage, shapes: impl IntoIterator<Item = &'a Shape>)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The part of `image` inside the last crop, or all of it.
|
/// 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<PixelRect>) -> RgbaImage {
|
pub fn crop(image: RgbaImage, rect: Option<PixelRect>) -> RgbaImage {
|
||||||
match rect {
|
match rect {
|
||||||
Some(r) => image::imageops::crop_imm(&image, r.x, r.y, r.width, r.height).to_image(),
|
Some(r) => image::imageops::crop_imm(&image, r.x, r.y, r.width, r.height).to_image(),
|
||||||
|
|||||||
+18
-7
@@ -16,6 +16,8 @@ use eframe::egui::{
|
|||||||
};
|
};
|
||||||
use image::RgbaImage;
|
use image::RgbaImage;
|
||||||
|
|
||||||
|
use crate::frame::PixelRect;
|
||||||
|
|
||||||
use self::doc::Doc;
|
use self::doc::Doc;
|
||||||
use self::shape::{Drag, PALETTE, Prim, Style, Tool, WIDTHS, geometry};
|
use self::shape::{Drag, PALETTE, Prim, Style, Tool, WIDTHS, geometry};
|
||||||
use self::view::Fit;
|
use self::view::Fit;
|
||||||
@@ -105,7 +107,8 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn png(&self) -> Result<Vec<u8>> {
|
fn png(&self) -> Result<Vec<u8>> {
|
||||||
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 {
|
fn save(&mut self, copy: bool) -> bool {
|
||||||
@@ -293,8 +296,14 @@ impl Editor {
|
|||||||
let area = ui.max_rect();
|
let area = ui.max_rect();
|
||||||
let (response, painter) = ui.allocate_painter(area.size(), Sense::drag());
|
let (response, painter) = ui.allocate_painter(area.size(), Sense::drag());
|
||||||
let size = self.base.dimensions();
|
let size = self.base.dimensions();
|
||||||
let fit = Fit::new(
|
let shown = self.doc.crop().unwrap_or(PixelRect {
|
||||||
size,
|
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()),
|
(area.min.x, area.min.y, area.width(), area.height()),
|
||||||
ui.ctx().pixels_per_point(),
|
ui.ctx().pixels_per_point(),
|
||||||
);
|
);
|
||||||
@@ -302,12 +311,14 @@ impl Editor {
|
|||||||
let (x, y) = fit.to_screen(p);
|
let (x, y) = fit.to_screen(p);
|
||||||
Pos2::new(x, y)
|
Pos2::new(x, y)
|
||||||
};
|
};
|
||||||
let image_rect =
|
let (x0, y0) = (shown.x as f32, shown.y as f32);
|
||||||
Rect::from_min_max(to_pos((0.0, 0.0)), to_pos((size.0 as f32, size.1 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(
|
painter.image(
|
||||||
self.texture.id(),
|
self.texture.id(),
|
||||||
image_rect,
|
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,
|
Color32::WHITE,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -330,7 +341,7 @@ impl Editor {
|
|||||||
self.drag = Some(Drag::new(self.tool, self.style(), p));
|
self.drag = Some(Drag::new(self.tool, self.style(), p));
|
||||||
}
|
}
|
||||||
if let (Some(drag), Some(p)) = (&mut self.drag, pointer) {
|
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()
|
if response.drag_stopped()
|
||||||
&& let Some(drag) = self.drag.take()
|
&& let Some(drag) = self.drag.take()
|
||||||
|
|||||||
+73
-15
@@ -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::editor::shape::P;
|
||||||
|
use crate::frame::PixelRect;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub struct Fit {
|
pub struct Fit {
|
||||||
/// Points per image pixel.
|
/// Points per image pixel.
|
||||||
pub scale: f32,
|
pub scale: f32,
|
||||||
/// Where the image's top-left corner is, in points.
|
/// Where `origin` is drawn, in points.
|
||||||
pub offset: P,
|
pub offset: P,
|
||||||
|
/// The image pixel at the top-left of what is shown (the crop's corner).
|
||||||
|
pub origin: P,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Fit {
|
impl Fit {
|
||||||
/// Fits `image` into `area` (x, y, w, h in points), centred, never
|
/// Fits the whole image; see `with_crop`.
|
||||||
/// above 100 % (one image pixel per physical pixel).
|
#[cfg(test)]
|
||||||
pub fn new(image: (u32, u32), area: (f32, f32, f32, f32), pixels_per_point: f32) -> Fit {
|
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 (x, y, w, h) = area;
|
||||||
let scale = (w / iw).min(h / ih).min(1.0 / pixels_per_point);
|
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
|
// 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(x + (w - iw * scale) / 2.0),
|
||||||
snap(y + (h - ih * 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 {
|
pub fn to_screen(self, p: P) -> P {
|
||||||
(
|
(
|
||||||
self.offset.0 + p.0 * self.scale,
|
self.offset.0 + (p.0 - self.origin.0) * self.scale,
|
||||||
self.offset.1 + p.1 * self.scale,
|
self.offset.1 + (p.1 - self.origin.1) * self.scale,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_image(self, p: P) -> P {
|
pub fn to_image(self, p: P) -> P {
|
||||||
(
|
(
|
||||||
(p.0 - self.offset.0) / self.scale,
|
(p.0 - self.offset.0) / self.scale + self.origin.0,
|
||||||
(p.1 - self.offset.1) / self.scale,
|
(p.1 - self.offset.1) / self.scale + self.origin.1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `p` moved onto the image's area.
|
/// `p` moved into `rect`.
|
||||||
pub fn clamp(p: P, image: (u32, u32)) -> P {
|
pub fn clamp(p: P, rect: PixelRect) -> P {
|
||||||
(
|
(
|
||||||
p.0.clamp(0.0, image.0 as f32),
|
p.0.clamp(rect.x as f32, (rect.x + rect.width) as f32),
|
||||||
p.1.clamp(0.0, image.1 as f32),
|
p.1.clamp(rect.y as f32, (rect.y + rect.height) as f32),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +120,48 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clamp_keeps_points_on_the_image() {
|
fn clamp_keeps_points_on_the_image() {
|
||||||
assert_eq!(clamp((-5.0, 50.0), (100, 80)), (0.0, 50.0));
|
let r = PixelRect {
|
||||||
assert_eq!(clamp((150.0, 90.0), (100, 80)), (100.0, 80.0));
|
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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user