Repository: flxzt/rnote (language: rust)

Issue:
Laser tool? (feature request)
A lot of handwriting notes app provide a laser too, so If you are presenting your screen, you can use that laser tool to draw over, but that draw is temporary and is timed, and disppears after some seconds. It would be good to have this feature.
A sample what I am talking about: https://youtu.be/G6lAWTgY0mo?t=527, this is actually a video about a notetaking app on IOS, but I just wanted to show how this feature works.


Discussion / hints:

Fix patch:
diff --git a/crates/rnote-engine/src/meson.build b/crates/rnote-engine/src/meson.build
index 1885a1f681..80347fc64f 100644
--- a/crates/rnote-engine/src/meson.build
+++ b/crates/rnote-engine/src/meson.build
@@ -35,6 +35,7 @@ rnote_engine_sources = files(
     'pens/shaper.rs',
     'pens/shortcuts.rs',
     'pens/tools/mod.rs',
+    'pens/tools/laser.rs',
     'pens/tools/offsetcamera.rs',
     'pens/tools/verticalspace.rs',
     'pens/tools/zoom.rs',
diff --git a/crates/rnote-engine/src/pens/mod.rs b/crates/rnote-engine/src/pens/mod.rs
index 0c3c677abf..ce6ff0ddfc 100644
--- a/crates/rnote-engine/src/pens/mod.rs
+++ b/crates/rnote-engine/src/pens/mod.rs
@@ -113,6 +113,19 @@ impl PenBehaviour for Pen {
         }
     }
 
+    fn handle_animation_frame(&mut self, engine_view: &mut EngineViewMut, optimize_epd: bool) {
+        match self {
+            Pen::Brush(brush) => brush.handle_animation_frame(engine_view, optimize_epd),
+            Pen::Shaper(shaper) => shaper.handle_animation_frame(engine_view, optimize_epd),
+            Pen::Typewriter(typewriter) => {
+                typewriter.handle_animation_frame(engine_view, optimize_epd)
+            }
+            Pen::Eraser(eraser) => eraser.handle_animation_frame(engine_view, optimize_epd),
+            Pen::Selector(selector) => selector.handle_animation_frame(engine_view, optimize_epd),
+            Pen::Tools(tools) => tools.handle_animation_frame(engine_view, optimize_epd),
+        }
+    }
+
     fn fetch_clipboard_content(
         &self,
         engine_view: &EngineView,
diff --git a/crates/rnote-engine/src/pens/pensconfig/toolsconfig.rs b/crates/rnote-engine/src/pens/pensconfig/toolsconfig.rs
index 578ef7780e..9fc40e407a 100644
--- a/crates/rnote-engine/src/pens/pensconfig/toolsconfig.rs
+++ b/crates/rnote-engine/src/pens/pensconfig/toolsconfig.rs
@@ -22,6 +22,8 @@ pub enum ToolStyle {
     OffsetCamera,
     #[serde(rename = "zoom")]
     Zoom,
+    #[serde(rename = "laser")]
+    Laser,
 }
 
 impl Default for ToolStyle {
diff --git a/crates/rnote-engine/src/pens/tools/laser.rs b/crates/rnote-engine/src/pens/tools/laser.rs
new file mode 100644
index 0000000000..6afce05818
--- /dev/null
+++ b/crates/rnote-engine/src/pens/tools/laser.rs
@@ -0,0 +1,266 @@
+// Imports
+use super::ToolsState;
+use crate::engine::{EngineView, EngineViewMut};
+use crate::{DrawableOnDoc, WidgetFlags};
+use p2d::bounding_volume::Aabb;
+use p2d::bounding_volume::BoundingVolume;
+use piet::RenderContext;
+use rnote_compose::builders::buildable::Buildable;
+use rnote_compose::builders::buildable::BuilderCreator;
+use rnote_compose::builders::buildable::BuilderProgress;
+use rnote_compose::builders::PenPathCurvedBuilder;
+use rnote_compose::color;
+use rnote_compose::eventresult::{EventPropagation, EventResult};
+use rnote_compose::ext::AabbExt;
+use rnote_compose::penevent::{PenEvent, PenProgress};
+use rnote_compose::penpath::Element;
+use rnote_compose::penpath::Segment;
+use rnote_compose::shapes::Shapeable;
+use rnote_compose::Constraints;
+use rnote_compose::PenPath;
+use std::time::Duration;
+use std::time::Instant;
+
+#[derive(Debug)]
+pub struct LaserTool {
+    state: ToolsState,
+    path_builder: Option<Box<dyn Buildable<Emit = Segment>>>,
+    pen_paths: Vec<PenPath>,
+    fade_start_time: Option<Instant>,
+    opacity: u8,
+}
+
+impl Default for LaserTool {
+    fn default() -> Self {
+        Self {
+            state: ToolsState::default(),
+            path_builder: None,
+            pen_paths: Vec::new(),
+            fade_start_time: None,
+            opacity: u8::MAX,
+        }
+    }
+}
+
+impl LaserTool {
+    const FULL_FADE_DURATION: Duration = Duration::from_secs(1);
+
+    const OUTER_STROKE_WIDTH: f64 = 6.0;
+    const INNER_STROKE_WIDTH: f64 = 1.0;
+
+    const INNER_STROKE_COLOR: piet::Color = color::GNOME_BRIGHTS[1];
+    const OUTER_STROKE_COLOR: piet::Color = color::GNOME_REDS[1];
+
+    const STYLE: piet::StrokeStyle = piet::StrokeStyle::new()
+        .line_join(piet::LineJoin::Round)
+        .line_cap(piet::LineCap::Round);
+
+    pub fn add_new_stroke(&mut self, element: Element) {
+        self.pen_paths.push(PenPath::new(element));
+        self.stop_fade();
+    }
+
+    pub fn extend_last_stroke(&mut self, progress: BuilderProgress<Segment>) {
+        if let Some(last_stroke) = self.pen_paths.last_mut() {
+            match progress {
+                BuilderProgress::InProgress => {}
+                BuilderProgress::EmitContinue(segments) | BuilderProgress::Finished(segments) => {
+                    last_stroke.extend(segments);
+                }
+            };
+        }
+    }
+
+    pub fn start_fade(&mut self, now: Instant) {
+        self.fade_start_time = Some(now);
+    }
+
+    pub fn stop_fade(&mut self) {
+        self.fade_start_time = None;
+        self.opacity = u8::MAX;
+    }
+
+    /// Returns `Some(bool)` if the fade is in progress, otherwise `None`.
+    pub fn has_fully_faded(&self) -> Option<bool> {
+        self.fade_start_time
+            .map(|time| time.elapsed() >= LaserTool::FULL_FADE_DURATION)
+    }
+
+    pub fn handle_event(
+        &mut self,
+        event: PenEvent,
+        now: Instant,
+        engine_view: &mut EngineViewMut,
+    ) -> (EventResult<PenProgress>, WidgetFlags) {
+        let widget_flags = WidgetFlags::default();
+
+        let event_result = match (&mut self.state, &event) {
+            (ToolsState::Idle, PenEvent::Down { element, .. }) => {
+                self.add_new_stroke(*element);
+
+                self.path_builder = Some(Box::new(PenPathCurvedBuilder::start(*element, now)));
+                self.state = ToolsState::Active;
+
+                EventResult {
+                    handled: true,
+                    propagate: EventPropagation::Stop,
+                    progress: PenProgress::InProgress,
+                }
+            }
+            (ToolsState::Idle, _) => EventResult {
+                handled: false,
+                propagate: EventPropagation::Proceed,
+                progress: PenProgress::Idle,
+            },
+            (ToolsState::Active, PenEvent::Down { .. }) => {
+                if let Some(builder) = &mut self.path_builder {
+                    let builder_result = builder.handle_event(event, now, Constraints::default());
+
+                    self.extend_last_stroke(builder_result.progress);
+                }
+
+                EventResult {
+                    handled: true,
+                    propagate: EventPropagation::Stop,
+                    progress: PenProgress::InProgress,
+                }
+            }
+            (ToolsState::Active, PenEvent::Up { .. }) => {
+                let mut progress = PenProgress::Finished;
+
+                if let Some(builder) = &mut self.path_builder {
+                    let builder_result = builder.handle_event(event, now, Constraints::default());
+
+                    self.extend_last_stroke(builder_result.progress);
+                    self.start_fade(now);
+
+                    engine_view.animation.claim_frame();
+                    progress = PenProgress::InProgress;
+                }
+
+                self.reset(false);
+
+                EventResult {
+                    handled: true,
+                    propagate: EventPropagation::Stop,
+                    progress,
+                }
+            }
+            (ToolsState::Active, PenEvent::Proximity { .. }) => EventResult {
+                handled: false,
+                propagate: EventPropagation::Proceed,
+                progress: PenProgress::InProgress,
+            },
+            (ToolsState::Active, PenEvent::KeyPressed { .. }) => EventResult {
+                handled: false,
+                propagate: EventPropagation::Proceed,
+                progress: PenProgress::InProgress,
+            },
+            (ToolsState::Active, PenEvent::Cancel) => {
+                self.reset(true);
+
+                EventResult {
+                    handled: true,
+                    propagate: EventPropagation::Stop,
+                    progress: PenProgress::Finished,
+                }
+            }
+            (ToolsState::Active, PenEvent::Text { .. }) => EventResult {
+                handled: false,
+                propagate: EventPropagation::Proceed,
+                progress: PenProgress::InProgress,
+            },
+        };
+
+        (event_result, widget_flags)
+    }
+
+    pub fn handle_animation_frame(&mut self, engine_view: &mut EngineViewMut, optimize_epd: bool) {
+        let Some(faded) = self.has_fully_faded() else {
+            return;
+        };
+
+        if faded {
+            self.reset(true);
+        } else {
+            if !optimize_epd {
+                let transparency = self
+                    .fade_start_time
+                    .unwrap() // Never fails because `has_fully_faded` has not returned `None`.
+                    .elapsed()
+                    .div_duration_f64(Self::FULL_FADE_DURATION)
+                    .clamp(0.0, 1.0);
+
+                self.opacity = ((1.0 - transparency) * 255.0) as u8;
+            }
+
+            engine_view.animation.claim_frame();
+        }
+    }
+
+    fn reset(&mut self, clear: bool) {
+        self.state = ToolsState::Idle;
+        self.path_builder = None;
+
+        if clear {
+            self.pen_paths.clear();
+            self.stop_fade();
+        }
+    }
+}
+
+impl DrawableOnDoc for LaserTool {
+    fn bounds_on_doc(&self, engine_view: &EngineView) -> Option<Aabb> {
+        if self.has_fully_faded() == Some(true) {
+            return None;
+        }
+
+        let strokes = self.pen_paths.iter();
+
+        strokes
+            .fold(None, |acc: Option<Aabb>, stroke| {
+                let bounds = stroke.bounds();
+
+                match acc {
+                    Some(acc) => Some(acc.merged(&bounds)),
+                    None => Some(bounds),
+                }
+            })
+            .map(|bounds| {
+                bounds.extend_by(na::Vector2::repeat(
+                    Self::OUTER_STROKE_WIDTH / engine_view.camera.total_zoom(),
+                ))
+            })
+    }
+
+    fn draw_on_doc(
+        &self,
+        cx: &mut piet_cairo::CairoRenderContext,
+        engine_view: &EngineView,
+    ) -> anyhow::Result<()> {
+        cx.save().map_err(|e| anyhow::anyhow!("{e:?}"))?;
+
+        let total_zoom = engine_view.camera.total_zoom();
+
+        for pen_path in &self.pen_paths {
+            let bez_path = pen_path.to_kurbo_flattened(0.5);
+
+            cx.stroke_styled(
+                &bez_path,
+                &Self::OUTER_STROKE_COLOR.with_a8(self.opacity),
+                Self::OUTER_STROKE_WIDTH / total_zoom,
+                &LaserTool::STYLE,
+            );
+
+            cx.stroke_styled(
+                &bez_path,
+                &Self::INNER_STROKE_COLOR.with_a8(self.opacity),
+                Self::INNER_STROKE_WIDTH / total_zoom,
+                &LaserTool::STYLE,
+            );
+        }
+
+        cx.restore().map_err(|e| anyhow::anyhow!("{e:?}"))?;
+        Ok(())
+    }
+}
diff --git a/crates/rnote-engine/src/pens/tools/mod.rs b/crates/rnote-engine/src/pens/tools/mod.rs
index 6feac7f6b5..a3dd1e02ad 100644
--- a/crates/rnote-engine/src/pens/tools/mod.rs
+++ b/crates/rnote-engine/src/pens/tools/mod.rs
@@ -1,9 +1,11 @@
 // Modules
+mod laser;
 mod offsetcamera;
 mod verticalspace;
 mod zoom;
 
 // Re-Exports
+use laser::LaserTool;
 use offsetcamera::OffsetCameraTool;
 use verticalspace::VerticalSpaceTool;
 use zoom::ZoomTool;
@@ -32,11 +34,12 @@ impl Default for ToolsState {
     }
 }
 
-#[derive(Clone, Debug, Default)]
+#[derive(Debug, Default)]
 pub struct Tools {
     verticalspace_tool: VerticalSpaceTool,
     offsetcamera_tool: OffsetCameraTool,
     zoom_tool: ZoomTool,
+    laser_tool: LaserTool,
 }
 
 impl PenBehaviour for Tools {
@@ -69,6 +72,16 @@ impl PenBehaviour for Tools {
             }
             ToolStyle::OffsetCamera => self.offsetcamera_tool.handle_event(event, now, engine_view),
             ToolStyle::Zoom => self.zoom_tool.handle_event(event, now, engine_view),
+            ToolStyle::Laser => self.laser_tool.handle_event(event, now, engine_view),
+        }
+    }
+
+    fn handle_animation_frame(&mut self, engine_view: &mut EngineViewMut, optimize_epd: bool) {
+        match engine_view.pens_config.tools_config.style {
+            ToolStyle::Laser => self
+                .laser_tool
+                .handle_animation_frame(engine_view, optimize_epd),
+            _ => {}
         }
     }
 }
@@ -79,6 +92,7 @@ impl DrawableOnDoc for Tools {
             ToolStyle::VerticalSpace => self.verticalspace_tool.bounds_on_doc(engine_view),
             ToolStyle::OffsetCamera => self.offsetcamera_tool.bounds_on_doc(engine_view),
             ToolStyle::Zoom => self.zoom_tool.bounds_on_doc(engine_view),
+            ToolStyle::Laser => self.laser_tool.bounds_on_doc(engine_view),
         }
     }
 
@@ -99,6 +113,9 @@ impl DrawableOnDoc for Tools {
             ToolStyle::Zoom => {
                 self.zoom_tool.draw_on_doc(cx, engine_view)?;
             }
+            ToolStyle::Laser => {
+                self.laser_tool.draw_on_doc(cx, engine_view)?;
+            }
         }
 
         cx.restore().map_err(|e| anyhow::anyhow!("{e:?}"))?;
diff --git a/crates/rnote-ui/data/icons/scalable/actions/pen-tools-laser-symbolic.svg b/crates/rnote-ui/data/icons/scalable/actions/pen-tools-laser-symbolic.svg
new file mode 100644
index 0000000000..afce639d4b
--- /dev/null
+++ b/crates/rnote-ui/data/icons/scalable/actions/pen-tools-laser-symbolic.svg
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   height="16px"
+   viewBox="0 0 16 16"
+   width="16px"
+   version="1.1"
+   id="svg1"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <defs
+     id="defs1" />
+  <path
+     d="m 7.9999999,1.5995243 c -3.5252619,0 -6.4004756,2.8752137 -6.4004756,6.4004756 0,3.5252621 2.8752137,6.4004761 6.4004756,6.4004761 3.5252621,0 6.4004761,-2.875214 6.4004761,-6.4004761 0,-3.5252619 -2.875214,-6.4004756 -6.4004761,-6.4004756 z m 0,1.6001189 c 2.6501961,0 4.8003571,2.1501598 4.8003571,4.8003567 0,2.6501961 -2.150161,4.8003571 -4.8003571,4.8003571 -2.6501969,0 -4.8003567,-2.150161 -4.8003567,-4.8003571 0,-2.6501969 2.1501598,-4.8003567 4.8003567,-4.8003567 z m 0,2.4001783 c -1.3250986,0 -2.4001784,1.0750798 -2.4001784,2.4001784 0,1.3250984 1.0750798,2.4001781 2.4001784,2.4001781 1.3250984,0 2.4001781,-1.0750797 2.4001781,-2.4001781 0,-1.3250986 -1.0750797,-2.4001784 -2.4001781,-2.4001784 z m 0,0.8000596 c 0.8844411,0 1.600119,0.7156779 1.600119,1.6001188 0,0.8844411 -0.7156779,1.600119 -1.600119,1.600119 -0.8844409,0 -1.6001188,-0.7156779 -1.6001188,-1.600119 0,-0.8844409 0.7156779,-1.6001188 1.6001188,-1.6001188 z m 0,0.8000593 c -0.4406576,0 -0.8000595,0.3594019 -0.8000595,0.8000595 0,0.4406577 0.3594019,0.8000595 0.8000595,0.8000595 0.4406577,0 0.8000595,-0.3594018 0.8000595,-0.8000595 0,-0.4406576 -0.3594018,-0.8000595 -0.8000595,-0.8000595 z m 0,0"
+     fill="#222222"
+     id="path1"
+     style="stroke-width:0.800059" />
+</svg>
diff --git a/crates/rnote-ui/data/meson.build b/crates/rnote-ui/data/meson.build
index b1ae9b76b9..a187fba6f8 100644
--- a/crates/rnote-ui/data/meson.build
+++ b/crates/rnote-ui/data/meson.build
@@ -230,6 +230,7 @@ rnote_ui_gresources_icons_files = files(
     'icons/scalable/actions/pen-shaper-style-rough-symbolic.svg',
     'icons/scalable/actions/pen-shaper-style-smooth-symbolic.svg',
     'icons/scalable/actions/pen-shaper-symbolic.svg',
+    'icons/scalable/actions/pen-tools-laser-symbolic.svg',
     'icons/scalable/actions/pen-tools-offsetcameratool-symbolic.svg',
     'icons/scalable/actions/pen-tools-symbolic.svg',
     'icons/scalable/actions/pen-tools-verticalspacetool-symbolic.svg',
diff --git a/crates/rnote-ui/data/resources.gresource.xml b/crates/rnote-ui/data/resources.gresource.xml
index a1deb7af1f..356b29244b 100644
--- a/crates/rnote-ui/data/resources.gresource.xml
+++ b/crates/rnote-ui/data/resources.gresource.xml
@@ -100,6 +100,7 @@
         <file compressed="true">icons/scalable/actions/pen-shaper-style-rough-symbolic.svg</file>
         <file compressed="true">icons/scalable/actions/pen-shaper-style-smooth-symbolic.svg</file>
         <file compressed="true">icons/scalable/actions/pen-shaper-symbolic.svg</file>
+        <file compressed="true">icons/scalable/actions/pen-tools-laser-symbolic.svg</file>
         <file compressed="true">icons/scalable/actions/pen-tools-offsetcameratool-symbolic.svg</file>
         <file compressed="true">icons/scalable/actions/pen-tools-symbolic.svg</file>
         <file compressed="true">icons/scalable/actions/pen-tools-verticalspacetool-symbolic.svg</file>
diff --git a/crates/rnote-ui/data/ui/penssidebar/toolspage.ui b/crates/rnote-ui/data/ui/penssidebar/toolspage.ui
index cf7b9d1648..7db845c062 100644
--- a/crates/rnote-ui/data/ui/penssidebar/toolspage.ui
+++ b/crates/rnote-ui/data/ui/penssidebar/toolspage.ui
@@ -71,6 +71,22 @@
             </style>
           </object>
         </child>
+        <child>
+          <object class="GtkSeparator">
+            <property name="orientation">vertical</property>
+          </object>
+        </child>
+        <child>
+          <object class="GtkToggleButton" id="toolstyle_laser_toggle">
+            <property name="tooltip_text" translatable="yes">Laser</property>
+            <property name="icon-name">pen-tools-laser-symbolic</property>
+            <property name="group">toolstyle_verticalspace_toggle</property>
+            <style>
+              <class name="flat" />
+              <class name="sidebar_action_button" />
+            </style>
+          </object>
+        </child>
       </object>
     </child>
 
diff --git a/crates/rnote-ui/src/penssidebar/toolspage.rs b/crates/rnote-ui/src/penssidebar/toolspage.rs
index cdaac6004a..548aa26c1c 100644
--- a/crates/rnote-ui/src/penssidebar/toolspage.rs
+++ b/crates/rnote-ui/src/penssidebar/toolspage.rs
@@ -19,6 +19,8 @@ mod imp {
         #[template_child]
         pub(crate) toolstyle_zoom_toggle: TemplateChild<ToggleButton>,
         #[template_child]
+        pub(crate) toolstyle_laser_toggle: TemplateChild<ToggleButton>,
+        #[template_child]
         pub(crate) verticalspace_menubutton: TemplateChild<MenuButton>,
         #[template_child]
         pub(crate) verticalspace_popover: TemplateChild<Popover>,
@@ -88,6 +90,8 @@ impl RnToolsPage {
             Some(ToolStyle::OffsetCamera)
         } else if imp.toolstyle_zoom_toggle.is_active() {
             Some(ToolStyle::Zoom)
+        } else if imp.toolstyle_laser_toggle.is_active() {
+            Some(ToolStyle::Laser)
         } else {
             None
         }
@@ -106,6 +110,7 @@ impl RnToolsPage {
             ToolStyle::VerticalSpace => imp.toolstyle_verticalspace_toggle.set_active(true),
             ToolStyle::OffsetCamera => imp.toolstyle_offsetcamera_toggle.set_active(true),
             ToolStyle::Zoom => imp.toolstyle_zoom_toggle.set_active(true),
+            ToolStyle::Laser => imp.toolstyle_laser_toggle.set_active(true),
         }
     }
 
@@ -124,6 +129,8 @@ impl RnToolsPage {
 
                 if toggle.is_active() {
                     canvas.engine_mut().pens_config.tools_config.style = ToolStyle::VerticalSpace;
+                    let widget_flags = canvas.engine_mut().reinstall_pen_current_style();
+                    canvas.emit_handle_widget_flags(widget_flags);
                 }
             }
         ));
@@ -138,6 +145,8 @@ impl RnToolsPage {
 
                 if toggle.is_active() {
                     canvas.engine_mut().pens_config.tools_config.style = ToolStyle::OffsetCamera;
+                    let widget_flags = canvas.engine_mut().reinstall_pen_current_style();
+                    canvas.emit_handle_widget_flags(widget_flags);
                 }
             }
         ));
@@ -152,6 +161,24 @@ impl RnToolsPage {
 
                 if toggle.is_active() {
                     canvas.engine_mut().pens_config.tools_config.style = ToolStyle::Zoom;
+                    let widget_flags = canvas.engine_mut().reinstall_pen_current_style();
+                    canvas.emit_handle_widget_flags(widget_flags);
+                }
+            }
+        ));
+
+        imp.toolstyle_laser_toggle.connect_toggled(clone!(
+            #[weak]
+            appwindow,
+            move |toggle| {
+                let Some(canvas) = appwindow.active_tab_canvas() else {
+                    return;
+                };
+
+                if toggle.is_active() {
+                    canvas.engine_mut().pens_config.tools_config.style = ToolStyle::Laser;
+                    let widget_flags = canvas.engine_mut().reinstall_pen_current_style();
+                    canvas.emit_handle_widget_flags(widget_flags);
                 }
             }
         ));


Test patch: