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..134d7e69548 --- /dev/null +++ b/apps/desktop-gpui/kernels/src/mask_style.rs @@ -0,0 +1,329 @@ +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. + } +} + +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 +} + +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..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" @@ -1661,6 +1660,16 @@ empty_hint = "Fügen Sie mit den Werkzeugen oben Anmerkungen hinzu" reorder_hint = "Zum Sortieren ziehen • Oben = vorne" title = "Ebenen" +[screenshot_editor.mask] +corner_radius = "Ecken" +darkness = "Abdunkeln" +feather = "Weiche Kante" +settings = "Maskeneinstellungen" +shape_ellipse = "Ellipse" +shape_rectangle = "Rechteck" +solid = "Deckend" +tint = "Tönung" + [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..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" @@ -1663,6 +1662,16 @@ empty_hint = "Use the tools above to add annotations" reorder_hint = "Drag to reorder • Top = front" title = "Layers" +[screenshot_editor.mask] +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." } +settings = "Mask settings" +shape_ellipse = "Ellipse" +shape_rectangle = "Rectangle" +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.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..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" @@ -1661,6 +1660,16 @@ empty_hint = "Usa las herramientas de arriba para añadir anotaciones" reorder_hint = "Arrastra para reordenar • Arriba = delante" title = "Capas" +[screenshot_editor.mask] +corner_radius = "Esquinas" +darkness = "Oscuridad" +feather = "Difuminado" +settings = "Ajustes de máscara" +shape_ellipse = "Elipse" +shape_rectangle = "Rectángulo" +solid = "Sólido" +tint = "Tinte" + [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..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" @@ -1661,6 +1660,16 @@ 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] +corner_radius = "Coins" +darkness = "Assombrissement" +feather = "Adoucissement" +settings = "Réglages du masque" +shape_ellipse = "Ellipse" +shape_rectangle = "Rectangle" +solid = "Plein" +tint = "Teinte" + [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..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 = "पारदर्शी" @@ -1661,6 +1660,16 @@ empty_hint = "एनोटेशन जोड़ने के लिए ऊप reorder_hint = "क्रम बदलने के लिए खींचें • ऊपर = सबसे आगे" title = "लेयर" +[screenshot_editor.mask] +corner_radius = "कोने" +darkness = "गहराई" +feather = "फेदर" +settings = "मास्क सेटिंग्स" +shape_ellipse = "दीर्घवृत्त" +shape_rectangle = "आयत" +solid = "ठोस" +tint = "टिंट" + [screenshot_editor.menu] delete = "हटाएँ" open_folder = "फ़ोल्डर खोलें" diff --git a/apps/desktop-gpui/locales/ja.toml b/apps/desktop-gpui/locales/ja.toml index c04e300ea12..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 = "透明" @@ -1661,6 +1660,16 @@ empty_hint = "上のツールで注釈を追加できます" reorder_hint = "ドラッグで並べ替え • 上 = 最前面" title = "レイヤー" +[screenshot_editor.mask] +corner_radius = "角" +darkness = "暗さ" +feather = "ぼかし幅" +settings = "マスクの設定" +shape_ellipse = "楕円" +shape_rectangle = "長方形" +solid = "単色" +tint = "色味" + [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..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 = "透明" @@ -1661,6 +1660,16 @@ empty_hint = "使用上方的工具添加标注" reorder_hint = "拖动以重新排序 • 顶部 = 最前" title = "图层" +[screenshot_editor.mask] +corner_radius = "圆角" +darkness = "暗度" +feather = "羽化" +settings = "遮罩设置" +shape_ellipse = "椭圆" +shape_rectangle = "矩形" +solid = "纯色" +tint = "色调" + [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..bf8ebefe6b2 100644 --- a/apps/desktop-gpui/src/i18n/dialogs_surface_tests.rs +++ b/apps/desktop-gpui/src/i18n/dialogs_surface_tests.rs @@ -671,13 +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::corner_radius(), "Corners"), + (tr::screenshot_editor::mask::darkness(), "Darkness"), + (tr::screenshot_editor::mask::feather(), "Feather"), + (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::solid(), "Solid"), + (tr::screenshot_editor::mask::tint(), "Tint"), (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..5aef81d52fd 100644 --- a/apps/desktop-gpui/src/screenshot_annotations.rs +++ b/apps/desktop-gpui/src/screenshot_annotations.rs @@ -47,6 +47,8 @@ use crate::screenshot_editor::ScreenshotEditorWindow; use crate::theme::Theme; use crate::ui; +mod mask; + // --------------------------------------------------------------------------- // Tools // --------------------------------------------------------------------------- @@ -1093,15 +1095,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 +1125,24 @@ enum AnnotationSlider { Opacity, MaskLevel, TextSize, + MaskFeather, + MaskRadius, + MaskTint, + HighlightDarkness, + HighlightBlur, } impl AnnotationSlider { - const ALL: [AnnotationSlider; 4] = [ + const ALL: [AnnotationSlider; 9] = [ AnnotationSlider::Width, AnnotationSlider::Opacity, AnnotationSlider::MaskLevel, AnnotationSlider::TextSize, + AnnotationSlider::MaskFeather, + AnnotationSlider::MaskRadius, + AnnotationSlider::MaskTint, + AnnotationSlider::HighlightDarkness, + AnnotationSlider::HighlightBlur, ]; /// `(min, max, step)` -- `AnnotationConfig.tsx:60-157`. @@ -1136,8 +1150,13 @@ 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), + Self::HighlightDarkness => (0.05, 1., 0.05), + Self::HighlightBlur => (0., 40., 1.), } } @@ -1147,6 +1166,11 @@ 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", + Self::HighlightDarkness => "highlight-darkness", + Self::HighlightBlur => "highlight-blur", } } @@ -1156,6 +1180,16 @@ 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 + } + Self::HighlightDarkness => mask::highlight_darkness(annotation) as f32, + Self::HighlightBlur => mask::highlight_blur(annotation) as f32, } } @@ -1173,6 +1207,16 @@ 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 { + Self::MaskFeather => &mut options.feather, + Self::MaskRadius => &mut options.corner_radius, + _ => &mut options.tint, + } + } }; if *field == value { return false; @@ -1220,6 +1264,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_settings: Option, + mask_settings_anchor: BoundsCell, } /// One paint of the layer, copied out of the window so the closure owns it. @@ -1623,6 +1671,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 +1697,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 +2532,8 @@ impl ScreenshotEditorWindow { window: &Window, cx: &mut Context, ) -> Vec { - let mut layers = Vec::new(); + 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( @@ -2575,30 +2640,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 +2655,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 +2739,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 +2755,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, }) } @@ -2801,12 +2778,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); + tinted = Self::ensure_mask_tint_visible(options); + &mut options.color + } }; if *field == value { - return false; + return tinted; } *field = value; true @@ -3070,6 +3053,9 @@ impl ScreenshotEditorWindow { ColorTarget::Fill => { "screenshot-annotation-hex-fill" } + ColorTarget::Mask => { + "screenshot-annotation-hex-mask" + } }, input, ) @@ -3106,6 +3092,11 @@ impl ScreenshotEditorWindow { { self.annotation_state.color_popover = None; } + if let Some(id) = &self.annotation_state.mask_settings + && self.selected_annotation.as_deref() != Some(id.as_str()) + { + self.annotation_state.mask_settings = None; + } for target in ColorTarget::ALL { let Some(input) = self.annotation_state.color_inputs.get(&target).cloned() else { continue; @@ -4006,20 +3997,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 +4019,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 +4030,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 +4154,7 @@ mod tests { text: None, mask_type: None, mask_level: None, + mask_options: None, points: None, }]; rescale_annotations(&mut annotations, &previous, ¤t); @@ -4246,6 +4238,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 +4274,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..aa177e27b06 --- /dev/null +++ b/apps/desktop-gpui/src/screenshot_annotations/mask.rs @@ -0,0 +1,930 @@ +use cap_gpui_kernels::mask_style::{MaskEffect, MaskStyle}; +use cap_project::{MaskOptions, MaskShape}; + +use super::*; + +const TINT_ON_COLOR_PICK: f64 = 0.4; +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 +/// 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 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::Sensitive => tr::editor::panel::mask::sensitive(), + Self::Highlight => tr::editor::panel::mask::highlight(), + } + } + + /// 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::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 + } +} + +/// `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 { + Rectangle, + Ellipse, +} + +impl MaskShapeChoice { + const ALL: [MaskShapeChoice; 2] = [Self::Rectangle, Self::Ellipse]; + + fn of(options: &MaskOptions) -> Self { + match options.shape { + MaskShape::Ellipse => Self::Ellipse, + MaskShape::Rectangle => Self::Rectangle, + } + } + + fn id(self) -> &'static str { + match self { + Self::Rectangle => "rectangle", + Self::Ellipse => "ellipse", + } + } + + fn icon(self) -> &'static str { + match self { + Self::Rectangle => "icons/square.svg", + Self::Ellipse => "icons/circle.svg", + } + } + + fn label(self) -> &'static str { + match self { + Self::Rectangle => tr::screenshot_editor::mask::shape_rectangle(), + Self::Ellipse => tr::screenshot_editor::mask::shape_ellipse(), + } + } + + fn apply(self, options: &mut MaskOptions) -> bool { + let shape = match self { + Self::Rectangle => MaskShape::Rectangle, + Self::Ellipse => MaskShape::Ellipse, + }; + if options.shape == shape { + return false; + } + options.shape = shape; + true + } +} + +/// 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 + } +} + +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) + } +} + +pub(super) fn set_highlight_darkness(annotation: &mut Annotation, darkness: f64) -> bool { + if highlight_darkness(annotation) == darkness { + return false; + } + let options = annotation.mask_options.get_or_insert_with(Default::default); + if options.solid { + annotation.opacity = darkness; + } else { + options.tint = darkness; + } + true +} + +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 +/// `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(), + } +} + +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 { + 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, + ); + } + + pub(super) fn ensure_mask_tint_visible(options: &mut MaskOptions) -> bool { + if options.solid || options.invert || options.tint > 0. { + return false; + } + options.tint = TINT_ON_COLOR_PICK; + true + } + + fn toggle_mask_settings(&mut self, cx: &mut Context) { + let Some(id) = self.selected_annotation.clone() else { + return; + }; + let open = self.annotation_state.mask_settings.as_deref() == Some(id.as_str()); + self.annotation_state.color_popover = None; + self.annotation_state.mask_settings = (!open).then_some(id); + cx.notify(); + } + + pub(super) fn render_mask_controls( + &self, + annotation: &Annotation, + cx: &mut Context, + ) -> Vec { + let theme = self.theme; + let kind = MaskKindChoice::of(annotation); + let options = annotation.mask_options.clone().unwrap_or_default(); + + 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-kind-{}", + if choice == MaskKindChoice::Sensitive { + "sensitive" + } else { + "highlight" + } + ), + Some(choice.label().into()), + None, + choice == kind, + cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.edit_selected_mask(|annotation| choice.apply(annotation), window, cx); + }), + )); + } + let mut items = vec![kinds.into_any_element(), bar_divider(&theme)]; + let mut mode: Vec = Vec::new(); + + 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, + ); + }), + )); + } + mode.push(pills.into_any_element()); + if style == MaskStyleChoice::Solid { + mode.push(mask_item( + &theme, + tr::screenshot_editor::style::color(), + None, + self.render_color_button(ColorTarget::Mask, cx), + )); + } else { + mode.push(mask_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 => { + mode.push(mask_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(), + )); + mode.push(mask_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( + 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.)); + 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(shapes.into_any_element()); + + let cell = self.annotation_state.mask_settings_anchor.clone(); + let open = self.annotation_state.mask_settings.is_some(); + items.push( + div() + .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(), + ); + + vec![ + div() + .flex() + .flex_row() + .items_center() + .gap(px(12.)) + .flex_shrink_0() + .children(items) + .into_any_element(), + ] + } + + /// 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 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)?; + let theme = self.theme; + let options = annotation.mask_options.clone().unwrap_or_default(); + let viewport = window.viewport_size(); + let anchor = self.annotation_state.mask_settings_anchor.get(); + let left = anchor + .map(|bounds| f32::from(bounds.origin.x + bounds.size.width) - SETTINGS_WIDTH) + .unwrap_or(16.) + .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 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, + None, + 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, + None, + cx, + )); + } + if !options.invert && !options.solid { + 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( + div() + .absolute() + .top_0() + .left_0() + .size_full() + .child( + div() + .id("screenshot-mask-settings-backdrop") + .occlude() + .absolute() + .top_0() + .left_0() + .size_full() + .on_click(cx.listener(|this, _, _window, cx| { + this.annotation_state.mask_settings = None; + cx.notify(); + })), + ) + .child( + div() + .id("screenshot-mask-settings-popover") + .occlude() + .absolute() + .left(px(left)) + .top(px(top)) + .w(px(SETTINGS_WIDTH)) + .overflow_hidden() + .rounded(px(12.)) + .border_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(10.)), + blur_radius: px(30.), + spread_radius: px(0.), + inset: false, + }]) + .on_click(|_, _window, cx| cx.stop_propagation()) + .child(body), + ) + .into_any_element(), + ) + } + + /// `slider_row` from the header popovers, over an annotation slider. + fn mask_slider_row( + &self, + label: &'static str, + value: String, + slider: AnnotationSlider, + accessory: Option, + cx: &mut Context, + ) -> AnyElement { + let theme = self.theme; + div() + .flex() + .flex_col() + .gap(px(8.)) + .child( + div() + .flex() + .flex_row() + .items_center() + .justify_between() + .text_size(px(12.)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme.gray_11) + .child(label) + .child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(8.)) + .children(accessory) + .child(value), + ), + ) + .child( + self.annotation_slider(slider, px(SETTINGS_WIDTH - 32.), cx) + .into_any_element(), + ) + .into_any_element() + } +} + +/// `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.)) + .h(px(20.)) + .flex_shrink_0() + .bg(theme.gray_4) + .into_any_element() +} + +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() + .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 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 highlight_blur_moves_darkness_between_opacity_and_tint() { + let mut annotation = mask(); + MaskKindChoice::Highlight.apply(&mut annotation); + assert!(set_highlight_darkness(&mut annotation, 0.8)); + assert_eq!(annotation.opacity, 0.8); + + 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] + 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 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(); + 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 = 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/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" <&2; exit 2 ;; esac