mirror of
https://github.com/devmobasa/wayscriber.git
synced 2026-06-03 03:54:42 +02:00
Add blur annotation tool
This commit is contained in:
@@ -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 |
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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::{
|
||||
|
||||
@@ -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(®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<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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user