From ad3674700f4dc1dc9125ef5b11a4f07b1cf39c14 Mon Sep 17 00:00:00 2001 From: Aatman Jain Date: Thu, 29 Oct 2026 23:16:52 +0000 Subject: [PATCH 1/7] feat(screenshot): mask studio for the screenshot editor Masks gain shapes (rectangle, rounded, ellipse), feathered edges, an invert mode that hides everything outside the area, colour tint, a Solid redaction style, opacity, and seven one-click presets. The look is baked into each overlay by a new kernel (mask_style), so the preview and the export composite the same pixels. Options live in an optional maskOptions field that older builds ignore; Solid keeps a hiding maskType alongside so a redaction never degrades to nothing. --- .../screenshot-mask-cache-benchmark.rs | 8 +- apps/desktop-gpui/kernels/src/lib.rs | 1 + apps/desktop-gpui/kernels/src/mask_cache.rs | 31 +- apps/desktop-gpui/kernels/src/mask_style.rs | 335 +++++ apps/desktop-gpui/locales/de.toml | 32 + apps/desktop-gpui/locales/en.toml | 32 + apps/desktop-gpui/locales/es.toml | 32 + apps/desktop-gpui/locales/fr.toml | 32 + apps/desktop-gpui/locales/hi.toml | 32 + apps/desktop-gpui/locales/ja.toml | 32 + apps/desktop-gpui/locales/zh-CN.toml | 32 + .../src/i18n/dialogs_surface_tests.rs | 61 + .../src/screenshot_annotations.rs | 208 ++- .../src/screenshot_annotations/mask.rs | 1291 +++++++++++++++++ apps/desktop-gpui/src/screenshot_editor.rs | 10 + apps/desktop-gpui/src/screenshot_export.rs | 30 +- crates/project/src/configuration.rs | 62 + 17 files changed, 2130 insertions(+), 131 deletions(-) create mode 100644 apps/desktop-gpui/kernels/src/mask_style.rs create mode 100644 apps/desktop-gpui/src/screenshot_annotations/mask.rs diff --git a/apps/desktop-gpui/kernels/examples/screenshot-mask-cache-benchmark.rs b/apps/desktop-gpui/kernels/examples/screenshot-mask-cache-benchmark.rs index 0dfc358085f..d56e9536180 100644 --- a/apps/desktop-gpui/kernels/examples/screenshot-mask-cache-benchmark.rs +++ b/apps/desktop-gpui/kernels/examples/screenshot-mask-cache-benchmark.rs @@ -1,4 +1,5 @@ use cap_gpui_kernels::mask_cache::{MaskEntry, MaskFrame, MaskRequest, mask_region, update_masks}; +use cap_gpui_kernels::mask_style::MaskStyle; use std::{hint::black_box, sync::Arc, time::Instant}; const SIZE: (u32, u32) = (3840, 2160); @@ -31,6 +32,7 @@ fn requests(count: usize, pixelate: bool, event: usize) -> Vec { .map(|index| { let x = 80. + (index % 4) as f64 * 720. + if index == 0 { event as f64 } else { 0. }; let y = 80. + (index / 4) as f64 * 440.; + let region = mask_region([x, y, 640., 360.], content, SIZE).unwrap(); MaskRequest { id: index.to_string(), frame: MaskFrame { @@ -40,9 +42,9 @@ fn requests(count: usize, pixelate: bool, event: usize) -> Vec { padding: 0_f64.to_bits(), crop: None, }, - region: mask_region([x, y, 640., 360.], content, SIZE).unwrap(), - pixelate, - level: 16_f64.to_bits(), + region, + shape: region, + style: MaskStyle::plain(pixelate, 16.), } }) .collect() diff --git a/apps/desktop-gpui/kernels/src/lib.rs b/apps/desktop-gpui/kernels/src/lib.rs index 3971ea2da35..a0aab5ecfea 100644 --- a/apps/desktop-gpui/kernels/src/lib.rs +++ b/apps/desktop-gpui/kernels/src/lib.rs @@ -1,6 +1,7 @@ pub mod codec; pub mod frame; pub mod mask_cache; +pub mod mask_style; pub mod png; pub mod screenshot; pub mod texture; diff --git a/apps/desktop-gpui/kernels/src/mask_cache.rs b/apps/desktop-gpui/kernels/src/mask_cache.rs index b9e3afdcf03..8c0d2cff0f0 100644 --- a/apps/desktop-gpui/kernels/src/mask_cache.rs +++ b/apps/desktop-gpui/kernels/src/mask_cache.rs @@ -1,4 +1,4 @@ -use crate::screenshot::mask_filter; +use crate::mask_style::{MaskStyle, styled_mask}; use std::{collections::HashSet, hash::Hash}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -15,19 +15,19 @@ pub struct MaskRequest { pub id: String, pub frame: MaskFrame, pub region: (u32, u32, u32, u32), - pub pixelate: bool, - pub level: u64, + pub shape: (u32, u32, u32, u32), + pub style: MaskStyle, } impl MaskRequest { pub fn filter(&self, rgba: &[u8]) -> Option { - mask_filter( + styled_mask( rgba, self.frame.size.0, self.frame.size.1, self.region, - self.pixelate, - f64::from_bits(self.level), + self.shape, + &self.style, ) } } @@ -199,6 +199,8 @@ pub fn mask_region( #[cfg(test)] mod tests { use super::*; + use crate::mask_style::MaskEffect; + use crate::screenshot::mask_filter; use std::sync::Arc; fn requests(count: usize) -> Vec { @@ -213,8 +215,8 @@ mod tests { crop: None, }, region: (index as u32 * 4, 4, 32, 24), - pixelate: false, - level: 16_f64.to_bits(), + shape: (index as u32 * 4, 4, 32, 24), + style: MaskStyle::plain(false, 16.), }) .collect() } @@ -230,8 +232,8 @@ mod tests { let mut next = original.clone(); match change { 0 => next[0].region.0 += 1, - 1 => next[0].level = 20_f64.to_bits(), - 2 => next[0].pixelate = true, + 1 => next[0].style.level = 20_f64.to_bits(), + 2 => next[0].style.effect = MaskEffect::Pixelate, 3 => next.push(requests(4).pop().unwrap()), _ => next.reverse(), } @@ -387,8 +389,8 @@ mod tests { fn new_and_changed_styles_are_computed() { let mut requests = requests(4); let previous = cached(&requests[..3]); - requests[0].pixelate = true; - requests[1].level = 20_f64.to_bits(); + requests[0].style.effect = MaskEffect::Pixelate; + requests[1].style.level = 20_f64.to_bits(); assert_eq!( plan_masks(&previous, &requests), vec![None, None, Some(2), None] @@ -491,11 +493,12 @@ mod tests { fn overlapping_masks_always_sample_the_unmodified_frame() { let rgba: Vec = (0..64 * 48 * 4).map(|i| (i * 37 % 251) as u8).collect(); let mut requests = requests(4); - requests[1].pixelate = true; + requests[1].style.effect = MaskEffect::Pixelate; let previous = update_masks(&[], &requests, |request| { request.filter(&rgba).map(Arc::new) }); requests[1].region.0 += 1; + requests[1].shape.0 += 1; requests.reverse(); let mut calls = 0; let next = update_masks(&previous, &requests, |request| { @@ -509,7 +512,7 @@ mod tests { 64, 48, entry.request.region, - entry.request.pixelate, + entry.request.style.effect == MaskEffect::Pixelate, 16., ) .unwrap(); diff --git a/apps/desktop-gpui/kernels/src/mask_style.rs b/apps/desktop-gpui/kernels/src/mask_style.rs new file mode 100644 index 00000000000..08cee3950f2 --- /dev/null +++ b/apps/desktop-gpui/kernels/src/mask_style.rs @@ -0,0 +1,335 @@ +use crate::screenshot::mask_filter; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MaskEffect { + Blur, + Pixelate, + Solid, +} + +/// Every float is stored as its bit pattern so a style can be compared for +/// cache reuse without tripping over `f64`'s missing `Eq`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct MaskStyle { + pub effect: MaskEffect, + pub level: u64, + pub ellipse: bool, + pub corner_radius: u64, + pub feather: u64, + pub invert: bool, + pub color: [u8; 3], + pub tint: u64, + pub opacity: u64, +} + +impl MaskStyle { + pub fn plain(pixelate: bool, level: f64) -> Self { + Self { + effect: if pixelate { + MaskEffect::Pixelate + } else { + MaskEffect::Blur + }, + level: level.to_bits(), + ellipse: false, + corner_radius: 0_f64.to_bits(), + feather: 0_f64.to_bits(), + invert: false, + color: [0, 0, 0], + tint: 0_f64.to_bits(), + opacity: 1_f64.to_bits(), + } + } + + fn get(bits: u64, fallback: f64) -> f64 { + let value = f64::from_bits(bits); + if value.is_finite() { value } else { fallback } + } + + pub fn level(&self) -> f64 { + Self::get(self.level, 1.).max(1.) + } + + pub fn corner_radius(&self) -> f64 { + Self::get(self.corner_radius, 0.).max(0.) + } + + pub fn feather(&self) -> f64 { + Self::get(self.feather, 0.).max(0.) + } + + pub fn tint(&self) -> f64 { + Self::get(self.tint, 0.).clamp(0., 1.) + } + + /// A corrupt opacity falls back to fully opaque: a mask that silently + /// vanished would leak exactly what it was drawn to hide. + pub fn opacity(&self) -> f64 { + Self::get(self.opacity, 1.).clamp(0., 1.) + } + + fn shaped(&self) -> bool { + self.ellipse + || self.invert + || self.corner_radius() > 0. + || self.feather() > 0. + || self.opacity() < 1. + } +} + +/// Signed distance from `point` to the shape's edge, negative inside. +fn shape_distance(point: (f64, f64), shape: [f64; 4], ellipse: bool, radius: f64) -> f64 { + let [x, y, width, height] = shape; + let half = (width / 2., height / 2.); + if half.0 <= 0. || half.1 <= 0. { + return f64::INFINITY; + } + let local = ( + (point.0 - (x + half.0)).abs(), + (point.1 - (y + half.1)).abs(), + ); + if ellipse { + // Scaling the normalized radius by the short semi-axis keeps the + // distance in pixels along that axis, which is what a feather of N + // pixels has to mean on a circle. + let k = ((local.0 / half.0).powi(2) + (local.1 / half.1).powi(2)).sqrt(); + return (k - 1.) * half.0.min(half.1); + } + let radius = radius.min(half.0).min(half.1); + let q = (local.0 - (half.0 - radius), local.1 - (half.1 - radius)); + let outside = (q.0.max(0.).powi(2) + q.1.max(0.).powi(2)).sqrt(); + outside + q.0.max(q.1).min(0.) - radius +} + +/// How much of the pixel at `point` the mask covers, in `0..=1`. Without a +/// feather the edge is a one-pixel antialiased step that still covers every +/// pixel whose centre sits inside an axis-aligned rect; with one, coverage +/// eases in over `feather` pixels inward so the mask never bleeds past its +/// own rect. +pub fn shape_coverage( + point: (f64, f64), + shape: [f64; 4], + ellipse: bool, + radius: f64, + feather: f64, +) -> f64 { + let distance = shape_distance(point, shape, ellipse, radius); + if feather < 1. { + return (0.5 - distance).clamp(0., 1.); + } + let t = (-distance / feather).clamp(0., 1.); + t * t * (3. - 2. * t) +} + +fn mix(channel: u8, target: u8, amount: f64) -> u8 { + (f64::from(channel) + (f64::from(target) - f64::from(channel)) * amount).round() as u8 +} + +/// One mask's pixels for `region` of the frame, with the style baked in: +/// the effect, the tint, then the shape's coverage folded into alpha so the +/// result composites straight over the untouched frame. `shape` is the +/// mask's own rect in frame pixels -- equal to `region` for a normal mask, +/// and the hole cut out of `region` for an inverted one. +pub fn styled_mask( + rgba: &[u8], + frame_width: u32, + frame_height: u32, + region: (u32, u32, u32, u32), + shape: (u32, u32, u32, u32), + style: &MaskStyle, +) -> Option { + let mut image = match style.effect { + MaskEffect::Solid => { + if frame_width == 0 + || frame_height == 0 + || rgba.len() < frame_width as usize * 4 * frame_height as usize + { + return None; + } + let [r, g, b] = style.color; + image::RgbaImage::from_pixel(region.2, region.3, image::Rgba([r, g, b, 255])) + } + MaskEffect::Blur | MaskEffect::Pixelate => mask_filter( + rgba, + frame_width, + frame_height, + region, + style.effect == MaskEffect::Pixelate, + style.level(), + )?, + }; + + let tint = style.tint(); + if style.effect != MaskEffect::Solid && tint > 0. { + let [r, g, b] = style.color; + for pixel in image.pixels_mut() { + pixel.0[0] = mix(pixel.0[0], r, tint); + pixel.0[1] = mix(pixel.0[1], g, tint); + pixel.0[2] = mix(pixel.0[2], b, tint); + } + } + + if !style.shaped() { + return Some(image); + } + + let shape = [ + f64::from(shape.0) - f64::from(region.0), + f64::from(shape.1) - f64::from(region.1), + f64::from(shape.2), + f64::from(shape.3), + ]; + let ellipse = style.ellipse; + let radius = style.corner_radius(); + let feather = style.feather(); + let opacity = style.opacity(); + let invert = style.invert; + for (x, y, pixel) in image.enumerate_pixels_mut() { + let point = (f64::from(x) + 0.5, f64::from(y) + 0.5); + let mut coverage = shape_coverage(point, shape, ellipse, radius, feather); + if invert { + coverage = 1. - coverage; + } + let alpha = f64::from(pixel.0[3]) * coverage * opacity; + pixel.0[3] = alpha.round().clamp(0., 255.) as u8; + } + Some(image) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(width: u32, height: u32) -> Vec { + (0..width * height) + .flat_map(|index| { + [ + (index * 7) as u8, + (index * 13) as u8, + (index * 3) as u8, + 255, + ] + }) + .collect() + } + + fn style(effect: MaskEffect) -> MaskStyle { + MaskStyle { + effect, + ..MaskStyle::plain(false, 8.) + } + } + + #[test] + fn plain_styles_match_the_unstyled_filter_byte_for_byte() { + let rgba = frame(32, 24); + for pixelate in [false, true] { + let region = (3, 2, 20, 15); + let styled = styled_mask( + &rgba, + 32, + 24, + region, + region, + &MaskStyle::plain(pixelate, 8.), + ) + .unwrap(); + let plain = mask_filter(&rgba, 32, 24, region, pixelate, 8.).unwrap(); + assert_eq!(styled, plain); + } + } + + #[test] + fn solid_redaction_ignores_the_source_pixels() { + let rgba = frame(16, 16); + let mut solid = style(MaskEffect::Solid); + solid.color = [12, 34, 56]; + let image = styled_mask(&rgba, 16, 16, (2, 2, 8, 8), (2, 2, 8, 8), &solid).unwrap(); + assert!(image.pixels().all(|pixel| pixel.0 == [12, 34, 56, 255])); + assert!(styled_mask(&rgba[..8], 16, 16, (0, 0, 4, 4), (0, 0, 4, 4), &solid).is_none()); + } + + #[test] + fn tint_pulls_every_pixel_toward_the_colour() { + let rgba = vec![0u8; 8 * 8 * 4] + .chunks(4) + .flat_map(|_| [0, 100, 200, 255]) + .collect::>(); + let mut tinted = style(MaskEffect::Pixelate); + tinted.color = [255, 255, 255]; + tinted.tint = 0.5_f64.to_bits(); + let image = styled_mask(&rgba, 8, 8, (0, 0, 8, 8), (0, 0, 8, 8), &tinted).unwrap(); + assert!(image.pixels().all(|pixel| pixel.0 == [128, 178, 228, 255])); + } + + #[test] + fn an_ellipse_clears_its_corners_and_keeps_its_centre() { + let rgba = frame(40, 40); + let mut ellipse = style(MaskEffect::Solid); + ellipse.ellipse = true; + let image = styled_mask(&rgba, 40, 40, (0, 0, 40, 40), (0, 0, 40, 40), &ellipse).unwrap(); + assert_eq!(image.get_pixel(0, 0).0[3], 0); + assert_eq!(image.get_pixel(39, 39).0[3], 0); + assert_eq!(image.get_pixel(20, 20).0[3], 255); + assert!(image.get_pixel(20, 0).0[3] > 240); + } + + #[test] + fn rounded_corners_cut_only_the_corners() { + let rgba = frame(40, 40); + let mut rounded = style(MaskEffect::Solid); + rounded.corner_radius = 12_f64.to_bits(); + let image = styled_mask(&rgba, 40, 40, (0, 0, 40, 40), (0, 0, 40, 40), &rounded).unwrap(); + assert_eq!(image.get_pixel(0, 0).0[3], 0); + assert_eq!(image.get_pixel(20, 0).0[3], 255); + assert_eq!(image.get_pixel(0, 20).0[3], 255); + assert_eq!(image.get_pixel(20, 20).0[3], 255); + } + + #[test] + fn feather_fades_inward_from_the_edge() { + let rgba = frame(40, 40); + let mut feathered = style(MaskEffect::Solid); + feathered.feather = 10_f64.to_bits(); + let image = styled_mask(&rgba, 40, 40, (0, 0, 40, 40), (0, 0, 40, 40), &feathered).unwrap(); + let edge = image.get_pixel(0, 20).0[3]; + let mid = image.get_pixel(5, 20).0[3]; + let inner = image.get_pixel(20, 20).0[3]; + assert!(edge < mid, "{edge} < {mid}"); + assert!(mid < inner, "{mid} < {inner}"); + assert_eq!(inner, 255); + } + + #[test] + fn invert_covers_everything_but_the_hole() { + let rgba = frame(40, 40); + let mut inverted = style(MaskEffect::Solid); + inverted.invert = true; + let image = + styled_mask(&rgba, 40, 40, (0, 0, 40, 40), (10, 10, 20, 20), &inverted).unwrap(); + assert_eq!(image.dimensions(), (40, 40)); + assert_eq!(image.get_pixel(2, 2).0[3], 255); + assert_eq!(image.get_pixel(20, 20).0[3], 0); + assert_eq!(image.get_pixel(35, 20).0[3], 255); + } + + #[test] + fn opacity_scales_alpha() { + let rgba = frame(8, 8); + let mut faded = style(MaskEffect::Solid); + faded.opacity = 0.5_f64.to_bits(); + let image = styled_mask(&rgba, 8, 8, (0, 0, 8, 8), (0, 0, 8, 8), &faded).unwrap(); + assert!(image.pixels().all(|pixel| pixel.0[3] == 128)); + } + + #[test] + fn non_finite_style_values_fall_back_to_safe_defaults() { + let mut broken = style(MaskEffect::Blur); + broken.level = f64::NAN.to_bits(); + broken.feather = f64::INFINITY.to_bits(); + broken.opacity = f64::NAN.to_bits(); + assert_eq!(broken.level(), 1.); + assert_eq!(broken.feather(), 0.); + assert_eq!(broken.opacity(), 1.); + } +} diff --git a/apps/desktop-gpui/locales/de.toml b/apps/desktop-gpui/locales/de.toml index 20701865b6e..fbf7c0ba03d 100644 --- a/apps/desktop-gpui/locales/de.toml +++ b/apps/desktop-gpui/locales/de.toml @@ -1661,6 +1661,38 @@ empty_hint = "Fügen Sie mit den Werkzeugen oben Anmerkungen hinzu" reorder_hint = "Zum Sortieren ziehen • Oben = vorne" title = "Ebenen" +[screenshot_editor.mask] +adjust = "Anpassen" +corner_radius = "Ecken" +feather = "Weiche Kante" +invert = "Umkehren" +invert_hint = "Alles außer diesem Bereich verbergen" +presets = "Vorlagen" +presets_title = "Maskenvorlagen" +privacy_note = "Weichzeichnen und Verpixeln lassen sich manchmal rückgängig machen. Verwenden Sie Schwärzen für Passwörter und Schlüssel." +shape = "Form" +shape_ellipse = "Ellipse" +shape_rectangle = "Rechteck" +shape_rounded = "Abgerundet" +solid = "Deckend" +tint = "Tönung" + +[screenshot_editor.mask.preset] +cap_blue = "Cap-Blau" +cap_blue_hint = "Verpixelt mit Cap-Blau-Tönung" +focus = "Fokus" +focus_hint = "Alles drumherum weichzeichnen" +frosted = "Milchglas" +frosted_hint = "Milchiges Glas über dem Bereich" +mosaic = "Mosaik" +mosaic_hint = "Grobe Pixel" +redact = "Schwärzen" +redact_hint = "Deckender Balken, nichts wiederherstellbar" +soft_blur = "Sanfte Unschärfe" +soft_blur_hint = "Leichte Unschärfe mit weichen Kanten" +spotlight = "Spotlight" +spotlight_hint = "Alles drumherum abdunkeln" + [screenshot_editor.menu] delete = "Löschen" open_folder = "Ordner öffnen" diff --git a/apps/desktop-gpui/locales/en.toml b/apps/desktop-gpui/locales/en.toml index ff6f72e44b1..5a6563f4f7e 100644 --- a/apps/desktop-gpui/locales/en.toml +++ b/apps/desktop-gpui/locales/en.toml @@ -1663,6 +1663,38 @@ empty_hint = "Use the tools above to add annotations" reorder_hint = "Drag to reorder • Top = front" title = "Layers" +[screenshot_editor.mask] +adjust = "Adjust" +corner_radius = { text = "Corners", context = "Corner radius of a rectangular mask, in a narrow slider row." } +feather = { text = "Feather", context = "How softly a mask fades out at its edges." } +invert = { text = "Invert", context = "Mask everything outside the drawn area instead of inside it." } +invert_hint = "Hide everything except this area" +presets = "Presets" +presets_title = "Mask presets" +privacy_note = "Blur and pixelate can sometimes be reversed. Use Redact for passwords and keys." +shape = "Shape" +shape_ellipse = "Ellipse" +shape_rectangle = "Rectangle" +shape_rounded = "Rounded" +solid = { text = "Solid", context = "Mask style that covers the area with a flat colour." } +tint = { text = "Tint", context = "How strongly a colour is mixed into a blurred or pixelated mask." } + +[screenshot_editor.mask.preset] +cap_blue = "Cap Blue" +cap_blue_hint = "Pixelated with a Cap blue tint" +focus = "Focus" +focus_hint = "Blur everything around it" +frosted = "Frosted" +frosted_hint = "Milky glass over the area" +mosaic = "Mosaic" +mosaic_hint = "Chunky pixels" +redact = "Redact" +redact_hint = "Solid bar, nothing recoverable" +soft_blur = "Soft blur" +soft_blur_hint = "Gentle blur with soft edges" +spotlight = "Spotlight" +spotlight_hint = "Dim everything around it" + [screenshot_editor.menu] delete = "Delete" open_folder = "Open Folder" diff --git a/apps/desktop-gpui/locales/es.toml b/apps/desktop-gpui/locales/es.toml index 3156d6cb740..18ddfb8a7a7 100644 --- a/apps/desktop-gpui/locales/es.toml +++ b/apps/desktop-gpui/locales/es.toml @@ -1661,6 +1661,38 @@ empty_hint = "Usa las herramientas de arriba para añadir anotaciones" reorder_hint = "Arrastra para reordenar • Arriba = delante" title = "Capas" +[screenshot_editor.mask] +adjust = "Ajustar" +corner_radius = "Esquinas" +feather = "Difuminado" +invert = "Invertir" +invert_hint = "Oculta todo excepto esta zona" +presets = "Ajustes predefinidos" +presets_title = "Máscaras predefinidas" +privacy_note = "El desenfoque y el pixelado a veces se pueden revertir. Usa Tachar para contraseñas y claves." +shape = "Forma" +shape_ellipse = "Elipse" +shape_rectangle = "Rectángulo" +shape_rounded = "Redondeado" +solid = "Sólido" +tint = "Tinte" + +[screenshot_editor.mask.preset] +cap_blue = "Azul Cap" +cap_blue_hint = "Pixelado con un tinte azul Cap" +focus = "Enfoque" +focus_hint = "Desenfoca todo a su alrededor" +frosted = "Esmerilado" +frosted_hint = "Cristal lechoso sobre la zona" +mosaic = "Mosaico" +mosaic_hint = "Píxeles grandes" +redact = "Tachar" +redact_hint = "Barra sólida, nada recuperable" +soft_blur = "Desenfoque suave" +soft_blur_hint = "Desenfoque ligero con bordes suaves" +spotlight = "Foco" +spotlight_hint = "Oscurece todo a su alrededor" + [screenshot_editor.menu] delete = "Eliminar" open_folder = "Abrir carpeta" diff --git a/apps/desktop-gpui/locales/fr.toml b/apps/desktop-gpui/locales/fr.toml index 16385c4743d..8767cca7620 100644 --- a/apps/desktop-gpui/locales/fr.toml +++ b/apps/desktop-gpui/locales/fr.toml @@ -1661,6 +1661,38 @@ empty_hint = "Utilisez les outils ci-dessus pour ajouter des annotations" reorder_hint = "Glissez pour réorganiser • Haut = premier plan" title = "Calques" +[screenshot_editor.mask] +adjust = "Ajuster" +corner_radius = "Coins" +feather = "Adoucissement" +invert = "Inverser" +invert_hint = "Masquer tout sauf cette zone" +presets = "Préréglages" +presets_title = "Préréglages de masque" +privacy_note = "Le flou et la pixellisation peuvent parfois être inversés. Utilisez Caviarder pour les mots de passe et les clés." +shape = "Forme" +shape_ellipse = "Ellipse" +shape_rectangle = "Rectangle" +shape_rounded = "Arrondi" +solid = "Plein" +tint = "Teinte" + +[screenshot_editor.mask.preset] +cap_blue = "Bleu Cap" +cap_blue_hint = "Pixellisé avec une teinte bleu Cap" +focus = "Focus" +focus_hint = "Flouter tout autour" +frosted = "Givré" +frosted_hint = "Verre laiteux sur la zone" +mosaic = "Mosaïque" +mosaic_hint = "Gros pixels" +redact = "Caviarder" +redact_hint = "Barre pleine, rien de récupérable" +soft_blur = "Flou doux" +soft_blur_hint = "Flou léger aux bords adoucis" +spotlight = "Projecteur" +spotlight_hint = "Assombrir tout autour" + [screenshot_editor.menu] delete = "Supprimer" open_folder = "Ouvrir le dossier" diff --git a/apps/desktop-gpui/locales/hi.toml b/apps/desktop-gpui/locales/hi.toml index b8d7d9f0015..2f1a0e37649 100644 --- a/apps/desktop-gpui/locales/hi.toml +++ b/apps/desktop-gpui/locales/hi.toml @@ -1661,6 +1661,38 @@ empty_hint = "एनोटेशन जोड़ने के लिए ऊप reorder_hint = "क्रम बदलने के लिए खींचें • ऊपर = सबसे आगे" title = "लेयर" +[screenshot_editor.mask] +adjust = "समायोजित करें" +corner_radius = "कोने" +feather = "फेदर" +invert = "उलटें" +invert_hint = "इस हिस्से को छोड़कर सब कुछ छिपाएँ" +presets = "प्रीसेट" +presets_title = "मास्क प्रीसेट" +privacy_note = "ब्लर और पिक्सलेट को कभी-कभी उलटा जा सकता है। पासवर्ड और कुंजियों के लिए रिडैक्ट का उपयोग करें।" +shape = "आकार" +shape_ellipse = "दीर्घवृत्त" +shape_rectangle = "आयत" +shape_rounded = "गोल किनारे" +solid = "ठोस" +tint = "टिंट" + +[screenshot_editor.mask.preset] +cap_blue = "Cap नीला" +cap_blue_hint = "Cap नीले टिंट के साथ पिक्सलेट" +focus = "फ़ोकस" +focus_hint = "आसपास सब कुछ ब्लर करें" +frosted = "फ्रॉस्टेड" +frosted_hint = "हिस्से पर दूधिया काँच" +mosaic = "मोज़ेक" +mosaic_hint = "बड़े पिक्सल" +redact = "रिडैक्ट" +redact_hint = "ठोस पट्टी, कुछ भी वापस नहीं मिलेगा" +soft_blur = "हल्का ब्लर" +soft_blur_hint = "मुलायम किनारों वाला हल्का ब्लर" +spotlight = "स्पॉटलाइट" +spotlight_hint = "आसपास सब कुछ धुंधला-गहरा करें" + [screenshot_editor.menu] delete = "हटाएँ" open_folder = "फ़ोल्डर खोलें" diff --git a/apps/desktop-gpui/locales/ja.toml b/apps/desktop-gpui/locales/ja.toml index c04e300ea12..b00b261841c 100644 --- a/apps/desktop-gpui/locales/ja.toml +++ b/apps/desktop-gpui/locales/ja.toml @@ -1661,6 +1661,38 @@ empty_hint = "上のツールで注釈を追加できます" reorder_hint = "ドラッグで並べ替え • 上 = 最前面" title = "レイヤー" +[screenshot_editor.mask] +adjust = "調整" +corner_radius = "角" +feather = "ぼかし幅" +invert = "反転" +invert_hint = "この範囲以外をすべて隠します" +presets = "プリセット" +presets_title = "マスクのプリセット" +privacy_note = "ぼかしやモザイクは復元される場合があります。パスワードやキーには黒塗りを使ってください。" +shape = "形" +shape_ellipse = "楕円" +shape_rectangle = "長方形" +shape_rounded = "角丸" +solid = "単色" +tint = "色味" + +[screenshot_editor.mask.preset] +cap_blue = "Capブルー" +cap_blue_hint = "Capブルーの色味でモザイク" +focus = "フォーカス" +focus_hint = "周囲をすべてぼかします" +frosted = "すりガラス" +frosted_hint = "範囲に乳白色のガラスをかけます" +mosaic = "モザイク" +mosaic_hint = "粗いピクセル" +redact = "黒塗り" +redact_hint = "塗りつぶしで、元に戻せません" +soft_blur = "ソフトぼかし" +soft_blur_hint = "縁がやわらかい軽いぼかし" +spotlight = "スポットライト" +spotlight_hint = "周囲をすべて暗くします" + [screenshot_editor.menu] delete = "削除" open_folder = "フォルダを開く" diff --git a/apps/desktop-gpui/locales/zh-CN.toml b/apps/desktop-gpui/locales/zh-CN.toml index 0d3ddef7591..e217979c735 100644 --- a/apps/desktop-gpui/locales/zh-CN.toml +++ b/apps/desktop-gpui/locales/zh-CN.toml @@ -1661,6 +1661,38 @@ empty_hint = "使用上方的工具添加标注" reorder_hint = "拖动以重新排序 • 顶部 = 最前" title = "图层" +[screenshot_editor.mask] +adjust = "调整" +corner_radius = "圆角" +feather = "羽化" +invert = "反转" +invert_hint = "隐藏此区域以外的所有内容" +presets = "预设" +presets_title = "遮罩预设" +privacy_note = "模糊和像素化有时可以被还原。密码和密钥请使用涂黑。" +shape = "形状" +shape_ellipse = "椭圆" +shape_rectangle = "矩形" +shape_rounded = "圆角矩形" +solid = "纯色" +tint = "色调" + +[screenshot_editor.mask.preset] +cap_blue = "Cap 蓝" +cap_blue_hint = "带 Cap 蓝色调的像素化" +focus = "聚焦" +focus_hint = "模糊周围所有内容" +frosted = "磨砂" +frosted_hint = "在区域上覆盖乳白玻璃" +mosaic = "马赛克" +mosaic_hint = "粗大的像素" +redact = "涂黑" +redact_hint = "纯色遮挡,无法恢复" +soft_blur = "柔和模糊" +soft_blur_hint = "边缘柔和的轻度模糊" +spotlight = "聚光灯" +spotlight_hint = "调暗周围所有内容" + [screenshot_editor.menu] delete = "删除" open_folder = "打开文件夹" diff --git a/apps/desktop-gpui/src/i18n/dialogs_surface_tests.rs b/apps/desktop-gpui/src/i18n/dialogs_surface_tests.rs index c8f881b9bbe..a9e20b36492 100644 --- a/apps/desktop-gpui/src/i18n/dialogs_surface_tests.rs +++ b/apps/desktop-gpui/src/i18n/dialogs_surface_tests.rs @@ -678,6 +678,67 @@ fn dialog_surfaces_match_the_literals_they_replaced() { tr::screenshot_editor::annotation::transparent(), "Transparent", ), + (tr::screenshot_editor::mask::adjust(), "Adjust"), + (tr::screenshot_editor::mask::corner_radius(), "Corners"), + (tr::screenshot_editor::mask::feather(), "Feather"), + (tr::screenshot_editor::mask::invert(), "Invert"), + ( + tr::screenshot_editor::mask::invert_hint(), + "Hide everything except this area", + ), + (tr::screenshot_editor::mask::presets(), "Presets"), + (tr::screenshot_editor::mask::presets_title(), "Mask presets"), + ( + tr::screenshot_editor::mask::privacy_note(), + "Blur and pixelate can sometimes be reversed. Use Redact for passwords and keys.", + ), + (tr::screenshot_editor::mask::shape(), "Shape"), + (tr::screenshot_editor::mask::shape_ellipse(), "Ellipse"), + (tr::screenshot_editor::mask::shape_rectangle(), "Rectangle"), + (tr::screenshot_editor::mask::shape_rounded(), "Rounded"), + (tr::screenshot_editor::mask::solid(), "Solid"), + (tr::screenshot_editor::mask::tint(), "Tint"), + (tr::screenshot_editor::mask::preset::cap_blue(), "Cap Blue"), + ( + tr::screenshot_editor::mask::preset::cap_blue_hint(), + "Pixelated with a Cap blue tint", + ), + (tr::screenshot_editor::mask::preset::focus(), "Focus"), + ( + tr::screenshot_editor::mask::preset::focus_hint(), + "Blur everything around it", + ), + (tr::screenshot_editor::mask::preset::frosted(), "Frosted"), + ( + tr::screenshot_editor::mask::preset::frosted_hint(), + "Milky glass over the area", + ), + (tr::screenshot_editor::mask::preset::mosaic(), "Mosaic"), + ( + tr::screenshot_editor::mask::preset::mosaic_hint(), + "Chunky pixels", + ), + (tr::screenshot_editor::mask::preset::redact(), "Redact"), + ( + tr::screenshot_editor::mask::preset::redact_hint(), + "Solid bar, nothing recoverable", + ), + ( + tr::screenshot_editor::mask::preset::soft_blur(), + "Soft blur", + ), + ( + tr::screenshot_editor::mask::preset::soft_blur_hint(), + "Gentle blur with soft edges", + ), + ( + tr::screenshot_editor::mask::preset::spotlight(), + "Spotlight", + ), + ( + tr::screenshot_editor::mask::preset::spotlight_hint(), + "Dim everything around it", + ), (tr::screenshot_editor::aspect::auto(), "Auto"), (tr::screenshot_editor::aspect::classic(), "Classic ⋅4:3"), (tr::screenshot_editor::aspect::square(), "Square ⋅1:1"), diff --git a/apps/desktop-gpui/src/screenshot_annotations.rs b/apps/desktop-gpui/src/screenshot_annotations.rs index a493e263073..86d148a3d9f 100644 --- a/apps/desktop-gpui/src/screenshot_annotations.rs +++ b/apps/desktop-gpui/src/screenshot_annotations.rs @@ -47,6 +47,10 @@ use crate::screenshot_editor::ScreenshotEditorWindow; use crate::theme::Theme; use crate::ui; +mod mask; + +pub(crate) use mask::MaskPanel; + // --------------------------------------------------------------------------- // Tools // --------------------------------------------------------------------------- @@ -1093,15 +1097,17 @@ struct Reorder { enum ColorTarget { Stroke, Fill, + Mask, } impl ColorTarget { - const ALL: [ColorTarget; 2] = [ColorTarget::Stroke, ColorTarget::Fill]; + const ALL: [ColorTarget; 3] = [ColorTarget::Stroke, ColorTarget::Fill, ColorTarget::Mask]; fn id(self) -> &'static str { match self { Self::Stroke => "screenshot-annotation-stroke", Self::Fill => "screenshot-annotation-fill", + Self::Mask => "screenshot-annotation-mask-color", } } @@ -1121,14 +1127,20 @@ enum AnnotationSlider { Opacity, MaskLevel, TextSize, + MaskFeather, + MaskRadius, + MaskTint, } impl AnnotationSlider { - const ALL: [AnnotationSlider; 4] = [ + const ALL: [AnnotationSlider; 7] = [ AnnotationSlider::Width, AnnotationSlider::Opacity, AnnotationSlider::MaskLevel, AnnotationSlider::TextSize, + AnnotationSlider::MaskFeather, + AnnotationSlider::MaskRadius, + AnnotationSlider::MaskTint, ]; /// `(min, max, step)` -- `AnnotationConfig.tsx:60-157`. @@ -1136,8 +1148,11 @@ impl AnnotationSlider { match self { Self::Width => (1., 20., 1.), Self::Opacity => (0.1, 1., 0.1), - Self::MaskLevel => (4., 50., 1.), + Self::MaskLevel => (4., 80., 1.), Self::TextSize => (12., 100., 1.), + Self::MaskFeather => (0., 64., 1.), + Self::MaskRadius => (0., 64., 1.), + Self::MaskTint => (0., 1., 0.05), } } @@ -1147,6 +1162,9 @@ impl AnnotationSlider { Self::Opacity => "opacity", Self::MaskLevel => "mask-level", Self::TextSize => "text-size", + Self::MaskFeather => "mask-feather", + Self::MaskRadius => "mask-radius", + Self::MaskTint => "mask-tint", } } @@ -1156,6 +1174,14 @@ impl AnnotationSlider { Self::Opacity => annotation.opacity as f32, Self::MaskLevel => annotation.mask_level.unwrap_or(MASK_FALLBACK_LEVEL) as f32, Self::TextSize => annotation.height as f32, + Self::MaskFeather | Self::MaskRadius | Self::MaskTint => { + let options = annotation.mask_options.clone().unwrap_or_default(); + (match self { + Self::MaskFeather => options.feather, + Self::MaskRadius => options.corner_radius, + _ => options.tint, + }) as f32 + } } } @@ -1173,6 +1199,14 @@ impl AnnotationSlider { *level = value; return true; } + Self::MaskFeather | Self::MaskRadius | Self::MaskTint => { + let options = annotation.mask_options.get_or_insert_with(Default::default); + match self { + Self::MaskFeather => &mut options.feather, + Self::MaskRadius => &mut options.corner_radius, + _ => &mut options.tint, + } + } }; if *field == value { return false; @@ -1220,6 +1254,10 @@ pub struct AnnotationState { masks: Vec>, mask_pending: Option>, mask_task: Option>, + /// The mask bar's open panel, and the mask it was opened for -- closed, + /// not repointed, when the selection moves. + mask_panel: Option<(MaskPanel, String)>, + mask_anchors: HashMap, } /// One paint of the layer, copied out of the window so the closure owns it. @@ -1623,6 +1661,8 @@ impl ScreenshotEditorWindow { }) .flatten(); + let mask_style = is_mask.then(|| self.mask_style_source()).flatten(); + let is_text = annotation_type == AnnotationType::Text; let annotation = Annotation { id: crate::store::new_uuid_v4(), @@ -1647,11 +1687,25 @@ impl ScreenshotEditorWindow { .map_or(DEFAULT_STROKE_WIDTH, |(_, width, _)| *width) }, fill_color: TRANSPARENT.to_string(), - opacity: style.as_ref().map_or(1., |(_, _, opacity)| *opacity), + opacity: mask_style.as_ref().map_or_else( + || style.as_ref().map_or(1., |(_, _, opacity)| *opacity), + |source| source.opacity, + ), rotation: 0., text: is_text.then(|| tr::screenshot_editor::annotation::default_text().to_string()), - mask_type: is_mask.then_some(MaskType::Pixelate), - mask_level: is_mask.then_some(MASK_DEFAULT_LEVEL), + mask_type: is_mask.then(|| { + mask_style + .as_ref() + .and_then(|source| source.mask_type) + .unwrap_or(MaskType::Pixelate) + }), + mask_level: is_mask.then(|| { + mask_style + .as_ref() + .and_then(|source| source.mask_level) + .unwrap_or(MASK_DEFAULT_LEVEL) + }), + mask_options: mask_style.and_then(|source| source.mask_options), points: (annotation_type == AnnotationType::Draw).then(|| vec![[x, y]]), }; @@ -2468,7 +2522,7 @@ impl ScreenshotEditorWindow { window: &Window, cx: &mut Context, ) -> Vec { - let mut layers = Vec::new(); + let mut layers: Vec = self.render_mask_panel(window, cx).into_iter().collect(); if self.annotation_state.gesture.is_some() { layers.push( ui::Slider::drag_layer( @@ -2575,30 +2629,7 @@ impl ScreenshotEditorWindow { )); } if is_mask { - let mask_type = annotation.mask_type.unwrap_or(MaskType::Blur); - row = row - .child(config_item( - &theme, - tr::screenshot_editor::annotation::mask_style(), - None, - div() - .flex() - .flex_row() - .gap(px(4.)) - .child(self.render_mask_pill(MaskType::Blur, mask_type, cx)) - .child(self.render_mask_pill(MaskType::Pixelate, mask_type, cx)) - .into_any_element(), - )) - .child(config_item( - &theme, - tr::screenshot_editor::annotation::intensity(), - Some(format!( - "{}", - annotation.mask_level.unwrap_or(MASK_FALLBACK_LEVEL).round() as i64 - )), - self.annotation_slider(AnnotationSlider::MaskLevel, px(96.), cx) - .into_any_element(), - )); + row = row.children(self.render_mask_controls(annotation, cx)); } if is_text { row = row.child(config_item( @@ -2613,69 +2644,6 @@ impl ScreenshotEditorWindow { Some(row.into_any_element()) } - /// The two `Style` pills (`AnnotationConfig.tsx:100-123`). - fn render_mask_pill( - &self, - value: MaskType, - current: MaskType, - cx: &mut Context, - ) -> AnyElement { - let theme = self.theme; - let active = value == current; - let (id, label) = match value { - MaskType::Blur => ("Blur", tr::screenshot_editor::style::blur()), - MaskType::Pixelate => ("Pixelate", tr::screenshot_editor::annotation::pixelate()), - }; - div() - .id(gpui::SharedString::from(format!( - "screenshot-mask-style-{id}" - ))) - .flex() - .items_center() - .justify_center() - .h(px(24.)) - .px(px(10.)) - .rounded(px(6.)) - .cursor_pointer() - .text_size(px(12.)) - .font_weight(FontWeight::MEDIUM) - .when(active, |this| { - this.bg(theme.blue_9).text_color(gpui::white()) - }) - .when(!active, |this| { - this.bg(theme.gray_3) - .text_color(theme.gray_11) - .hover(|style| style.bg(theme.gray_4)) - }) - .child(label) - .on_click(cx.listener(move |this, _, window, cx| { - cx.stop_propagation(); - let Some(id) = this.selected_annotation.clone() else { - return; - }; - this.edit_project( - move |project| { - let Some(annotation) = project - .annotations - .iter_mut() - .find(|annotation| annotation.id == id) - else { - return false; - }; - if annotation.mask_type == Some(value) { - return false; - } - annotation.mask_type = Some(value); - true - }, - window, - cx, - ); - this.refresh_mask_overlays(window, cx); - })) - .into_any_element() - } - fn annotation_slider( &self, slider: AnnotationSlider, @@ -2760,9 +2728,6 @@ impl ScreenshotEditorWindow { window, cx, ); - if slider == AnnotationSlider::MaskLevel { - self.refresh_mask_overlays(window, cx); - } } fn end_annotation_slider(&mut self, cx: &mut Context) { @@ -2779,6 +2744,7 @@ impl ScreenshotEditorWindow { Some(match target { ColorTarget::Stroke => annotation.stroke_color.clone(), ColorTarget::Fill => annotation.fill_color.clone(), + ColorTarget::Mask => annotation.mask_options.clone().unwrap_or_default().color, }) } @@ -2804,6 +2770,11 @@ impl ScreenshotEditorWindow { let field = match target { ColorTarget::Stroke => &mut annotation.stroke_color, ColorTarget::Fill => &mut annotation.fill_color, + ColorTarget::Mask => { + let options = annotation.mask_options.get_or_insert_with(Default::default); + Self::ensure_mask_tint_visible(options); + &mut options.color + } }; if *field == value { return false; @@ -3070,6 +3041,9 @@ impl ScreenshotEditorWindow { ColorTarget::Fill => { "screenshot-annotation-hex-fill" } + ColorTarget::Mask => { + "screenshot-annotation-hex-mask" + } }, input, ) @@ -3106,6 +3080,11 @@ impl ScreenshotEditorWindow { { self.annotation_state.color_popover = None; } + if let Some((_, id)) = &self.annotation_state.mask_panel + && self.selected_annotation.as_deref() != Some(id.as_str()) + { + self.annotation_state.mask_panel = None; + } for target in ColorTarget::ALL { let Some(input) = self.annotation_state.color_inputs.get(&target).cloned() else { continue; @@ -4006,20 +3985,15 @@ fn mask_request(mask: &Annotation, frame: MaskFrame) -> Option { if mask.annotation_type != AnnotationType::Mask { return None; } + let content = frame.content.map(f64::from_bits); + let (region, shape) = mask::mask_regions(mask, content, content, frame.size)?; Some(MaskRequest { id: mask.id.clone(), - region: cap_gpui_kernels::mask_cache::mask_region( - [mask.x, mask.y, mask.width, mask.height], - frame.content.map(f64::from_bits), - frame.size, - )?, + region, + shape, frame, - pixelate: mask.mask_type == Some(MaskType::Pixelate), - level: mask - .mask_level - .unwrap_or(MASK_FALLBACK_LEVEL) - .max(1.) - .to_bits(), + // The preview filters the atlas's BGRA, so the colour swaps to match. + style: mask::mask_style(mask, true), }) } @@ -4033,6 +4007,7 @@ pub(crate) fn masked_region_image( size: (u32, u32), mask: &Annotation, rect: Rect, + content: Rect, ) -> Option<(u32, u32, image::RgbaImage)> { let (frame_width, frame_height) = size; if frame_width == 0 || frame_height == 0 { @@ -4043,15 +4018,19 @@ pub(crate) fn masked_region_image( return None; } - let region = mask_region(mask, rect, (frame_width, frame_height))?; - let level = mask.mask_level.unwrap_or(MASK_FALLBACK_LEVEL).max(1.); - let processed = cap_gpui_kernels::screenshot::mask_filter( + let (region, shape) = mask::mask_regions( + mask, + [rect.x, rect.y, rect.width, rect.height], + [content.x, content.y, content.width, content.height], + size, + )?; + let processed = cap_gpui_kernels::mask_style::styled_mask( rgba, frame_width, frame_height, region, - mask.mask_type == Some(MaskType::Pixelate), - level, + shape, + &mask::mask_style(mask, false), )?; Some((region.0, region.1, processed)) @@ -4163,6 +4142,7 @@ mod tests { text: None, mask_type: None, mask_level: None, + mask_options: None, points: None, }]; rescale_annotations(&mut annotations, &previous, ¤t); @@ -4246,6 +4226,7 @@ mod tests { text: Some("Hello".into()), mask_type: None, mask_level: None, + mask_options: None, points: None, }; assert_eq!(layer_label(&annotation), "Hello"); @@ -4281,6 +4262,7 @@ mod tests { text: None, mask_type: None, mask_level: None, + mask_options: None, points: None, } } diff --git a/apps/desktop-gpui/src/screenshot_annotations/mask.rs b/apps/desktop-gpui/src/screenshot_annotations/mask.rs new file mode 100644 index 00000000000..c81946a8fc3 --- /dev/null +++ b/apps/desktop-gpui/src/screenshot_annotations/mask.rs @@ -0,0 +1,1291 @@ +use cap_gpui_kernels::mask_style::{MaskEffect, MaskStyle}; +use cap_project::{MaskOptions, MaskShape}; +use gpui::{linear_color_stop, linear_gradient}; + +use super::*; + +const BRAND_BLUE: &str = "#4785FF"; +const ROUNDED_DEFAULT_RADIUS: f64 = 12.; +const TINT_ON_COLOR_PICK: f64 = 0.4; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum MaskPanel { + Presets, + Adjust, +} + +impl MaskPanel { + fn id(self) -> &'static str { + match self { + Self::Presets => "screenshot-mask-presets", + Self::Adjust => "screenshot-mask-adjust", + } + } + + fn width(self) -> f32 { + match self { + Self::Presets => 300., + Self::Adjust => 260., + } + } +} + +/// What the Style pills choose between. `Solid` is stored as a flag next to +/// `maskType` so older builds still hide the region with a blur. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MaskStyleChoice { + Blur, + Pixelate, + Solid, +} + +impl MaskStyleChoice { + const ALL: [MaskStyleChoice; 3] = [Self::Blur, Self::Pixelate, Self::Solid]; + + fn of(annotation: &Annotation) -> Self { + if annotation + .mask_options + .as_ref() + .is_some_and(|options| options.solid) + { + return Self::Solid; + } + match annotation.mask_type { + Some(MaskType::Pixelate) => Self::Pixelate, + _ => Self::Blur, + } + } + + fn id(self) -> &'static str { + match self { + Self::Blur => "blur", + Self::Pixelate => "pixelate", + Self::Solid => "solid", + } + } + + fn label(self) -> &'static str { + match self { + Self::Blur => tr::screenshot_editor::style::blur(), + Self::Pixelate => tr::screenshot_editor::annotation::pixelate(), + Self::Solid => tr::screenshot_editor::mask::solid(), + } + } + + fn apply(self, annotation: &mut Annotation) -> bool { + if Self::of(annotation) == self { + return false; + } + let options = annotation.mask_options.get_or_insert_with(Default::default); + options.solid = self == Self::Solid; + match self { + Self::Blur => annotation.mask_type = Some(MaskType::Blur), + Self::Pixelate => annotation.mask_type = Some(MaskType::Pixelate), + Self::Solid => {} + } + true + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MaskShapeChoice { + Square, + Rounded, + Ellipse, +} + +impl MaskShapeChoice { + const ALL: [MaskShapeChoice; 3] = [Self::Square, Self::Rounded, Self::Ellipse]; + + fn of(options: &MaskOptions) -> Self { + match options.shape { + MaskShape::Ellipse => Self::Ellipse, + MaskShape::Rectangle if options.corner_radius > 0. => Self::Rounded, + MaskShape::Rectangle => Self::Square, + } + } + + fn id(self) -> &'static str { + match self { + Self::Square => "square", + Self::Rounded => "rounded", + Self::Ellipse => "ellipse", + } + } + + fn icon(self) -> &'static str { + match self { + Self::Square => "icons/square.svg", + Self::Rounded => "icons/corners.svg", + Self::Ellipse => "icons/circle.svg", + } + } + + fn label(self) -> &'static str { + match self { + Self::Square => tr::screenshot_editor::mask::shape_rectangle(), + Self::Rounded => tr::screenshot_editor::mask::shape_rounded(), + Self::Ellipse => tr::screenshot_editor::mask::shape_ellipse(), + } + } + + fn apply(self, options: &mut MaskOptions) -> bool { + if Self::of(options) == self { + return false; + } + match self { + Self::Square => { + options.shape = MaskShape::Rectangle; + options.corner_radius = 0.; + } + Self::Rounded => { + options.shape = MaskShape::Rectangle; + if options.corner_radius <= 0. { + options.corner_radius = ROUNDED_DEFAULT_RADIUS; + } + } + Self::Ellipse => options.shape = MaskShape::Ellipse, + } + true + } +} + +/// One-click looks. Each preset writes every mask field, so applying one is +/// a complete restyle rather than a merge with whatever was there. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MaskPreset { + SoftBlur, + Mosaic, + Redact, + Frosted, + Spotlight, + Focus, + CapBlue, +} + +struct PresetLook { + mask_type: MaskType, + level: f64, + opacity: f64, + options: MaskOptions, +} + +impl MaskPreset { + const ALL: [MaskPreset; 7] = [ + Self::SoftBlur, + Self::Mosaic, + Self::Redact, + Self::Frosted, + Self::Spotlight, + Self::Focus, + Self::CapBlue, + ]; + + fn id(self) -> &'static str { + match self { + Self::SoftBlur => "soft-blur", + Self::Mosaic => "mosaic", + Self::Redact => "redact", + Self::Frosted => "frosted", + Self::Spotlight => "spotlight", + Self::Focus => "focus", + Self::CapBlue => "cap-blue", + } + } + + fn label(self) -> &'static str { + match self { + Self::SoftBlur => tr::screenshot_editor::mask::preset::soft_blur(), + Self::Mosaic => tr::screenshot_editor::mask::preset::mosaic(), + Self::Redact => tr::screenshot_editor::mask::preset::redact(), + Self::Frosted => tr::screenshot_editor::mask::preset::frosted(), + Self::Spotlight => tr::screenshot_editor::mask::preset::spotlight(), + Self::Focus => tr::screenshot_editor::mask::preset::focus(), + Self::CapBlue => tr::screenshot_editor::mask::preset::cap_blue(), + } + } + + fn hint(self) -> &'static str { + match self { + Self::SoftBlur => tr::screenshot_editor::mask::preset::soft_blur_hint(), + Self::Mosaic => tr::screenshot_editor::mask::preset::mosaic_hint(), + Self::Redact => tr::screenshot_editor::mask::preset::redact_hint(), + Self::Frosted => tr::screenshot_editor::mask::preset::frosted_hint(), + Self::Spotlight => tr::screenshot_editor::mask::preset::spotlight_hint(), + Self::Focus => tr::screenshot_editor::mask::preset::focus_hint(), + Self::CapBlue => tr::screenshot_editor::mask::preset::cap_blue_hint(), + } + } + + fn look(self) -> PresetLook { + let base = MaskOptions::default(); + match self { + Self::SoftBlur => PresetLook { + mask_type: MaskType::Blur, + level: 24., + opacity: 1., + options: MaskOptions { + corner_radius: 10., + feather: 6., + ..base + }, + }, + Self::Mosaic => PresetLook { + mask_type: MaskType::Pixelate, + level: 14., + opacity: 1., + options: base, + }, + Self::Redact => PresetLook { + mask_type: MaskType::Blur, + level: 40., + opacity: 1., + options: MaskOptions { + solid: true, + corner_radius: 4., + ..base + }, + }, + Self::Frosted => PresetLook { + mask_type: MaskType::Blur, + level: 48., + opacity: 1., + options: MaskOptions { + corner_radius: 16., + color: "#FFFFFF".to_string(), + tint: 0.45, + ..base + }, + }, + Self::Spotlight => PresetLook { + mask_type: MaskType::Blur, + level: 8., + opacity: 0.65, + options: MaskOptions { + shape: MaskShape::Ellipse, + feather: 32., + invert: true, + solid: true, + ..base + }, + }, + Self::Focus => PresetLook { + mask_type: MaskType::Blur, + level: 36., + opacity: 1., + options: MaskOptions { + corner_radius: 16., + feather: 20., + invert: true, + ..base + }, + }, + Self::CapBlue => PresetLook { + mask_type: MaskType::Pixelate, + level: 10., + opacity: 1., + options: MaskOptions { + corner_radius: 12., + color: BRAND_BLUE.to_string(), + tint: 0.55, + ..base + }, + }, + } + } + + fn apply(self, annotation: &mut Annotation) -> bool { + if self.matches(annotation) { + return false; + } + let look = self.look(); + annotation.mask_type = Some(look.mask_type); + annotation.mask_level = Some(look.level); + annotation.opacity = look.opacity; + annotation.mask_options = Some(look.options); + true + } + + fn matches(self, annotation: &Annotation) -> bool { + let look = self.look(); + let options = annotation.mask_options.clone().unwrap_or_default(); + let solid = look.options.solid; + annotation.mask_type.unwrap_or(MaskType::Blur) == look.mask_type + && (solid || annotation.mask_level.unwrap_or(MASK_FALLBACK_LEVEL) == look.level) + && annotation.opacity == look.opacity + && options.shape == look.options.shape + && options.corner_radius == look.options.corner_radius + && options.feather == look.options.feather + && options.invert == look.options.invert + && options.solid == solid + && options.tint == look.options.tint + && (look.options.tint <= 0. && !solid + || options.color.eq_ignore_ascii_case(&look.options.color)) + } + + fn of(annotation: &Annotation) -> Option { + Self::ALL + .into_iter() + .find(|preset| preset.matches(annotation)) + } + + fn swatch(self, theme: &Theme) -> AnyElement { + let scene = |inner: gpui::Div| { + div() + .relative() + .w(px(52.)) + .h(px(36.)) + .flex_shrink_0() + .rounded(px(8.)) + .overflow_hidden() + .bg(linear_gradient( + 135., + linear_color_stop(gpui::rgb(0xF8B26A), 0.), + linear_color_stop(gpui::rgb(0x6C8CFF), 1.), + )) + .child(inner) + }; + let blob = |x: f32, y: f32, size: f32, color: u32, blur: f32| { + div() + .absolute() + .left(px(x)) + .top(px(y)) + .size(px(size)) + .rounded_full() + .bg(gpui::rgb(color)) + .shadow(vec![gpui::BoxShadow { + color: Theme::with_alpha(gpui::rgb(color), 0.9), + offset: gpui::point(px(0.), px(0.)), + blur_radius: px(blur), + spread_radius: px(blur / 2.), + inset: false, + }]) + }; + let tiles = |palette: [u32; 6], tint: Option<(u32, f32)>| { + let mut grid = div() + .absolute() + .top_0() + .left_0() + .size_full() + .flex() + .flex_row() + .flex_wrap(); + for color in palette { + grid = grid.child(div().w(px(52. / 3.)).h(px(18.)).bg(gpui::rgb(match tint { + Some((tint, amount)) => mix_rgb(color, tint, amount), + None => color, + }))); + } + grid + }; + let theme = *theme; + match self { + Self::SoftBlur => scene( + div() + .absolute() + .size_full() + .child(blob(8., 8., 12., 0xFFE0B0, 10.)) + .child(blob(28., 14., 14., 0x8FA8FF, 10.)), + ), + Self::Mosaic => scene(tiles( + [0xF8B26A, 0xE58FA0, 0x9C8CF0, 0xD99A7C, 0xA88FD8, 0x6C8CFF], + None, + )), + Self::Redact => scene( + div().absolute().size_full().child( + div() + .absolute() + .left(px(6.)) + .top(px(13.)) + .w(px(40.)) + .h(px(10.)) + .rounded(px(2.)) + .bg(gpui::rgb(0x111111)), + ), + ), + Self::Frosted => scene( + div() + .absolute() + .size_full() + .child(blob(6., 6., 14., 0xFFD08A, 8.)) + .child( + div() + .absolute() + .left(px(6.)) + .top(px(6.)) + .w(px(40.)) + .h(px(24.)) + .rounded(px(6.)) + .bg(Theme::with_alpha(gpui::rgb(0xFFFFFF), 0.55)) + .border_1() + .border_color(Theme::with_alpha(gpui::rgb(0xFFFFFF), 0.8)), + ), + ), + Self::Spotlight => scene( + div() + .absolute() + .size_full() + .bg(Theme::with_alpha(gpui::rgb(0x000000), 0.6)) + .child( + div() + .absolute() + .left(px(16.)) + .top(px(8.)) + .w(px(20.)) + .h(px(20.)) + .rounded_full() + .bg(linear_gradient( + 135., + linear_color_stop(gpui::rgb(0xF8B26A), 0.), + linear_color_stop(gpui::rgb(0x9C8CF0), 1.), + )) + .shadow(vec![gpui::BoxShadow { + color: Theme::with_alpha(gpui::rgb(0xFFFFFF), 0.35), + offset: gpui::point(px(0.), px(0.)), + blur_radius: px(8.), + spread_radius: px(2.), + inset: false, + }]), + ), + ), + Self::Focus => scene( + div() + .absolute() + .size_full() + .child(blob(2., 2., 10., 0xFFE0B0, 8.)) + .child(blob(40., 22., 10., 0x8FA8FF, 8.)) + .child( + div() + .absolute() + .left(px(14.)) + .top(px(9.)) + .w(px(24.)) + .h(px(18.)) + .rounded(px(4.)) + .border_1() + .border_color(gpui::white()) + .bg(linear_gradient( + 90., + linear_color_stop(gpui::rgb(0xF29B78), 0.), + linear_color_stop(gpui::rgb(0x8C8CF6), 1.), + )), + ), + ), + Self::CapBlue => scene(tiles( + [0xF8B26A, 0xE58FA0, 0x9C8CF0, 0xD99A7C, 0xA88FD8, 0x6C8CFF], + Some((0x4785FF, 0.6)), + )), + } + .border_1() + .border_color(theme.gray_4) + .into_any_element() + } +} + +/// The kernel's view of one mask. The preview filters the atlas's BGRA, so +/// `bgra` swaps the colour's channels to match the bytes it will be mixed +/// into. +pub(super) fn mask_style(mask: &Annotation, bgra: bool) -> MaskStyle { + let options = mask.mask_options.clone().unwrap_or_default(); + let [r, g, b, _] = hex_to_rgb(&options.color).unwrap_or([0, 0, 0, 255]); + MaskStyle { + effect: if options.solid { + MaskEffect::Solid + } else if mask.mask_type == Some(MaskType::Pixelate) { + MaskEffect::Pixelate + } else { + MaskEffect::Blur + }, + level: mask + .mask_level + .unwrap_or(MASK_FALLBACK_LEVEL) + .max(1.) + .to_bits(), + ellipse: options.shape == MaskShape::Ellipse, + corner_radius: options.corner_radius.to_bits(), + feather: options.feather.to_bits(), + invert: options.invert, + color: if bgra { [b, g, r] } else { [r, g, b] }, + tint: options.tint.to_bits(), + opacity: mask.opacity.to_bits(), + } +} + +/// The pixels one mask resamples and the rect its shape occupies, both in +/// whole frame pixels. A normal mask works inside its own rect; an inverted +/// one covers the screenshot's whole content rect with its own rect as the +/// hole. Everything is clipped to `clip` first. +pub(super) fn mask_regions( + mask: &Annotation, + clip: [f64; 4], + content: [f64; 4], + frame: (u32, u32), +) -> Option<((u32, u32, u32, u32), (u32, u32, u32, u32))> { + let shape = cap_gpui_kernels::mask_cache::mask_region( + [mask.x, mask.y, mask.width, mask.height], + clip, + frame, + )?; + let inverted = mask + .mask_options + .as_ref() + .is_some_and(|options| options.invert); + if !inverted { + return Some((shape, shape)); + } + let region = cap_gpui_kernels::mask_cache::mask_region(content, clip, frame)?; + Some((region, shape)) +} + +impl ScreenshotEditorWindow { + /// A new mask copies the look of the selected mask, or else the last one + /// drawn, so a run of redactions does not need restyling one by one. + pub(super) fn mask_style_source(&self) -> Option { + self.selected() + .filter(|annotation| annotation.annotation_type == AnnotationType::Mask) + .or_else(|| { + self.project + .annotations + .iter() + .rev() + .find(|annotation| annotation.annotation_type == AnnotationType::Mask) + }) + .cloned() + } + + fn edit_selected_mask( + &mut self, + change: impl FnOnce(&mut Annotation) -> bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(id) = self.selected_annotation.clone() else { + return; + }; + self.edit_project( + move |project| { + project + .annotations + .iter_mut() + .find(|annotation| { + annotation.id == id && annotation.annotation_type == AnnotationType::Mask + }) + .is_some_and(change) + }, + window, + cx, + ); + } + + /// Picking a colour on a blur or pixelate mask with no tint would change + /// nothing visible, so the first pick also turns the tint up. + pub(super) fn ensure_mask_tint_visible(options: &mut MaskOptions) { + if !options.solid && options.tint <= 0. { + options.tint = TINT_ON_COLOR_PICK; + } + } + + fn toggle_mask_panel(&mut self, panel: MaskPanel, cx: &mut Context) { + let Some(id) = self.selected_annotation.clone() else { + return; + }; + let open = self + .annotation_state + .mask_panel + .as_ref() + .is_some_and(|(open, open_id)| *open == panel && open_id == &id); + self.annotation_state.color_popover = None; + self.annotation_state.mask_panel = (!open).then_some((panel, id)); + cx.notify(); + } + + fn mask_anchor(&self, panel: MaskPanel) -> BoundsCell { + self.annotation_state + .mask_anchors + .get(&panel) + .cloned() + .unwrap_or_default() + } + + pub(super) fn render_mask_controls( + &self, + annotation: &Annotation, + cx: &mut Context, + ) -> Vec { + let theme = self.theme; + let style = MaskStyleChoice::of(annotation); + let options = annotation.mask_options.clone().unwrap_or_default(); + let shape = MaskShapeChoice::of(&options); + let preset = MaskPreset::of(annotation); + let open = self + .annotation_state + .mask_panel + .as_ref() + .map(|(panel, _)| *panel); + + let mut items = vec![self.render_mask_panel_button( + MaskPanel::Presets, + "icons/sparkles.svg", + preset.map_or(tr::screenshot_editor::mask::presets(), MaskPreset::label), + open == Some(MaskPanel::Presets), + preset.is_some(), + cx, + )]; + + let mut pills = div().flex().flex_row().gap(px(4.)); + for choice in MaskStyleChoice::ALL { + pills = pills.child(mask_pill( + &theme, + format!("screenshot-mask-style-{}", choice.id()), + Some(choice.label().into()), + None, + choice == style, + cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.edit_selected_mask(|annotation| choice.apply(annotation), window, cx); + }), + )); + } + items.push(config_item( + &theme, + tr::screenshot_editor::annotation::mask_style(), + None, + pills.into_any_element(), + )); + + if style != MaskStyleChoice::Solid { + items.push(config_item( + &theme, + tr::screenshot_editor::annotation::intensity(), + Some(format!( + "{}", + annotation.mask_level.unwrap_or(MASK_FALLBACK_LEVEL).round() as i64 + )), + self.annotation_slider(AnnotationSlider::MaskLevel, px(88.), cx) + .into_any_element(), + )); + } + + let mut shapes = div().flex().flex_row().gap(px(4.)); + for choice in MaskShapeChoice::ALL { + let label = choice.label(); + shapes = shapes.child( + div() + .id(gpui::SharedString::from(format!( + "screenshot-mask-shape-tip-{}", + choice.id() + ))) + .child(mask_pill( + &theme, + format!("screenshot-mask-shape-{}", choice.id()), + None, + Some(choice.icon()), + choice == shape, + cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.edit_selected_mask( + |annotation| { + choice.apply( + annotation + .mask_options + .get_or_insert_with(Default::default), + ) + }, + window, + cx, + ); + }), + )) + .tooltip_show_delay(ui::TOOLTIP_SHOW_DELAY) + .tooltip(move |_window, cx| ui::Tooltip::new(&theme, label).view(cx)), + ); + } + items.push(config_item( + &theme, + tr::screenshot_editor::mask::shape(), + None, + shapes.into_any_element(), + )); + + items.push(config_item( + &theme, + tr::screenshot_editor::style::color(), + None, + self.render_color_button(ColorTarget::Mask, cx), + )); + + let inverted = options.invert; + items.push( + div() + .id("screenshot-mask-invert-tip") + .child(mask_pill( + &theme, + "screenshot-mask-invert".to_string(), + Some(tr::screenshot_editor::mask::invert().into()), + Some("icons/box-select.svg"), + inverted, + cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.edit_selected_mask( + |annotation| { + let options = + annotation.mask_options.get_or_insert_with(Default::default); + options.invert = !options.invert; + true + }, + window, + cx, + ); + }), + )) + .tooltip_show_delay(ui::TOOLTIP_SHOW_DELAY) + .tooltip(move |_window, cx| { + ui::Tooltip::new(&theme, tr::screenshot_editor::mask::invert_hint()).view(cx) + }) + .into_any_element(), + ); + + items.push(self.render_mask_panel_button( + MaskPanel::Adjust, + "icons/sliders-horizontal.svg", + tr::screenshot_editor::mask::adjust(), + open == Some(MaskPanel::Adjust), + false, + cx, + )); + + items + } + + fn render_mask_panel_button( + &self, + panel: MaskPanel, + icon: &'static str, + label: &'static str, + open: bool, + highlighted: bool, + cx: &mut Context, + ) -> AnyElement { + let theme = self.theme; + let cell = self.mask_anchor(panel); + let accent = open || highlighted; + div() + .relative() + .flex_shrink_0() + .child( + canvas( + move |bounds, _window, _cx| cell.set(Some(bounds)), + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) + .child( + div() + .id(panel.id()) + .flex() + .flex_row() + .items_center() + .gap(px(6.)) + .h(px(26.)) + .pl(px(8.)) + .pr(px(6.)) + .rounded(px(7.)) + .cursor_pointer() + .text_size(px(12.)) + .font_weight(FontWeight::MEDIUM) + .border_1() + .when(accent, |this| { + this.bg(theme.blue_3) + .border_color(theme.blue_6) + .text_color(theme.blue_11) + }) + .when(!accent, |this| { + this.bg(theme.gray_3) + .border_color(theme.gray_4) + .text_color(theme.gray_11) + .hover(|style| style.bg(theme.gray_4)) + }) + .child(svg().path(icon).size(px(14.)).text_color(if accent { + theme.blue_11 + } else { + theme.gray_11 + })) + .child(label) + .child( + svg() + .path("icons/chevron-down.svg") + .size(px(12.)) + .text_color(theme.gray_10), + ) + .on_click(cx.listener(move |this, _, _window, cx| { + cx.stop_propagation(); + this.toggle_mask_panel(panel, cx); + })), + ) + .into_any_element() + } + + pub(super) fn render_mask_panel( + &self, + window: &Window, + cx: &mut Context, + ) -> Option { + let (panel, id) = self.annotation_state.mask_panel.clone()?; + if self.selected_annotation.as_deref() != Some(id.as_str()) { + return None; + } + let annotation = self + .annotation(&id) + .filter(|annotation| annotation.annotation_type == AnnotationType::Mask)? + .clone(); + let theme = self.theme; + let width = panel.width(); + let viewport = window.viewport_size(); + let anchor = self.mask_anchor(panel).get(); + let left = anchor + .map(|bounds| f32::from(bounds.origin.x)) + .unwrap_or(16.) + .clamp(8., (f32::from(viewport.width) - width - 8.).max(8.)); + let top = anchor + .map(|bounds| f32::from(bounds.origin.y + bounds.size.height) + 8.) + .unwrap_or(120.); + + let body = match panel { + MaskPanel::Presets => self.render_mask_presets(&annotation, cx), + MaskPanel::Adjust => self.render_mask_adjust(&annotation, cx), + }; + + Some( + div() + .absolute() + .top_0() + .left_0() + .size_full() + .child( + div() + .id("screenshot-mask-panel-backdrop") + .occlude() + .absolute() + .top_0() + .left_0() + .size_full() + .on_click(cx.listener(|this, _, _window, cx| { + this.annotation_state.mask_panel = None; + cx.notify(); + })), + ) + .child( + div() + .id("screenshot-mask-panel") + .occlude() + .absolute() + .left(px(left)) + .top(px(top)) + .w(px(width)) + .p(px(10.)) + .flex() + .flex_col() + .gap(px(10.)) + .rounded(px(10.)) + .border_1() + .border_color(theme.gray_4) + .bg(if theme.is_dark() { + theme.gray_2 + } else { + theme.gray_1 + }) + .shadow(vec![gpui::BoxShadow { + color: Theme::with_alpha(gpui::rgb(0x000000), 0.18), + offset: gpui::point(px(0.), px(8.)), + blur_radius: px(24.), + spread_radius: px(0.), + inset: false, + }]) + .on_click(|_, _window, cx| cx.stop_propagation()) + .child( + div() + .text_size(px(11.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.gray_10) + .child(match panel { + MaskPanel::Presets => { + tr::screenshot_editor::mask::presets_title() + } + MaskPanel::Adjust => tr::screenshot_editor::mask::adjust(), + }), + ) + .child(body), + ) + .into_any_element(), + ) + } + + fn render_mask_presets(&self, annotation: &Annotation, cx: &mut Context) -> AnyElement { + let theme = self.theme; + let current = MaskPreset::of(annotation); + let mut list = div().flex().flex_col().gap(px(4.)); + for preset in MaskPreset::ALL { + let active = current == Some(preset); + list = list.child( + div() + .id(gpui::SharedString::from(format!( + "screenshot-mask-preset-{}", + preset.id() + ))) + .flex() + .flex_row() + .items_center() + .gap(px(10.)) + .p(px(6.)) + .rounded(px(8.)) + .cursor_pointer() + .border_1() + .when(active, |this| { + this.bg(theme.blue_3).border_color(theme.blue_6) + }) + .when(!active, |this| { + this.border_color(gpui::transparent_black()) + .hover(|style| style.bg(theme.gray_3)) + }) + .child(preset.swatch(&theme)) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .gap(px(2.)) + .child( + div() + .text_size(px(12.)) + .font_weight(FontWeight::MEDIUM) + .text_color(if active { theme.blue_11 } else { theme.gray_12 }) + .child(preset.label()), + ) + .child( + div() + .text_size(px(11.)) + .text_color(theme.gray_10) + .child(preset.hint()), + ), + ) + .when(active, |this| { + this.child( + svg() + .path("icons/check.svg") + .size(px(14.)) + .text_color(theme.blue_11), + ) + }) + .on_click(cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.edit_selected_mask(|annotation| preset.apply(annotation), window, cx); + })), + ); + } + div() + .flex() + .flex_col() + .gap(px(8.)) + .child(list) + .child( + div() + .flex() + .flex_row() + .items_start() + .gap(px(6.)) + .pt(px(8.)) + .border_t_1() + .border_color(theme.gray_3) + .child( + svg() + .path("icons/shield.svg") + .size(px(12.)) + .mt(px(1.)) + .flex_shrink_0() + .text_color(theme.gray_9), + ) + .child( + div() + .text_size(px(10.)) + .text_color(theme.gray_9) + .child(tr::screenshot_editor::mask::privacy_note()), + ), + ) + .into_any_element() + } + + fn render_mask_adjust(&self, annotation: &Annotation, cx: &mut Context) -> AnyElement { + let theme = self.theme; + let options = annotation.mask_options.clone().unwrap_or_default(); + let row = |label: &'static str, value: String, slider: AnyElement| { + div() + .flex() + .flex_col() + .gap(px(6.)) + .child( + div() + .flex() + .flex_row() + .justify_between() + .text_size(px(11.)) + .font_weight(FontWeight::MEDIUM) + .child(div().text_color(theme.gray_10).child(label)) + .child(div().text_color(theme.gray_12).child(value)), + ) + .child(slider) + }; + let slider_width = px(MaskPanel::Adjust.width() - 20.); + let mut body = div().flex().flex_col().gap(px(12.)).child(row( + tr::screenshot_editor::mask::feather(), + format!("{}px", options.feather.round() as i64), + self.annotation_slider(AnnotationSlider::MaskFeather, slider_width, cx) + .into_any_element(), + )); + if options.shape == MaskShape::Rectangle { + body = body.child(row( + tr::screenshot_editor::mask::corner_radius(), + format!("{}px", options.corner_radius.round() as i64), + self.annotation_slider(AnnotationSlider::MaskRadius, slider_width, cx) + .into_any_element(), + )); + } + if !options.solid { + body = body.child(row( + tr::screenshot_editor::mask::tint(), + format!("{}%", (options.tint * 100.).round() as i64), + self.annotation_slider(AnnotationSlider::MaskTint, slider_width, cx) + .into_any_element(), + )); + } + body.child(row( + tr::screenshot_editor::style::opacity(), + format!("{}%", (annotation.opacity * 100.).round() as i64), + self.annotation_slider(AnnotationSlider::Opacity, slider_width, cx) + .into_any_element(), + )) + .child( + div() + .flex() + .flex_row() + .items_center() + .justify_between() + .pt(px(10.)) + .border_t_1() + .border_color(theme.gray_3) + .child( + div() + .flex() + .flex_col() + .gap(px(2.)) + .child( + div() + .text_size(px(12.)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme.gray_12) + .child(tr::screenshot_editor::mask::invert()), + ) + .child( + div() + .text_size(px(10.)) + .text_color(theme.gray_9) + .child(tr::screenshot_editor::mask::invert_hint()), + ), + ) + .child( + ui::Toggle::plain(&theme, "screenshot-mask-invert-toggle", options.invert) + .on_click(cx.listener(|this, _, window, cx| { + cx.stop_propagation(); + this.edit_selected_mask( + |annotation| { + let options = annotation + .mask_options + .get_or_insert_with(Default::default); + options.invert = !options.invert; + true + }, + window, + cx, + ); + })), + ), + ) + .into_any_element() + } +} + +fn mix_rgb(base: u32, tint: u32, amount: f32) -> u32 { + let channel = |shift: u32| { + let from = ((base >> shift) & 0xFF) as f32; + let to = ((tint >> shift) & 0xFF) as f32; + ((from + (to - from) * amount).round() as u32) << shift + }; + channel(16) | channel(8) | channel(0) +} + +fn mask_pill( + theme: &Theme, + id: String, + label: Option, + icon: Option<&'static str>, + active: bool, + on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, +) -> AnyElement { + let theme = *theme; + let text = if active { + gpui::white() + } else { + theme.gray_11.into() + }; + div() + .id(gpui::SharedString::from(id)) + .flex() + .flex_row() + .items_center() + .justify_center() + .gap(px(5.)) + .h(px(24.)) + .min_w(px(24.)) + .px(px(if label.is_some() { 10. } else { 5. })) + .rounded(px(6.)) + .cursor_pointer() + .text_size(px(12.)) + .font_weight(FontWeight::MEDIUM) + .text_color(text) + .when(active, |this| this.bg(theme.blue_9)) + .when(!active, |this| { + this.bg(theme.gray_3).hover(|style| style.bg(theme.gray_4)) + }) + .when_some(icon, |this, icon| { + this.child(svg().path(icon).size(px(14.)).text_color(text)) + }) + .when_some(label, |this, label| this.child(label)) + .on_click(on_click) + .into_any_element() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mask() -> Annotation { + Annotation { + id: "m".to_string(), + annotation_type: AnnotationType::Mask, + x: 10., + y: 10., + width: 40., + height: 20., + stroke_color: TRANSPARENT.to_string(), + stroke_width: 0., + fill_color: TRANSPARENT.to_string(), + opacity: 1., + rotation: 0., + text: None, + mask_type: Some(MaskType::Pixelate), + mask_level: Some(MASK_DEFAULT_LEVEL), + mask_options: None, + points: None, + } + } + + #[test] + fn every_preset_round_trips_through_its_own_matcher() { + for preset in MaskPreset::ALL { + let mut annotation = mask(); + assert!(preset.apply(&mut annotation)); + assert_eq!(MaskPreset::of(&annotation), Some(preset), "{preset:?}"); + assert!(!preset.apply(&mut annotation)); + } + } + + #[test] + fn presets_are_distinct() { + for (index, preset) in MaskPreset::ALL.into_iter().enumerate() { + let mut annotation = mask(); + preset.apply(&mut annotation); + for other in &MaskPreset::ALL[index + 1..] { + assert!(!other.matches(&annotation), "{preset:?} vs {other:?}"); + } + } + } + + #[test] + fn solid_keeps_a_hiding_mask_type_for_older_builds() { + let mut annotation = mask(); + assert!(MaskStyleChoice::Solid.apply(&mut annotation)); + assert_eq!(MaskStyleChoice::of(&annotation), MaskStyleChoice::Solid); + assert!(annotation.mask_type.is_some()); + assert!(MaskStyleChoice::Blur.apply(&mut annotation)); + assert_eq!(annotation.mask_type, Some(MaskType::Blur)); + assert!(!annotation.mask_options.unwrap().solid); + } + + #[test] + fn shape_choices_set_radius_and_ellipse() { + let mut options = MaskOptions::default(); + assert_eq!(MaskShapeChoice::of(&options), MaskShapeChoice::Square); + assert!(MaskShapeChoice::Rounded.apply(&mut options)); + assert_eq!(options.corner_radius, ROUNDED_DEFAULT_RADIUS); + assert!(MaskShapeChoice::Ellipse.apply(&mut options)); + assert_eq!(MaskShapeChoice::of(&options), MaskShapeChoice::Ellipse); + assert!(MaskShapeChoice::Square.apply(&mut options)); + assert_eq!(options.corner_radius, 0.); + assert_eq!(options.shape, MaskShape::Rectangle); + } + + #[test] + fn style_maps_options_and_swaps_colour_for_bgra() { + let mut annotation = mask(); + annotation.opacity = 0.5; + annotation.mask_options = Some(MaskOptions { + shape: MaskShape::Ellipse, + invert: true, + color: "#102030".to_string(), + tint: 0.25, + ..MaskOptions::default() + }); + let rgba = mask_style(&annotation, false); + let bgra = mask_style(&annotation, true); + assert_eq!(rgba.effect, MaskEffect::Pixelate); + assert!(rgba.ellipse && rgba.invert); + assert_eq!(rgba.color, [0x10, 0x20, 0x30]); + assert_eq!(bgra.color, [0x30, 0x20, 0x10]); + assert_eq!(rgba.tint(), 0.25); + assert_eq!(rgba.opacity(), 0.5); + } + + #[test] + fn a_legacy_mask_maps_to_the_plain_kernel_style() { + let annotation = mask(); + assert_eq!( + mask_style(&annotation, true), + MaskStyle::plain(true, MASK_DEFAULT_LEVEL) + ); + } + + #[test] + fn an_inverted_mask_covers_the_content_with_itself_as_the_hole() { + let mut annotation = mask(); + let content = [0., 0., 100., 80.]; + assert_eq!( + mask_regions(&annotation, content, content, (100, 80)), + Some(((10, 10, 40, 20), (10, 10, 40, 20))) + ); + annotation.mask_options = Some(MaskOptions { + invert: true, + ..MaskOptions::default() + }); + assert_eq!( + mask_regions(&annotation, content, content, (100, 80)), + Some(((0, 0, 100, 80), (10, 10, 40, 20))) + ); + let clip = [0., 0., 120., 90.]; + let inset = [5., 5., 90., 70.]; + assert_eq!( + mask_regions(&annotation, clip, inset, (120, 90)), + Some(((5, 5, 90, 70), (10, 10, 40, 20))) + ); + } +} diff --git a/apps/desktop-gpui/src/screenshot_editor.rs b/apps/desktop-gpui/src/screenshot_editor.rs index b2b8ceb07df..ece322f335e 100644 --- a/apps/desktop-gpui/src/screenshot_editor.rs +++ b/apps/desktop-gpui/src/screenshot_editor.rs @@ -412,12 +412,21 @@ async fn render_export_rgba( cap_project::XY::new(base_width, base_height), ); + let content = crate::screenshot_annotations::image_rect( + (f64::from(preview.0), f64::from(preview.1)), + Some((f64::from(source.width()), f64::from(source.height()))), + config.background.padding, + config.background.crop.as_ref(), + config.aspect_ratio.as_ref(), + ); + Ok(crate::screenshot_export::RawFrame { rgba, width: frame.width, height: frame.height, base_width: preview.0, base_height: preview.1, + content: Some(content), }) } @@ -5255,6 +5264,7 @@ mod tests { text: None, mask_type: None, mask_level: None, + mask_options: None, points: None, } } diff --git a/apps/desktop-gpui/src/screenshot_export.rs b/apps/desktop-gpui/src/screenshot_export.rs index 34a9caf436e..84be5e770f5 100644 --- a/apps/desktop-gpui/src/screenshot_export.rs +++ b/apps/desktop-gpui/src/screenshot_export.rs @@ -42,6 +42,9 @@ pub struct RawFrame { /// this config actually has. pub base_width: u32, pub base_height: u32, + /// Where the screenshot sits inside the preview frame -- what an inverted + /// mask covers. `None` treats the whole frame as content. + pub content: Option, } /// The composited output canvas -- straight-alpha RGBA. @@ -79,6 +82,13 @@ pub fn scale_annotations(list: &[Annotation], scale_x: f64, scale_y: f64) -> Vec height: annotation.height * scale_y, stroke_width: annotation.stroke_width * scalar, mask_level: annotation.mask_level.map(|level| level * scalar), + mask_options: annotation.mask_options.as_ref().map(|options| { + cap_project::MaskOptions { + corner_radius: options.corner_radius * scalar, + feather: options.feather * scalar, + ..options.clone() + } + }), ..annotation.clone() }) .collect() @@ -205,12 +215,18 @@ pub fn composite(raw: &RawFrame, config: &ProjectConfiguration) -> Composited { width: f64::from(width), height: f64::from(height), }; + let content = raw.content.map_or(full, |content| Rect { + x: content.x * scale_x, + y: content.y * scale_y, + width: content.width * scale_x, + height: content.height * scale_y, + }); for mask in &scaled { if mask.annotation_type != AnnotationType::Mask { continue; } if let Some((x0, y0, region)) = - annotations::masked_region_image(&raw.rgba, (width, height), mask, full) + annotations::masked_region_image(&raw.rgba, (width, height), mask, full, content) { pixels::blit_over(&mut canvas, (width, height), ®ion, (x0, y0)); } @@ -709,6 +725,7 @@ mod tests { text: None, mask_type: None, mask_level: None, + mask_options: None, points: None, } } @@ -741,6 +758,7 @@ mod tests { height: 1, base_width: 2, base_height: 1, + content: None, }; let mut config = ProjectConfiguration::default(); invisible_background(&mut config); @@ -765,6 +783,7 @@ mod tests { height: 2, base_width: 4, base_height: 2, + content: None, }; let mut config = ProjectConfiguration::default(); invisible_background(&mut config); @@ -992,6 +1011,7 @@ mod tests { height: 16, base_width: 16, base_height: 16, + content: None, }; for invisible in [false, true] { let mut config = ProjectConfiguration::default(); @@ -1030,6 +1050,7 @@ mod tests { height: 1, base_width: 1, base_height: 1, + content: None, }; let config = ProjectConfiguration::default(); assert_eq!( @@ -1067,6 +1088,7 @@ mod tests { height, base_width: width, base_height: height, + content: None, }; let mut config = ProjectConfiguration::default(); invisible_background(&mut config); @@ -1119,6 +1141,7 @@ mod tests { height, base_width: width, base_height: height, + content: None, }; let config = ProjectConfiguration::default(); let mut before = Vec::new(); @@ -1185,6 +1208,7 @@ mod tests { height: 100, base_width: 100, base_height: 100, + content: None, }; let mut text = annotation(AnnotationType::Text, 95., -5., 1., 32.); text.text = Some("jÁW".into()); @@ -1230,6 +1254,7 @@ mod tests { height: 100, base_width: 100, base_height: 100, + content: None, }; let mut config = ProjectConfiguration { annotations: vec![ @@ -1271,6 +1296,7 @@ mod tests { height: 64, base_width: 64, base_height: 64, + content: None, }; for visible in [true, false] { let mut config = ProjectConfiguration::default(); @@ -1304,6 +1330,7 @@ mod tests { height: 100, base_width: 50, base_height: 50, + content: None, }; for invisible in [false, true] { for (x, y, pixel, size) in [ @@ -1361,6 +1388,7 @@ mod tests { height: 4, base_width: 2, base_height: 2, + content: None, }; for invisible in [false, true] { for (x, y, pixel, size) in [ diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index bb0a0491097..23a24ebc179 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -2545,6 +2545,43 @@ pub enum MaskType { Pixelate, } +#[derive(Type, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub enum MaskShape { + #[default] + Rectangle, + Ellipse, +} + +/// Styling layered on top of `maskType`. Older builds ignore the field and +/// fall back to plain blur or pixelate, so `solid` never weakens a redaction: +/// it rides alongside a `maskType` that still hides the region. +#[derive(Type, Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase", default)] +pub struct MaskOptions { + pub shape: MaskShape, + pub corner_radius: f64, + pub feather: f64, + pub invert: bool, + pub solid: bool, + pub color: String, + pub tint: f64, +} + +impl Default for MaskOptions { + fn default() -> Self { + Self { + shape: MaskShape::Rectangle, + corner_radius: 0.0, + feather: 0.0, + invert: false, + solid: false, + color: "#000000".to_string(), + tint: 0.0, + } + } +} + #[derive(Debug, PartialEq)] pub enum AnnotationValidationError { MaskTypeMissing { @@ -2608,6 +2645,8 @@ pub struct Annotation { pub mask_type: Option, #[serde(default)] pub mask_level: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mask_options: Option, #[serde(default)] pub points: Option>, } @@ -3263,6 +3302,29 @@ mod tests { use super::*; use serde_json::Value; + #[test] + fn mask_options_are_optional_and_fill_missing_fields() { + let legacy: Annotation = serde_json::from_str( + r#"{"id":"a","type":"mask","x":0,"y":0,"width":10,"height":10,"strokeColor":"transparent","strokeWidth":0,"fillColor":"transparent","opacity":1,"rotation":0,"text":null,"maskType":"blur","maskLevel":7}"#, + ) + .unwrap(); + assert!(legacy.mask_options.is_none()); + assert!(legacy.validate().is_ok()); + let json = serde_json::to_value(&legacy).unwrap(); + assert!(json.get("maskOptions").is_none()); + + let partial: Annotation = serde_json::from_str( + r#"{"id":"b","type":"mask","x":0,"y":0,"width":10,"height":10,"strokeColor":"transparent","strokeWidth":0,"fillColor":"transparent","opacity":1,"rotation":0,"text":null,"maskType":"pixelate","maskLevel":7,"maskOptions":{"shape":"ellipse","invert":true}}"#, + ) + .unwrap(); + let options = partial.mask_options.unwrap(); + assert_eq!(options.shape, MaskShape::Ellipse); + assert!(options.invert); + assert!(!options.solid); + assert_eq!(options.color, "#000000"); + assert_eq!(options.tint, 0.0); + } + #[test] fn studio_sound_defaults_old_projects_to_balanced_and_round_trips_tiers() { let legacy: AudioConfiguration = From 4f20ec7e3b3156f04cf3ff0d0e186c2fc0f8ebe0 Mon Sep 17 00:00:00 2001 From: Aatman Jain Date: Thu, 29 Oct 2026 23:28:11 +0000 Subject: [PATCH 2/7] fix(screenshot): anchor mask popovers and count colour-pick tint changes --- apps/desktop-gpui/kernels/src/mask_style.rs | 6 --- .../src/screenshot_annotations.rs | 11 +++++- .../src/screenshot_annotations/mask.rs | 38 ++++++++++++------- 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/apps/desktop-gpui/kernels/src/mask_style.rs b/apps/desktop-gpui/kernels/src/mask_style.rs index 08cee3950f2..134d7e69548 100644 --- a/apps/desktop-gpui/kernels/src/mask_style.rs +++ b/apps/desktop-gpui/kernels/src/mask_style.rs @@ -77,7 +77,6 @@ impl MaskStyle { } } -/// Signed distance from `point` to the shape's edge, negative inside. fn shape_distance(point: (f64, f64), shape: [f64; 4], ellipse: bool, radius: f64) -> f64 { let [x, y, width, height] = shape; let half = (width / 2., height / 2.); @@ -125,11 +124,6 @@ fn mix(channel: u8, target: u8, amount: f64) -> u8 { (f64::from(channel) + (f64::from(target) - f64::from(channel)) * amount).round() as u8 } -/// One mask's pixels for `region` of the frame, with the style baked in: -/// the effect, the tint, then the shape's coverage folded into alpha so the -/// result composites straight over the untouched frame. `shape` is the -/// mask's own rect in frame pixels -- equal to `region` for a normal mask, -/// and the hole cut out of `region` for an inverted one. pub fn styled_mask( rgba: &[u8], frame_width: u32, diff --git a/apps/desktop-gpui/src/screenshot_annotations.rs b/apps/desktop-gpui/src/screenshot_annotations.rs index 86d148a3d9f..e06fb178a1c 100644 --- a/apps/desktop-gpui/src/screenshot_annotations.rs +++ b/apps/desktop-gpui/src/screenshot_annotations.rs @@ -1316,6 +1316,12 @@ impl ScreenshotEditorWindow { .slider_tracks .insert(slider, ui::SliderTrack::default()); } + + for panel in [MaskPanel::Presets, MaskPanel::Adjust] { + self.annotation_state + .mask_anchors + .insert(panel, BoundsCell::default()); + } } // -- Frame-space plumbing ------------------------------------------------- @@ -2767,17 +2773,18 @@ impl ScreenshotEditorWindow { else { return false; }; + let mut tinted = false; let field = match target { ColorTarget::Stroke => &mut annotation.stroke_color, ColorTarget::Fill => &mut annotation.fill_color, ColorTarget::Mask => { let options = annotation.mask_options.get_or_insert_with(Default::default); - Self::ensure_mask_tint_visible(options); + tinted = Self::ensure_mask_tint_visible(options); &mut options.color } }; if *field == value { - return false; + return tinted; } *field = value; true diff --git a/apps/desktop-gpui/src/screenshot_annotations/mask.rs b/apps/desktop-gpui/src/screenshot_annotations/mask.rs index c81946a8fc3..67e3ce6afa7 100644 --- a/apps/desktop-gpui/src/screenshot_annotations/mask.rs +++ b/apps/desktop-gpui/src/screenshot_annotations/mask.rs @@ -150,8 +150,6 @@ impl MaskShapeChoice { } } -/// One-click looks. Each preset writes every mask field, so applying one is -/// a complete restyle rather than a merge with whatever was there. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum MaskPreset { SoftBlur, @@ -511,10 +509,6 @@ pub(super) fn mask_style(mask: &Annotation, bgra: bool) -> MaskStyle { } } -/// The pixels one mask resamples and the rect its shape occupies, both in -/// whole frame pixels. A normal mask works inside its own rect; an inverted -/// one covers the screenshot's whole content rect with its own rect as the -/// hole. Everything is clipped to `clip` first. pub(super) fn mask_regions( mask: &Annotation, clip: [f64; 4], @@ -538,8 +532,6 @@ pub(super) fn mask_regions( } impl ScreenshotEditorWindow { - /// A new mask copies the look of the selected mask, or else the last one - /// drawn, so a run of redactions does not need restyling one by one. pub(super) fn mask_style_source(&self) -> Option { self.selected() .filter(|annotation| annotation.annotation_type == AnnotationType::Mask) @@ -577,12 +569,12 @@ impl ScreenshotEditorWindow { ); } - /// Picking a colour on a blur or pixelate mask with no tint would change - /// nothing visible, so the first pick also turns the tint up. - pub(super) fn ensure_mask_tint_visible(options: &mut MaskOptions) { - if !options.solid && options.tint <= 0. { - options.tint = TINT_ON_COLOR_PICK; + pub(super) fn ensure_mask_tint_visible(options: &mut MaskOptions) -> bool { + if options.solid || options.tint > 0. { + return false; } + options.tint = TINT_ON_COLOR_PICK; + true } fn toggle_mask_panel(&mut self, panel: MaskPanel, cx: &mut Context) { @@ -1235,6 +1227,26 @@ mod tests { assert_eq!(options.shape, MaskShape::Rectangle); } + #[test] + fn a_colour_pick_reports_the_tint_it_turns_on() { + let mut options = MaskOptions::default(); + assert!(ScreenshotEditorWindow::ensure_mask_tint_visible( + &mut options + )); + assert_eq!(options.tint, TINT_ON_COLOR_PICK); + assert!(!ScreenshotEditorWindow::ensure_mask_tint_visible( + &mut options + )); + let mut solid = MaskOptions { + solid: true, + ..MaskOptions::default() + }; + assert!(!ScreenshotEditorWindow::ensure_mask_tint_visible( + &mut solid + )); + assert_eq!(solid.tint, 0.); + } + #[test] fn style_maps_options_and_swaps_colour_for_bgra() { let mut annotation = mask(); From ff55a043cb48f6f51708265b67d723ec1f535406 Mon Sep 17 00:00:00 2001 From: Aatman Jain Date: Thu, 29 Oct 2026 23:40:47 +0000 Subject: [PATCH 3/7] improve(screenshot): rebuild the mask bar around the video editor's mask model Masks are Sensitive (blur, pixelate or solid) or Highlight (dim and optionally blur everything outside), the same split the video editor's mask panel uses. Shape sits in the bar; feather, corners and tint move into one settings popover that reuses the header popovers' chrome. The presets list, invert pill and Adjust panel are gone. --- apps/desktop-gpui/locales/de.toml | 27 +- apps/desktop-gpui/locales/en.toml | 27 +- apps/desktop-gpui/locales/es.toml | 27 +- apps/desktop-gpui/locales/fr.toml | 27 +- apps/desktop-gpui/locales/hi.toml | 27 +- apps/desktop-gpui/locales/ja.toml | 27 +- apps/desktop-gpui/locales/zh-CN.toml | 27 +- .../src/i18n/dialogs_surface_tests.rs | 58 +- .../src/screenshot_annotations.rs | 33 +- .../src/screenshot_annotations/mask.rs | 1176 ++++++----------- 10 files changed, 409 insertions(+), 1047 deletions(-) diff --git a/apps/desktop-gpui/locales/de.toml b/apps/desktop-gpui/locales/de.toml index fbf7c0ba03d..42f87362475 100644 --- a/apps/desktop-gpui/locales/de.toml +++ b/apps/desktop-gpui/locales/de.toml @@ -1598,7 +1598,6 @@ default_text = "Text" done = "Fertig" fill = "Füllung" intensity = "Stärke" -mask_style = "Stil" pixelate = "Verpixeln" stroke = "Kontur" transparent = "Transparent" @@ -1662,37 +1661,15 @@ reorder_hint = "Zum Sortieren ziehen • Oben = vorne" title = "Ebenen" [screenshot_editor.mask] -adjust = "Anpassen" corner_radius = "Ecken" +darkness = "Abdunkeln" feather = "Weiche Kante" -invert = "Umkehren" -invert_hint = "Alles außer diesem Bereich verbergen" -presets = "Vorlagen" -presets_title = "Maskenvorlagen" -privacy_note = "Weichzeichnen und Verpixeln lassen sich manchmal rückgängig machen. Verwenden Sie Schwärzen für Passwörter und Schlüssel." -shape = "Form" +settings = "Maskeneinstellungen" shape_ellipse = "Ellipse" shape_rectangle = "Rechteck" -shape_rounded = "Abgerundet" solid = "Deckend" tint = "Tönung" -[screenshot_editor.mask.preset] -cap_blue = "Cap-Blau" -cap_blue_hint = "Verpixelt mit Cap-Blau-Tönung" -focus = "Fokus" -focus_hint = "Alles drumherum weichzeichnen" -frosted = "Milchglas" -frosted_hint = "Milchiges Glas über dem Bereich" -mosaic = "Mosaik" -mosaic_hint = "Grobe Pixel" -redact = "Schwärzen" -redact_hint = "Deckender Balken, nichts wiederherstellbar" -soft_blur = "Sanfte Unschärfe" -soft_blur_hint = "Leichte Unschärfe mit weichen Kanten" -spotlight = "Spotlight" -spotlight_hint = "Alles drumherum abdunkeln" - [screenshot_editor.menu] delete = "Löschen" open_folder = "Ordner öffnen" diff --git a/apps/desktop-gpui/locales/en.toml b/apps/desktop-gpui/locales/en.toml index 5a6563f4f7e..841ece74249 100644 --- a/apps/desktop-gpui/locales/en.toml +++ b/apps/desktop-gpui/locales/en.toml @@ -1600,7 +1600,6 @@ default_text = { text = "Text", context = "Initial content of a new text annotat done = "Done" fill = "Fill" intensity = "Intensity" -mask_style = { text = "Style", context = "Picks how a mask hides content: blur or pixelate." } pixelate = "Pixelate" stroke = { text = "Stroke", context = "Outline colour of a shape annotation." } transparent = "Transparent" @@ -1664,37 +1663,15 @@ reorder_hint = "Drag to reorder • Top = front" title = "Layers" [screenshot_editor.mask] -adjust = "Adjust" corner_radius = { text = "Corners", context = "Corner radius of a rectangular mask, in a narrow slider row." } +darkness = { text = "Darkness", context = "How much a highlight mask dims everything outside it." } feather = { text = "Feather", context = "How softly a mask fades out at its edges." } -invert = { text = "Invert", context = "Mask everything outside the drawn area instead of inside it." } -invert_hint = "Hide everything except this area" -presets = "Presets" -presets_title = "Mask presets" -privacy_note = "Blur and pixelate can sometimes be reversed. Use Redact for passwords and keys." -shape = "Shape" +settings = "Mask settings" shape_ellipse = "Ellipse" shape_rectangle = "Rectangle" -shape_rounded = "Rounded" solid = { text = "Solid", context = "Mask style that covers the area with a flat colour." } tint = { text = "Tint", context = "How strongly a colour is mixed into a blurred or pixelated mask." } -[screenshot_editor.mask.preset] -cap_blue = "Cap Blue" -cap_blue_hint = "Pixelated with a Cap blue tint" -focus = "Focus" -focus_hint = "Blur everything around it" -frosted = "Frosted" -frosted_hint = "Milky glass over the area" -mosaic = "Mosaic" -mosaic_hint = "Chunky pixels" -redact = "Redact" -redact_hint = "Solid bar, nothing recoverable" -soft_blur = "Soft blur" -soft_blur_hint = "Gentle blur with soft edges" -spotlight = "Spotlight" -spotlight_hint = "Dim everything around it" - [screenshot_editor.menu] delete = "Delete" open_folder = "Open Folder" diff --git a/apps/desktop-gpui/locales/es.toml b/apps/desktop-gpui/locales/es.toml index 18ddfb8a7a7..26b4fe0f98d 100644 --- a/apps/desktop-gpui/locales/es.toml +++ b/apps/desktop-gpui/locales/es.toml @@ -1598,7 +1598,6 @@ default_text = "Texto" done = "Listo" fill = "Relleno" intensity = "Intensidad" -mask_style = "Estilo" pixelate = "Pixelar" stroke = "Trazo" transparent = "Transparente" @@ -1662,37 +1661,15 @@ reorder_hint = "Arrastra para reordenar • Arriba = delante" title = "Capas" [screenshot_editor.mask] -adjust = "Ajustar" corner_radius = "Esquinas" +darkness = "Oscuridad" feather = "Difuminado" -invert = "Invertir" -invert_hint = "Oculta todo excepto esta zona" -presets = "Ajustes predefinidos" -presets_title = "Máscaras predefinidas" -privacy_note = "El desenfoque y el pixelado a veces se pueden revertir. Usa Tachar para contraseñas y claves." -shape = "Forma" +settings = "Ajustes de máscara" shape_ellipse = "Elipse" shape_rectangle = "Rectángulo" -shape_rounded = "Redondeado" solid = "Sólido" tint = "Tinte" -[screenshot_editor.mask.preset] -cap_blue = "Azul Cap" -cap_blue_hint = "Pixelado con un tinte azul Cap" -focus = "Enfoque" -focus_hint = "Desenfoca todo a su alrededor" -frosted = "Esmerilado" -frosted_hint = "Cristal lechoso sobre la zona" -mosaic = "Mosaico" -mosaic_hint = "Píxeles grandes" -redact = "Tachar" -redact_hint = "Barra sólida, nada recuperable" -soft_blur = "Desenfoque suave" -soft_blur_hint = "Desenfoque ligero con bordes suaves" -spotlight = "Foco" -spotlight_hint = "Oscurece todo a su alrededor" - [screenshot_editor.menu] delete = "Eliminar" open_folder = "Abrir carpeta" diff --git a/apps/desktop-gpui/locales/fr.toml b/apps/desktop-gpui/locales/fr.toml index 8767cca7620..181b273b60c 100644 --- a/apps/desktop-gpui/locales/fr.toml +++ b/apps/desktop-gpui/locales/fr.toml @@ -1598,7 +1598,6 @@ default_text = "Texte" done = "Terminé" fill = "Remplissage" intensity = "Intensité" -mask_style = "Style" pixelate = "Pixeliser" stroke = "Contour" transparent = "Transparent" @@ -1662,37 +1661,15 @@ reorder_hint = "Glissez pour réorganiser • Haut = premier plan" title = "Calques" [screenshot_editor.mask] -adjust = "Ajuster" corner_radius = "Coins" +darkness = "Assombrissement" feather = "Adoucissement" -invert = "Inverser" -invert_hint = "Masquer tout sauf cette zone" -presets = "Préréglages" -presets_title = "Préréglages de masque" -privacy_note = "Le flou et la pixellisation peuvent parfois être inversés. Utilisez Caviarder pour les mots de passe et les clés." -shape = "Forme" +settings = "Réglages du masque" shape_ellipse = "Ellipse" shape_rectangle = "Rectangle" -shape_rounded = "Arrondi" solid = "Plein" tint = "Teinte" -[screenshot_editor.mask.preset] -cap_blue = "Bleu Cap" -cap_blue_hint = "Pixellisé avec une teinte bleu Cap" -focus = "Focus" -focus_hint = "Flouter tout autour" -frosted = "Givré" -frosted_hint = "Verre laiteux sur la zone" -mosaic = "Mosaïque" -mosaic_hint = "Gros pixels" -redact = "Caviarder" -redact_hint = "Barre pleine, rien de récupérable" -soft_blur = "Flou doux" -soft_blur_hint = "Flou léger aux bords adoucis" -spotlight = "Projecteur" -spotlight_hint = "Assombrir tout autour" - [screenshot_editor.menu] delete = "Supprimer" open_folder = "Ouvrir le dossier" diff --git a/apps/desktop-gpui/locales/hi.toml b/apps/desktop-gpui/locales/hi.toml index 2f1a0e37649..9d835648b6c 100644 --- a/apps/desktop-gpui/locales/hi.toml +++ b/apps/desktop-gpui/locales/hi.toml @@ -1598,7 +1598,6 @@ default_text = "टेक्स्ट" done = "हो गया" fill = "फ़िल" intensity = "तीव्रता" -mask_style = "स्टाइल" pixelate = "पिक्सलेट" stroke = "स्ट्रोक" transparent = "पारदर्शी" @@ -1662,37 +1661,15 @@ reorder_hint = "क्रम बदलने के लिए खींचें title = "लेयर" [screenshot_editor.mask] -adjust = "समायोजित करें" corner_radius = "कोने" +darkness = "गहराई" feather = "फेदर" -invert = "उलटें" -invert_hint = "इस हिस्से को छोड़कर सब कुछ छिपाएँ" -presets = "प्रीसेट" -presets_title = "मास्क प्रीसेट" -privacy_note = "ब्लर और पिक्सलेट को कभी-कभी उलटा जा सकता है। पासवर्ड और कुंजियों के लिए रिडैक्ट का उपयोग करें।" -shape = "आकार" +settings = "मास्क सेटिंग्स" shape_ellipse = "दीर्घवृत्त" shape_rectangle = "आयत" -shape_rounded = "गोल किनारे" solid = "ठोस" tint = "टिंट" -[screenshot_editor.mask.preset] -cap_blue = "Cap नीला" -cap_blue_hint = "Cap नीले टिंट के साथ पिक्सलेट" -focus = "फ़ोकस" -focus_hint = "आसपास सब कुछ ब्लर करें" -frosted = "फ्रॉस्टेड" -frosted_hint = "हिस्से पर दूधिया काँच" -mosaic = "मोज़ेक" -mosaic_hint = "बड़े पिक्सल" -redact = "रिडैक्ट" -redact_hint = "ठोस पट्टी, कुछ भी वापस नहीं मिलेगा" -soft_blur = "हल्का ब्लर" -soft_blur_hint = "मुलायम किनारों वाला हल्का ब्लर" -spotlight = "स्पॉटलाइट" -spotlight_hint = "आसपास सब कुछ धुंधला-गहरा करें" - [screenshot_editor.menu] delete = "हटाएँ" open_folder = "फ़ोल्डर खोलें" diff --git a/apps/desktop-gpui/locales/ja.toml b/apps/desktop-gpui/locales/ja.toml index b00b261841c..268d5c0b49f 100644 --- a/apps/desktop-gpui/locales/ja.toml +++ b/apps/desktop-gpui/locales/ja.toml @@ -1598,7 +1598,6 @@ default_text = "テキスト" done = "完了" fill = "塗りつぶし" intensity = "強さ" -mask_style = "スタイル" pixelate = "モザイク" stroke = "線" transparent = "透明" @@ -1662,37 +1661,15 @@ reorder_hint = "ドラッグで並べ替え • 上 = 最前面" title = "レイヤー" [screenshot_editor.mask] -adjust = "調整" corner_radius = "角" +darkness = "暗さ" feather = "ぼかし幅" -invert = "反転" -invert_hint = "この範囲以外をすべて隠します" -presets = "プリセット" -presets_title = "マスクのプリセット" -privacy_note = "ぼかしやモザイクは復元される場合があります。パスワードやキーには黒塗りを使ってください。" -shape = "形" +settings = "マスクの設定" shape_ellipse = "楕円" shape_rectangle = "長方形" -shape_rounded = "角丸" solid = "単色" tint = "色味" -[screenshot_editor.mask.preset] -cap_blue = "Capブルー" -cap_blue_hint = "Capブルーの色味でモザイク" -focus = "フォーカス" -focus_hint = "周囲をすべてぼかします" -frosted = "すりガラス" -frosted_hint = "範囲に乳白色のガラスをかけます" -mosaic = "モザイク" -mosaic_hint = "粗いピクセル" -redact = "黒塗り" -redact_hint = "塗りつぶしで、元に戻せません" -soft_blur = "ソフトぼかし" -soft_blur_hint = "縁がやわらかい軽いぼかし" -spotlight = "スポットライト" -spotlight_hint = "周囲をすべて暗くします" - [screenshot_editor.menu] delete = "削除" open_folder = "フォルダを開く" diff --git a/apps/desktop-gpui/locales/zh-CN.toml b/apps/desktop-gpui/locales/zh-CN.toml index e217979c735..501a9e22e9e 100644 --- a/apps/desktop-gpui/locales/zh-CN.toml +++ b/apps/desktop-gpui/locales/zh-CN.toml @@ -1598,7 +1598,6 @@ default_text = "文本" done = "完成" fill = "填充" intensity = "强度" -mask_style = "样式" pixelate = "像素化" stroke = "描边" transparent = "透明" @@ -1662,37 +1661,15 @@ reorder_hint = "拖动以重新排序 • 顶部 = 最前" title = "图层" [screenshot_editor.mask] -adjust = "调整" corner_radius = "圆角" +darkness = "暗度" feather = "羽化" -invert = "反转" -invert_hint = "隐藏此区域以外的所有内容" -presets = "预设" -presets_title = "遮罩预设" -privacy_note = "模糊和像素化有时可以被还原。密码和密钥请使用涂黑。" -shape = "形状" +settings = "遮罩设置" shape_ellipse = "椭圆" shape_rectangle = "矩形" -shape_rounded = "圆角矩形" solid = "纯色" tint = "色调" -[screenshot_editor.mask.preset] -cap_blue = "Cap 蓝" -cap_blue_hint = "带 Cap 蓝色调的像素化" -focus = "聚焦" -focus_hint = "模糊周围所有内容" -frosted = "磨砂" -frosted_hint = "在区域上覆盖乳白玻璃" -mosaic = "马赛克" -mosaic_hint = "粗大的像素" -redact = "涂黑" -redact_hint = "纯色遮挡,无法恢复" -soft_blur = "柔和模糊" -soft_blur_hint = "边缘柔和的轻度模糊" -spotlight = "聚光灯" -spotlight_hint = "调暗周围所有内容" - [screenshot_editor.menu] delete = "删除" open_folder = "打开文件夹" diff --git a/apps/desktop-gpui/src/i18n/dialogs_surface_tests.rs b/apps/desktop-gpui/src/i18n/dialogs_surface_tests.rs index a9e20b36492..bf8ebefe6b2 100644 --- a/apps/desktop-gpui/src/i18n/dialogs_surface_tests.rs +++ b/apps/desktop-gpui/src/i18n/dialogs_surface_tests.rs @@ -671,74 +671,20 @@ fn dialog_surfaces_match_the_literals_they_replaced() { (tr::screenshot_editor::annotation::done(), "Done"), (tr::screenshot_editor::annotation::fill(), "Fill"), (tr::screenshot_editor::annotation::intensity(), "Intensity"), - (tr::screenshot_editor::annotation::mask_style(), "Style"), (tr::screenshot_editor::annotation::pixelate(), "Pixelate"), (tr::screenshot_editor::annotation::stroke(), "Stroke"), ( tr::screenshot_editor::annotation::transparent(), "Transparent", ), - (tr::screenshot_editor::mask::adjust(), "Adjust"), (tr::screenshot_editor::mask::corner_radius(), "Corners"), + (tr::screenshot_editor::mask::darkness(), "Darkness"), (tr::screenshot_editor::mask::feather(), "Feather"), - (tr::screenshot_editor::mask::invert(), "Invert"), - ( - tr::screenshot_editor::mask::invert_hint(), - "Hide everything except this area", - ), - (tr::screenshot_editor::mask::presets(), "Presets"), - (tr::screenshot_editor::mask::presets_title(), "Mask presets"), - ( - tr::screenshot_editor::mask::privacy_note(), - "Blur and pixelate can sometimes be reversed. Use Redact for passwords and keys.", - ), - (tr::screenshot_editor::mask::shape(), "Shape"), + (tr::screenshot_editor::mask::settings(), "Mask settings"), (tr::screenshot_editor::mask::shape_ellipse(), "Ellipse"), (tr::screenshot_editor::mask::shape_rectangle(), "Rectangle"), - (tr::screenshot_editor::mask::shape_rounded(), "Rounded"), (tr::screenshot_editor::mask::solid(), "Solid"), (tr::screenshot_editor::mask::tint(), "Tint"), - (tr::screenshot_editor::mask::preset::cap_blue(), "Cap Blue"), - ( - tr::screenshot_editor::mask::preset::cap_blue_hint(), - "Pixelated with a Cap blue tint", - ), - (tr::screenshot_editor::mask::preset::focus(), "Focus"), - ( - tr::screenshot_editor::mask::preset::focus_hint(), - "Blur everything around it", - ), - (tr::screenshot_editor::mask::preset::frosted(), "Frosted"), - ( - tr::screenshot_editor::mask::preset::frosted_hint(), - "Milky glass over the area", - ), - (tr::screenshot_editor::mask::preset::mosaic(), "Mosaic"), - ( - tr::screenshot_editor::mask::preset::mosaic_hint(), - "Chunky pixels", - ), - (tr::screenshot_editor::mask::preset::redact(), "Redact"), - ( - tr::screenshot_editor::mask::preset::redact_hint(), - "Solid bar, nothing recoverable", - ), - ( - tr::screenshot_editor::mask::preset::soft_blur(), - "Soft blur", - ), - ( - tr::screenshot_editor::mask::preset::soft_blur_hint(), - "Gentle blur with soft edges", - ), - ( - tr::screenshot_editor::mask::preset::spotlight(), - "Spotlight", - ), - ( - tr::screenshot_editor::mask::preset::spotlight_hint(), - "Dim everything around it", - ), (tr::screenshot_editor::aspect::auto(), "Auto"), (tr::screenshot_editor::aspect::classic(), "Classic ⋅4:3"), (tr::screenshot_editor::aspect::square(), "Square ⋅1:1"), diff --git a/apps/desktop-gpui/src/screenshot_annotations.rs b/apps/desktop-gpui/src/screenshot_annotations.rs index e06fb178a1c..5aef81d52fd 100644 --- a/apps/desktop-gpui/src/screenshot_annotations.rs +++ b/apps/desktop-gpui/src/screenshot_annotations.rs @@ -49,8 +49,6 @@ use crate::ui; mod mask; -pub(crate) use mask::MaskPanel; - // --------------------------------------------------------------------------- // Tools // --------------------------------------------------------------------------- @@ -1130,10 +1128,12 @@ enum AnnotationSlider { MaskFeather, MaskRadius, MaskTint, + HighlightDarkness, + HighlightBlur, } impl AnnotationSlider { - const ALL: [AnnotationSlider; 7] = [ + const ALL: [AnnotationSlider; 9] = [ AnnotationSlider::Width, AnnotationSlider::Opacity, AnnotationSlider::MaskLevel, @@ -1141,6 +1141,8 @@ impl AnnotationSlider { AnnotationSlider::MaskFeather, AnnotationSlider::MaskRadius, AnnotationSlider::MaskTint, + AnnotationSlider::HighlightDarkness, + AnnotationSlider::HighlightBlur, ]; /// `(min, max, step)` -- `AnnotationConfig.tsx:60-157`. @@ -1153,6 +1155,8 @@ impl AnnotationSlider { Self::MaskFeather => (0., 64., 1.), Self::MaskRadius => (0., 64., 1.), Self::MaskTint => (0., 1., 0.05), + Self::HighlightDarkness => (0.05, 1., 0.05), + Self::HighlightBlur => (0., 40., 1.), } } @@ -1165,6 +1169,8 @@ impl AnnotationSlider { Self::MaskFeather => "mask-feather", Self::MaskRadius => "mask-radius", Self::MaskTint => "mask-tint", + Self::HighlightDarkness => "highlight-darkness", + Self::HighlightBlur => "highlight-blur", } } @@ -1182,6 +1188,8 @@ impl AnnotationSlider { _ => options.tint, }) as f32 } + Self::HighlightDarkness => mask::highlight_darkness(annotation) as f32, + Self::HighlightBlur => mask::highlight_blur(annotation) as f32, } } @@ -1199,6 +1207,8 @@ impl AnnotationSlider { *level = value; return true; } + Self::HighlightDarkness => return mask::set_highlight_darkness(annotation, value), + Self::HighlightBlur => return mask::set_highlight_blur(annotation, value), Self::MaskFeather | Self::MaskRadius | Self::MaskTint => { let options = annotation.mask_options.get_or_insert_with(Default::default); match self { @@ -1256,8 +1266,8 @@ pub struct AnnotationState { mask_task: Option>, /// The mask bar's open panel, and the mask it was opened for -- closed, /// not repointed, when the selection moves. - mask_panel: Option<(MaskPanel, String)>, - mask_anchors: HashMap, + mask_settings: Option, + mask_settings_anchor: BoundsCell, } /// One paint of the layer, copied out of the window so the closure owns it. @@ -1316,12 +1326,6 @@ impl ScreenshotEditorWindow { .slider_tracks .insert(slider, ui::SliderTrack::default()); } - - for panel in [MaskPanel::Presets, MaskPanel::Adjust] { - self.annotation_state - .mask_anchors - .insert(panel, BoundsCell::default()); - } } // -- Frame-space plumbing ------------------------------------------------- @@ -2528,7 +2532,8 @@ impl ScreenshotEditorWindow { window: &Window, cx: &mut Context, ) -> Vec { - let mut layers: Vec = self.render_mask_panel(window, cx).into_iter().collect(); + let mut layers: Vec = + self.render_mask_settings(window, cx).into_iter().collect(); if self.annotation_state.gesture.is_some() { layers.push( ui::Slider::drag_layer( @@ -3087,10 +3092,10 @@ impl ScreenshotEditorWindow { { self.annotation_state.color_popover = None; } - if let Some((_, id)) = &self.annotation_state.mask_panel + if let Some(id) = &self.annotation_state.mask_settings && self.selected_annotation.as_deref() != Some(id.as_str()) { - self.annotation_state.mask_panel = None; + self.annotation_state.mask_settings = None; } for target in ColorTarget::ALL { let Some(input) = self.annotation_state.color_inputs.get(&target).cloned() else { diff --git a/apps/desktop-gpui/src/screenshot_annotations/mask.rs b/apps/desktop-gpui/src/screenshot_annotations/mask.rs index 67e3ce6afa7..b6daf7f962f 100644 --- a/apps/desktop-gpui/src/screenshot_annotations/mask.rs +++ b/apps/desktop-gpui/src/screenshot_annotations/mask.rs @@ -1,37 +1,80 @@ use cap_gpui_kernels::mask_style::{MaskEffect, MaskStyle}; use cap_project::{MaskOptions, MaskShape}; -use gpui::{linear_color_stop, linear_gradient}; use super::*; -const BRAND_BLUE: &str = "#4785FF"; -const ROUNDED_DEFAULT_RADIUS: f64 = 12.; const TINT_ON_COLOR_PICK: f64 = 0.4; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(crate) enum MaskPanel { - Presets, - Adjust, +const HIGHLIGHT_DARKNESS: f64 = 0.6; +const HIGHLIGHT_FEATHER: f64 = 24.; +const HIGHLIGHT_COLOR: &str = "#000000"; +const SETTINGS_WIDTH: f32 = 240.; + +/// The video editor's two mask kinds (`editor_panels::render_mask_panel`): +/// Sensitive hides what is inside the mask, Highlight dims everything +/// outside it. A highlight is stored as an inverted mask so older builds, +/// which ignore `maskOptions`, still read a valid blur. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MaskKindChoice { + Sensitive, + Highlight, } -impl MaskPanel { - fn id(self) -> &'static str { +impl MaskKindChoice { + fn of(annotation: &Annotation) -> Self { + if annotation + .mask_options + .as_ref() + .is_some_and(|options| options.invert) + { + Self::Highlight + } else { + Self::Sensitive + } + } + + fn label(self) -> &'static str { match self { - Self::Presets => "screenshot-mask-presets", - Self::Adjust => "screenshot-mask-adjust", + Self::Sensitive => tr::editor::panel::mask::sensitive(), + Self::Highlight => tr::editor::panel::mask::highlight(), } } - fn width(self) -> f32 { + /// Each kind seeds its own defaults on the switch, the way the video + /// editor's kind radio does. + fn apply(self, annotation: &mut Annotation) -> bool { + if Self::of(annotation) == self { + return false; + } + let options = annotation.mask_options.get_or_insert_with(Default::default); match self { - Self::Presets => 300., - Self::Adjust => 260., + Self::Sensitive => { + options.invert = false; + options.solid = false; + options.tint = 0.; + options.feather = 0.; + annotation.opacity = 1.; + if annotation.mask_type.is_none() { + annotation.mask_type = Some(MaskType::Blur); + } + } + Self::Highlight => { + options.invert = true; + options.solid = true; + options.tint = 0.; + options.color = HIGHLIGHT_COLOR.to_string(); + if options.feather <= 0. { + options.feather = HIGHLIGHT_FEATHER; + } + annotation.opacity = HIGHLIGHT_DARKNESS; + annotation.mask_type = Some(MaskType::Blur); + } } + true } } -/// What the Style pills choose between. `Solid` is stored as a flag next to -/// `maskType` so older builds still hide the region with a blur. +/// `Solid` is stored as a flag next to `maskType` so older builds still hide +/// the region with a blur. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum MaskStyleChoice { Blur, @@ -89,395 +132,107 @@ impl MaskStyleChoice { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum MaskShapeChoice { - Square, - Rounded, + Rectangle, Ellipse, } impl MaskShapeChoice { - const ALL: [MaskShapeChoice; 3] = [Self::Square, Self::Rounded, Self::Ellipse]; + const ALL: [MaskShapeChoice; 2] = [Self::Rectangle, Self::Ellipse]; fn of(options: &MaskOptions) -> Self { match options.shape { MaskShape::Ellipse => Self::Ellipse, - MaskShape::Rectangle if options.corner_radius > 0. => Self::Rounded, - MaskShape::Rectangle => Self::Square, + MaskShape::Rectangle => Self::Rectangle, } } fn id(self) -> &'static str { match self { - Self::Square => "square", - Self::Rounded => "rounded", + Self::Rectangle => "rectangle", Self::Ellipse => "ellipse", } } fn icon(self) -> &'static str { match self { - Self::Square => "icons/square.svg", - Self::Rounded => "icons/corners.svg", + Self::Rectangle => "icons/square.svg", Self::Ellipse => "icons/circle.svg", } } fn label(self) -> &'static str { match self { - Self::Square => tr::screenshot_editor::mask::shape_rectangle(), - Self::Rounded => tr::screenshot_editor::mask::shape_rounded(), + Self::Rectangle => tr::screenshot_editor::mask::shape_rectangle(), Self::Ellipse => tr::screenshot_editor::mask::shape_ellipse(), } } fn apply(self, options: &mut MaskOptions) -> bool { - if Self::of(options) == self { + let shape = match self { + Self::Rectangle => MaskShape::Rectangle, + Self::Ellipse => MaskShape::Ellipse, + }; + if options.shape == shape { return false; } - match self { - Self::Square => { - options.shape = MaskShape::Rectangle; - options.corner_radius = 0.; - } - Self::Rounded => { - options.shape = MaskShape::Rectangle; - if options.corner_radius <= 0. { - options.corner_radius = ROUNDED_DEFAULT_RADIUS; - } - } - Self::Ellipse => options.shape = MaskShape::Ellipse, - } + options.shape = shape; true } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum MaskPreset { - SoftBlur, - Mosaic, - Redact, - Frosted, - Spotlight, - Focus, - CapBlue, -} - -struct PresetLook { - mask_type: MaskType, - level: f64, - opacity: f64, - options: MaskOptions, -} - -impl MaskPreset { - const ALL: [MaskPreset; 7] = [ - Self::SoftBlur, - Self::Mosaic, - Self::Redact, - Self::Frosted, - Self::Spotlight, - Self::Focus, - Self::CapBlue, - ]; - - fn id(self) -> &'static str { - match self { - Self::SoftBlur => "soft-blur", - Self::Mosaic => "mosaic", - Self::Redact => "redact", - Self::Frosted => "frosted", - Self::Spotlight => "spotlight", - Self::Focus => "focus", - Self::CapBlue => "cap-blue", - } - } - - fn label(self) -> &'static str { - match self { - Self::SoftBlur => tr::screenshot_editor::mask::preset::soft_blur(), - Self::Mosaic => tr::screenshot_editor::mask::preset::mosaic(), - Self::Redact => tr::screenshot_editor::mask::preset::redact(), - Self::Frosted => tr::screenshot_editor::mask::preset::frosted(), - Self::Spotlight => tr::screenshot_editor::mask::preset::spotlight(), - Self::Focus => tr::screenshot_editor::mask::preset::focus(), - Self::CapBlue => tr::screenshot_editor::mask::preset::cap_blue(), - } - } - - fn hint(self) -> &'static str { - match self { - Self::SoftBlur => tr::screenshot_editor::mask::preset::soft_blur_hint(), - Self::Mosaic => tr::screenshot_editor::mask::preset::mosaic_hint(), - Self::Redact => tr::screenshot_editor::mask::preset::redact_hint(), - Self::Frosted => tr::screenshot_editor::mask::preset::frosted_hint(), - Self::Spotlight => tr::screenshot_editor::mask::preset::spotlight_hint(), - Self::Focus => tr::screenshot_editor::mask::preset::focus_hint(), - Self::CapBlue => tr::screenshot_editor::mask::preset::cap_blue_hint(), - } - } - - fn look(self) -> PresetLook { - let base = MaskOptions::default(); - match self { - Self::SoftBlur => PresetLook { - mask_type: MaskType::Blur, - level: 24., - opacity: 1., - options: MaskOptions { - corner_radius: 10., - feather: 6., - ..base - }, - }, - Self::Mosaic => PresetLook { - mask_type: MaskType::Pixelate, - level: 14., - opacity: 1., - options: base, - }, - Self::Redact => PresetLook { - mask_type: MaskType::Blur, - level: 40., - opacity: 1., - options: MaskOptions { - solid: true, - corner_radius: 4., - ..base - }, - }, - Self::Frosted => PresetLook { - mask_type: MaskType::Blur, - level: 48., - opacity: 1., - options: MaskOptions { - corner_radius: 16., - color: "#FFFFFF".to_string(), - tint: 0.45, - ..base - }, - }, - Self::Spotlight => PresetLook { - mask_type: MaskType::Blur, - level: 8., - opacity: 0.65, - options: MaskOptions { - shape: MaskShape::Ellipse, - feather: 32., - invert: true, - solid: true, - ..base - }, - }, - Self::Focus => PresetLook { - mask_type: MaskType::Blur, - level: 36., - opacity: 1., - options: MaskOptions { - corner_radius: 16., - feather: 20., - invert: true, - ..base - }, - }, - Self::CapBlue => PresetLook { - mask_type: MaskType::Pixelate, - level: 10., - opacity: 1., - options: MaskOptions { - corner_radius: 12., - color: BRAND_BLUE.to_string(), - tint: 0.55, - ..base - }, - }, - } +/// A highlight dims the outside with flat black when its blur is off, and +/// with a black tint over the blur when it is on: the kernel has no +/// "no effect" mode, so darkness lives in opacity or tint depending on which. +pub(super) fn highlight_darkness(annotation: &Annotation) -> f64 { + let options = annotation.mask_options.clone().unwrap_or_default(); + if options.solid { + annotation.opacity + } else { + options.tint } +} - fn apply(self, annotation: &mut Annotation) -> bool { - if self.matches(annotation) { - return false; - } - let look = self.look(); - annotation.mask_type = Some(look.mask_type); - annotation.mask_level = Some(look.level); - annotation.opacity = look.opacity; - annotation.mask_options = Some(look.options); - true +pub(super) fn highlight_blur(annotation: &Annotation) -> f64 { + let options = annotation.mask_options.clone().unwrap_or_default(); + if options.solid { + 0. + } else { + annotation.mask_level.unwrap_or(MASK_FALLBACK_LEVEL) } +} - fn matches(self, annotation: &Annotation) -> bool { - let look = self.look(); - let options = annotation.mask_options.clone().unwrap_or_default(); - let solid = look.options.solid; - annotation.mask_type.unwrap_or(MaskType::Blur) == look.mask_type - && (solid || annotation.mask_level.unwrap_or(MASK_FALLBACK_LEVEL) == look.level) - && annotation.opacity == look.opacity - && options.shape == look.options.shape - && options.corner_radius == look.options.corner_radius - && options.feather == look.options.feather - && options.invert == look.options.invert - && options.solid == solid - && options.tint == look.options.tint - && (look.options.tint <= 0. && !solid - || options.color.eq_ignore_ascii_case(&look.options.color)) +pub(super) fn set_highlight_darkness(annotation: &mut Annotation, darkness: f64) -> bool { + if highlight_darkness(annotation) == darkness { + return false; } - - fn of(annotation: &Annotation) -> Option { - Self::ALL - .into_iter() - .find(|preset| preset.matches(annotation)) + let options = annotation.mask_options.get_or_insert_with(Default::default); + if options.solid { + annotation.opacity = darkness; + } else { + options.tint = darkness; } + true +} - fn swatch(self, theme: &Theme) -> AnyElement { - let scene = |inner: gpui::Div| { - div() - .relative() - .w(px(52.)) - .h(px(36.)) - .flex_shrink_0() - .rounded(px(8.)) - .overflow_hidden() - .bg(linear_gradient( - 135., - linear_color_stop(gpui::rgb(0xF8B26A), 0.), - linear_color_stop(gpui::rgb(0x6C8CFF), 1.), - )) - .child(inner) - }; - let blob = |x: f32, y: f32, size: f32, color: u32, blur: f32| { - div() - .absolute() - .left(px(x)) - .top(px(y)) - .size(px(size)) - .rounded_full() - .bg(gpui::rgb(color)) - .shadow(vec![gpui::BoxShadow { - color: Theme::with_alpha(gpui::rgb(color), 0.9), - offset: gpui::point(px(0.), px(0.)), - blur_radius: px(blur), - spread_radius: px(blur / 2.), - inset: false, - }]) - }; - let tiles = |palette: [u32; 6], tint: Option<(u32, f32)>| { - let mut grid = div() - .absolute() - .top_0() - .left_0() - .size_full() - .flex() - .flex_row() - .flex_wrap(); - for color in palette { - grid = grid.child(div().w(px(52. / 3.)).h(px(18.)).bg(gpui::rgb(match tint { - Some((tint, amount)) => mix_rgb(color, tint, amount), - None => color, - }))); - } - grid - }; - let theme = *theme; - match self { - Self::SoftBlur => scene( - div() - .absolute() - .size_full() - .child(blob(8., 8., 12., 0xFFE0B0, 10.)) - .child(blob(28., 14., 14., 0x8FA8FF, 10.)), - ), - Self::Mosaic => scene(tiles( - [0xF8B26A, 0xE58FA0, 0x9C8CF0, 0xD99A7C, 0xA88FD8, 0x6C8CFF], - None, - )), - Self::Redact => scene( - div().absolute().size_full().child( - div() - .absolute() - .left(px(6.)) - .top(px(13.)) - .w(px(40.)) - .h(px(10.)) - .rounded(px(2.)) - .bg(gpui::rgb(0x111111)), - ), - ), - Self::Frosted => scene( - div() - .absolute() - .size_full() - .child(blob(6., 6., 14., 0xFFD08A, 8.)) - .child( - div() - .absolute() - .left(px(6.)) - .top(px(6.)) - .w(px(40.)) - .h(px(24.)) - .rounded(px(6.)) - .bg(Theme::with_alpha(gpui::rgb(0xFFFFFF), 0.55)) - .border_1() - .border_color(Theme::with_alpha(gpui::rgb(0xFFFFFF), 0.8)), - ), - ), - Self::Spotlight => scene( - div() - .absolute() - .size_full() - .bg(Theme::with_alpha(gpui::rgb(0x000000), 0.6)) - .child( - div() - .absolute() - .left(px(16.)) - .top(px(8.)) - .w(px(20.)) - .h(px(20.)) - .rounded_full() - .bg(linear_gradient( - 135., - linear_color_stop(gpui::rgb(0xF8B26A), 0.), - linear_color_stop(gpui::rgb(0x9C8CF0), 1.), - )) - .shadow(vec![gpui::BoxShadow { - color: Theme::with_alpha(gpui::rgb(0xFFFFFF), 0.35), - offset: gpui::point(px(0.), px(0.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }]), - ), - ), - Self::Focus => scene( - div() - .absolute() - .size_full() - .child(blob(2., 2., 10., 0xFFE0B0, 8.)) - .child(blob(40., 22., 10., 0x8FA8FF, 8.)) - .child( - div() - .absolute() - .left(px(14.)) - .top(px(9.)) - .w(px(24.)) - .h(px(18.)) - .rounded(px(4.)) - .border_1() - .border_color(gpui::white()) - .bg(linear_gradient( - 90., - linear_color_stop(gpui::rgb(0xF29B78), 0.), - linear_color_stop(gpui::rgb(0x8C8CF6), 1.), - )), - ), - ), - Self::CapBlue => scene(tiles( - [0xF8B26A, 0xE58FA0, 0x9C8CF0, 0xD99A7C, 0xA88FD8, 0x6C8CFF], - Some((0x4785FF, 0.6)), - )), - } - .border_1() - .border_color(theme.gray_4) - .into_any_element() +pub(super) fn set_highlight_blur(annotation: &mut Annotation, blur: f64) -> bool { + if highlight_blur(annotation) == blur { + return false; + } + let darkness = highlight_darkness(annotation); + let options = annotation.mask_options.get_or_insert_with(Default::default); + options.color = HIGHLIGHT_COLOR.to_string(); + if blur < 1. { + options.solid = true; + options.tint = 0.; + annotation.opacity = darkness; + } else { + options.solid = false; + options.tint = darkness; + annotation.opacity = 1.; + annotation.mask_type = Some(MaskType::Blur); + annotation.mask_level = Some(blur); } + true } /// The kernel's view of one mask. The preview filters the atlas's BGRA, so @@ -570,94 +325,120 @@ impl ScreenshotEditorWindow { } pub(super) fn ensure_mask_tint_visible(options: &mut MaskOptions) -> bool { - if options.solid || options.tint > 0. { + if options.solid || options.invert || options.tint > 0. { return false; } options.tint = TINT_ON_COLOR_PICK; true } - fn toggle_mask_panel(&mut self, panel: MaskPanel, cx: &mut Context) { + fn toggle_mask_settings(&mut self, cx: &mut Context) { let Some(id) = self.selected_annotation.clone() else { return; }; - let open = self - .annotation_state - .mask_panel - .as_ref() - .is_some_and(|(open, open_id)| *open == panel && open_id == &id); + let open = self.annotation_state.mask_settings.as_deref() == Some(id.as_str()); self.annotation_state.color_popover = None; - self.annotation_state.mask_panel = (!open).then_some((panel, id)); + self.annotation_state.mask_settings = (!open).then_some(id); cx.notify(); } - fn mask_anchor(&self, panel: MaskPanel) -> BoundsCell { - self.annotation_state - .mask_anchors - .get(&panel) - .cloned() - .unwrap_or_default() - } - pub(super) fn render_mask_controls( &self, annotation: &Annotation, cx: &mut Context, ) -> Vec { let theme = self.theme; - let style = MaskStyleChoice::of(annotation); + let kind = MaskKindChoice::of(annotation); let options = annotation.mask_options.clone().unwrap_or_default(); - let shape = MaskShapeChoice::of(&options); - let preset = MaskPreset::of(annotation); - let open = self - .annotation_state - .mask_panel - .as_ref() - .map(|(panel, _)| *panel); - let mut items = vec![self.render_mask_panel_button( - MaskPanel::Presets, - "icons/sparkles.svg", - preset.map_or(tr::screenshot_editor::mask::presets(), MaskPreset::label), - open == Some(MaskPanel::Presets), - preset.is_some(), - cx, - )]; - - let mut pills = div().flex().flex_row().gap(px(4.)); - for choice in MaskStyleChoice::ALL { - pills = pills.child(mask_pill( + let mut kinds = div().flex().flex_row().gap(px(4.)); + for choice in [MaskKindChoice::Sensitive, MaskKindChoice::Highlight] { + kinds = kinds.child(mask_pill( &theme, - format!("screenshot-mask-style-{}", choice.id()), + format!( + "screenshot-mask-kind-{}", + if choice == MaskKindChoice::Sensitive { + "sensitive" + } else { + "highlight" + } + ), Some(choice.label().into()), None, - choice == style, + choice == kind, cx.listener(move |this, _, window, cx| { cx.stop_propagation(); this.edit_selected_mask(|annotation| choice.apply(annotation), window, cx); }), )); } - items.push(config_item( - &theme, - tr::screenshot_editor::annotation::mask_style(), - None, - pills.into_any_element(), - )); - - if style != MaskStyleChoice::Solid { - items.push(config_item( - &theme, - tr::screenshot_editor::annotation::intensity(), - Some(format!( - "{}", - annotation.mask_level.unwrap_or(MASK_FALLBACK_LEVEL).round() as i64 - )), - self.annotation_slider(AnnotationSlider::MaskLevel, px(88.), cx) - .into_any_element(), - )); + let mut items = vec![kinds.into_any_element(), bar_divider(&theme)]; + + match kind { + MaskKindChoice::Sensitive => { + let style = MaskStyleChoice::of(annotation); + let mut pills = div().flex().flex_row().gap(px(4.)); + for choice in MaskStyleChoice::ALL { + pills = pills.child(mask_pill( + &theme, + format!("screenshot-mask-style-{}", choice.id()), + Some(choice.label().into()), + None, + choice == style, + cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.edit_selected_mask( + |annotation| choice.apply(annotation), + window, + cx, + ); + }), + )); + } + items.push(pills.into_any_element()); + if style == MaskStyleChoice::Solid { + items.push(config_item( + &theme, + tr::screenshot_editor::style::color(), + None, + self.render_color_button(ColorTarget::Mask, cx), + )); + } else { + items.push(config_item( + &theme, + tr::screenshot_editor::annotation::intensity(), + Some(format!( + "{}", + annotation.mask_level.unwrap_or(MASK_FALLBACK_LEVEL).round() as i64 + )), + self.annotation_slider(AnnotationSlider::MaskLevel, px(88.), cx) + .into_any_element(), + )); + } + } + MaskKindChoice::Highlight => { + items.push(config_item( + &theme, + tr::screenshot_editor::mask::darkness(), + Some(format!( + "{}%", + (highlight_darkness(annotation) * 100.).round() as i64 + )), + self.annotation_slider(AnnotationSlider::HighlightDarkness, px(80.), cx) + .into_any_element(), + )); + items.push(config_item( + &theme, + tr::screenshot_editor::style::blur(), + Some(format!("{}", highlight_blur(annotation).round() as i64)), + self.annotation_slider(AnnotationSlider::HighlightBlur, px(80.), cx) + .into_any_element(), + )); + } } + items.push(bar_divider(&theme)); + let shape = MaskShapeChoice::of(&options); let mut shapes = div().flex().flex_row().gap(px(4.)); for choice in MaskShapeChoice::ALL { let label = choice.label(); @@ -692,161 +473,112 @@ impl ScreenshotEditorWindow { .tooltip(move |_window, cx| ui::Tooltip::new(&theme, label).view(cx)), ); } - items.push(config_item( - &theme, - tr::screenshot_editor::mask::shape(), - None, - shapes.into_any_element(), - )); - - items.push(config_item( - &theme, - tr::screenshot_editor::style::color(), - None, - self.render_color_button(ColorTarget::Mask, cx), - )); + items.push(shapes.into_any_element()); - let inverted = options.invert; + let cell = self.annotation_state.mask_settings_anchor.clone(); + let open = self.annotation_state.mask_settings.is_some(); items.push( div() - .id("screenshot-mask-invert-tip") - .child(mask_pill( - &theme, - "screenshot-mask-invert".to_string(), - Some(tr::screenshot_editor::mask::invert().into()), - Some("icons/box-select.svg"), - inverted, - cx.listener(move |this, _, window, cx| { - cx.stop_propagation(); - this.edit_selected_mask( - |annotation| { - let options = - annotation.mask_options.get_or_insert_with(Default::default); - options.invert = !options.invert; - true - }, - window, - cx, - ); - }), - )) - .tooltip_show_delay(ui::TOOLTIP_SHOW_DELAY) - .tooltip(move |_window, cx| { - ui::Tooltip::new(&theme, tr::screenshot_editor::mask::invert_hint()).view(cx) - }) + .relative() + .flex_shrink_0() + .child( + canvas( + move |bounds, _window, _cx| cell.set(Some(bounds)), + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) + .child( + ui::EditorButton::plain(&theme, "screenshot-mask-settings") + .left_icon("icons/sliders-horizontal.svg") + .icon_size(px(16.)) + .pressed(open) + .tooltip(&theme, tr::screenshot_editor::mask::settings()) + .on_click(cx.listener(|this, _, _window, cx| { + cx.stop_propagation(); + this.toggle_mask_settings(cx); + })), + ) .into_any_element(), ); - items.push(self.render_mask_panel_button( - MaskPanel::Adjust, - "icons/sliders-horizontal.svg", - tr::screenshot_editor::mask::adjust(), - open == Some(MaskPanel::Adjust), - false, - cx, - )); - - items - } - - fn render_mask_panel_button( - &self, - panel: MaskPanel, - icon: &'static str, - label: &'static str, - open: bool, - highlighted: bool, - cx: &mut Context, - ) -> AnyElement { - let theme = self.theme; - let cell = self.mask_anchor(panel); - let accent = open || highlighted; - div() - .relative() - .flex_shrink_0() - .child( - canvas( - move |bounds, _window, _cx| cell.set(Some(bounds)), - |_, _, _, _| {}, - ) - .absolute() - .size_full(), - ) - .child( - div() - .id(panel.id()) - .flex() - .flex_row() - .items_center() - .gap(px(6.)) - .h(px(26.)) - .pl(px(8.)) - .pr(px(6.)) - .rounded(px(7.)) - .cursor_pointer() - .text_size(px(12.)) - .font_weight(FontWeight::MEDIUM) - .border_1() - .when(accent, |this| { - this.bg(theme.blue_3) - .border_color(theme.blue_6) - .text_color(theme.blue_11) - }) - .when(!accent, |this| { - this.bg(theme.gray_3) - .border_color(theme.gray_4) - .text_color(theme.gray_11) - .hover(|style| style.bg(theme.gray_4)) - }) - .child(svg().path(icon).size(px(14.)).text_color(if accent { - theme.blue_11 - } else { - theme.gray_11 - })) - .child(label) - .child( - svg() - .path("icons/chevron-down.svg") - .size(px(12.)) - .text_color(theme.gray_10), - ) - .on_click(cx.listener(move |this, _, _window, cx| { - cx.stop_propagation(); - this.toggle_mask_panel(panel, cx); - })), - ) - .into_any_element() + vec![ + div() + .flex() + .flex_row() + .items_center() + .gap(px(12.)) + .flex_shrink_0() + .children(items) + .into_any_element(), + ] } - pub(super) fn render_mask_panel( + /// The header popovers' chrome (`render_popover`): same surface, same + /// slider rows, anchored under the bar's settings button. + pub(super) fn render_mask_settings( &self, window: &Window, cx: &mut Context, ) -> Option { - let (panel, id) = self.annotation_state.mask_panel.clone()?; + let id = self.annotation_state.mask_settings.clone()?; if self.selected_annotation.as_deref() != Some(id.as_str()) { return None; } let annotation = self .annotation(&id) - .filter(|annotation| annotation.annotation_type == AnnotationType::Mask)? - .clone(); + .filter(|annotation| annotation.annotation_type == AnnotationType::Mask)?; let theme = self.theme; - let width = panel.width(); + let options = annotation.mask_options.clone().unwrap_or_default(); let viewport = window.viewport_size(); - let anchor = self.mask_anchor(panel).get(); + let anchor = self.annotation_state.mask_settings_anchor.get(); let left = anchor - .map(|bounds| f32::from(bounds.origin.x)) + .map(|bounds| f32::from(bounds.origin.x + bounds.size.width) - SETTINGS_WIDTH) .unwrap_or(16.) - .clamp(8., (f32::from(viewport.width) - width - 8.).max(8.)); + .clamp( + 8., + (f32::from(viewport.width) - SETTINGS_WIDTH - 8.).max(8.), + ); let top = anchor .map(|bounds| f32::from(bounds.origin.y + bounds.size.height) + 8.) .unwrap_or(120.); - let body = match panel { - MaskPanel::Presets => self.render_mask_presets(&annotation, cx), - MaskPanel::Adjust => self.render_mask_adjust(&annotation, cx), - }; + let mut body = div() + .p(px(16.)) + .flex() + .flex_col() + .gap(px(16.)) + .child(self.mask_slider_row( + tr::screenshot_editor::mask::feather(), + format!("{}px", options.feather.round() as i64), + AnnotationSlider::MaskFeather, + cx, + )); + if options.shape == MaskShape::Rectangle { + body = body.child(self.mask_slider_row( + tr::screenshot_editor::mask::corner_radius(), + format!("{}px", options.corner_radius.round() as i64), + AnnotationSlider::MaskRadius, + cx, + )); + } + if !options.invert && !options.solid { + body = body.child( + div() + .flex() + .flex_row() + .items_end() + .gap(px(12.)) + .child(div().flex_1().min_w_0().child(self.mask_slider_row( + tr::screenshot_editor::mask::tint(), + format!("{}%", (options.tint * 100.).round() as i64), + AnnotationSlider::MaskTint, + cx, + ))) + .child(self.render_color_button(ColorTarget::Mask, cx)), + ); + } Some( div() @@ -856,264 +588,84 @@ impl ScreenshotEditorWindow { .size_full() .child( div() - .id("screenshot-mask-panel-backdrop") + .id("screenshot-mask-settings-backdrop") .occlude() .absolute() .top_0() .left_0() .size_full() .on_click(cx.listener(|this, _, _window, cx| { - this.annotation_state.mask_panel = None; + this.annotation_state.mask_settings = None; cx.notify(); })), ) .child( div() - .id("screenshot-mask-panel") + .id("screenshot-mask-settings-popover") .occlude() .absolute() .left(px(left)) .top(px(top)) - .w(px(width)) - .p(px(10.)) - .flex() - .flex_col() - .gap(px(10.)) - .rounded(px(10.)) + .w(px(SETTINGS_WIDTH)) + .overflow_hidden() + .rounded(px(12.)) .border_1() - .border_color(theme.gray_4) - .bg(if theme.is_dark() { - theme.gray_2 - } else { - theme.gray_1 - }) + .border_color(theme.gray_3) + .bg(theme.gray_1) .shadow(vec![gpui::BoxShadow { color: Theme::with_alpha(gpui::rgb(0x000000), 0.18), - offset: gpui::point(px(0.), px(8.)), - blur_radius: px(24.), + offset: gpui::point(px(0.), px(10.)), + blur_radius: px(30.), spread_radius: px(0.), inset: false, }]) .on_click(|_, _window, cx| cx.stop_propagation()) - .child( - div() - .text_size(px(11.)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(theme.gray_10) - .child(match panel { - MaskPanel::Presets => { - tr::screenshot_editor::mask::presets_title() - } - MaskPanel::Adjust => tr::screenshot_editor::mask::adjust(), - }), - ) .child(body), ) .into_any_element(), ) } - fn render_mask_presets(&self, annotation: &Annotation, cx: &mut Context) -> AnyElement { + /// `slider_row` from the header popovers, over an annotation slider. + fn mask_slider_row( + &self, + label: &'static str, + value: String, + slider: AnnotationSlider, + cx: &mut Context, + ) -> AnyElement { let theme = self.theme; - let current = MaskPreset::of(annotation); - let mut list = div().flex().flex_col().gap(px(4.)); - for preset in MaskPreset::ALL { - let active = current == Some(preset); - list = list.child( - div() - .id(gpui::SharedString::from(format!( - "screenshot-mask-preset-{}", - preset.id() - ))) - .flex() - .flex_row() - .items_center() - .gap(px(10.)) - .p(px(6.)) - .rounded(px(8.)) - .cursor_pointer() - .border_1() - .when(active, |this| { - this.bg(theme.blue_3).border_color(theme.blue_6) - }) - .when(!active, |this| { - this.border_color(gpui::transparent_black()) - .hover(|style| style.bg(theme.gray_3)) - }) - .child(preset.swatch(&theme)) - .child( - div() - .flex() - .flex_col() - .flex_1() - .min_w_0() - .gap(px(2.)) - .child( - div() - .text_size(px(12.)) - .font_weight(FontWeight::MEDIUM) - .text_color(if active { theme.blue_11 } else { theme.gray_12 }) - .child(preset.label()), - ) - .child( - div() - .text_size(px(11.)) - .text_color(theme.gray_10) - .child(preset.hint()), - ), - ) - .when(active, |this| { - this.child( - svg() - .path("icons/check.svg") - .size(px(14.)) - .text_color(theme.blue_11), - ) - }) - .on_click(cx.listener(move |this, _, window, cx| { - cx.stop_propagation(); - this.edit_selected_mask(|annotation| preset.apply(annotation), window, cx); - })), - ); - } div() .flex() .flex_col() .gap(px(8.)) - .child(list) .child( div() .flex() .flex_row() - .items_start() - .gap(px(6.)) - .pt(px(8.)) - .border_t_1() - .border_color(theme.gray_3) - .child( - svg() - .path("icons/shield.svg") - .size(px(12.)) - .mt(px(1.)) - .flex_shrink_0() - .text_color(theme.gray_9), - ) - .child( - div() - .text_size(px(10.)) - .text_color(theme.gray_9) - .child(tr::screenshot_editor::mask::privacy_note()), - ), + .items_center() + .justify_between() + .text_size(px(12.)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme.gray_11) + .child(label) + .child(value), ) - .into_any_element() - } - - fn render_mask_adjust(&self, annotation: &Annotation, cx: &mut Context) -> AnyElement { - let theme = self.theme; - let options = annotation.mask_options.clone().unwrap_or_default(); - let row = |label: &'static str, value: String, slider: AnyElement| { - div() - .flex() - .flex_col() - .gap(px(6.)) - .child( - div() - .flex() - .flex_row() - .justify_between() - .text_size(px(11.)) - .font_weight(FontWeight::MEDIUM) - .child(div().text_color(theme.gray_10).child(label)) - .child(div().text_color(theme.gray_12).child(value)), - ) - .child(slider) - }; - let slider_width = px(MaskPanel::Adjust.width() - 20.); - let mut body = div().flex().flex_col().gap(px(12.)).child(row( - tr::screenshot_editor::mask::feather(), - format!("{}px", options.feather.round() as i64), - self.annotation_slider(AnnotationSlider::MaskFeather, slider_width, cx) - .into_any_element(), - )); - if options.shape == MaskShape::Rectangle { - body = body.child(row( - tr::screenshot_editor::mask::corner_radius(), - format!("{}px", options.corner_radius.round() as i64), - self.annotation_slider(AnnotationSlider::MaskRadius, slider_width, cx) - .into_any_element(), - )); - } - if !options.solid { - body = body.child(row( - tr::screenshot_editor::mask::tint(), - format!("{}%", (options.tint * 100.).round() as i64), - self.annotation_slider(AnnotationSlider::MaskTint, slider_width, cx) + .child( + self.annotation_slider(slider, px(SETTINGS_WIDTH - 32.), cx) .into_any_element(), - )); - } - body.child(row( - tr::screenshot_editor::style::opacity(), - format!("{}%", (annotation.opacity * 100.).round() as i64), - self.annotation_slider(AnnotationSlider::Opacity, slider_width, cx) - .into_any_element(), - )) - .child( - div() - .flex() - .flex_row() - .items_center() - .justify_between() - .pt(px(10.)) - .border_t_1() - .border_color(theme.gray_3) - .child( - div() - .flex() - .flex_col() - .gap(px(2.)) - .child( - div() - .text_size(px(12.)) - .font_weight(FontWeight::MEDIUM) - .text_color(theme.gray_12) - .child(tr::screenshot_editor::mask::invert()), - ) - .child( - div() - .text_size(px(10.)) - .text_color(theme.gray_9) - .child(tr::screenshot_editor::mask::invert_hint()), - ), - ) - .child( - ui::Toggle::plain(&theme, "screenshot-mask-invert-toggle", options.invert) - .on_click(cx.listener(|this, _, window, cx| { - cx.stop_propagation(); - this.edit_selected_mask( - |annotation| { - let options = annotation - .mask_options - .get_or_insert_with(Default::default); - options.invert = !options.invert; - true - }, - window, - cx, - ); - })), - ), - ) - .into_any_element() + ) + .into_any_element() } } -fn mix_rgb(base: u32, tint: u32, amount: f32) -> u32 { - let channel = |shift: u32| { - let from = ((base >> shift) & 0xFF) as f32; - let to = ((tint >> shift) & 0xFF) as f32; - ((from + (to - from) * amount).round() as u32) << shift - }; - channel(16) | channel(8) | channel(0) +fn bar_divider(theme: &Theme) -> AnyElement { + div() + .w(px(1.)) + .h(px(20.)) + .flex_shrink_0() + .bg(theme.gray_4) + .into_any_element() } fn mask_pill( @@ -1136,7 +688,6 @@ fn mask_pill( .flex_row() .items_center() .justify_center() - .gap(px(5.)) .h(px(24.)) .min_w(px(24.)) .px(px(if label.is_some() { 10. } else { 5. })) @@ -1183,48 +734,47 @@ mod tests { } #[test] - fn every_preset_round_trips_through_its_own_matcher() { - for preset in MaskPreset::ALL { - let mut annotation = mask(); - assert!(preset.apply(&mut annotation)); - assert_eq!(MaskPreset::of(&annotation), Some(preset), "{preset:?}"); - assert!(!preset.apply(&mut annotation)); - } - } - - #[test] - fn presets_are_distinct() { - for (index, preset) in MaskPreset::ALL.into_iter().enumerate() { - let mut annotation = mask(); - preset.apply(&mut annotation); - for other in &MaskPreset::ALL[index + 1..] { - assert!(!other.matches(&annotation), "{preset:?} vs {other:?}"); - } - } + fn highlight_seeds_a_dim_feathered_inverted_mask_and_sensitive_undoes_it() { + let mut annotation = mask(); + assert_eq!(MaskKindChoice::of(&annotation), MaskKindChoice::Sensitive); + assert!(MaskKindChoice::Highlight.apply(&mut annotation)); + let options = annotation.mask_options.clone().unwrap(); + assert!(options.invert && options.solid); + assert_eq!(options.feather, HIGHLIGHT_FEATHER); + assert_eq!(highlight_darkness(&annotation), HIGHLIGHT_DARKNESS); + assert_eq!(highlight_blur(&annotation), 0.); + assert!(!MaskKindChoice::Highlight.apply(&mut annotation)); + + assert!(MaskKindChoice::Sensitive.apply(&mut annotation)); + let options = annotation.mask_options.clone().unwrap(); + assert!(!options.invert && !options.solid); + assert_eq!(options.feather, 0.); + assert_eq!(annotation.opacity, 1.); } #[test] - fn solid_keeps_a_hiding_mask_type_for_older_builds() { + fn highlight_blur_moves_darkness_between_opacity_and_tint() { let mut annotation = mask(); - assert!(MaskStyleChoice::Solid.apply(&mut annotation)); - assert_eq!(MaskStyleChoice::of(&annotation), MaskStyleChoice::Solid); - assert!(annotation.mask_type.is_some()); - assert!(MaskStyleChoice::Blur.apply(&mut annotation)); - assert_eq!(annotation.mask_type, Some(MaskType::Blur)); - assert!(!annotation.mask_options.unwrap().solid); - } + MaskKindChoice::Highlight.apply(&mut annotation); + assert!(set_highlight_darkness(&mut annotation, 0.8)); + assert_eq!(annotation.opacity, 0.8); - #[test] - fn shape_choices_set_radius_and_ellipse() { - let mut options = MaskOptions::default(); - assert_eq!(MaskShapeChoice::of(&options), MaskShapeChoice::Square); - assert!(MaskShapeChoice::Rounded.apply(&mut options)); - assert_eq!(options.corner_radius, ROUNDED_DEFAULT_RADIUS); - assert!(MaskShapeChoice::Ellipse.apply(&mut options)); - assert_eq!(MaskShapeChoice::of(&options), MaskShapeChoice::Ellipse); - assert!(MaskShapeChoice::Square.apply(&mut options)); - assert_eq!(options.corner_radius, 0.); - assert_eq!(options.shape, MaskShape::Rectangle); + assert!(set_highlight_blur(&mut annotation, 12.)); + let options = annotation.mask_options.clone().unwrap(); + assert!(!options.solid); + assert_eq!(options.tint, 0.8); + assert_eq!(annotation.opacity, 1.); + assert_eq!(annotation.mask_level, Some(12.)); + assert_eq!(highlight_darkness(&annotation), 0.8); + assert_eq!(highlight_blur(&annotation), 12.); + + assert!(set_highlight_darkness(&mut annotation, 0.3)); + assert_eq!(annotation.mask_options.as_ref().unwrap().tint, 0.3); + + assert!(set_highlight_blur(&mut annotation, 0.)); + assert!(annotation.mask_options.as_ref().unwrap().solid); + assert_eq!(annotation.opacity, 0.3); + assert!(!set_highlight_blur(&mut annotation, 0.)); } #[test] @@ -1247,6 +797,28 @@ mod tests { assert_eq!(solid.tint, 0.); } + #[test] + fn solid_keeps_a_hiding_mask_type_for_older_builds() { + let mut annotation = mask(); + assert!(MaskStyleChoice::Solid.apply(&mut annotation)); + assert_eq!(MaskStyleChoice::of(&annotation), MaskStyleChoice::Solid); + assert!(annotation.mask_type.is_some()); + assert!(MaskStyleChoice::Blur.apply(&mut annotation)); + assert_eq!(annotation.mask_type, Some(MaskType::Blur)); + assert!(!annotation.mask_options.unwrap().solid); + } + + #[test] + fn shape_choices_switch_between_rectangle_and_ellipse() { + let mut options = MaskOptions::default(); + assert_eq!(MaskShapeChoice::of(&options), MaskShapeChoice::Rectangle); + assert!(MaskShapeChoice::Ellipse.apply(&mut options)); + assert_eq!(options.shape, MaskShape::Ellipse); + assert!(!MaskShapeChoice::Ellipse.apply(&mut options)); + assert!(MaskShapeChoice::Rectangle.apply(&mut options)); + assert_eq!(options.shape, MaskShape::Rectangle); + } + #[test] fn style_maps_options_and_swaps_colour_for_bgra() { let mut annotation = mask(); From 75ad6278e9b890564ee44d8ca99ad11289b9d010 Mon Sep 17 00:00:00 2001 From: Aatman Jain Date: Thu, 29 Oct 2026 23:57:24 +0000 Subject: [PATCH 4/7] fix(screenshot): keep the mask bar steady across modes and lift the tint swatch --- .../src/screenshot_annotations/mask.rs | 95 +++++++++++++++---- 1 file changed, 75 insertions(+), 20 deletions(-) diff --git a/apps/desktop-gpui/src/screenshot_annotations/mask.rs b/apps/desktop-gpui/src/screenshot_annotations/mask.rs index b6daf7f962f..aa177e27b06 100644 --- a/apps/desktop-gpui/src/screenshot_annotations/mask.rs +++ b/apps/desktop-gpui/src/screenshot_annotations/mask.rs @@ -8,6 +8,8 @@ const HIGHLIGHT_DARKNESS: f64 = 0.6; const HIGHLIGHT_FEATHER: f64 = 24.; const HIGHLIGHT_COLOR: &str = "#000000"; const SETTINGS_WIDTH: f32 = 240.; +const MODE_SLOT_WIDTH: f32 = 390.; +const VALUE_WIDTH: f32 = 30.; /// The video editor's two mask kinds (`editor_panels::render_mask_panel`): /// Sensitive hides what is inside the mask, Highlight dims everything @@ -373,6 +375,7 @@ impl ScreenshotEditorWindow { )); } let mut items = vec![kinds.into_any_element(), bar_divider(&theme)]; + let mut mode: Vec = Vec::new(); match kind { MaskKindChoice::Sensitive => { @@ -395,16 +398,16 @@ impl ScreenshotEditorWindow { }), )); } - items.push(pills.into_any_element()); + mode.push(pills.into_any_element()); if style == MaskStyleChoice::Solid { - items.push(config_item( + mode.push(mask_item( &theme, tr::screenshot_editor::style::color(), None, self.render_color_button(ColorTarget::Mask, cx), )); } else { - items.push(config_item( + mode.push(mask_item( &theme, tr::screenshot_editor::annotation::intensity(), Some(format!( @@ -417,7 +420,7 @@ impl ScreenshotEditorWindow { } } MaskKindChoice::Highlight => { - items.push(config_item( + mode.push(mask_item( &theme, tr::screenshot_editor::mask::darkness(), Some(format!( @@ -427,7 +430,7 @@ impl ScreenshotEditorWindow { self.annotation_slider(AnnotationSlider::HighlightDarkness, px(80.), cx) .into_any_element(), )); - items.push(config_item( + mode.push(mask_item( &theme, tr::screenshot_editor::style::blur(), Some(format!("{}", highlight_blur(annotation).round() as i64)), @@ -437,6 +440,17 @@ impl ScreenshotEditorWindow { } } + items.push( + div() + .min_w(px(MODE_SLOT_WIDTH)) + .flex() + .flex_row() + .items_center() + .gap(px(12.)) + .children(mode) + .into_any_element(), + ); + items.push(bar_divider(&theme)); let shape = MaskShapeChoice::of(&options); let mut shapes = div().flex().flex_row().gap(px(4.)); @@ -553,6 +567,7 @@ impl ScreenshotEditorWindow { tr::screenshot_editor::mask::feather(), format!("{}px", options.feather.round() as i64), AnnotationSlider::MaskFeather, + None, cx, )); if options.shape == MaskShape::Rectangle { @@ -560,24 +575,18 @@ impl ScreenshotEditorWindow { tr::screenshot_editor::mask::corner_radius(), format!("{}px", options.corner_radius.round() as i64), AnnotationSlider::MaskRadius, + None, cx, )); } if !options.invert && !options.solid { - body = body.child( - div() - .flex() - .flex_row() - .items_end() - .gap(px(12.)) - .child(div().flex_1().min_w_0().child(self.mask_slider_row( - tr::screenshot_editor::mask::tint(), - format!("{}%", (options.tint * 100.).round() as i64), - AnnotationSlider::MaskTint, - cx, - ))) - .child(self.render_color_button(ColorTarget::Mask, cx)), - ); + body = body.child(self.mask_slider_row( + tr::screenshot_editor::mask::tint(), + format!("{}%", (options.tint * 100.).round() as i64), + AnnotationSlider::MaskTint, + Some(self.render_color_button(ColorTarget::Mask, cx)), + cx, + )); } Some( @@ -632,6 +641,7 @@ impl ScreenshotEditorWindow { label: &'static str, value: String, slider: AnnotationSlider, + accessory: Option, cx: &mut Context, ) -> AnyElement { let theme = self.theme; @@ -649,7 +659,15 @@ impl ScreenshotEditorWindow { .font_weight(FontWeight::MEDIUM) .text_color(theme.gray_11) .child(label) - .child(value), + .child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(8.)) + .children(accessory) + .child(value), + ), ) .child( self.annotation_slider(slider, px(SETTINGS_WIDTH - 32.), cx) @@ -659,6 +677,43 @@ impl ScreenshotEditorWindow { } } +/// `config_item` with a fixed-width readout, so the slider beside it does +/// not slide as the number grows a digit. +fn mask_item( + theme: &Theme, + label: &'static str, + value: Option, + control: AnyElement, +) -> AnyElement { + let theme = *theme; + div() + .flex() + .flex_row() + .items_center() + .gap(px(8.)) + .flex_shrink_0() + .child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(4.)) + .flex_shrink_0() + .text_size(px(11.)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme.gray_10) + .child(label) + .children(value.map(|value| { + div() + .min_w(px(VALUE_WIDTH)) + .text_color(theme.gray_11) + .child(value) + })), + ) + .child(control) + .into_any_element() +} + fn bar_divider(theme: &Theme) -> AnyElement { div() .w(px(1.)) From d7903e0c88dd0016347360a80d1efa89843e2d52 Mon Sep 17 00:00:00 2001 From: Aatman Jain Date: Fri, 30 Oct 2026 09:58:10 +0000 Subject: [PATCH 5/7] ci(linux-harness): hand the Vulkan loader lavapipe alone on the vkms images The GNOME export run on this branch lost Cap to a SIGSEGV while the shared rendering device was being created: the loader had just logged "Removing driver libvulkan_radeon.so due to not having any physical devices", which is where it destroys that driver's instance and dlcloses its library, while the main thread was still drawing with lavapipe. The hardware drivers never find a device on vkms/vgem, so VK_DRIVER_FILES now names the lavapipe manifest alone, next to the existing device_select workaround. A real GPU passed in through DOCKER_EXTRA keeps the full driver list. --- scripts/linux-desktop-harness/README.md | 8 +++++--- scripts/linux-desktop-harness/run-desktop.sh | 12 ++++++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/scripts/linux-desktop-harness/README.md b/scripts/linux-desktop-harness/README.md index af183b426c6..5a3f060c8f4 100644 --- a/scripts/linux-desktop-harness/README.md +++ b/scripts/linux-desktop-harness/README.md @@ -77,9 +77,11 @@ Hyprland image needs `modprobe vkms` on the host. With `GNOME_UNSAFE=1`, `shot.sh` takes a screenshot of the GNOME session. Harness-only workarounds (not in the app): Mesa's device_select Vulkan layer is -disabled on the vkms images because it crashes on their virtual devices, and -the GNOME session gets a target bound to `graphical-session.target`, which a -real login activates. +disabled on the vkms images because it crashes on their virtual devices, the +Vulkan loader on those images is pointed at lavapipe alone (`VK_DRIVER_FILES`) +because unloading the hardware drivers that find no device crashed Cap while it +drew with lavapipe, and the GNOME session gets a target bound to +`graphical-session.target`, which a real login activates. ## Checks without a desktop diff --git a/scripts/linux-desktop-harness/run-desktop.sh b/scripts/linux-desktop-harness/run-desktop.sh index bf7f98d1d7f..e215a9208ad 100644 --- a/scripts/linux-desktop-harness/run-desktop.sh +++ b/scripts/linux-desktop-harness/run-desktop.sh @@ -14,9 +14,17 @@ extra_env=() # the layer off). Real GPUs are not affected, so the harness (not Cap) turns # that layer off. no_device_select=(-e VK_LOADER_LAYERS_DISABLE=VK_LAYER_MESA_device_select) +# The images' hardware Vulkan drivers (radv, anv, nouveau, ...) find no device +# on vkms/vgem, so the loader destroys and unloads each of them while Cap's +# main thread keeps drawing with lavapipe, which shares Mesa's libraries with +# them; a GNOME export run segfaulted right there, in the middle of dropping +# radv. Lavapipe is the only driver with a device on these images, so the loader +# gets that one alone, unless DOCKER_EXTRA hands a real GPU's drivers in. +lavapipe_only=() +[[ ${DOCKER_EXTRA:-} == *--gpus* ]] || lavapipe_only=(-e VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.json) case "$desktop" in - gnome) image=cap-harness-gnome dockerfile=gnome.Dockerfile up=gnome-up.sh extra_env=("${no_device_select[@]}") ;; - hyprland|niri|sway) image=cap-harness-arch dockerfile=arch.Dockerfile up=arch-up.sh extra_env=("${no_device_select[@]}") ;; + gnome) image=cap-harness-gnome dockerfile=gnome.Dockerfile up=gnome-up.sh extra_env=("${no_device_select[@]}" "${lavapipe_only[@]}") ;; + hyprland|niri|sway) image=cap-harness-arch dockerfile=arch.Dockerfile up=arch-up.sh extra_env=("${no_device_select[@]}" "${lavapipe_only[@]}") ;; kde) image=cap-harness-kde dockerfile=kde.Dockerfile up=kde-up.sh ;; *) echo "unknown desktop $desktop" >&2; exit 2 ;; esac From 7db67a1ebf89111a49db7dd73d5aa7b12298a3bd Mon Sep 17 00:00:00 2001 From: Aatman Jain Date: Fri, 30 Oct 2026 10:20:57 +0000 Subject: [PATCH 6/7] ci(linux-harness): leave the Vulkan driver list to callers that set one The Hyprland OpenGL step hides every Vulkan driver with VK_ICD_FILENAMES=/nonexistent.json to put Cap on wgpu's GL backend. The loader prefers VK_DRIVER_FILES over that older variable, so the lavapipe pin brought the driver back and the step's "(Gl)" adapter check failed. The pin now stays off whenever the caller passes VK_DRIVER_FILES or VK_ICD_FILENAMES itself. --- scripts/linux-desktop-harness/run-desktop.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/linux-desktop-harness/run-desktop.sh b/scripts/linux-desktop-harness/run-desktop.sh index e215a9208ad..b1ecf2c8e0e 100644 --- a/scripts/linux-desktop-harness/run-desktop.sh +++ b/scripts/linux-desktop-harness/run-desktop.sh @@ -19,9 +19,15 @@ no_device_select=(-e VK_LOADER_LAYERS_DISABLE=VK_LAYER_MESA_device_select) # main thread keeps drawing with lavapipe, which shares Mesa's libraries with # them; a GNOME export run segfaulted right there, in the middle of dropping # radv. Lavapipe is the only driver with a device on these images, so the loader -# gets that one alone, unless DOCKER_EXTRA hands a real GPU's drivers in. +# gets that one alone, unless DOCKER_EXTRA hands a real GPU's drivers in or the +# caller picks the driver list itself (VK_DRIVER_FILES wins over the older +# VK_ICD_FILENAMES in the loader, so a caller hiding every driver that way must +# not be overridden). lavapipe_only=() -[[ ${DOCKER_EXTRA:-} == *--gpus* ]] || lavapipe_only=(-e VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.json) +case "${DOCKER_EXTRA:-} $*" in + *--gpus*|*VK_DRIVER_FILES=*|*VK_ICD_FILENAMES=*) ;; + *) lavapipe_only=(-e VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.json) ;; +esac case "$desktop" in gnome) image=cap-harness-gnome dockerfile=gnome.Dockerfile up=gnome-up.sh extra_env=("${no_device_select[@]}" "${lavapipe_only[@]}") ;; hyprland|niri|sway) image=cap-harness-arch dockerfile=arch.Dockerfile up=arch-up.sh extra_env=("${no_device_select[@]}" "${lavapipe_only[@]}") ;; From 01a7ab847aee2a83bf783935ef51c7f6f8977aa5 Mon Sep 17 00:00:00 2001 From: Aatman Jain Date: Fri, 30 Oct 2026 10:54:37 +0000 Subject: [PATCH 7/7] ci(linux-harness): write Hyprland's config as hyprland.lua Arch now ships Hyprland 0.57 (2026-10-28), which removed its hyprlang config support and reads hyprland.lua alone. The harness's hyprland.conf was ignored: Hyprland logged "No config file found; attempting to generate", left the vkms output at its 1024x768 default and stopped logging to stdout, so every "Hyprland screenshot" step since 2026-10-29 failed on screenshot_size. The same monitor, misc, debug and autostart settings now go into hyprland.lua, with stdout logging turned on so compositor.log keeps collecting them. --- scripts/linux-desktop-harness/arch-up.sh | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/scripts/linux-desktop-harness/arch-up.sh b/scripts/linux-desktop-harness/arch-up.sh index 6a1a8028862..2fcb1097a1e 100755 --- a/scripts/linux-desktop-harness/arch-up.sh +++ b/scripts/linux-desktop-harness/arch-up.sh @@ -23,12 +23,19 @@ common=(LIBSEAT_BACKEND=seatd XDG_SESSION_TYPE=wayland) case "$desktop" in hyprland) - cat >"$HOME/.config/hypr/hyprland.conf" <"$HOME/.config/hypr/hyprland.lua" <"$HOME/.config/hypr/xdph.conf" <