diff --git a/src/canvas.rs b/src/canvas.rs new file mode 100644 index 0000000..1ee8c13 --- /dev/null +++ b/src/canvas.rs @@ -0,0 +1,232 @@ +use druid::kurbo::BezPath; + +struct Interval { + start_pos: f64, + end_pos: f64, +} + +impl Interval { + fn new(x1: f64, x2: f64) -> Interval { + Interval { + start_pos: x1.min(x2), + end_pos: x1.max(x2), + } + } + + fn new_ordered(start: f64, end: f64) -> Interval { + Interval { + start_pos: start, + end_pos: end, + } + } + + fn overlaps(&self, other: &Self) -> bool { + self.start_pos < other.end_pos && other.start_pos < self.end_pos + } +} + +// O(1) complexity +// TODO: Implement this in a way that is not so flamboyant +// TODO: Implement intersection test for bezier curves +fn segment_intersects_rect(segment: &druid::kurbo::PathSeg, rect: druid::Rect) -> bool { + match segment { + druid::kurbo::PathSeg::Line(line) => { + // A Segment intersects the rect + // if their projections on the x and y line both overlap + let line_x_proj = Interval::new(line.p0.x, line.p1.x); + let line_y_proj = Interval::new(line.p0.y, line.p1.y); + + let rect_x_proj = Interval::new_ordered(rect.min_x(), rect.max_x()); + let rect_y_proj = Interval::new_ordered(rect.min_y(), rect.max_y()); + + line_x_proj.overlaps(&rect_x_proj) && line_y_proj.overlaps(&rect_y_proj) + }, + + _ => { + unimplemented!(); + } + } +} + + +#[derive(Clone, druid::Data)] +pub struct Path { + pub kurbo_path: BezPath, +} + +impl Path { + fn intersects_rect(&self, rect: druid::Rect) -> bool { + // The path intersects the rect if any of its segments does + self.kurbo_path.segments().any(|segment| segment_intersects_rect(&segment, rect)) + } +} + +#[derive(Clone, druid::Data)] +pub enum CanvasElement { + Freehand { path: Path, thickness: f64 }, +} + +impl CanvasElement { + pub fn bounding_box(&self) -> druid::Rect { + match self { + CanvasElement::Freehand { path, thickness } => { + use druid::kurbo::Shape; + path.kurbo_path + .bounding_box() + .inflate(*thickness, *thickness) + } + } + } + pub fn draw(&self, ctx: &mut druid::PaintCtx) { + match self { + CanvasElement::Freehand { path, thickness } => { + use druid::RenderContext; + let stroke_color = druid::Color::rgb8(0, 128, 0); + ctx.stroke(&path.kurbo_path, &stroke_color, *thickness); + } + } + } + + pub fn get_path_mut(&mut self) -> Option<&mut Path> { + match self { + CanvasElement::Freehand { path, .. } => Some(path), + } + } +} + +// A canvas contains all elements to be drawn +#[derive(Clone, druid::Data)] +pub struct Canvas { + pub elements: im::Vector, + pub version_id: usize +} + +impl Canvas { + pub fn new() -> Canvas { + Canvas { + elements: im::vector![], + version_id: 0, + } + } + + pub fn unchanged(&self, other: &Canvas) -> bool { + self.version_id == other.version_id + } + + // Completely erase all strokes that intersect the rect + pub fn erase_strokes(&mut self, rect: druid::Rect) { + // TODO: Improve efficiency + + // Linear search through all elements in the canvas + // to find those whose bounding box intersects the eraser rect + // (note that this doesn't imply that the element itself intersects the eraser rect, + // but it helps filtering out elements that are too far away from the eraser) + let mut new_elements = im::Vector::new(); + + for elem in self.elements.iter() { + // Check if the element intersects the eraser rect + if elem.bounding_box().intersect(rect).area() > 0.0 { + // Check the element type, we only erase Freehand paths + match elem { + CanvasElement::Freehand {path, ..} => { + // Check whether the path intersects the rect, + // if so, remove it. + if !path.intersects_rect(rect) { + new_elements.push_back(elem.clone()); + } else { + self.version_id += 1; + } + } + } + } else { + new_elements.push_back(elem.clone()); + } + } + + // Update elements list + self.elements = new_elements; + } + + // O(n) complexity, where n is the number of segments belonging to paths + // that intersect the eraser rectangle + // Erase all portions of paths lying in the rect + pub fn erase_paths(&mut self, rect: druid::Rect) { + // TODO: Improve efficiency + + // Linear search through all elements in the canvas + // to find those whose bounding box intersects the eraser rect + // (note that this doesn't imply that the element itself intersects the eraser rect, + // but it helps filtering out elements that are too far away from the eraser) + let mut new_elements = im::Vector::new(); + + for elem in self.elements.iter() { + // Check if the element intersects the eraser rect + if elem.bounding_box().intersect(rect).area() > 0.0 { + // Check the element type, we only erase Freehand paths + match elem { + CanvasElement::Freehand {path, thickness} => { + // Remove segments intersecting the eraser + // And return the remaining subpaths + let (path_changed, new_paths) = Canvas::erase_segments_from_freehand_path(&path, rect); + + if path_changed { + // There has been some change, update the canvas + self.version_id += 1; + for path in new_paths { + new_elements.push_back(CanvasElement::Freehand { + path: path, + thickness: *thickness, + }); + } + } + } + } + } else { + new_elements.push_back(elem.clone()); + } + } + + // Update elements list + self.elements = new_elements; + } + + // Erase segments from a path, and returns the new subpaths that are left + fn erase_segments_from_freehand_path(path: &Path, rect: druid::Rect) -> (bool, im::Vector) { + + // Vector of pieces + let mut pieces = im::Vector::new(); + // Current subpath + let mut curr_piece = im::Vector::new(); + + // Flag indicating whether the segment has been modified + let mut erased = false; + + for segment in path.kurbo_path.segments() { + if segment_intersects_rect(&segment, rect) { + erased = true; + // We found a segment to erase, so split the path here + // If the path is not empty, add it to the pieces vector + if !curr_piece.is_empty() { + pieces.push_back(curr_piece); + // Recreate accumulator + curr_piece = im::Vector::new(); + } + } else { + // Add segment to curr piece + curr_piece.push_back(segment); + } + } + + // We end up with `pieces`, a vector of non empty paths + let paths = pieces.iter().map(|segments| Path { + kurbo_path: BezPath::from_path_segments(segments.iter().cloned()), + }).collect(); + + (erased, paths) + } + + pub fn add_element(&mut self, element: CanvasElement) { + self.version_id += 1; + self.elements.push_back(element); + } +} diff --git a/src/canvas_tools.rs b/src/canvas_tools.rs new file mode 100644 index 0000000..c106679 --- /dev/null +++ b/src/canvas_tools.rs @@ -0,0 +1,133 @@ +use crate::canvas::*; +use crate::versioned_canvas::VersionedCanvas; + +use druid::kurbo::BezPath; +use druid::{Data, Event, Rect}; + +// Tools that can be used to interact with the canvas +#[derive(Clone, Data)] +pub enum CanvasTool { + Pen { + current_element: Option, + }, + Eraser { + eraser_rect: Option, + }, + + StrokeEraser { + eraser_rect: Option, + }, +} + +impl CanvasTool { + pub fn new_pen() -> CanvasTool { + CanvasTool::Pen { + current_element: None, + } + } + + pub fn new_eraser() -> CanvasTool { + CanvasTool::Eraser { eraser_rect: None } + } + + pub fn new_stroke_eraser() -> CanvasTool { + CanvasTool::StrokeEraser { eraser_rect: None } + } + + pub fn handle_event(&mut self, event: &Event, canvas: &mut VersionedCanvas) { + match self { + CanvasTool::Pen { current_element } => { + CanvasTool::pen_handle_event(current_element, event, canvas) + } + CanvasTool::Eraser { eraser_rect } => { + CanvasTool::eraser_handle_event(eraser_rect, event, canvas, false) + } + CanvasTool::StrokeEraser { eraser_rect } => { + CanvasTool::eraser_handle_event(eraser_rect, event, canvas, true) + } + } + } + + fn pen_handle_event( + current_element: &mut Option, + event: &Event, + canvas: &mut VersionedCanvas, + ) { + match event { + Event::MouseDown(mouse_event) => { + let mut kurbo_path = BezPath::new(); + kurbo_path.move_to((mouse_event.pos.x, mouse_event.pos.y)); + *current_element = Some(CanvasElement::Freehand { + path: Path { kurbo_path }, + thickness: 2.0, + }); + } + Event::MouseMove(mouse_event) => { + if current_element.is_some() { + if let Some(current_element) = current_element.as_mut() { + current_element + .get_path_mut() + .unwrap() + .kurbo_path + .line_to((mouse_event.pos.x, mouse_event.pos.y)); + } + } + } + Event::MouseUp(_) => { + if current_element.is_some() { + if let Some(current_element) = current_element.take() { + canvas.update(move |canvas: &mut Canvas| { + canvas.add_element(current_element) + }); + } + } + } + _ => {} + } + } + + fn eraser_handle_event( + eraser_rect: &mut Option, + event: &Event, + canvas: &mut VersionedCanvas, + is_stroke_eraser: bool + ) { + match event { + Event::MouseDown(mouse_event) => { + let rect = druid::Rect::from_center_size(mouse_event.pos, druid::Size::new(10.0, 10.0)); + *eraser_rect = Some(rect); + // Create a new undo version each time the mouse is down + canvas.update(|canvas: &mut Canvas| { + if is_stroke_eraser { + canvas.erase_strokes(rect) + } else { + canvas.erase_paths(rect) + } + }); + } + Event::MouseMove(mouse_event) => { + if eraser_rect.is_some() { + let rect = druid::Rect::from_center_size( + mouse_event.pos, + druid::Size::new(10.0, 10.0), + ); + *eraser_rect = Some(rect); + // We don't want too many levels of undoing + // So we make irreversible changes as long as + // the mouse is pressed. + canvas.irreversible_update(|canvas: &mut Canvas| { + if is_stroke_eraser { + canvas.erase_strokes(rect) + } else { + canvas.erase_paths(rect) + } + }); + } + } + Event::MouseUp(_) => { + *eraser_rect = None; + } + _ => {} + } + } +} diff --git a/src/lib.rs b/src/lib.rs index bf2baa7..44639cf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,112 +1,3 @@ -#[derive(Clone, druid::Data)] -pub struct Path { - pub kurbo_path: druid::kurbo::BezPath, -} - -#[derive(Clone, druid::Data)] -pub enum CanvasElement { - Freehand { path: Path, thickness: f64 }, -} - -impl CanvasElement { - pub fn bounding_box(&self) -> druid::Rect { - match self { - CanvasElement::Freehand { path, thickness } => { - use druid::kurbo::Shape; - path.kurbo_path - .bounding_box() - .inflate(*thickness, *thickness) - } - } - } - pub fn draw(&self, ctx: &mut druid::PaintCtx) { - match self { - CanvasElement::Freehand { path, thickness } => { - use druid::RenderContext; - let stroke_color = druid::Color::rgb8(0, 128, 0); - ctx.stroke(&path.kurbo_path, &stroke_color, *thickness); - } - } - } - - pub fn get_path_mut(&mut self) -> Option<&mut Path> { - match self { - CanvasElement::Freehand { path, .. } => Some(path), - } - } -} - -use im::Vector; - -// A canvas contains all elements to be drawn -pub type Canvas = Vector; - -#[derive(Clone, druid::Data)] -pub struct VersionedCanvas { - // We internally guarantee that this vector - // is never empty - versions: Vector, - curr_version: usize, -} - -impl VersionedCanvas { - pub fn new(canvas: Canvas) -> VersionedCanvas { - VersionedCanvas { - versions: im::vector![canvas], - curr_version: 0, - } - } - - // Get current canvas version - pub fn get(&self) -> &Canvas { - let focus = self.versions.focus(); - self.versions.get(self.curr_version).unwrap() - } - - fn has_newer_versions(&self) -> bool { - self.curr_version + 1 < self.versions.len() - } - - fn has_older_versions(&self) -> bool { - self.curr_version > 0 - } - - pub fn undo(&mut self) { - if self.has_older_versions() { - self.curr_version = self.curr_version - 1; - } - } - - pub fn redo(&mut self) { - if self.has_newer_versions() { - self.curr_version = self.curr_version + 1; - } - } - - pub fn update(&mut self, update_fn: impl FnOnce(&Canvas) -> Canvas) { - // This is a linear history, - // so we first check if there are newer versions, if so - // this means we are in the past, so a change in the past destroys the future - // and creates a new future. - if self.has_newer_versions() { - self.versions = self.versions.take(self.curr_version + 1); - } - let new_version = update_fn(self.get()); - self.versions.push_back(new_version); - self.curr_version = self.curr_version + 1; - } - - // Do inplace update, which will be irreversible - pub fn irreversible_update(&mut self, update_fn: impl FnOnce(&mut Canvas)) { - // This is a linear history, - // so we first check if there are newer versions, if so - // this means we are in the past, so a change in the past destroys the future - // and creates a new future. - if self.has_newer_versions() { - self.versions = self.versions.take(self.curr_version + 1); - } - - update_fn(self.versions.back_mut().unwrap()); - } -} - +pub mod canvas; +pub mod canvas_tools; +pub mod versioned_canvas; diff --git a/src/main.rs b/src/main.rs index fa001d2..1043dff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,35 +14,33 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use druid::im::{vector}; -use druid::kurbo::BezPath; use druid::widget::prelude::*; -use druid::{AppLauncher, Color, Data, Event, LocalizedString, WindowDesc}; +use druid::{ + AppLauncher, Color, Data, Event, Lens, LensExt, LocalizedString, WidgetExt, WindowDesc, +}; -use stiletto::{CanvasElement, VersionedCanvas, Canvas}; +use stiletto::canvas::Canvas; +use stiletto::canvas_tools::CanvasTool; +use stiletto::versioned_canvas::VersionedCanvas; -#[derive(Clone, Data)] +#[derive(Clone, Data, Lens)] struct CanvasData { - current_element: Option, - //elements: Vector, - elements: VersionedCanvas, + #[lens(name = "current_tool_lens")] + current_tool: CanvasTool, + canvas: VersionedCanvas, } impl CanvasData { - fn is_drawing(&self) -> bool { - self.current_element.is_some() - } - fn perform_undo(&mut self) { - if !self.is_drawing() { - self.elements.undo(); - } + self.canvas.undo(); } fn perform_redo(&mut self) { - if !self.is_drawing() { - self.elements.redo(); - } + self.canvas.redo(); + } + + fn handle_tool_event(&mut self, event: &Event) { + self.current_tool.handle_event(event, &mut self.canvas); } } @@ -50,41 +48,7 @@ struct CanvasWidget; impl Widget for CanvasWidget { fn event(&mut self, _ctx: &mut EventCtx, event: &Event, data: &mut CanvasData, _env: &Env) { - match event { - Event::MouseDown(mouse_event) => { - let mut kurbo_path = BezPath::new(); - kurbo_path.move_to((mouse_event.pos.x, mouse_event.pos.y)); - data.current_element = Some(CanvasElement::Freehand { - path: stiletto::Path { kurbo_path }, - thickness: 2.0, - }); - } - Event::MouseMove(mouse_event) => { - if data.is_drawing() { - if let Some(current_element) = data.current_element.as_mut() { - current_element - .get_path_mut() - .unwrap() - .kurbo_path - .line_to((mouse_event.pos.x, mouse_event.pos.y)); - } - } - } - Event::MouseUp(_) => { - if data.is_drawing() { - if let Some(current_element) = data.current_element.take() { - - data.elements.update(move |canvas: &Canvas| -> Canvas { - let mut new_canvas = canvas.clone(); - new_canvas.push_back(current_element); - return new_canvas; - }); - - } - } - } - _ => {} - } + data.handle_tool_event(event); } fn lifecycle( @@ -103,16 +67,44 @@ impl Widget for CanvasWidget { data: &CanvasData, _env: &Env, ) { - // the current_element is moved to the elements array, no need to repaint - if old_data.is_drawing() && !data.is_drawing() { - return; - } - if data.is_drawing() { - if let Some(e) = data.current_element.as_ref() { - ctx.request_paint_rect(e.bounding_box()); + // TODO: Polish a bit this logic + match (&old_data.current_tool, &data.current_tool) { + ( + CanvasTool::Pen { + current_element: old_element, + }, + CanvasTool::Pen { + current_element: new_element, + }, + ) => { + // the current_element is moved to the elements array, no need to repaint + if old_element.is_some() && !new_element.is_some() { + return; + } + + if new_element.is_some() { + if let Some(e) = new_element.as_ref() { + ctx.request_paint_rect(e.bounding_box()); + } + } else { + ctx.request_paint(); + } + } + (CanvasTool::Eraser{..}, CanvasTool::Eraser{ eraser_rect }) => { + if let Some(rect) = eraser_rect { + ctx.request_paint_rect(*rect); + } else { + ctx.request_paint(); + } + } + (CanvasTool::StrokeEraser{..}, CanvasTool::StrokeEraser{..}) => { + ctx.request_paint(); + } + + _ => { + // we just changed the canvas tool, there is no need to repaint + return; } - } else { - ctx.request_paint(); } } @@ -143,24 +135,75 @@ impl Widget for CanvasWidget { // and we only want to clear this widget's area. ctx.fill(rect, &Color::WHITE); - for element in data.elements.get().iter() { + for element in data.canvas.get().elements.iter() { element.draw(ctx); } - if let Some(element) = &data.current_element { - element.draw(ctx); + + match &data.current_tool { + CanvasTool::Pen { current_element } => { + if let Some(element) = ¤t_element { + element.draw(ctx); + } + } + _ => {} } } } +// Enum containing an entry for each canvas tool +// it is used to implement the radio group for selecting the tool +#[derive(Clone, Data, PartialEq)] +enum ToolType { + Pen, + Eraser, + StrokeEraser, +} + +// Make a CanvasTool of a given type +fn update_canvas_tool_from_type(tool: &mut CanvasTool, new_type: ToolType) { + match new_type { + ToolType::Pen => *tool = CanvasTool::new_pen(), + ToolType::Eraser => *tool = CanvasTool::new_eraser(), + ToolType::StrokeEraser => *tool = CanvasTool::new_stroke_eraser(), + } +} + +// Get a ToolType from a CanvasTool +fn canvas_tool_type(tool: &CanvasTool) -> ToolType { + match tool { + CanvasTool::Pen { .. } => ToolType::Pen, + CanvasTool::Eraser { .. } => ToolType::Eraser, + CanvasTool::StrokeEraser { .. } => ToolType::StrokeEraser, + } +} + fn build_ui() -> impl Widget { - use druid::widget::{Align, Button, CrossAxisAlignment, Flex, SizedBox}; + use druid::widget::{Align, Button, CrossAxisAlignment, Flex, Radio, SizedBox}; + + let toolbar = Flex::row() .cross_axis_alignment(CrossAxisAlignment::Center) .with_spacer(30.0) .with_child( - Button::new("Undo").on_click(|_ctx: &mut EventCtx, data: &mut CanvasData, _env: &Env| data.perform_undo()) + Button::new("Undo").on_click(|_ctx, data: &mut CanvasData, _env| data.perform_undo()), ) - .with_child(Button::new("Redo").on_click(|_ctx: &mut EventCtx, data: &mut CanvasData, _env: &Env| data.perform_redo())); + .with_child( + Button::new("Redo").on_click(|_ctx, data: &mut CanvasData, _env| data.perform_redo()), + ) + .with_child( + Radio::new("Pen", ToolType::Pen).lens(CanvasData::current_tool_lens.map(canvas_tool_type, update_canvas_tool_from_type)) + ) + .with_child( + Radio::new("Eraser", ToolType::Eraser).lens(CanvasData::current_tool_lens.map(canvas_tool_type, update_canvas_tool_from_type)) + ) + .with_child( + Radio::new("Stroke Eraser", ToolType::StrokeEraser).lens(CanvasData::current_tool_lens.map(canvas_tool_type, update_canvas_tool_from_type)) + ); + // ^ We use a lens to make the radiogroup act only on the tool portion of data. + // Actually we must also perform a conversion, between ToolType and CanvasTool, + // that's why we perform a map on the lens. + + Flex::column() .cross_axis_alignment(CrossAxisAlignment::Center) @@ -176,8 +219,8 @@ pub fn main() { LocalizedString::new("custom-widget-demo-window-title").with_placeholder("Stiletto"), ); let canvas_data = CanvasData { - current_element: None, - elements: VersionedCanvas::new(vector![]), + current_tool: CanvasTool::new_pen(), + canvas: VersionedCanvas::new(Canvas::new()), }; AppLauncher::with_window(window) .use_simple_logger() diff --git a/src/versioned_canvas.rs b/src/versioned_canvas.rs new file mode 100644 index 0000000..2996409 --- /dev/null +++ b/src/versioned_canvas.rs @@ -0,0 +1,88 @@ +use crate::canvas::Canvas; + +#[derive(Clone, druid::Data)] +pub struct VersionedCanvas { + // We internally guarantee that this vector + // is never empty + versions: im::Vector, + curr_version: usize, +} + +impl VersionedCanvas { + pub fn new(canvas: Canvas) -> VersionedCanvas { + VersionedCanvas { + versions: im::vector![canvas], + curr_version: 0, + } + } + + // Get current canvas version + pub fn get(&self) -> &Canvas { + self.versions.get(self.curr_version).unwrap() + } + + fn has_newer_versions(&self) -> bool { + self.curr_version + 1 < self.versions.len() + } + + fn has_older_versions(&self) -> bool { + self.curr_version > 0 + } + + pub fn version(&self) -> usize { + self.curr_version + } + + pub fn undo(&mut self) { + if self.has_older_versions() { + self.curr_version = self.curr_version - 1; + } + } + + pub fn redo(&mut self) { + if self.has_newer_versions() { + self.curr_version = self.curr_version + 1; + } + } + + // TODO: Right now the update function internally checks + // whether the canvas has been changed by leveraging the + // Copy on Write semantics of im::Vector. + // Is this a good solution? Does this work correctly? THINK ABOUT THIS + pub fn update(&mut self, update_fn: impl FnOnce(&mut Canvas)) { + let mut new_version = self.get().clone(); + update_fn(&mut new_version); + + // Only push new version if there has been an actual change in the vector + if !new_version.unchanged(&self.get()) { + // This is a linear history, + // so we first check if there are newer versions, if so + // this means we are in the past, so a change in the past destroys the future + // and creates a new future. + if self.has_newer_versions() { + self.versions = self.versions.take(self.curr_version + 1); + } + + self.versions.push_back(new_version); + self.curr_version = self.curr_version + 1; + } + } + + // Do inplace update, which will be irreversible + pub fn irreversible_update(&mut self, update_fn: impl FnOnce(&mut Canvas)) { + let old_version = self.get().version_id; + + update_fn(self.versions.back_mut().unwrap()); + + // Check whether there has been a new change + if old_version != self.get().version_id { + // This is a linear history, + // so we first check if there are newer versions, if so + // this means we are in the past, so a change in the past destroys the future + // and creates a new future. + if self.has_newer_versions() { + self.versions = self.versions.take(self.curr_version + 1); + } + } + } +}