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,