From ff0fb843e1f2a081f3b8d8cfe4a2001a238d0487 Mon Sep 17 00:00:00 2001 From: "Rene D. Obermueller" Date: Fri, 27 Mar 2026 20:52:52 +0100 Subject: [PATCH 1/3] feat: add pixelate and "independent" pixelate Closes: #90 This implements a pixelate tool with two modes - regular mode with a normal block pixelation, this is, depending on selected area and image resolution, reversible - "independent mode" that takes data from outside the selected area, the area is entirely overwritten by this. Then, the regular pixelation creates a block-pixelate look. Some things are still open: [ ] figure out a better icon - help needed please [ ] reach out to flameshot guys regarding credits [ ] figure out if BLOCKSIZE is good at a value of 32. This may actually need to be adjustable. Known limitations: - in independent mode, canvas background may contribute to the mix of pixels if the selection is started at image origin - in idependent mode, if the selection "leaves" Satty's window, nothing happens. We would be lacking one fringe in that instance. This PR was inspired by - https://github.com/flameshot-org/flameshot/pull/3765/changes - #468 by kikeijuu (superseded by this PR) Regarding the credits, the idea with taking "fringes" was taken from said PR, but I reimplemented the gist of it according to my understanding. I did, for example, skip any additional noise which might be suitable for adding later. Also, actually iterating the source and target data works a bit different due to the objects used. I don't think we need to talk about a separate licence for this bit, but we'll see what the flameshot guys say about this. --- README.md | 13 +- build.rs | 1 + cli/src/command_line.rs | 2 + src/configuration.rs | 3 + src/tools/mod.rs | 11 ++ src/tools/pixelate.rs | 363 ++++++++++++++++++++++++++++++++++++++++ src/ui/toolbars.rs | 9 + 7 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 src/tools/pixelate.rs diff --git a/README.md b/README.md index 5df18fe..47a83c7 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Default single-key shortcuts: - t: Text tool - m: Numbered Marker tool - u: Blur tool +- x: Pixelate tool - g: Highlight tool ### Tool Modifiers and Keys @@ -79,6 +80,7 @@ Default single-key shortcuts: - Highlight: Hold Ctrl to switch between block and freehand mode (default configurable, see below), hold Shift for a square (if the default mode is block) or a straight line (if the default mode is freehand) - Line: Hold Shift to make line snap to 15° steps - Rectangle: Hold Alt to center the rectangle around origin, hold Shift for a square +- Pixelate: Hold Alt to use pixelation that takes data from outside the selection as a source. NEXTRELEASE - Text: - Press Shift+Enter to insert line break. - Combine Ctrl with Left or Right for word jump or Ctrl with Backspace or Delete for word delete. @@ -120,7 +122,7 @@ early-exit = true early-exit-save-as = true # Draw corners of rectangles round if the value is greater than 0 (0 disables rounded corners) corner-roundness = 12 -# Select the tool on startup [possible values: pointer, crop, line, arrow, rectangle, text, marker, blur, brush] +# Select the tool on startup [possible values: pointer, crop, line, arrow, rectangle, text, marker, blur, pixelate, brush] initial-tool = "brush" # Configure the command to be called on copy, for example `wl-copy` copy-command = "wl-copy" @@ -189,6 +191,8 @@ ellipse = "e" text = "t" marker = "m" blur = "u" +# NEXTRELEASE +pixelate = "x" highlight = "g" # Font to use for text annotations @@ -266,7 +270,7 @@ Options: --corner-roundness Draw corners of rectangles round if the value is greater than 0 (Defaults to 12) (0 disables rounded corners) --initial-tool - Select the tool on startup [aliases: --init-tool] [possible values: pointer, crop, line, arrow, rectangle, ellipse, text, marker, blur, highlight, brush] + Select the tool on startup [aliases: --init-tool] [possible values: pointer, crop, line, arrow, rectangle, ellipse, text, marker, blur, pixelate, highlight, brush] --copy-command Configure the command to be called on copy, for example `wl-copy` --annotation-size-factor @@ -444,3 +448,8 @@ Made with [contrib.rocks](https://contrib.rocks). The source code is released under the MPL-2.0 license. The Font 'Roboto Regular' from Google is released under Apache-2.0 license. + +## Credits + +- Pixelate "independent mode" was inspired by https://github.com/flameshot-org/flameshot/pull/3765/changes + diff --git a/build.rs b/build.rs index 5afd94b..d521f92 100644 --- a/build.rs +++ b/build.rs @@ -73,6 +73,7 @@ fn main() -> Result<(), io::Error> { "paint-bucket-regular", "page-fit-regular", "resize-large-regular", + "tetris-app-regular", ], ); diff --git a/cli/src/command_line.rs b/cli/src/command_line.rs index cdc5eec..ab66ef9 100644 --- a/cli/src/command_line.rs +++ b/cli/src/command_line.rs @@ -221,6 +221,7 @@ pub enum Tools { Text, Marker, Blur, + Pixelate, Highlight, Brush, } @@ -254,6 +255,7 @@ impl std::fmt::Display for Tools { Text => "text", Marker => "marker", Blur => "blur", + Pixelate => "pixelate", Highlight => "highlight", Brush => "brush", }; diff --git a/src/configuration.rs b/src/configuration.rs index 9948762..c10f146 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -124,6 +124,7 @@ impl Keybinds { self.update_keybind(file_keybinds.text, Tools::Text); self.update_keybind(file_keybinds.marker, Tools::Marker); self.update_keybind(file_keybinds.blur, Tools::Blur); + self.update_keybind(file_keybinds.pixelate, Tools::Pixelate); self.update_keybind(file_keybinds.highlight, Tools::Highlight); } } @@ -141,6 +142,7 @@ impl Default for Keybinds { shortcuts.insert('t', Tools::Text); shortcuts.insert('m', Tools::Marker); shortcuts.insert('u', Tools::Blur); + shortcuts.insert('x', Tools::Pixelate); shortcuts.insert('g', Tools::Highlight); Self { shortcuts } @@ -735,6 +737,7 @@ struct KeybindsFile { text: Option, marker: Option, blur: Option, + pixelate: Option, highlight: Option, } diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 90fd548..47c2db3 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -36,6 +36,7 @@ mod ellipse; mod highlight; mod line; mod marker; +mod pixelate; mod pointer; mod rectangle; mod text; @@ -167,6 +168,7 @@ pub use crop::CropTool; pub use ellipse::EllipseTool; pub use highlight::{HighlightTool, Highlighters}; pub use line::LineTool; +pub use pixelate::PixelateTool; pub use rectangle::RectangleTool; pub use text::TextTool; @@ -186,6 +188,7 @@ pub enum Tools { Blur = 8, Highlight = 9, Brush = 10, + Pixelate = 11, } impl Tools { @@ -202,6 +205,7 @@ impl Tools { Tools::Marker => "Numbered Marker", Tools::Blur => "Blur", Tools::Highlight => "Highlight", + Tools::Pixelate => "Pixelate", } } } @@ -221,6 +225,7 @@ impl Display for Tools { Self::Blur => write!(f, "blur"), Self::Highlight => write!(f, "highlight"), Self::Brush => write!(f, "brush"), + Self::Pixelate => write!(f, "pixelate"), } } } @@ -250,6 +255,10 @@ impl ToolsManager { ); tools.insert(Tools::Text, Rc::new(RefCell::new(TextTool::default()))); tools.insert(Tools::Blur, Rc::new(RefCell::new(BlurTool::default()))); + tools.insert( + Tools::Pixelate, + Rc::new(RefCell::new(PixelateTool::default())), + ); tools.insert( Tools::Highlight, Rc::new(RefCell::new(HighlightTool::default())), @@ -305,6 +314,7 @@ impl FromVariant for Tools { 8 => Some(Tools::Blur), 9 => Some(Tools::Highlight), 10 => Some(Tools::Brush), + 11 => Some(Tools::Pixelate), _ => None, }) } @@ -322,6 +332,7 @@ impl From for Tools { command_line::Tools::Text => Self::Text, command_line::Tools::Marker => Self::Marker, command_line::Tools::Blur => Self::Blur, + command_line::Tools::Pixelate => Self::Pixelate, command_line::Tools::Highlight => Self::Highlight, command_line::Tools::Brush => Self::Brush, } diff --git a/src/tools/pixelate.rs b/src/tools/pixelate.rs new file mode 100644 index 0000000..b38bbde --- /dev/null +++ b/src/tools/pixelate.rs @@ -0,0 +1,363 @@ +use std::cell::RefCell; + +use crate::{ + math::{self, Vec2D}, + sketch_board::{MouseButton, MouseEventMsg, MouseEventType, SketchBoardInput}, + style::Style, + tools::Cow, +}; +use anyhow::Result; +use femtovg::imgref::Img; +use femtovg::rgb::RGBA8; +use femtovg::{Color, ImageFlags, ImageId, Paint, Path, rgb::Rgba}; +use relm4::gtk::gdk::ModifierType; +use relm4::{Sender, gtk::gdk::Key}; + +use super::{Drawable, DrawableClone, Tool, ToolUpdateResult, Tools}; + +static BLOCKSIZE: usize = 32; + +#[derive(Clone, Debug)] +pub struct Pixelate { + top_left: Vec2D, + size: Option, + editing: bool, + independent_mode: bool, + cached_image: RefCell>, +} + +impl Pixelate { + fn pixelate( + canvas: &mut femtovg::Canvas, + pos: Vec2D, + size: Vec2D, + independent_mode: bool, + ) -> Result> { + let transformed_pos = canvas.transform().transform_point(pos.x, pos.y); + let transformed_size = size * canvas.transform().average_scale(); + + let pos_x = transformed_pos.0 as usize; + let pos_y = transformed_pos.1 as usize; + let width = (transformed_size.x as usize / BLOCKSIZE) * BLOCKSIZE; + let height = (transformed_size.y as usize / BLOCKSIZE) * BLOCKSIZE; + + if width == 0 || height == 0 { + return Ok(None); + } + + let img = canvas.screenshot()?; + let buf = if independent_mode { + Self::pixelate_independent(canvas, pos_x, pos_y, width, height)? + } else { + let (buf, _, _) = img + .sub_image(pos_x, pos_y, width, height) + .to_contiguous_buf(); + Some(buf) + }; + + if let Some(b) = buf + && let Some(dest_img) = Self::pixelate_regular(b, width, height)? + { + let dst_image_id = canvas.create_image(dest_img.as_ref(), ImageFlags::empty())?; + Ok(Some(dst_image_id)) + } else { + Ok(None) + } + } + + fn pixelate_independent( + canvas: &mut femtovg::Canvas, + pos_x: usize, + pos_y: usize, + width: usize, + height: usize, + ) -> Result>> { + //TODO: no fringe, no luck! + if pos_x < 1 + || pos_y < 1 + || canvas.width() as usize <= pos_x + width + || canvas.height() as usize <= pos_y + height + { + return Ok(None); + } + + let img = canvas.screenshot()?; + + let (buf_north, _, _) = img + .sub_image(pos_x, pos_y - 1, width, 1) + .to_contiguous_buf(); + let (buf_south, _, _) = img + .sub_image(pos_x, pos_y + height + 1, width, 1) + .to_contiguous_buf(); + let (buf_west, _, _) = img + .sub_image(pos_x - 1, pos_y, 1, height) + .to_contiguous_buf(); + let (buf_east, _, _) = img + .sub_image(pos_x + width + 1, pos_y, 1, height) + .to_contiguous_buf(); + + let mut buf_new = vec![Rgba::new(0, 0, 0, 0); width * height]; + + for y in 0..height { + for x in 0..width { + let pix_north = buf_north[x]; + let pix_south = buf_south[x]; + let pix_west = buf_west[y]; + let pix_east = buf_east[y]; + + let weight_n: f32 = (height - y) as f32 / (height as f32); + let weight_s: f32 = y as f32 / (height as f32); + let weight_w: f32 = (width - x) as f32 / (width as f32); + let weight_e: f32 = x as f32 / (width as f32); + + let new_pixel = RGBA8 { + r: (pix_north.r as f32 * weight_n + + pix_south.r as f32 * weight_s + + pix_west.r as f32 * weight_w + + pix_east.r as f32 * weight_e) as u8, + g: (pix_north.g as f32 * weight_n + + pix_south.g as f32 * weight_s + + pix_west.g as f32 * weight_w + + pix_east.g as f32 * weight_e) as u8, + b: (pix_north.b as f32 * weight_n + + pix_south.b as f32 * weight_s + + pix_west.b as f32 * weight_w + + pix_east.b as f32 * weight_e) as u8, + a: 255, + }; + + buf_new[y * width + x] = new_pixel; + } + } + + Ok(Some(buf_new.into())) + } + + fn pixelate_regular( + input_buf: Cow<[RGBA8]>, + width: usize, + height: usize, + ) -> Result>>>> { + let mut buf_new = vec![Rgba::new(0, 0, 0, 0); width * height]; + + let blocks_x = width / BLOCKSIZE; + let blocks_y = height / BLOCKSIZE; + + for block_y in 0..blocks_y { + for block_x in 0..blocks_x { + let x0 = block_x * BLOCKSIZE; + let y0 = block_y * BLOCKSIZE; + let x1 = x0 + BLOCKSIZE; + let y1 = y0 + BLOCKSIZE; + + let mut r: u64 = 0; + let mut g: u64 = 0; + let mut b: u64 = 0; + let mut counter = 0; + for y in y0..y1 { + for x in x0..x1 { + let pixel = input_buf[x + y * width]; + r += pixel.r as u64; + g += pixel.g as u64; + b += pixel.b as u64; + counter += 1; + } + } + counter = counter.max(1); + + let new_pixel = RGBA8 { + r: (r / counter) as u8, + g: (g / counter) as u8, + b: (b / counter) as u8, + a: 255, + }; + + for y in y0..y1 { + for x in x0..x1 { + buf_new[y * width + x] = new_pixel; + } + } + } + } + + let dst_image = Img::new(buf_new, width, height); + Ok(Some(dst_image)) + } +} + +impl Drawable for Pixelate { + fn draw( + &self, + canvas: &mut femtovg::Canvas, + _font: femtovg::FontId, + bounds: (Vec2D, Vec2D), + ) -> Result<()> { + let size = match self.size { + Some(s) => s, + None => return Ok(()), // early exit if none + }; + let (pos, size) = math::rect_ensure_in_bounds( + math::rect_ensure_positive_size(self.top_left, size), + bounds, + ); + if self.editing { + // set style + let mut color = if self.independent_mode { + Color::white() + } else { + Color::black() + }; + color.set_alphaf(0.6); + let paint = Paint::color(color); + + // make rect + let mut path = Path::new(); + path.rect(pos.x, pos.y, size.x, size.y); + + // draw + canvas.fill_path(&path, &paint); + } else { + if size.x < BLOCKSIZE as f32 || size.y < BLOCKSIZE as f32 { + return Ok(()); + } + + canvas.save(); + canvas.flush(); + + // create new cached image + if self.cached_image.borrow().is_none() + && let Some(x) = Self::pixelate(canvas, pos, size, self.independent_mode)? + { + self.cached_image.borrow_mut().replace(x); + } + + if self.cached_image.borrow().is_some() { + let mut path = Path::new(); + path.rect(pos.x, pos.y, size.x, size.y); + + canvas.fill_path( + &path, + &Paint::image( + self.cached_image.borrow().unwrap(), // this unwrap is safe because we placed it above + pos.x, + pos.y, + size.x, + size.y, + 0f32, + 1f32, + ), + ); + canvas.restore(); + } + } + Ok(()) + } +} + +#[derive(Default)] +pub struct PixelateTool { + pixelate: Option, + input_enabled: bool, + sender: Option>, +} + +impl Tool for PixelateTool { + fn input_enabled(&self) -> bool { + self.input_enabled + } + + fn set_input_enabled(&mut self, value: bool) { + self.input_enabled = value; + } + + fn get_tool_type(&self) -> super::Tools { + Tools::Pixelate + } + + fn handle_mouse_event(&mut self, event: MouseEventMsg) -> ToolUpdateResult { + match event.type_ { + MouseEventType::BeginDrag => { + if event.button == MouseButton::Middle { + return ToolUpdateResult::Unmodified; + } + + // start new + self.pixelate = Some(Pixelate { + top_left: event.pos, + size: None, + editing: true, + independent_mode: event.modifier.intersects(ModifierType::ALT_MASK), + cached_image: RefCell::new(None), + }); + + ToolUpdateResult::Redraw + } + MouseEventType::EndDrag => { + if event.button == MouseButton::Middle { + return ToolUpdateResult::Unmodified; + } + + if let Some(a) = &mut self.pixelate { + if event.pos == Vec2D::zero() { + self.pixelate = None; + + ToolUpdateResult::Redraw + } else { + a.size = Some(event.pos); + a.independent_mode = event.modifier.intersects(ModifierType::ALT_MASK); + a.editing = false; + + let result = a.clone_box(); + self.pixelate = None; + + ToolUpdateResult::Commit(result) + } + } else { + ToolUpdateResult::Unmodified + } + } + MouseEventType::UpdateDrag => { + if event.button == MouseButton::Middle { + return ToolUpdateResult::Unmodified; + } + + if let Some(a) = &mut self.pixelate { + if event.pos == Vec2D::zero() { + return ToolUpdateResult::Unmodified; + } + a.independent_mode = event.modifier.intersects(ModifierType::ALT_MASK); + a.size = Some(event.pos); + + ToolUpdateResult::Redraw + } else { + ToolUpdateResult::Unmodified + } + } + _ => ToolUpdateResult::Unmodified, + } + } + + fn handle_key_event(&mut self, event: crate::sketch_board::KeyEventMsg) -> ToolUpdateResult { + if event.key == Key::Escape && self.pixelate.is_some() { + self.pixelate = None; + ToolUpdateResult::Redraw + } else { + ToolUpdateResult::Unmodified + } + } + + fn handle_style_event(&mut self, _style: Style) -> ToolUpdateResult { + ToolUpdateResult::Unmodified + } + + fn get_drawable(&self) -> Option<&dyn Drawable> { + match &self.pixelate { + Some(d) => Some(d), + None => None, + } + } + + fn set_sender(&mut self, sender: Sender) { + self.sender = Some(sender); + } +} diff --git a/src/ui/toolbars.rs b/src/ui/toolbars.rs index 585df84..6641bcc 100644 --- a/src/ui/toolbars.rs +++ b/src/ui/toolbars.rs @@ -249,6 +249,14 @@ impl SimpleComponent for ToolsToolbar { // tooltip set programmatically ActionablePlus::set_action::: Tools::Blur, }, + #[name(pixelate_button)] + gtk::ToggleButton { + set_focusable: false, + set_hexpand: false, + + set_icon_name: "tetris-app-regular", + ActionablePlus::set_action::: Tools::Pixelate, + }, #[name(highlight_button)] gtk::ToggleButton { set_focusable: false, @@ -341,6 +349,7 @@ impl SimpleComponent for ToolsToolbar { (Tools::Text, widgets.text_button.clone()), (Tools::Marker, widgets.marker_button.clone()), (Tools::Blur, widgets.blur_button.clone()), + (Tools::Pixelate, widgets.pixelate_button.clone()), (Tools::Highlight, widgets.highlight_button.clone()), ]); From 66ce0ab26d7932a1c1bcb7e704d53bc1b5fc65dd Mon Sep 17 00:00:00 2001 From: "Rene D. Obermueller" Date: Fri, 27 Mar 2026 22:11:05 +0100 Subject: [PATCH 2/3] fix weighting --- src/tools/pixelate.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/tools/pixelate.rs b/src/tools/pixelate.rs index b38bbde..486952c 100644 --- a/src/tools/pixelate.rs +++ b/src/tools/pixelate.rs @@ -111,18 +111,21 @@ impl Pixelate { let weight_e: f32 = x as f32 / (width as f32); let new_pixel = RGBA8 { - r: (pix_north.r as f32 * weight_n + r: ((pix_north.r as f32 * weight_n + pix_south.r as f32 * weight_s + pix_west.r as f32 * weight_w - + pix_east.r as f32 * weight_e) as u8, - g: (pix_north.g as f32 * weight_n + + pix_east.r as f32 * weight_e) + / 2.0) as u8, + g: ((pix_north.g as f32 * weight_n + pix_south.g as f32 * weight_s + pix_west.g as f32 * weight_w - + pix_east.g as f32 * weight_e) as u8, - b: (pix_north.b as f32 * weight_n + + pix_east.g as f32 * weight_e) + / 2.0) as u8, + b: ((pix_north.b as f32 * weight_n + pix_south.b as f32 * weight_s + pix_west.b as f32 * weight_w - + pix_east.b as f32 * weight_e) as u8, + + pix_east.b as f32 * weight_e) + / 2.0) as u8, a: 255, }; From 05a65bdc998ef6d0a83a1ecdb9f2c4e7e331aa36 Mon Sep 17 00:00:00 2001 From: "Rene D. Obermueller" Date: Fri, 27 Mar 2026 22:42:03 +0100 Subject: [PATCH 3/3] refactor --- src/tools/pixelate.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/tools/pixelate.rs b/src/tools/pixelate.rs index 486952c..fb22c9a 100644 --- a/src/tools/pixelate.rs +++ b/src/tools/pixelate.rs @@ -28,10 +28,10 @@ pub struct Pixelate { impl Pixelate { fn pixelate( + &self, canvas: &mut femtovg::Canvas, pos: Vec2D, size: Vec2D, - independent_mode: bool, ) -> Result> { let transformed_pos = canvas.transform().transform_point(pos.x, pos.y); let transformed_size = size * canvas.transform().average_scale(); @@ -46,8 +46,8 @@ impl Pixelate { } let img = canvas.screenshot()?; - let buf = if independent_mode { - Self::pixelate_independent(canvas, pos_x, pos_y, width, height)? + let buf = if self.independent_mode { + Self::fill_area_from_fringes(canvas, pos_x, pos_y, width, height)? } else { let (buf, _, _) = img .sub_image(pos_x, pos_y, width, height) @@ -65,14 +65,14 @@ impl Pixelate { } } - fn pixelate_independent( + fn fill_area_from_fringes( canvas: &mut femtovg::Canvas, pos_x: usize, pos_y: usize, width: usize, height: usize, ) -> Result>> { - //TODO: no fringe, no luck! + //TODO: missing fringe, no luck! if pos_x < 1 || pos_y < 1 || canvas.width() as usize <= pos_x + width @@ -229,7 +229,7 @@ impl Drawable for Pixelate { // create new cached image if self.cached_image.borrow().is_none() - && let Some(x) = Self::pixelate(canvas, pos, size, self.independent_mode)? + && let Some(x) = self.pixelate(canvas, pos, size)? { self.cached_image.borrow_mut().replace(x); }