Add blur annotation tool

This commit is contained in:
devmobasa
2026-04-14 17:38:30 +02:00
parent 8997e8fe8b
commit af54d10084
55 changed files with 1014 additions and 31 deletions
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="4" y="4" width="16" height="16" rx="3"/>
<circle cx="9" cy="9" r="1.2" fill="currentColor" stroke="none"/>
<circle cx="15" cy="9" r="1.2" fill="currentColor" stroke="none" opacity="0.75"/>
<circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/>
<circle cx="9" cy="15" r="1.2" fill="currentColor" stroke="none" opacity="0.75"/>
<circle cx="15" cy="15" r="1.2" fill="currentColor" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 617 B

+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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;
+25 -2
View File
@@ -18,6 +18,7 @@ pub struct FrozenState {
pub(super) active_geometry: Option<OutputGeometry>,
pub(super) capture: Option<CaptureSession>,
pub(super) image: Option<FrozenImage>,
image_generation: u64,
pub(super) portal_rx: Option<PortalCaptureRx>,
pub(super) portal_in_progress: bool,
pub(super) portal_target_output_id: Option<u32>,
@@ -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);
}
}
@@ -2,15 +2,23 @@ use super::super::super::*;
use crate::draw::Color;
pub(super) struct CanvasEraserContext {
surface: Option<cairo::ImageSurface>,
pattern: Option<cairo::SurfacePattern>,
backdrop_cache_key: Option<u64>,
bg_color: Option<Color>,
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<CanvasEraserContext> {
let mut eraser_surface: Option<cairo::ImageSurface> = None;
let mut eraser_pattern: Option<cairo::SurfacePattern> = None;
let mut backdrop_cache_key: Option<u64> = None;
let mut eraser_bg_color: Option<Color> = 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,
})
}
}
+43 -3
View File
@@ -49,6 +49,15 @@ 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, *x, *y, *w, *h, *strength, &replay_ctx, true);
}
other => {
crate::draw::render_shape(ctx, other);
}
@@ -144,9 +153,40 @@ 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,
x,
y,
w,
h,
self.input_state.current_thickness,
&replay_ctx,
false,
);
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");
}
@@ -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),
@@ -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,
@@ -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),
@@ -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 =
@@ -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 {
@@ -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,
},
};
@@ -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);
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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");
}
+20 -3
View File
@@ -16,6 +16,7 @@ pub struct ZoomState {
pub(super) active_geometry: Option<OutputGeometry>,
pub(super) capture: Option<CaptureSession>,
pub(super) image: Option<FrozenImage>,
image_generation: u64,
pub(super) portal_rx: Option<PortalCaptureRx>,
pub(super) portal_in_progress: bool,
pub(super) portal_target_output_id: Option<u32>,
@@ -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);
}
}
+10
View File
@@ -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",
+3
View File
@@ -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,
+1
View File
@@ -51,6 +51,7 @@ pub enum Action {
SelectRectTool,
SelectEllipseTool,
SelectArrowTool,
SelectBlurTool,
SelectHighlightTool,
IncreaseFontSize,
DecreaseFontSize,
@@ -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,
@@ -47,6 +47,9 @@ pub struct ToolKeybindingsConfig {
#[serde(default = "default_select_arrow_tool")]
pub select_arrow_tool: Vec<String>,
#[serde(default = "default_select_blur_tool")]
pub select_blur_tool: Vec<String>,
#[serde(default = "default_select_highlight_tool")]
pub select_highlight_tool: Vec<String>,
@@ -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(),
+4
View File
@@ -54,6 +54,10 @@ pub(crate) fn default_select_arrow_tool() -> Vec<String> {
Vec::new()
}
pub(crate) fn default_select_blur_tool() -> Vec<String> {
Vec::new()
}
pub(crate) fn default_select_highlight_tool() -> Vec<String> {
Vec::new()
}
+4 -3
View File
@@ -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,
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::{
+568
View File
@@ -0,0 +1,568 @@
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;
#[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, 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<BlurCacheKey, CachedBlurRegion>,
access_order: VecDeque<BlurCacheKey>,
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<CachedBlurRegion> {
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<BlurRenderCache> = 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<cairo::ImageSurface> {
let region = cairo::ImageSurface::create(cairo::Format::ARgb32, width, height).ok()?;
let ctx = cairo::Context::new(&region).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<cairo::ImageSurface> {
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<BlurSurfaceStats> {
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,
) -> ((f64, f64, f64, f64), (f64, f64, f64, f64)) {
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<BlurCacheKey> {
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<BlurCacheKey>,
compute: impl FnOnce() -> Option<CachedBlurRegion>,
) -> Option<CachedBlurRegion> {
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<CachedBlurRegion> {
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,
x: i32,
y: i32,
w: i32,
h: i32,
strength: f64,
replay_ctx: &EraserReplayContext<'_>,
cacheable: bool,
) {
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());
}
}
+2
View File
@@ -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::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;
+16
View File
@@ -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);
}
+10
View File
@@ -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,
+9 -1
View File
@@ -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<u64>,
/// Solid background color (board modes) when no pattern is available.
pub bg_color: Option<Color>,
/// 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,
}
+24
View File
@@ -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<Rect> {
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)
);
}
}
+2 -2
View File
@@ -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,
+17 -2
View File
@@ -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<ArrowLabel>,
},
/// 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",
+11
View File
@@ -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;
+3
View File
@@ -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));
+15 -2
View File
@@ -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(
@@ -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 {
@@ -87,6 +87,9 @@ pub(super) fn shape_thickness(shape: &Shape) -> Option<f64> {
| Shape::Rect { thick, .. }
| Shape::Ellipse { thick, .. }
| Shape::Arrow { thick, .. }
| Shape::BlurRect {
strength: thick, ..
}
| Shape::MarkerStroke { thick, .. } => Some(*thick),
_ => None,
}
+1 -1
View File
@@ -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).
@@ -217,6 +217,9 @@ impl InputState {
1 => {
self.set_tool_override(Some(Tool::Ellipse));
}
2 => {
self.set_tool_override(Some(Tool::Blur));
}
_ => {}
}
}
@@ -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,
@@ -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;
@@ -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,
@@ -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 {
+3
View File
@@ -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,
+19
View File
@@ -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(),
+19
View File
@@ -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,
+23
View File
@@ -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();
+9
View File
@@ -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();
+2
View File
@@ -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)
+7 -1
View File
@@ -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),
+5
View File
@@ -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) {
@@ -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 {
@@ -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),
+2 -1
View File
@@ -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",
+1
View File
@@ -56,6 +56,7 @@ pub(crate) fn action_for_tool(tool: Tool) -> Option<Action> {
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),
+12
View File
@@ -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,