diff --git a/README.md b/README.md
index af97f713..de2bb9f6 100644
--- a/README.md
+++ b/README.md
@@ -617,7 +617,7 @@ Drag modifier mappings are configurable in `config.toml` via `[drawing]` (`drag_
-Arrow labels can auto-number when enabled in the arrow toolbar; reset with Ctrl+Shift+R. Step markers auto-increment and reset from the toolbar (or bind `reset_step_markers` in `config.toml`). Preset slots can be saved/cleared from the toolbar; edit names and advanced fields in `config.toml`.
+Arrow labels can auto-number when enabled in the arrow toolbar; reset with Ctrl+Shift+R. Step markers auto-increment and reset from the toolbar (or bind `reset_step_markers` in `config.toml`). Preset slots can be saved/cleared from the toolbar; edit names and advanced fields in `config.toml`. Blur has no default keyboard shortcut; bind `select_blur_tool` in `config.toml` if you want direct keyboard access.
---
diff --git a/assets/icons/blur.svg b/assets/icons/blur.svg
new file mode 100644
index 00000000..93adfcf3
--- /dev/null
+++ b/assets/icons/blur.svg
@@ -0,0 +1,8 @@
+
diff --git a/config.example.toml b/config.example.toml
index 152809ca..62853b62 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -87,6 +87,7 @@ select_line_tool = []
select_rect_tool = []
select_ellipse_tool = []
select_arrow_tool = []
+select_blur_tool = []
select_highlight_tool = []
toggle_highlight_tool = ["Ctrl+Alt+H"]
diff --git a/configurator/src/models/fields/tool.rs b/configurator/src/models/fields/tool.rs
index fcab813d..1990e2e9 100644
--- a/configurator/src/models/fields/tool.rs
+++ b/configurator/src/models/fields/tool.rs
@@ -29,6 +29,7 @@ pub enum ToolOption {
Rect,
Ellipse,
Arrow,
+ Blur,
Marker,
StepMarker,
Highlight,
@@ -44,6 +45,7 @@ impl ToolOption {
Self::Rect,
Self::Ellipse,
Self::Arrow,
+ Self::Blur,
Self::Marker,
Self::StepMarker,
Self::Highlight,
@@ -59,6 +61,7 @@ impl ToolOption {
Self::Rect => "Rectangle",
Self::Ellipse => "Ellipse",
Self::Arrow => "Arrow",
+ Self::Blur => "Blur",
Self::Marker => "Marker",
Self::StepMarker => "Step",
Self::Highlight => "Highlight",
@@ -74,6 +77,7 @@ impl ToolOption {
Self::Rect => Tool::Rect,
Self::Ellipse => Tool::Ellipse,
Self::Arrow => Tool::Arrow,
+ Self::Blur => Tool::Blur,
Self::Marker => Tool::Marker,
Self::StepMarker => Tool::StepMarker,
Self::Highlight => Tool::Highlight,
@@ -89,6 +93,7 @@ impl ToolOption {
Tool::Rect => Self::Rect,
Tool::Ellipse => Self::Ellipse,
Tool::Arrow => Self::Arrow,
+ Tool::Blur => Self::Blur,
Tool::Marker => Self::Marker,
Tool::StepMarker => Self::StepMarker,
Tool::Highlight => Self::Highlight,
diff --git a/configurator/src/models/keybindings/field/config/read.rs b/configurator/src/models/keybindings/field/config/read.rs
index 78688091..8eaed2a4 100644
--- a/configurator/src/models/keybindings/field/config/read.rs
+++ b/configurator/src/models/keybindings/field/config/read.rs
@@ -49,6 +49,7 @@ impl KeybindingField {
Self::SelectRectTool => &config.tools.select_rect_tool,
Self::SelectEllipseTool => &config.tools.select_ellipse_tool,
Self::SelectArrowTool => &config.tools.select_arrow_tool,
+ Self::SelectBlurTool => &config.tools.select_blur_tool,
Self::SelectHighlightTool => &config.tools.select_highlight_tool,
Self::IncreaseFontSize => &config.tools.increase_font_size,
Self::DecreaseFontSize => &config.tools.decrease_font_size,
diff --git a/configurator/src/models/keybindings/field/config/write.rs b/configurator/src/models/keybindings/field/config/write.rs
index 41df65b3..8d47e6e4 100644
--- a/configurator/src/models/keybindings/field/config/write.rs
+++ b/configurator/src/models/keybindings/field/config/write.rs
@@ -50,6 +50,7 @@ impl KeybindingField {
Self::SelectRectTool => config.tools.select_rect_tool = value,
Self::SelectEllipseTool => config.tools.select_ellipse_tool = value,
Self::SelectArrowTool => config.tools.select_arrow_tool = value,
+ Self::SelectBlurTool => config.tools.select_blur_tool = value,
Self::SelectHighlightTool => config.tools.select_highlight_tool = value,
Self::IncreaseFontSize => config.tools.increase_font_size = value,
Self::DecreaseFontSize => config.tools.decrease_font_size = value,
diff --git a/configurator/src/models/keybindings/field/labels.rs b/configurator/src/models/keybindings/field/labels.rs
index df7ba046..7534e883 100644
--- a/configurator/src/models/keybindings/field/labels.rs
+++ b/configurator/src/models/keybindings/field/labels.rs
@@ -44,6 +44,7 @@ impl KeybindingField {
Self::SelectRectTool => "Select rectangle tool",
Self::SelectEllipseTool => "Select ellipse tool",
Self::SelectArrowTool => "Select arrow tool",
+ Self::SelectBlurTool => "Select blur tool",
Self::SelectHighlightTool => "Select highlight tool",
Self::IncreaseFontSize => "Increase font size",
Self::DecreaseFontSize => "Decrease font size",
@@ -169,6 +170,7 @@ impl KeybindingField {
Self::SelectRectTool => "select_rect_tool",
Self::SelectEllipseTool => "select_ellipse_tool",
Self::SelectArrowTool => "select_arrow_tool",
+ Self::SelectBlurTool => "select_blur_tool",
Self::SelectHighlightTool => "select_highlight_tool",
Self::IncreaseFontSize => "increase_font_size",
Self::DecreaseFontSize => "decrease_font_size",
diff --git a/configurator/src/models/keybindings/field/list.rs b/configurator/src/models/keybindings/field/list.rs
index 6560e8d0..a495113f 100644
--- a/configurator/src/models/keybindings/field/list.rs
+++ b/configurator/src/models/keybindings/field/list.rs
@@ -44,6 +44,7 @@ impl KeybindingField {
Self::SelectRectTool,
Self::SelectEllipseTool,
Self::SelectArrowTool,
+ Self::SelectBlurTool,
Self::SelectHighlightTool,
Self::IncreaseFontSize,
Self::DecreaseFontSize,
diff --git a/configurator/src/models/keybindings/field/mod.rs b/configurator/src/models/keybindings/field/mod.rs
index 45875d51..6ae4c14d 100644
--- a/configurator/src/models/keybindings/field/mod.rs
+++ b/configurator/src/models/keybindings/field/mod.rs
@@ -46,6 +46,7 @@ pub enum KeybindingField {
SelectRectTool,
SelectEllipseTool,
SelectArrowTool,
+ SelectBlurTool,
SelectHighlightTool,
IncreaseFontSize,
DecreaseFontSize,
diff --git a/configurator/src/models/keybindings/field/tab.rs b/configurator/src/models/keybindings/field/tab.rs
index 55cc3623..b9ff2be2 100644
--- a/configurator/src/models/keybindings/field/tab.rs
+++ b/configurator/src/models/keybindings/field/tab.rs
@@ -33,6 +33,7 @@ impl KeybindingField {
| Self::SelectRectTool
| Self::SelectEllipseTool
| Self::SelectArrowTool
+ | Self::SelectBlurTool
| Self::SelectHighlightTool
| Self::ToggleHighlightTool
| Self::ResetArrowLabels
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index 972d628e..ee56c533 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -759,6 +759,7 @@ select_line_tool = []
select_rect_tool = []
select_ellipse_tool = []
select_arrow_tool = []
+select_blur_tool = []
select_highlight_tool = []
toggle_highlight_tool = ["Ctrl+Alt+H"]
diff --git a/src/backend/wayland/frozen/capture.rs b/src/backend/wayland/frozen/capture.rs
index 7b073b74..d4dcafb6 100644
--- a/src/backend/wayland/frozen/capture.rs
+++ b/src/backend/wayland/frozen/capture.rs
@@ -265,7 +265,7 @@ impl FrozenState {
capture.frame.destroy();
- self.image = Some(FrozenImage {
+ self.set_image(FrozenImage {
width: capture.width,
height: capture.height,
stride: (capture.width * 4) as i32,
diff --git a/src/backend/wayland/frozen/portal.rs b/src/backend/wayland/frozen/portal.rs
index 21765536..8dc7ecc8 100644
--- a/src/backend/wayland/frozen/portal.rs
+++ b/src/backend/wayland/frozen/portal.rs
@@ -113,7 +113,7 @@ impl FrozenState {
portal_output_matches(target_output, self.active_output_id);
if output_matches {
- self.image = Some(image);
+ self.set_image(image);
input_state.set_frozen_active(true);
input_state.dirty_tracker.mark_full();
input_state.needs_redraw = true;
diff --git a/src/backend/wayland/frozen/state.rs b/src/backend/wayland/frozen/state.rs
index 3dfa7c83..6f6bc50a 100644
--- a/src/backend/wayland/frozen/state.rs
+++ b/src/backend/wayland/frozen/state.rs
@@ -18,6 +18,7 @@ pub struct FrozenState {
pub(super) active_geometry: Option,
pub(super) capture: Option,
pub(super) image: Option,
+ image_generation: u64,
pub(super) portal_rx: Option,
pub(super) portal_in_progress: bool,
pub(super) portal_target_output_id: Option,
@@ -35,6 +36,7 @@ impl FrozenState {
active_geometry: None,
capture: None,
image: None,
+ image_generation: 0,
portal_rx: None,
portal_in_progress: false,
portal_target_output_id: None,
@@ -66,6 +68,15 @@ impl FrozenState {
self.image.as_ref()
}
+ pub fn image_generation(&self) -> u64 {
+ self.image_generation
+ }
+
+ pub fn set_image(&mut self, image: FrozenImage) {
+ self.image = Some(image);
+ self.bump_image_generation();
+ }
+
pub fn is_in_progress(&self) -> bool {
self.capture.is_some() || self.portal_in_progress || self.preflight_pending
}
@@ -97,14 +108,14 @@ impl FrozenState {
&& (img.width != phys_width || img.height != phys_height)
{
info!("Surface resized; clearing frozen image");
- self.image = None;
+ self.clear_image();
input_state.set_frozen_active(false);
}
}
/// Toggle unfreeze: drop the image and mark redraw.
pub fn unfreeze(&mut self, input_state: &mut InputState) {
- self.image = None;
+ self.clear_image();
input_state.set_frozen_active(false);
input_state.dirty_tracker.mark_full();
input_state.needs_redraw = true;
@@ -119,4 +130,16 @@ impl FrozenState {
input_state.set_frozen_active(false);
input_state.needs_redraw = true;
}
+
+ fn clear_image(&mut self) -> bool {
+ let had_image = self.image.take().is_some();
+ if had_image {
+ self.bump_image_generation();
+ }
+ had_image
+ }
+
+ fn bump_image_generation(&mut self) {
+ self.image_generation = self.image_generation.wrapping_add(1).max(1);
+ }
}
diff --git a/src/backend/wayland/state/render/canvas/background.rs b/src/backend/wayland/state/render/canvas/background.rs
index 9a26728d..8f8e195b 100644
--- a/src/backend/wayland/state/render/canvas/background.rs
+++ b/src/backend/wayland/state/render/canvas/background.rs
@@ -2,15 +2,23 @@ use super::super::super::*;
use crate::draw::Color;
pub(super) struct CanvasEraserContext {
+ surface: Option,
pattern: Option,
+ backdrop_cache_key: Option,
bg_color: Option,
+ logical_to_image_scale_x: f64,
+ logical_to_image_scale_y: f64,
}
impl CanvasEraserContext {
pub(super) fn replay_context(&self) -> crate::draw::EraserReplayContext<'_> {
crate::draw::EraserReplayContext {
pattern: self.pattern.as_ref().map(|p| p as &cairo::Pattern),
+ surface: self.surface.as_ref(),
+ backdrop_cache_key: self.backdrop_cache_key,
bg_color: self.bg_color,
+ logical_to_image_scale_x: self.logical_to_image_scale_x,
+ logical_to_image_scale_y: self.logical_to_image_scale_y,
}
}
}
@@ -23,13 +31,24 @@ impl WaylandState {
phys_width: u32,
phys_height: u32,
) -> Result {
+ let mut eraser_surface: Option = None;
let mut eraser_pattern: Option = None;
+ let mut backdrop_cache_key: Option = None;
let mut eraser_bg_color: Option = None;
+ let mut logical_to_image_scale_x = 1.0;
+ let mut logical_to_image_scale_y = 1.0;
let allow_background_image =
!self.zoom.is_engaged() || self.input_state.board_is_transparent();
let zoom_render_image = if self.zoom.active && allow_background_image {
- self.zoom.image().or_else(|| self.frozen.image())
+ self.zoom
+ .image()
+ .map(|image| (image, (self.zoom.image_generation() << 1) | 1))
+ .or_else(|| {
+ self.frozen
+ .image()
+ .map(|image| (image, self.frozen.image_generation() << 1))
+ })
} else {
None
};
@@ -37,12 +56,14 @@ impl WaylandState {
let background_image = if zoom_render_active {
zoom_render_image
} else if allow_background_image {
- self.frozen.image()
+ self.frozen
+ .image()
+ .map(|image| (image, self.frozen.image_generation() << 1))
} else {
None
};
- if let Some(image) = background_image {
+ if let Some((image, cache_key)) = background_image {
// SAFETY: we create a Cairo surface borrowing our owned buffer; it is dropped
// before commit, and we hold the buffer alive via `image.data`.
let surface = unsafe {
@@ -66,6 +87,8 @@ impl WaylandState {
} else {
1.0
};
+ logical_to_image_scale_x = (scale as f64) / scale_x.max(f64::MIN_POSITIVE);
+ logical_to_image_scale_y = (scale as f64) / scale_y.max(f64::MIN_POSITIVE);
let _ = ctx.save();
if zoom_render_active {
let scale_x_safe = scale_x.max(f64::MIN_POSITIVE);
@@ -92,7 +115,9 @@ impl WaylandState {
let scale_y_inv = 1.0 / (scale as f64 * scale_y.max(f64::MIN_POSITIVE));
matrix.scale(scale_x_inv, scale_y_inv);
pattern.set_matrix(matrix);
+ eraser_surface = Some(surface);
eraser_pattern = Some(pattern);
+ backdrop_cache_key = Some(cache_key);
} else {
match self.input_state.boards.active_background() {
crate::input::BoardBackground::Solid(color) => {
@@ -105,8 +130,12 @@ impl WaylandState {
}
Ok(CanvasEraserContext {
+ surface: eraser_surface,
pattern: eraser_pattern,
+ backdrop_cache_key,
bg_color: eraser_bg_color,
+ logical_to_image_scale_x,
+ logical_to_image_scale_y,
})
}
}
diff --git a/src/backend/wayland/state/render/canvas/mod.rs b/src/backend/wayland/state/render/canvas/mod.rs
index e3cbd427..030abf7b 100644
--- a/src/backend/wayland/state/render/canvas/mod.rs
+++ b/src/backend/wayland/state/render/canvas/mod.rs
@@ -49,6 +49,26 @@ impl WaylandState {
crate::draw::Shape::EraserStroke { points, brush } => {
crate::draw::render_eraser_stroke(ctx, points, brush, &replay_ctx);
}
+ crate::draw::Shape::BlurRect {
+ x,
+ y,
+ w,
+ h,
+ strength,
+ } => {
+ crate::draw::render_blur_rect(
+ ctx,
+ crate::draw::BlurRectParams {
+ x: *x,
+ y: *y,
+ w: *w,
+ h: *h,
+ strength: *strength,
+ cacheable: true,
+ },
+ &replay_ctx,
+ );
+ }
other => {
crate::draw::render_shape(ctx, other);
}
@@ -144,9 +164,42 @@ impl WaylandState {
self.render_eraser_hover_halos(ctx, mx, my);
- // Render provisional shape if actively drawing
- // Use optimized method that avoids cloning for freehand
- if self.input_state.render_provisional_shape(ctx, mx, my) {
+ // Render provisional shape if actively drawing.
+ let rendered_provisional = if let crate::input::DrawingState::Drawing {
+ tool: crate::input::Tool::Blur,
+ start_x,
+ start_y,
+ ..
+ } = &self.input_state.state
+ {
+ let (x, w) = if mx >= *start_x {
+ (*start_x, mx - start_x)
+ } else {
+ (mx, start_x - mx)
+ };
+ let (y, h) = if my >= *start_y {
+ (*start_y, my - start_y)
+ } else {
+ (my, start_y - my)
+ };
+ crate::draw::render_blur_rect(
+ ctx,
+ crate::draw::BlurRectParams {
+ x,
+ y,
+ w,
+ h,
+ strength: self.input_state.current_thickness,
+ cacheable: false,
+ },
+ &replay_ctx,
+ );
+ true
+ } else {
+ // Use optimized method that avoids cloning for freehand
+ self.input_state.render_provisional_shape(ctx, mx, my)
+ };
+ if rendered_provisional {
debug!("Rendered provisional shape");
}
diff --git a/src/backend/wayland/state/render/tool_preview.rs b/src/backend/wayland/state/render/tool_preview.rs
index 00f138ed..0319827f 100644
--- a/src/backend/wayland/state/render/tool_preview.rs
+++ b/src/backend/wayland/state/render/tool_preview.rs
@@ -54,6 +54,7 @@ pub(super) fn draw_tool_preview(
Tool::Rect => toolbar_icons::draw_icon_rect(ctx, icon_x, icon_y, icon_size),
Tool::Ellipse => toolbar_icons::draw_icon_circle(ctx, icon_x, icon_y, icon_size),
Tool::Arrow => toolbar_icons::draw_icon_arrow(ctx, icon_x, icon_y, icon_size),
+ Tool::Blur => toolbar_icons::draw_icon_blur(ctx, icon_x, icon_y, icon_size),
Tool::Marker => toolbar_icons::draw_icon_marker(ctx, icon_x, icon_y, icon_size),
Tool::StepMarker => toolbar_icons::draw_icon_step_marker(ctx, icon_x, icon_y, icon_size),
Tool::Highlight => toolbar_icons::draw_icon_highlight(ctx, icon_x, icon_y, icon_size),
diff --git a/src/backend/wayland/toolbar/layout/top/mod.rs b/src/backend/wayland/toolbar/layout/top/mod.rs
index 78235f1c..9b93340a 100644
--- a/src/backend/wayland/toolbar/layout/top/mod.rs
+++ b/src/backend/wayland/toolbar/layout/top/mod.rs
@@ -20,8 +20,15 @@ const TOOL_BUTTONS_FULL: &[Tool] = &[
Tool::Rect,
Tool::Ellipse,
Tool::Arrow,
+ Tool::Blur,
+];
+const SHAPE_BUTTONS: &[Tool] = &[
+ Tool::Line,
+ Tool::Rect,
+ Tool::Ellipse,
+ Tool::Arrow,
+ Tool::Blur,
];
-const SHAPE_BUTTONS: &[Tool] = &[Tool::Line, Tool::Rect, Tool::Ellipse, Tool::Arrow];
pub fn build_top_hits(
width: f64,
diff --git a/src/backend/wayland/toolbar/render/side_palette/presets/slot/content.rs b/src/backend/wayland/toolbar/render/side_palette/presets/slot/content.rs
index 1820967f..6fe294f4 100644
--- a/src/backend/wayland/toolbar/render/side_palette/presets/slot/content.rs
+++ b/src/backend/wayland/toolbar/render/side_palette/presets/slot/content.rs
@@ -121,6 +121,7 @@ fn draw_preset_icon(ctx: &cairo::Context, tool: Tool, x: f64, y: f64, size: f64)
Tool::Rect => toolbar_icons::draw_icon_rect(ctx, x, y, size),
Tool::Ellipse => toolbar_icons::draw_icon_circle(ctx, x, y, size),
Tool::Arrow => toolbar_icons::draw_icon_arrow(ctx, x, y, size),
+ Tool::Blur => toolbar_icons::draw_icon_blur(ctx, x, y, size),
Tool::Marker => toolbar_icons::draw_icon_marker(ctx, x, y, size),
Tool::StepMarker => toolbar_icons::draw_icon_step_marker(ctx, x, y, size),
Tool::Highlight => toolbar_icons::draw_icon_highlight(ctx, x, y, size),
diff --git a/src/backend/wayland/toolbar/render/top_strip/icons/shape_picker.rs b/src/backend/wayland/toolbar/render/top_strip/icons/shape_picker.rs
index d4749b7f..3e3a5ede 100644
--- a/src/backend/wayland/toolbar/render/top_strip/icons/shape_picker.rs
+++ b/src/backend/wayland/toolbar/render/top_strip/icons/shape_picker.rs
@@ -25,6 +25,7 @@ pub(super) fn draw_shape_picker_row(
(Tool::Rect, toolbar_icons::draw_icon_rect),
(Tool::Ellipse, toolbar_icons::draw_icon_circle),
(Tool::Arrow, toolbar_icons::draw_icon_arrow),
+ (Tool::Blur, toolbar_icons::draw_icon_blur),
];
for (tool, icon_fn) in shapes {
let is_active =
diff --git a/src/backend/wayland/toolbar/render/top_strip/icons/tool_row.rs b/src/backend/wayland/toolbar/render/top_strip/icons/tool_row.rs
index 39c416d7..fe9fe031 100644
--- a/src/backend/wayland/toolbar/render/top_strip/icons/tool_row.rs
+++ b/src/backend/wayland/toolbar/render/top_strip/icons/tool_row.rs
@@ -54,6 +54,7 @@ pub(super) fn draw_tool_row(
(Tool::Rect, toolbar_icons::draw_icon_rect as IconFn),
(Tool::Ellipse, toolbar_icons::draw_icon_circle as IconFn),
(Tool::Arrow, toolbar_icons::draw_icon_arrow as IconFn),
+ (Tool::Blur, toolbar_icons::draw_icon_blur as IconFn),
]
};
@@ -113,6 +114,7 @@ pub(super) fn draw_tool_row(
Tool::Rect => toolbar_icons::draw_icon_rect(layout.ctx, icon_x, icon_y, icon_size),
Tool::Ellipse => toolbar_icons::draw_icon_circle(layout.ctx, icon_x, icon_y, icon_size),
Tool::Arrow => toolbar_icons::draw_icon_arrow(layout.ctx, icon_x, icon_y, icon_size),
+ Tool::Blur => toolbar_icons::draw_icon_blur(layout.ctx, icon_x, icon_y, icon_size),
_ => toolbar_icons::draw_icon_rect(layout.ctx, icon_x, icon_y, icon_size),
}
layout.hits.push(HitRegion {
diff --git a/src/backend/wayland/toolbar/render/top_strip/mod.rs b/src/backend/wayland/toolbar/render/top_strip/mod.rs
index dca4d6fb..436bea9e 100644
--- a/src/backend/wayland/toolbar/render/top_strip/mod.rs
+++ b/src/backend/wayland/toolbar/render/top_strip/mod.rs
@@ -106,11 +106,13 @@ pub fn render_top_strip(
Some(Tool::Rect) => Some(Tool::Rect),
Some(Tool::Ellipse) => Some(Tool::Ellipse),
Some(Tool::Arrow) => Some(Tool::Arrow),
+ Some(Tool::Blur) => Some(Tool::Blur),
_ => match snapshot.active_tool {
Tool::Line => Some(Tool::Line),
Tool::Rect => Some(Tool::Rect),
Tool::Ellipse => Some(Tool::Ellipse),
Tool::Arrow => Some(Tool::Arrow),
+ Tool::Blur => Some(Tool::Blur),
_ => None,
},
};
diff --git a/src/backend/wayland/toolbar/render/top_strip/text.rs b/src/backend/wayland/toolbar/render/top_strip/text.rs
index 3f9e88c4..b48c08db 100644
--- a/src/backend/wayland/toolbar/render/top_strip/text.rs
+++ b/src/backend/wayland/toolbar/render/top_strip/text.rs
@@ -59,6 +59,7 @@ pub(super) fn draw_text_strip(
Tool::Rect,
Tool::Ellipse,
Tool::Arrow,
+ Tool::Blur,
]
};
@@ -245,7 +246,13 @@ pub(super) fn draw_text_strip(
if is_simple && snapshot.shape_picker_open {
let shape_y = y + btn_h + ToolbarLayoutSpec::TOP_SHAPE_ROW_GAP;
let mut shape_x = ToolbarLayoutSpec::TOP_START_X + handle_w + gap;
- let shapes: &[Tool] = &[Tool::Line, Tool::Rect, Tool::Ellipse, Tool::Arrow];
+ let shapes: &[Tool] = &[
+ Tool::Line,
+ Tool::Rect,
+ Tool::Ellipse,
+ Tool::Arrow,
+ Tool::Blur,
+ ];
for tool in shapes {
let label = tool_label(*tool);
let tooltip_label = tool_tooltip_label(*tool);
diff --git a/src/backend/wayland/zoom/capture.rs b/src/backend/wayland/zoom/capture.rs
index 23250ae4..414a62bf 100644
--- a/src/backend/wayland/zoom/capture.rs
+++ b/src/backend/wayland/zoom/capture.rs
@@ -260,7 +260,7 @@ impl ZoomState {
capture.frame.destroy();
- self.image = Some(FrozenImage {
+ self.set_image(FrozenImage {
width: capture.width,
height: capture.height,
stride: (capture.width * 4) as i32,
diff --git a/src/backend/wayland/zoom/portal.rs b/src/backend/wayland/zoom/portal.rs
index eaeb59b8..e5798b5d 100644
--- a/src/backend/wayland/zoom/portal.rs
+++ b/src/backend/wayland/zoom/portal.rs
@@ -115,7 +115,7 @@ impl ZoomState {
portal_output_matches(target_output, self.active_output_id);
if output_matches {
- self.image = Some(image);
+ self.set_image(image);
} else {
warn!("Portal zoom capture for inactive output discarded");
}
diff --git a/src/backend/wayland/zoom/state.rs b/src/backend/wayland/zoom/state.rs
index 0f7332ad..174b5016 100644
--- a/src/backend/wayland/zoom/state.rs
+++ b/src/backend/wayland/zoom/state.rs
@@ -16,6 +16,7 @@ pub struct ZoomState {
pub(super) active_geometry: Option,
pub(super) capture: Option,
pub(super) image: Option,
+ image_generation: u64,
pub(super) portal_rx: Option,
pub(super) portal_in_progress: bool,
pub(super) portal_target_output_id: Option,
@@ -40,6 +41,7 @@ impl ZoomState {
active_geometry: None,
capture: None,
image: None,
+ image_generation: 0,
portal_rx: None,
portal_in_progress: false,
portal_target_output_id: None,
@@ -77,9 +79,20 @@ impl ZoomState {
self.image.as_ref()
}
+ pub fn image_generation(&self) -> u64 {
+ self.image_generation
+ }
+
+ pub fn set_image(&mut self, image: FrozenImage) {
+ self.image = Some(image);
+ self.bump_image_generation();
+ }
+
pub fn clear_image(&mut self) -> bool {
- let had_image = self.image.is_some();
- self.image = None;
+ let had_image = self.image.take().is_some();
+ if had_image {
+ self.bump_image_generation();
+ }
had_image
}
@@ -166,11 +179,15 @@ impl ZoomState {
self.active = false;
self.locked = false;
self.reset_view();
- self.image = None;
+ self.clear_image();
}
input_state.set_zoom_status(self.active, self.locked, self.scale);
input_state.dirty_tracker.mark_full();
input_state.needs_redraw = true;
}
+
+ fn bump_image_generation(&mut self) {
+ self.image_generation = self.image_generation.wrapping_add(1).max(1);
+ }
}
diff --git a/src/config/action_meta/entries/tools.rs b/src/config/action_meta/entries/tools.rs
index 5e2d01f9..776858df 100644
--- a/src/config/action_meta/entries/tools.rs
+++ b/src/config/action_meta/entries/tools.rs
@@ -81,6 +81,16 @@ pub const ENTRIES: &[ActionMeta] = &[
true,
true
),
+ meta!(
+ SelectBlurTool,
+ "Blur Tool",
+ Some("Blur"),
+ "Blur sensitive regions on captured backgrounds",
+ Tools,
+ true,
+ true,
+ true
+ ),
meta!(
SelectHighlightTool,
"Highlight Tool",
diff --git a/src/config/action_meta/tests.rs b/src/config/action_meta/tests.rs
index 63e6802c..abf6fa20 100644
--- a/src/config/action_meta/tests.rs
+++ b/src/config/action_meta/tests.rs
@@ -44,6 +44,7 @@ const HELP_ACTIONS: &[Action] = &[
Action::SelectRectTool,
Action::SelectEllipseTool,
Action::SelectArrowTool,
+ Action::SelectBlurTool,
Action::ToggleHighlightTool,
Action::SelectMarkerTool,
Action::SelectStepMarkerTool,
@@ -92,6 +93,7 @@ const TOOLBAR_ACTIONS: &[Action] = &[
Action::SelectRectTool,
Action::SelectEllipseTool,
Action::SelectArrowTool,
+ Action::SelectBlurTool,
Action::SelectSelectionTool,
Action::SelectMarkerTool,
Action::SelectStepMarkerTool,
@@ -149,6 +151,7 @@ const PALETTE_ACTIONS: &[Action] = &[
Action::SelectRectTool,
Action::SelectEllipseTool,
Action::SelectArrowTool,
+ Action::SelectBlurTool,
Action::SelectHighlightTool,
Action::SelectMarkerTool,
Action::SelectStepMarkerTool,
diff --git a/src/config/keybindings/actions.rs b/src/config/keybindings/actions.rs
index 7576e3a6..ada818c3 100644
--- a/src/config/keybindings/actions.rs
+++ b/src/config/keybindings/actions.rs
@@ -51,6 +51,7 @@ pub enum Action {
SelectRectTool,
SelectEllipseTool,
SelectArrowTool,
+ SelectBlurTool,
SelectHighlightTool,
IncreaseFontSize,
DecreaseFontSize,
diff --git a/src/config/keybindings/config/map/tools.rs b/src/config/keybindings/config/map/tools.rs
index 0ac10fe4..a9dfa00c 100644
--- a/src/config/keybindings/config/map/tools.rs
+++ b/src/config/keybindings/config/map/tools.rs
@@ -33,6 +33,7 @@ impl KeybindingsConfig {
inserter.insert_all(&self.tools.select_rect_tool, Action::SelectRectTool)?;
inserter.insert_all(&self.tools.select_ellipse_tool, Action::SelectEllipseTool)?;
inserter.insert_all(&self.tools.select_arrow_tool, Action::SelectArrowTool)?;
+ inserter.insert_all(&self.tools.select_blur_tool, Action::SelectBlurTool)?;
inserter.insert_all(
&self.tools.select_highlight_tool,
Action::SelectHighlightTool,
diff --git a/src/config/keybindings/config/types/bindings/tools.rs b/src/config/keybindings/config/types/bindings/tools.rs
index ebf375ab..7e418e43 100644
--- a/src/config/keybindings/config/types/bindings/tools.rs
+++ b/src/config/keybindings/config/types/bindings/tools.rs
@@ -47,6 +47,9 @@ pub struct ToolKeybindingsConfig {
#[serde(default = "default_select_arrow_tool")]
pub select_arrow_tool: Vec,
+ #[serde(default = "default_select_blur_tool")]
+ pub select_blur_tool: Vec,
+
#[serde(default = "default_select_highlight_tool")]
pub select_highlight_tool: Vec,
@@ -83,6 +86,7 @@ impl Default for ToolKeybindingsConfig {
select_rect_tool: default_select_rect_tool(),
select_ellipse_tool: default_select_ellipse_tool(),
select_arrow_tool: default_select_arrow_tool(),
+ select_blur_tool: default_select_blur_tool(),
select_highlight_tool: default_select_highlight_tool(),
toggle_highlight_tool: default_toggle_highlight_tool(),
increase_font_size: default_increase_font_size(),
diff --git a/src/config/keybindings/defaults/tools.rs b/src/config/keybindings/defaults/tools.rs
index c0944840..a29c2d77 100644
--- a/src/config/keybindings/defaults/tools.rs
+++ b/src/config/keybindings/defaults/tools.rs
@@ -54,6 +54,10 @@ pub(crate) fn default_select_arrow_tool() -> Vec {
Vec::new()
}
+pub(crate) fn default_select_blur_tool() -> Vec {
+ Vec::new()
+}
+
pub(crate) fn default_select_highlight_tool() -> Vec {
Vec::new()
}
diff --git a/src/draw/mod.rs b/src/draw/mod.rs
index 4f67a7a0..dfbc8e29 100644
--- a/src/draw/mod.rs
+++ b/src/draw/mod.rs
@@ -25,9 +25,10 @@ pub use frame::{DrawnShape, Frame, ShapeId};
pub(crate) use render::render_eraser_stroke;
#[allow(unused_imports)]
pub use render::{
- EraserReplayContext, render_board_background, render_click_highlight, render_freehand_borrowed,
- render_marker_stroke_borrowed, render_selection_halo, render_selection_handles, render_shape,
- render_sticky_note, render_text, selection_handle_rects,
+ BlurRectParams, EraserReplayContext, render_blur_rect, render_board_background,
+ render_click_highlight, render_freehand_borrowed, render_marker_stroke_borrowed,
+ render_selection_halo, render_selection_handles, render_shape, render_sticky_note, render_text,
+ selection_handle_rects,
};
#[allow(unused_imports)]
pub use shape::{
diff --git a/src/draw/render/blur.rs b/src/draw/render/blur.rs
new file mode 100644
index 00000000..48337e41
--- /dev/null
+++ b/src/draw/render/blur.rs
@@ -0,0 +1,580 @@
+use super::types::EraserReplayContext;
+use std::{
+ cell::RefCell,
+ collections::{HashMap, VecDeque},
+};
+
+const PLACEHOLDER_FILL: (f64, f64, f64, f64) = (0.12, 0.15, 0.2, 0.82);
+const PLACEHOLDER_STROKE: (f64, f64, f64, f64) = (0.92, 0.94, 0.98, 0.35);
+const BLUR_CACHE_MAX_ENTRIES: usize = 8;
+const BLUR_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
+type Rgba = (f64, f64, f64, f64);
+type OverlayPalette = (Rgba, Rgba);
+
+#[derive(Clone, Copy, Debug)]
+struct BlurRecipe {
+ primary_factor: f64,
+ secondary_factor: f64,
+ padding_px: i32,
+ overlay_alpha: f64,
+}
+
+#[derive(Clone, Copy, Debug)]
+struct BlurSurfaceStats {
+ red: f64,
+ green: f64,
+ blue: f64,
+ luminance: f64,
+}
+
+#[derive(Clone, Copy, Debug)]
+pub struct BlurRectParams {
+ pub x: i32,
+ pub y: i32,
+ pub w: i32,
+ pub h: i32,
+ pub strength: f64,
+ pub cacheable: bool,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+struct BlurCacheKey {
+ backdrop_cache_key: u64,
+ src_x: i32,
+ src_y: i32,
+ src_w: i32,
+ src_h: i32,
+ primary_factor: u16,
+ secondary_factor: u16,
+}
+
+#[derive(Clone)]
+struct CachedBlurRegion {
+ surface: cairo::ImageSurface,
+ stats: BlurSurfaceStats,
+ approx_bytes: usize,
+}
+
+struct BlurRenderCache {
+ entries: HashMap,
+ access_order: VecDeque,
+ max_entries: usize,
+ max_bytes: usize,
+ cached_bytes: usize,
+}
+
+impl BlurRenderCache {
+ fn new(max_entries: usize, max_bytes: usize) -> Self {
+ Self {
+ entries: HashMap::new(),
+ access_order: VecDeque::new(),
+ max_entries,
+ max_bytes,
+ cached_bytes: 0,
+ }
+ }
+
+ fn get(&mut self, key: &BlurCacheKey) -> Option {
+ let entry = self.entries.get(key).cloned()?;
+ self.touch(*key);
+ Some(entry)
+ }
+
+ fn insert(&mut self, key: BlurCacheKey, entry: CachedBlurRegion) {
+ if let Some(previous) = self.entries.remove(&key) {
+ self.cached_bytes = self.cached_bytes.saturating_sub(previous.approx_bytes);
+ self.access_order.retain(|existing| existing != &key);
+ }
+
+ self.cached_bytes = self.cached_bytes.saturating_add(entry.approx_bytes);
+ self.entries.insert(key, entry);
+ self.access_order.push_back(key);
+ self.evict_if_needed();
+ }
+
+ fn touch(&mut self, key: BlurCacheKey) {
+ self.access_order.retain(|existing| existing != &key);
+ self.access_order.push_back(key);
+ }
+
+ fn evict_if_needed(&mut self) {
+ while self.entries.len() > self.max_entries
+ || (self.cached_bytes > self.max_bytes && self.entries.len() > 1)
+ {
+ let Some(oldest) = self.access_order.pop_front() else {
+ break;
+ };
+ if let Some(entry) = self.entries.remove(&oldest) {
+ self.cached_bytes = self.cached_bytes.saturating_sub(entry.approx_bytes);
+ }
+ }
+ }
+}
+
+thread_local! {
+ static BLUR_RENDER_CACHE: RefCell = RefCell::new(
+ BlurRenderCache::new(BLUR_CACHE_MAX_ENTRIES, BLUR_CACHE_MAX_BYTES)
+ );
+}
+
+fn normalize_rect(x: i32, y: i32, w: i32, h: i32) -> Option<(f64, f64, f64, f64)> {
+ let left = x.min(x + w) as f64;
+ let top = y.min(y + h) as f64;
+ let width = w.abs().max(1) as f64;
+ let height = h.abs().max(1) as f64;
+ (width > 0.0 && height > 0.0).then_some((left, top, width, height))
+}
+
+fn blur_recipe(strength: f64) -> BlurRecipe {
+ let clamped = strength.clamp(1.0, 50.0);
+ let normalized = ((clamped - 1.0) / 49.0).clamp(0.0, 1.0);
+
+ // Bias the low end upward so the default thickness already produces
+ // privacy-grade blur rather than a subtle aesthetic softening.
+ let shaped = normalized.powf(0.6);
+ let primary_factor = (8.0 + shaped * 28.0).round().clamp(8.0, 36.0);
+ let secondary_factor = (primary_factor * (1.28 + normalized * 0.32))
+ .round()
+ .clamp(primary_factor + 2.0, 52.0);
+
+ BlurRecipe {
+ primary_factor,
+ secondary_factor,
+ padding_px: (secondary_factor.ceil() as i32).saturating_mul(2).max(12),
+ overlay_alpha: 0.05 + shaped * 0.06,
+ }
+}
+
+pub(super) fn render_blur_placeholder(
+ ctx: &cairo::Context,
+ x: i32,
+ y: i32,
+ w: i32,
+ h: i32,
+ selected: bool,
+) {
+ let Some((left, top, width, height)) = normalize_rect(x, y, w, h) else {
+ return;
+ };
+
+ let _ = ctx.save();
+ ctx.rectangle(left, top, width, height);
+ let alpha = if selected { 0.92 } else { PLACEHOLDER_FILL.3 };
+ ctx.set_source_rgba(
+ PLACEHOLDER_FILL.0,
+ PLACEHOLDER_FILL.1,
+ PLACEHOLDER_FILL.2,
+ alpha,
+ );
+ let _ = ctx.fill_preserve();
+ ctx.set_source_rgba(
+ PLACEHOLDER_STROKE.0,
+ PLACEHOLDER_STROKE.1,
+ PLACEHOLDER_STROKE.2,
+ PLACEHOLDER_STROKE.3,
+ );
+ ctx.set_line_width(1.0);
+ let _ = ctx.stroke();
+
+ let band_count = 4;
+ let band_h = (height / band_count as f64).max(1.0);
+ for idx in 0..band_count {
+ if idx % 2 == 0 {
+ continue;
+ }
+ ctx.rectangle(left, top + idx as f64 * band_h, width, band_h.min(height));
+ ctx.set_source_rgba(1.0, 1.0, 1.0, 0.06);
+ let _ = ctx.fill();
+ }
+ let _ = ctx.restore();
+}
+
+fn copy_surface_region(
+ surface: &cairo::ImageSurface,
+ x: i32,
+ y: i32,
+ width: i32,
+ height: i32,
+) -> Option {
+ let region = cairo::ImageSurface::create(cairo::Format::ARgb32, width, height).ok()?;
+ let ctx = cairo::Context::new(®ion).ok()?;
+ let _ = ctx.set_source_surface(surface, -(x as f64), -(y as f64));
+ let _ = ctx.paint();
+ Some(region)
+}
+
+fn resample_dimensions(width: i32, height: i32, factor: f64) -> (i32, i32) {
+ (
+ ((width as f64) / factor).round().max(1.0) as i32,
+ ((height as f64) / factor).round().max(1.0) as i32,
+ )
+}
+
+fn resample_surface(
+ source: &cairo::ImageSurface,
+ width: i32,
+ height: i32,
+ filter: cairo::Filter,
+) -> Option {
+ let resampled = cairo::ImageSurface::create(cairo::Format::ARgb32, width, height).ok()?;
+ let ctx = cairo::Context::new(&resampled).ok()?;
+ let scale_x = width as f64 / source.width() as f64;
+ let scale_y = height as f64 / source.height() as f64;
+ ctx.scale(
+ scale_x.max(f64::MIN_POSITIVE),
+ scale_y.max(f64::MIN_POSITIVE),
+ );
+ let pattern = cairo::SurfacePattern::create(source);
+ pattern.set_filter(filter);
+ let _ = ctx.set_source(&pattern);
+ let _ = ctx.paint();
+ Some(resampled)
+}
+
+fn average_surface_stats(surface: &mut cairo::ImageSurface) -> Option {
+ let width = surface.width().max(1) as usize;
+ let height = surface.height().max(1) as usize;
+ let stride = surface.stride().max(4) as usize;
+ let step = (width.max(height) / 64).max(1);
+
+ surface.flush();
+ let data = surface.data().ok()?;
+ let mut total_red = 0.0;
+ let mut total_green = 0.0;
+ let mut total_blue = 0.0;
+ let mut total = 0.0;
+ let mut count = 0usize;
+
+ for y in (0..height).step_by(step) {
+ let row = &data[y * stride..];
+ for x in (0..width).step_by(step) {
+ let idx = x * 4;
+ if idx + 3 >= row.len() {
+ break;
+ }
+
+ let alpha = row[idx + 3] as f64 / 255.0;
+ if alpha <= f64::EPSILON {
+ continue;
+ }
+
+ let blue = row[idx] as f64 / 255.0;
+ let green = row[idx + 1] as f64 / 255.0;
+ let red = row[idx + 2] as f64 / 255.0;
+ let inv_alpha = alpha.recip();
+ let red = (red * inv_alpha).clamp(0.0, 1.0);
+ let green = (green * inv_alpha).clamp(0.0, 1.0);
+ let blue = (blue * inv_alpha).clamp(0.0, 1.0);
+
+ total_red += red;
+ total_green += green;
+ total_blue += blue;
+ total += red * 0.299 + green * 0.587 + blue * 0.114;
+ count += 1;
+ }
+ }
+
+ (count > 0).then_some(BlurSurfaceStats {
+ red: total_red / count as f64,
+ green: total_green / count as f64,
+ blue: total_blue / count as f64,
+ luminance: total / count as f64,
+ })
+}
+
+fn blur_overlay_palette(stats: BlurSurfaceStats, alpha: f64) -> OverlayPalette {
+ let fill = (stats.red, stats.green, stats.blue, alpha);
+
+ if stats.luminance > 0.62 {
+ (
+ fill,
+ (
+ (stats.red * 0.58).clamp(0.0, 1.0),
+ (stats.green * 0.58).clamp(0.0, 1.0),
+ (stats.blue * 0.58).clamp(0.0, 1.0),
+ (alpha + 0.08).min(0.22),
+ ),
+ )
+ } else {
+ (
+ fill,
+ (
+ (stats.red + (1.0 - stats.red) * 0.32).clamp(0.0, 1.0),
+ (stats.green + (1.0 - stats.green) * 0.32).clamp(0.0, 1.0),
+ (stats.blue + (1.0 - stats.blue) * 0.32).clamp(0.0, 1.0),
+ (alpha + 0.07).min(0.2),
+ ),
+ )
+ }
+}
+
+fn build_blur_cache_key(
+ replay_ctx: &EraserReplayContext<'_>,
+ recipe: BlurRecipe,
+ src_x: i32,
+ src_y: i32,
+ src_w: i32,
+ src_h: i32,
+) -> Option {
+ Some(BlurCacheKey {
+ backdrop_cache_key: replay_ctx.backdrop_cache_key?,
+ src_x,
+ src_y,
+ src_w,
+ src_h,
+ primary_factor: recipe.primary_factor.round() as u16,
+ secondary_factor: recipe.secondary_factor.round() as u16,
+ })
+}
+
+fn cacheable_blur_entry(
+ cache_key: Option,
+ compute: impl FnOnce() -> Option,
+) -> Option {
+ if let Some(key) = cache_key
+ && let Some(entry) = BLUR_RENDER_CACHE.with(|cache| cache.borrow_mut().get(&key))
+ {
+ return Some(entry);
+ }
+
+ let entry = compute()?;
+ if let Some(key) = cache_key {
+ BLUR_RENDER_CACHE.with(|cache| cache.borrow_mut().insert(key, entry.clone()));
+ }
+ Some(entry)
+}
+
+fn render_blur_region(
+ surface: &cairo::ImageSurface,
+ src_x: i32,
+ src_y: i32,
+ src_w: i32,
+ src_h: i32,
+ recipe: BlurRecipe,
+) -> Option {
+ let crop = copy_surface_region(surface, src_x, src_y, src_w, src_h)?;
+ let (small_w, small_h) = resample_dimensions(src_w, src_h, recipe.primary_factor);
+ let downscaled = resample_surface(&crop, small_w, small_h, cairo::Filter::Best)?;
+ let (tiny_w, tiny_h) = resample_dimensions(src_w, src_h, recipe.secondary_factor);
+ let tiny = resample_surface(&downscaled, tiny_w, tiny_h, cairo::Filter::Best)?;
+ let mut blurred = resample_surface(&tiny, src_w, src_h, cairo::Filter::Bilinear)?;
+ let stats = average_surface_stats(&mut blurred).unwrap_or(BlurSurfaceStats {
+ red: 0.6,
+ green: 0.62,
+ blue: 0.66,
+ luminance: 0.62,
+ });
+
+ Some(CachedBlurRegion {
+ approx_bytes: (src_w.max(1) as usize)
+ .saturating_mul(src_h.max(1) as usize)
+ .saturating_mul(4),
+ surface: blurred,
+ stats,
+ })
+}
+
+pub fn render_blur_rect(
+ ctx: &cairo::Context,
+ params: BlurRectParams,
+ replay_ctx: &EraserReplayContext<'_>,
+) {
+ let BlurRectParams {
+ x,
+ y,
+ w,
+ h,
+ strength,
+ cacheable,
+ } = params;
+ let Some((left, top, width, height)) = normalize_rect(x, y, w, h) else {
+ return;
+ };
+
+ let Some(surface) = replay_ctx.surface else {
+ render_blur_placeholder(ctx, x, y, w, h, false);
+ return;
+ };
+
+ let scale_x = replay_ctx.logical_to_image_scale_x.max(f64::MIN_POSITIVE);
+ let scale_y = replay_ctx.logical_to_image_scale_y.max(f64::MIN_POSITIVE);
+ let recipe = blur_recipe(strength);
+
+ let src_x = ((left * scale_x).floor() as i32).saturating_sub(recipe.padding_px);
+ let src_y = ((top * scale_y).floor() as i32).saturating_sub(recipe.padding_px);
+ let src_x2 = ((left + width) * scale_x).ceil() as i32 + recipe.padding_px;
+ let src_y2 = ((top + height) * scale_y).ceil() as i32 + recipe.padding_px;
+
+ let src_x = src_x.clamp(0, surface.width().saturating_sub(1));
+ let src_y = src_y.clamp(0, surface.height().saturating_sub(1));
+ let src_x2 = src_x2.clamp(src_x + 1, surface.width());
+ let src_y2 = src_y2.clamp(src_y + 1, surface.height());
+ let src_w = src_x2 - src_x;
+ let src_h = src_y2 - src_y;
+ let cache_key =
+ cacheable.then(|| build_blur_cache_key(replay_ctx, recipe, src_x, src_y, src_w, src_h));
+ let cache_key = cache_key.flatten();
+ let Some(blurred) = cacheable_blur_entry(cache_key, || {
+ render_blur_region(surface, src_x, src_y, src_w, src_h, recipe)
+ }) else {
+ render_blur_placeholder(ctx, x, y, w, h, false);
+ return;
+ };
+ let overlay_palette = blur_overlay_palette(blurred.stats, recipe.overlay_alpha);
+
+ let dest_x = src_x as f64 / scale_x;
+ let dest_y = src_y as f64 / scale_y;
+ let dest_w = src_w as f64 / scale_x;
+ let dest_h = src_h as f64 / scale_y;
+
+ let _ = ctx.save();
+ ctx.rectangle(left, top, width, height);
+ ctx.clip();
+ ctx.translate(dest_x, dest_y);
+ ctx.scale(dest_w / src_w.max(1) as f64, dest_h / src_h.max(1) as f64);
+ let pattern = cairo::SurfacePattern::create(&blurred.surface);
+ pattern.set_filter(cairo::Filter::Bilinear);
+ let _ = ctx.set_source(&pattern);
+ let _ = ctx.paint();
+ let _ = ctx.restore();
+
+ let _ = ctx.save();
+ ctx.rectangle(left, top, width, height);
+ ctx.set_source_rgba(
+ overlay_palette.0.0,
+ overlay_palette.0.1,
+ overlay_palette.0.2,
+ overlay_palette.0.3,
+ );
+ let _ = ctx.fill_preserve();
+ ctx.set_source_rgba(
+ overlay_palette.1.0,
+ overlay_palette.1.1,
+ overlay_palette.1.2,
+ overlay_palette.1.3,
+ );
+ ctx.set_line_width(1.0);
+ let _ = ctx.stroke();
+ let _ = ctx.restore();
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ BlurCacheKey, BlurRenderCache, BlurSurfaceStats, CachedBlurRegion, blur_overlay_palette,
+ blur_recipe,
+ };
+
+ #[test]
+ fn blur_recipe_keeps_default_strength_heavily_blurred_but_not_overwashed() {
+ let recipe = blur_recipe(12.0);
+
+ assert!(recipe.primary_factor >= 18.0);
+ assert!(recipe.secondary_factor > recipe.primary_factor);
+ assert!((0.05..=0.11).contains(&recipe.overlay_alpha));
+ }
+
+ #[test]
+ fn blur_recipe_clamps_extremes() {
+ let min = blur_recipe(-10.0);
+ let max = blur_recipe(500.0);
+
+ assert_eq!(min.primary_factor, 8.0);
+ assert_eq!(min.secondary_factor, 10.0);
+ assert!((0.05..=0.11).contains(&min.overlay_alpha));
+
+ assert!(max.primary_factor <= 36.0);
+ assert!(max.secondary_factor <= 52.0);
+ assert!((0.05..=0.11).contains(&max.overlay_alpha));
+ }
+
+ #[test]
+ fn overlay_palette_switches_contrast_for_light_and_dark_regions() {
+ let dark_region = blur_overlay_palette(
+ BlurSurfaceStats {
+ red: 0.2,
+ green: 0.24,
+ blue: 0.3,
+ luminance: 0.22,
+ },
+ 0.1,
+ );
+ let light_region = blur_overlay_palette(
+ BlurSurfaceStats {
+ red: 0.82,
+ green: 0.84,
+ blue: 0.88,
+ luminance: 0.84,
+ },
+ 0.1,
+ );
+
+ assert!((dark_region.0.0 - 0.2).abs() < f64::EPSILON);
+ assert!(dark_region.1.0 > dark_region.0.0);
+ assert!((light_region.0.0 - 0.82).abs() < f64::EPSILON);
+ assert!(light_region.1.0 < light_region.0.0);
+ }
+
+ #[test]
+ fn blur_render_cache_returns_cached_entry_for_same_key() {
+ let mut cache = BlurRenderCache::new(4, 1024);
+ let key = BlurCacheKey {
+ backdrop_cache_key: 1,
+ src_x: 10,
+ src_y: 20,
+ src_w: 30,
+ src_h: 40,
+ primary_factor: 18,
+ secondary_factor: 24,
+ };
+ let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 4, 4).expect("surface");
+ cache.insert(
+ key,
+ CachedBlurRegion {
+ surface,
+ stats: BlurSurfaceStats {
+ red: 0.4,
+ green: 0.42,
+ blue: 0.45,
+ luminance: 0.42,
+ },
+ approx_bytes: 64,
+ },
+ );
+
+ let cached = cache.get(&key).expect("cached entry");
+ assert!((cached.stats.luminance - 0.42).abs() < f64::EPSILON);
+ assert_eq!(cached.surface.width(), 4);
+ assert_eq!(cached.surface.height(), 4);
+ }
+
+ #[test]
+ fn blur_render_cache_evicts_oldest_entry_when_budget_is_exceeded() {
+ let mut cache = BlurRenderCache::new(2, 96);
+ let make_key = |backdrop_cache_key| BlurCacheKey {
+ backdrop_cache_key,
+ src_x: 0,
+ src_y: 0,
+ src_w: 2,
+ src_h: 2,
+ primary_factor: 18,
+ secondary_factor: 24,
+ };
+ let make_entry = || CachedBlurRegion {
+ surface: cairo::ImageSurface::create(cairo::Format::ARgb32, 4, 4).expect("surface"),
+ stats: BlurSurfaceStats {
+ red: 0.5,
+ green: 0.52,
+ blue: 0.55,
+ luminance: 0.5,
+ },
+ approx_bytes: 64,
+ };
+
+ cache.insert(make_key(1), make_entry());
+ cache.insert(make_key(2), make_entry());
+
+ assert!(cache.get(&make_key(1)).is_none());
+ assert!(cache.get(&make_key(2)).is_some());
+ }
+}
diff --git a/src/draw/render/mod.rs b/src/draw/render/mod.rs
index beffd23f..5afa1cbb 100644
--- a/src/draw/render/mod.rs
+++ b/src/draw/render/mod.rs
@@ -1,6 +1,7 @@
//! Cairo-based rendering functions for shapes.
mod background;
+mod blur;
mod highlight;
mod primitives;
mod selection;
@@ -10,6 +11,7 @@ mod text;
mod types;
pub use background::{fill_transparent, render_board_background};
+pub use blur::{BlurRectParams, render_blur_rect};
pub use highlight::render_click_highlight;
pub use selection::{render_selection_halo, render_selection_handles, selection_handle_rects};
pub use shapes::render_shape;
diff --git a/src/draw/render/selection.rs b/src/draw/render/selection.rs
index 1cd3205e..4d05d750 100644
--- a/src/draw/render/selection.rs
+++ b/src/draw/render/selection.rs
@@ -106,6 +106,22 @@ pub fn render_selection_halo(ctx: &cairo::Context, drawn: &DrawnShape) {
*head_at_end,
);
}
+ Shape::BlurRect { .. } => {
+ if let Some(bounds) = drawn.shape.bounding_box() {
+ let padding = 3.0;
+ let x = bounds.x as f64 - padding;
+ let y = bounds.y as f64 - padding;
+ let w = bounds.width as f64 + padding * 2.0;
+ let h = bounds.height as f64 + padding * 2.0;
+ ctx.set_source_rgba(glow.r, glow.g, glow.b, glow.a * 0.5);
+ ctx.rectangle(x, y, w, h);
+ let _ = ctx.fill();
+ ctx.set_source_rgba(glow.r, glow.g, glow.b, glow.a);
+ ctx.set_line_width(2.0);
+ ctx.rectangle(x, y, w, h);
+ let _ = ctx.stroke();
+ }
+ }
Shape::MarkerStroke { points, thick, .. } => {
render_freehand_borrowed(ctx, points, glow, thick + outline_width);
}
diff --git a/src/draw/render/shapes.rs b/src/draw/render/shapes.rs
index 3f7dd349..9c13adcf 100644
--- a/src/draw/render/shapes.rs
+++ b/src/draw/render/shapes.rs
@@ -1,3 +1,4 @@
+use super::blur::render_blur_placeholder;
use super::highlight::render_click_highlight;
use super::primitives::{render_arrow, render_ellipse, render_line, render_rect};
use super::strokes::{
@@ -120,6 +121,15 @@ pub fn render_shape(ctx: &cairo::Context, shape: &Shape) {
}
}
}
+ Shape::BlurRect {
+ x,
+ y,
+ w,
+ h,
+ strength: _,
+ } => {
+ render_blur_placeholder(ctx, *x, *y, *w, *h, false);
+ }
Shape::Text {
x,
y,
diff --git a/src/draw/render/types.rs b/src/draw/render/types.rs
index ca42310a..36e5ecfc 100644
--- a/src/draw/render/types.rs
+++ b/src/draw/render/types.rs
@@ -1,9 +1,17 @@
use crate::draw::Color;
-/// Background replay context for eraser strokes.
+/// Background replay context for tools that need access to the captured backdrop.
pub struct EraserReplayContext<'a> {
/// Optional pattern representing the current background (e.g., frozen image) in device space.
pub pattern: Option<&'a cairo::Pattern>,
+ /// Optional surface representing the captured background image.
+ pub surface: Option<&'a cairo::ImageSurface>,
+ /// Stable cache key for the currently active backdrop image generation.
+ pub backdrop_cache_key: Option,
/// Solid background color (board modes) when no pattern is available.
pub bg_color: Option,
+ /// Horizontal scale from logical canvas coordinates to captured image pixels.
+ pub logical_to_image_scale_x: f64,
+ /// Vertical scale from logical canvas coordinates to captured image pixels.
+ pub logical_to_image_scale_y: f64,
}
diff --git a/src/draw/shape/bounds.rs b/src/draw/shape/bounds.rs
index cb506532..ef13b688 100644
--- a/src/draw/shape/bounds.rs
+++ b/src/draw/shape/bounds.rs
@@ -59,6 +59,18 @@ pub(crate) fn bounding_box_for_rect(x: i32, y: i32, w: i32, h: i32, thick: f64)
ensure_positive_rect(min_x, min_y, max_x, max_y)
}
+pub(crate) fn bounding_box_for_blur(x: i32, y: i32, w: i32, h: i32) -> Option {
+ let x2 = x + w;
+ let y2 = y + h;
+ let padding = 1;
+ ensure_positive_rect(
+ x.min(x2) - padding,
+ y.min(y2) - padding,
+ x.max(x2) + padding,
+ y.max(y2) + padding,
+ )
+}
+
pub(crate) fn bounding_box_for_ellipse(
cx: i32,
cy: i32,
@@ -235,4 +247,16 @@ mod tests {
assert_eq!(stroke_padding(1.1), 1);
assert_eq!(stroke_padding(2.1), 2);
}
+
+ #[test]
+ fn bounding_box_for_blur_includes_outline_stroke() {
+ assert_eq!(
+ bounding_box_for_blur(10, 20, 30, 40),
+ Rect::new(9, 19, 32, 42)
+ );
+ assert_eq!(
+ bounding_box_for_blur(10, 20, -4, -6),
+ Rect::new(5, 13, 6, 8)
+ );
+ }
}
diff --git a/src/draw/shape/mod.rs b/src/draw/shape/mod.rs
index 5c3780fc..70cbc240 100644
--- a/src/draw/shape/mod.rs
+++ b/src/draw/shape/mod.rs
@@ -12,8 +12,8 @@ pub use types::{ArrowLabel, EraserBrush, EraserKind, Shape, StepMarkerLabel};
pub(crate) use arrow_label::{ARROW_LABEL_BACKGROUND, arrow_label_layout};
pub(crate) use bounds::{
- bounding_box_for_arrow, bounding_box_for_ellipse, bounding_box_for_eraser,
- bounding_box_for_line, bounding_box_for_points, bounding_box_for_rect,
+ bounding_box_for_arrow, bounding_box_for_blur, bounding_box_for_ellipse,
+ bounding_box_for_eraser, bounding_box_for_line, bounding_box_for_points, bounding_box_for_rect,
};
pub(crate) use step_marker::{
step_marker_bounds, step_marker_outline_thickness, step_marker_radius,
diff --git a/src/draw/shape/types.rs b/src/draw/shape/types.rs
index 75b8a7df..dddb0b74 100644
--- a/src/draw/shape/types.rs
+++ b/src/draw/shape/types.rs
@@ -1,6 +1,6 @@
use super::bounds::{
- bounding_box_for_arrow, bounding_box_for_ellipse, bounding_box_for_eraser,
- bounding_box_for_line, bounding_box_for_points, bounding_box_for_rect,
+ bounding_box_for_arrow, bounding_box_for_blur, bounding_box_for_ellipse,
+ bounding_box_for_eraser, bounding_box_for_line, bounding_box_for_points, bounding_box_for_rect,
};
use super::step_marker::step_marker_bounds;
use super::text::{bounding_box_for_sticky_note, bounding_box_for_text};
@@ -144,6 +144,19 @@ pub enum Shape {
#[serde(default, skip_serializing_if = "Option::is_none")]
label: Option,
},
+ /// Rectangular blur region over the captured background.
+ BlurRect {
+ /// Top-left X coordinate
+ x: i32,
+ /// Top-left Y coordinate
+ y: i32,
+ /// Width in pixels
+ w: i32,
+ /// Height in pixels
+ h: i32,
+ /// Blur strength, reusing the tool size slider semantics
+ strength: f64,
+ },
/// Numbered step marker bubble.
StepMarker {
/// Center X coordinate
@@ -286,6 +299,7 @@ impl Shape {
*head_at_end,
label.as_ref(),
),
+ Shape::BlurRect { x, y, w, h, .. } => bounding_box_for_blur(*x, *y, *w, *h),
Shape::Text {
x,
y,
@@ -332,6 +346,7 @@ impl Shape {
Shape::Rect { .. } => "Rectangle",
Shape::Ellipse { .. } => "Ellipse",
Shape::Arrow { .. } => "Arrow",
+ Shape::BlurRect { .. } => "Blur",
Shape::Text { .. } => "Text",
Shape::StickyNote { .. } => "Sticky Note",
Shape::MarkerStroke { .. } => "Marker",
diff --git a/src/input/hit_test/mod.rs b/src/input/hit_test/mod.rs
index b27315a8..bb4fe313 100644
--- a/src/input/hit_test/mod.rs
+++ b/src/input/hit_test/mod.rs
@@ -117,6 +117,17 @@ pub fn hit_test(shape: &DrawnShape, point: (i32, i32), tolerance: f64) -> bool {
}
hit
}
+ Shape::BlurRect { .. } => {
+ let inflate = tolerance.ceil() as i32;
+ if let Some(bounds) = shape.shape.bounding_box() {
+ bounds
+ .inflated(inflate)
+ .unwrap_or(bounds)
+ .contains(point.0, point.1)
+ } else {
+ false
+ }
+ }
Shape::Text { .. } | Shape::StickyNote { .. } => {
if let Some(bounds) = shape.shape.bounding_box() {
let inflate = tolerance.ceil() as i32;
diff --git a/src/input/state/actions/action_tools.rs b/src/input/state/actions/action_tools.rs
index 310d3c08..5b756150 100644
--- a/src/input/state/actions/action_tools.rs
+++ b/src/input/state/actions/action_tools.rs
@@ -75,6 +75,9 @@ impl InputState {
Action::SelectArrowTool => {
self.set_tool_override(Some(Tool::Arrow));
}
+ Action::SelectBlurTool => {
+ self.set_tool_override(Some(Tool::Blur));
+ }
Action::SelectHighlightTool => {
self.set_highlight_tool(true);
self.set_tool_override(Some(Tool::Highlight));
diff --git a/src/input/state/core/dirty.rs b/src/input/state/core/dirty.rs
index d853e094..67c97491 100644
--- a/src/input/state/core/dirty.rs
+++ b/src/input/state/core/dirty.rs
@@ -1,7 +1,7 @@
use super::base::{DrawingState, InputState, TextInputMode};
use crate::draw::shape::{
- bounding_box_for_arrow, bounding_box_for_ellipse, bounding_box_for_eraser,
- bounding_box_for_line, bounding_box_for_points, bounding_box_for_rect,
+ bounding_box_for_arrow, bounding_box_for_blur, bounding_box_for_ellipse,
+ bounding_box_for_eraser, bounding_box_for_line, bounding_box_for_points, bounding_box_for_rect,
bounding_box_for_sticky_note, bounding_box_for_text, step_marker_bounds,
};
use crate::input::tool::Tool;
@@ -101,6 +101,19 @@ impl InputState {
label.as_ref(),
)
}
+ Tool::Blur => {
+ let (x, w) = if current_x >= *start_x {
+ (*start_x, current_x - start_x)
+ } else {
+ (current_x, start_x - current_x)
+ };
+ let (y, h) = if current_y >= *start_y {
+ (*start_y, current_y - start_y)
+ } else {
+ (current_y, start_y - current_y)
+ };
+ bounding_box_for_blur(x, y, w, h)
+ }
Tool::StepMarker => {
let label = self.next_step_marker_label();
step_marker_bounds(
diff --git a/src/input/state/core/properties/apply_selection/actions/stroke.rs b/src/input/state/core/properties/apply_selection/actions/stroke.rs
index 206cc303..54056b9f 100644
--- a/src/input/state/core/properties/apply_selection/actions/stroke.rs
+++ b/src/input/state/core/properties/apply_selection/actions/stroke.rs
@@ -24,6 +24,7 @@ impl InputState {
| Shape::Rect { .. }
| Shape::Ellipse { .. }
| Shape::Arrow { .. }
+ | Shape::BlurRect { .. }
| Shape::MarkerStroke { .. }
) || (pressure_editable && matches!(shape, Shape::FreehandPressure { .. }))
},
@@ -33,6 +34,9 @@ impl InputState {
| Shape::Rect { thick, .. }
| Shape::Ellipse { thick, .. }
| Shape::Arrow { thick, .. }
+ | Shape::BlurRect {
+ strength: thick, ..
+ }
| Shape::MarkerStroke { thick, .. } => {
let next = (*thick + delta).clamp(MIN_STROKE_THICKNESS, MAX_STROKE_THICKNESS);
if (next - *thick).abs() > f64::EPSILON {
diff --git a/src/input/state/core/properties/summary.rs b/src/input/state/core/properties/summary.rs
index 29294c76..feadafb6 100644
--- a/src/input/state/core/properties/summary.rs
+++ b/src/input/state/core/properties/summary.rs
@@ -87,6 +87,9 @@ pub(super) fn shape_thickness(shape: &Shape) -> Option {
| Shape::Rect { thick, .. }
| Shape::Ellipse { thick, .. }
| Shape::Arrow { thick, .. }
+ | Shape::BlurRect {
+ strength: thick, ..
+ }
| Shape::MarkerStroke { thick, .. } => Some(*thick),
_ => None,
}
diff --git a/src/input/state/core/radial_menu/mod.rs b/src/input/state/core/radial_menu/mod.rs
index fcdb86f2..16530185 100644
--- a/src/input/state/core/radial_menu/mod.rs
+++ b/src/input/state/core/radial_menu/mod.rs
@@ -63,7 +63,7 @@ pub const TOOL_SEGMENT_COUNT: usize = 9;
pub const COLOR_SEGMENT_COUNT: usize = 8;
/// Sub-ring children for the Shapes segment (index 4).
-pub const SHAPES_CHILDREN: &[&str] = &["Rect", "Ellipse"];
+pub const SHAPES_CHILDREN: &[&str] = &["Rect", "Ellipse", "Blur"];
/// Sub-ring children for the Text segment (index 5).
pub const TEXT_CHILDREN: &[&str] = &["Text", "Sticky", "Step"];
/// Sub-ring children for the Actions segment (index 8).
diff --git a/src/input/state/core/radial_menu/state.rs b/src/input/state/core/radial_menu/state.rs
index 886850f4..18a5bc7b 100644
--- a/src/input/state/core/radial_menu/state.rs
+++ b/src/input/state/core/radial_menu/state.rs
@@ -217,6 +217,9 @@ impl InputState {
1 => {
self.set_tool_override(Some(Tool::Ellipse));
}
+ 2 => {
+ self.set_tool_override(Some(Tool::Blur));
+ }
_ => {}
}
}
diff --git a/src/input/state/core/selection_actions/resize.rs b/src/input/state/core/selection_actions/resize.rs
index 76bbc283..d863768c 100644
--- a/src/input/state/core/selection_actions/resize.rs
+++ b/src/input/state/core/selection_actions/resize.rs
@@ -196,6 +196,24 @@ impl InputState {
label: label.clone(),
}
}
+ Shape::BlurRect {
+ x,
+ y,
+ w,
+ h,
+ strength,
+ } => {
+ let (nx, ny) = Self::scale_point_i32(*x, *y, anchor_x, anchor_y, scale_x, scale_y);
+ let nw = Self::scale_size(*w, scale_x);
+ let nh = Self::scale_size(*h, scale_y);
+ Shape::BlurRect {
+ x: nx,
+ y: ny,
+ w: nw.max(1),
+ h: nh.max(1),
+ strength: *strength,
+ }
+ }
Shape::Freehand {
points,
color,
diff --git a/src/input/state/core/selection_actions/translation/transform.rs b/src/input/state/core/selection_actions/translation/transform.rs
index ae385ae6..1c0071b3 100644
--- a/src/input/state/core/selection_actions/translation/transform.rs
+++ b/src/input/state/core/selection_actions/translation/transform.rs
@@ -71,6 +71,10 @@ impl InputState {
*y1 += dy;
*y2 += dy;
}
+ Shape::BlurRect { x, y, .. } => {
+ *x += dx;
+ *y += dy;
+ }
Shape::Text { x, y, .. } => {
*x += dx;
*y += dy;
diff --git a/src/input/state/core/tool_controls/settings.rs b/src/input/state/core/tool_controls/settings.rs
index f071bbd8..9c0c81f0 100644
--- a/src/input/state/core/tool_controls/settings.rs
+++ b/src/input/state/core/tool_controls/settings.rs
@@ -1,4 +1,6 @@
-use super::super::base::{DrawingState, InputState, MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS};
+use super::super::base::{
+ DrawingState, InputState, MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS, UiToastKind,
+};
use crate::draw::{Color, FontDescriptor};
use crate::input::{
modifiers::DragToolBindings,
@@ -24,6 +26,11 @@ impl InputState {
self.tool_override = tool;
self.active_preset_slot = None;
+ if tool == Some(Tool::Blur) && !self.frozen_active && !self.pending_frozen_toggle {
+ self.request_frozen_toggle();
+ self.set_ui_toast(UiToastKind::Info, "Capturing background for blur...");
+ }
+
// Ensure we are not mid-drawing with a stale tool
if !matches!(
self.state,
diff --git a/src/input/state/core/utility/frozen_zoom.rs b/src/input/state/core/utility/frozen_zoom.rs
index 5a229f95..e4fe9236 100644
--- a/src/input/state/core/utility/frozen_zoom.rs
+++ b/src/input/state/core/utility/frozen_zoom.rs
@@ -13,6 +13,10 @@ impl InputState {
pending
}
+ pub(crate) fn pending_frozen_toggle(&self) -> bool {
+ self.pending_frozen_toggle
+ }
+
/// Updates the cached frozen-mode status and triggers a redraw when it changes.
pub fn set_frozen_active(&mut self, active: bool) {
if self.frozen_active != active {
diff --git a/src/input/state/mouse/press.rs b/src/input/state/mouse/press.rs
index 92bfa678..f5281a5f 100644
--- a/src/input/state/mouse/press.rs
+++ b/src/input/state/mouse/press.rs
@@ -295,6 +295,9 @@ impl InputState {
}
let tool = self.active_tool();
+ if tool == Tool::Blur && !self.frozen_active() && !self.pending_frozen_toggle() {
+ self.request_frozen_toggle();
+ }
if tool != Tool::Highlight && tool != Tool::Select {
self.state = DrawingState::Drawing {
tool,
diff --git a/src/input/state/mouse/release/drawing.rs b/src/input/state/mouse/release/drawing.rs
index 7098d39f..ab72e773 100644
--- a/src/input/state/mouse/release/drawing.rs
+++ b/src/input/state/mouse/release/drawing.rs
@@ -120,6 +120,25 @@ pub(super) fn finish_drawing(state: &mut InputState, tool: Tool, release: Drawin
head_at_end: state.arrow_head_at_end,
label,
},
+ Tool::Blur => {
+ let (left, width) = if end_x >= start_x {
+ (start_x, end_x - start_x)
+ } else {
+ (end_x, start_x - end_x)
+ };
+ let (top, height) = if end_y >= start_y {
+ (start_y, end_y - start_y)
+ } else {
+ (end_y, start_y - end_y)
+ };
+ Shape::BlurRect {
+ x: left,
+ y: top,
+ w: width,
+ h: height,
+ strength: state.current_thickness,
+ }
+ }
Tool::Marker => Shape::MarkerStroke {
points,
color: state.marker_color(),
diff --git a/src/input/state/render.rs b/src/input/state/render.rs
index 79ded188..3ccf0a89 100644
--- a/src/input/state/render.rs
+++ b/src/input/state/render.rs
@@ -80,6 +80,25 @@ impl InputState {
head_at_end: self.arrow_head_at_end,
label: self.next_arrow_label(),
}),
+ Tool::Blur => {
+ let (x, w) = if current_x >= *start_x {
+ (*start_x, current_x - start_x)
+ } else {
+ (current_x, start_x - current_x)
+ };
+ let (y, h) = if current_y >= *start_y {
+ (*start_y, current_y - start_y)
+ } else {
+ (current_y, start_y - current_y)
+ };
+ Some(Shape::BlurRect {
+ x,
+ y,
+ w,
+ h,
+ strength: self.current_thickness,
+ })
+ }
Tool::StepMarker => Some(Shape::StepMarker {
x: current_x,
y: current_y,
diff --git a/src/input/state/tests/drawing.rs b/src/input/state/tests/drawing.rs
index 662ab092..3753bcf3 100644
--- a/src/input/state/tests/drawing.rs
+++ b/src/input/state/tests/drawing.rs
@@ -70,6 +70,29 @@ fn custom_drag_bindings_remap_default_and_modifier_tools() {
assert_eq!(state.active_tool(), Tool::Rect);
}
+#[test]
+fn blur_drag_requests_frozen_capture_on_press() {
+ let mut state = create_test_input_state();
+ assert!(state.set_drag_tool_bindings(DragToolBindings {
+ drag: Tool::Blur,
+ shift_drag: Tool::Line,
+ ctrl_drag: Tool::Rect,
+ ctrl_shift_drag: Tool::Arrow,
+ tab_drag: Tool::Ellipse,
+ }));
+
+ state.on_mouse_press(MouseButton::Left, 12, 14);
+
+ assert!(state.take_pending_frozen_toggle());
+ assert!(matches!(
+ state.state,
+ DrawingState::Drawing {
+ tool: Tool::Blur,
+ ..
+ }
+ ));
+}
+
#[test]
fn drag_mapped_highlight_reports_highlight_active() {
let mut state = create_test_input_state();
diff --git a/src/input/state/tests/tool_controls.rs b/src/input/state/tests/tool_controls.rs
index 33a7b472..558e6f0d 100644
--- a/src/input/state/tests/tool_controls.rs
+++ b/src/input/state/tests/tool_controls.rs
@@ -40,6 +40,15 @@ fn set_tool_override_preserves_text_input_state() {
));
}
+#[test]
+fn blur_tool_override_requests_frozen_capture_when_needed() {
+ let mut state = create_test_input_state();
+
+ assert!(state.set_tool_override(Some(Tool::Blur)));
+ assert_eq!(state.tool_override(), Some(Tool::Blur));
+ assert!(state.take_pending_frozen_toggle());
+}
+
#[test]
fn presenter_locked_mode_rejects_non_highlight_tool_override() {
let mut state = create_test_input_state();
diff --git a/src/input/tool.rs b/src/input/tool.rs
index fb7ac859..ec132003 100644
--- a/src/input/tool.rs
+++ b/src/input/tool.rs
@@ -22,6 +22,8 @@ pub enum Tool {
Ellipse,
/// Arrow with directional head (Ctrl+Shift)
Arrow,
+ /// Privacy blur rectangle over the captured background
+ Blur,
/// Semi-transparent marker stroke for highlighting text
Marker,
/// Highlight-only tool (no drawing, emits click highlight)
diff --git a/src/toolbar_icons/svg.rs b/src/toolbar_icons/svg.rs
index 17d5db3a..beb0438a 100644
--- a/src/toolbar_icons/svg.rs
+++ b/src/toolbar_icons/svg.rs
@@ -163,6 +163,7 @@ svg_icon!(LINE, "../../assets/icons/minus.svg");
svg_icon!(RECT, "../../assets/icons/rectangle-horizontal.svg");
svg_icon!(CIRCLE, "../../assets/icons/circle.svg");
svg_icon!(ARROW, "../../assets/icons/arrow-up-right.svg");
+svg_icon!(BLUR, "../../assets/icons/blur.svg");
svg_icon!(ERASER, "../../assets/icons/eraser.svg");
svg_icon!(TEXT, "../../assets/icons/type.svg");
svg_icon!(NOTE, "../../assets/icons/sticky-note.svg");
@@ -195,6 +196,10 @@ pub fn render_arrow(ctx: &Context, x: f64, y: f64, size: f64) {
ARROW.render(ctx, x, y, size);
}
+pub fn render_blur(ctx: &Context, x: f64, y: f64, size: f64) {
+ BLUR.render(ctx, x, y, size);
+}
+
pub fn render_eraser(ctx: &Context, x: f64, y: f64, size: f64) {
ERASER.render(ctx, x, y, size);
}
@@ -241,13 +246,14 @@ mod tests {
#[test]
fn embedded_icons_render_non_empty_alpha() {
- let icons: [(&str, &SvgIcon); 12] = [
+ let icons: [(&str, &SvgIcon); 13] = [
("select", &*SELECT),
("pen", &*PEN),
("line", &*LINE),
("rect", &*RECT),
("circle", &*CIRCLE),
("arrow", &*ARROW),
+ ("blur", &*BLUR),
("eraser", &*ERASER),
("text", &*TEXT),
("note", &*NOTE),
diff --git a/src/toolbar_icons/tools.rs b/src/toolbar_icons/tools.rs
index aeb0b75a..4520a7e8 100644
--- a/src/toolbar_icons/tools.rs
+++ b/src/toolbar_icons/tools.rs
@@ -30,6 +30,11 @@ pub fn draw_icon_arrow(ctx: &Context, x: f64, y: f64, size: f64) {
super::svg::render_arrow(ctx, x, y, size);
}
+/// Draw a blur tool icon
+pub fn draw_icon_blur(ctx: &Context, x: f64, y: f64, size: f64) {
+ super::svg::render_blur(ctx, x, y, size);
+}
+
/// Draw an eraser tool icon
#[allow(dead_code)]
pub fn draw_icon_eraser(ctx: &Context, x: f64, y: f64, size: f64) {
diff --git a/src/ui/board_picker/page_panel/thumbnail/content.rs b/src/ui/board_picker/page_panel/thumbnail/content.rs
index 0ba9d88a..40824590 100644
--- a/src/ui/board_picker/page_panel/thumbnail/content.rs
+++ b/src/ui/board_picker/page_panel/thumbnail/content.rs
@@ -65,10 +65,14 @@ fn render_frame_shapes(
) {
let eraser_ctx = EraserReplayContext {
pattern: None,
+ surface: None,
+ backdrop_cache_key: None,
bg_color: match background {
BoardBackground::Solid(color) => Some(*color),
BoardBackground::Transparent => None,
},
+ logical_to_image_scale_x: 1.0,
+ logical_to_image_scale_y: 1.0,
};
for drawn in &frame.shapes {
diff --git a/src/ui/help_overlay/sections/builder/sections.rs b/src/ui/help_overlay/sections/builder/sections.rs
index b100d4f0..0b7bcd62 100644
--- a/src/ui/help_overlay/sections/builder/sections.rs
+++ b/src/ui/help_overlay/sections/builder/sections.rs
@@ -166,6 +166,10 @@ pub(super) fn build_main_sections(
binding_or_fallback(bindings, Action::SelectArrowTool, "Ctrl+Shift+Drag"),
action_label(Action::SelectArrowTool),
),
+ row(
+ binding_or_fallback(bindings, Action::SelectBlurTool, NOT_BOUND_LABEL),
+ action_label(Action::SelectBlurTool),
+ ),
row(
binding_or_fallback(bindings, Action::ToggleHighlightTool, NOT_BOUND_LABEL),
action_label(Action::ToggleHighlightTool),
diff --git a/src/ui/radial_menu.rs b/src/ui/radial_menu.rs
index 14ee198e..aa1c90be 100644
--- a/src/ui/radial_menu.rs
+++ b/src/ui/radial_menu.rs
@@ -291,7 +291,7 @@ fn tool_segment_matches(idx: u8, tool: Tool, input_state: &InputState) -> bool {
1 => tool == Tool::Marker,
2 => tool == Tool::Line,
3 => tool == Tool::Arrow,
- 4 => tool == Tool::Rect || tool == Tool::Ellipse,
+ 4 => tool == Tool::Rect || tool == Tool::Ellipse || tool == Tool::Blur,
5 => {
matches!(
input_state.state,
@@ -319,6 +319,7 @@ fn active_tool_short_label(tool: Tool, input_state: &InputState) -> &'static str
Tool::Arrow => "Arrow",
Tool::Rect => "Rect",
Tool::Ellipse => "Ellipse",
+ Tool::Blur => "Blur",
Tool::Eraser => "Eraser",
Tool::Select => "Select",
Tool::Highlight => "Highlight",
diff --git a/src/ui/toolbar/bindings.rs b/src/ui/toolbar/bindings.rs
index 89db9130..e2939205 100644
--- a/src/ui/toolbar/bindings.rs
+++ b/src/ui/toolbar/bindings.rs
@@ -56,6 +56,7 @@ pub(crate) fn action_for_tool(tool: Tool) -> Option {
Tool::Rect => Some(Action::SelectRectTool),
Tool::Ellipse => Some(Action::SelectEllipseTool),
Tool::Arrow => Some(Action::SelectArrowTool),
+ Tool::Blur => Some(Action::SelectBlurTool),
Tool::Marker => Some(Action::SelectMarkerTool),
Tool::StepMarker => Some(Action::SelectStepMarkerTool),
Tool::Highlight => Some(Action::SelectHighlightTool),
diff --git a/src/ui/toolbar/snapshot/types.rs b/src/ui/toolbar/snapshot/types.rs
index eef470bc..790ece8c 100644
--- a/src/ui/toolbar/snapshot/types.rs
+++ b/src/ui/toolbar/snapshot/types.rs
@@ -108,6 +108,18 @@ impl ToolContext {
show_marker_opacity: false,
show_font_controls: false,
},
+ Tool::Blur => Self {
+ needs_color: false,
+ needs_thickness: true,
+ tool_options_kind: ToolOptionsKind::Stroke,
+ thickness_label: "Blur",
+ show_fill_toggle: false,
+ show_arrow_labels: false,
+ show_step_counter: false,
+ show_eraser_mode: false,
+ show_marker_opacity: false,
+ show_font_controls: false,
+ },
Tool::Marker => Self {
needs_color: true,
needs_thickness: true,