From 172b5b818b63683a22f395b4ad000756982a9db1 Mon Sep 17 00:00:00 2001 From: aditya sharma Date: Tue, 9 Jun 2026 19:15:17 -0700 Subject: [PATCH 1/3] fix(windows): resolve gpui windows build Disambiguate external util imports and avoid borrowing the renderer immutably while direct composition is mutably borrowed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/gpui_windows/src/direct_manipulation.rs | 2 +- crates/gpui_windows/src/directx_renderer.rs | 3 ++- crates/gpui_windows/src/util.rs | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/gpui_windows/src/direct_manipulation.rs b/crates/gpui_windows/src/direct_manipulation.rs index 86a8332..2d6dac9 100644 --- a/crates/gpui_windows/src/direct_manipulation.rs +++ b/crates/gpui_windows/src/direct_manipulation.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use anyhow::Result; use gpui::*; -use util::ResultExt; +use ::util::ResultExt; use windows::Win32::{ Foundation::*, Graphics::{DirectManipulation::*, Gdi::*}, diff --git a/crates/gpui_windows/src/directx_renderer.rs b/crates/gpui_windows/src/directx_renderer.rs index ab8eec9..8920cc0 100644 --- a/crates/gpui_windows/src/directx_renderer.rs +++ b/crates/gpui_windows/src/directx_renderer.rs @@ -327,6 +327,7 @@ impl DirectXRenderer { } fn update_retained_layer_cache(&mut self, scene: &Scene) -> Result<()> { + let supports_retained_layer_scene = self.supports_retained_layer_scene(scene); let Some(direct_composition) = self.direct_composition.as_mut() else { return Ok(()); }; @@ -337,7 +338,7 @@ impl DirectXRenderer { .swap_chain .clone(); - if !self.supports_retained_layer_scene(scene) { + if !supports_retained_layer_scene { direct_composition.disable_retained_layers(&swap_chain)?; return Ok(()); } diff --git a/crates/gpui_windows/src/util.rs b/crates/gpui_windows/src/util.rs index dba3813..ac211e2 100644 --- a/crates/gpui_windows/src/util.rs +++ b/crates/gpui_windows/src/util.rs @@ -1,7 +1,7 @@ use std::sync::OnceLock; use anyhow::Context; -use util::ResultExt; +use ::util::ResultExt; use windows::{ UI::{ Color, From cb6e2c584900d7ed9aa816e8805597e65b51de40 Mon Sep 17 00:00:00 2001 From: aditya sharma Date: Tue, 9 Jun 2026 20:29:37 -0700 Subject: [PATCH 2/3] fix(gpui): dispatch a11y click semantically Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/gpui/src/elements/div.rs | 54 ++++++++++++++++++++++++++++++--- crates/gpui/src/window.rs | 20 ------------ 2 files changed, 49 insertions(+), 25 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index e3ef38b..52975e0 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -2175,6 +2175,18 @@ impl Interactivity { |window| { window.with_tab_group(tab_group, |window| { if let Some(hitbox) = hitbox { + let current_a11y_node_id = if window + .a11y + .is_active() + && self.current_a11y_node + { + global_id.and_then(|global_id| { + window.a11y.node_id_for_existing(global_id) + }) + } else { + None + }; + #[cfg(debug_assertions)] self.paint_debug_info( global_id, hitbox, &style, window, cx, @@ -2206,6 +2218,7 @@ impl Interactivity { self.paint_mouse_listeners( hitbox, element_state.as_mut(), + current_a11y_node_id, window, cx, ); @@ -2392,6 +2405,7 @@ impl Interactivity { &mut self, hitbox: &Hitbox, element_state: Option<&mut InteractiveElementState>, + a11y_node_id: Option, window: &mut Window, cx: &mut App, ) { @@ -2512,6 +2526,30 @@ impl Interactivity { let aux_click_listeners = mem::take(&mut self.aux_click_listeners); let can_drop_predicate = mem::take(&mut self.can_drop_predicate); + let has_explicit_a11y_click_listener = self.a11y_state.as_ref().is_some_and(|a11y_state| { + a11y_state + .action_listeners + .iter() + .any(|(action, _)| *action == accesskit::Action::Click) + }); + if let Some(node_id) = a11y_node_id + && !click_listeners.is_empty() + && !has_explicit_a11y_click_listener + { + let click_listeners = click_listeners.clone(); + let bounds = hitbox.bounds; + window.on_a11y_action(node_id, accesskit::Action::Click, move |_, window, cx| { + let click_event = ClickEvent::Keyboard(KeyboardClickEvent { + button: KeyboardButton::Enter, + bounds, + }); + + for listener in &click_listeners { + listener(&click_event, window, cx); + } + }); + } + if !drop_listeners.is_empty() { let hitbox = hitbox.clone(); window.on_mouse_event({ @@ -4194,19 +4232,24 @@ mod tests { } #[gpui::test] - fn a11y_translated_clickable_div_updates_bounds_and_clicks_at_translated_center( + fn a11y_translated_clickable_div_invokes_click_listener_without_mouse_event( cx: &mut TestAppContext, ) { - let clicked_at = Rc::new(Cell::new(None)); + let click_count = Rc::new(Cell::new(0)); + let mouse_position = Rc::new(Cell::new(None)); let cx = cx.add_empty_window(); let update = draw_accessible(cx, point(px(0.), px(0.)), size(px(100.), px(100.)), { - let clicked_at = clicked_at.clone(); + let click_count = click_count.clone(); + let mouse_position = mouse_position.clone(); move |_, _| { div() .id("translated-button") .role(accesskit::Role::Button) - .on_click(move |event, _, _| clicked_at.set(Some(event.position()))) + .on_click(move |event, _, _| { + click_count.set(click_count.get() + 1); + mouse_position.set(event.mouse_position()); + }) .translate(px(10.), px(20.)) .w(px(40.)) .h(px(30.)) @@ -4244,7 +4287,8 @@ mod tests { ); }); - assert_eq!(clicked_at.get(), Some(point(px(30.), px(35.)))); + assert_eq!(click_count.get(), 1); + assert_eq!(mouse_position.get(), None); } #[gpui::test] diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 32c8fdb..3a504f9 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -5414,26 +5414,6 @@ impl Window { } match request.action { - accesskit::Action::Click => { - if let Some(bounds) = self.a11y.node_bounds.get(&request.target_node).copied() { - let center = bounds.center(); - let mouse_down = PlatformInput::MouseDown(crate::MouseDownEvent { - button: MouseButton::Left, - position: center, - modifiers: Modifiers::default(), - click_count: 1, - first_mouse: false, - }); - let mouse_up = PlatformInput::MouseUp(MouseUpEvent { - button: MouseButton::Left, - position: center, - modifiers: Modifiers::default(), - click_count: 1, - }); - self.dispatch_event(mouse_down, cx); - self.dispatch_event(mouse_up, cx); - } - } accesskit::Action::Focus => { if let Some(focus_id) = self.a11y.focus_ids.get(&request.target_node).copied() && let Some(handle) = FocusHandle::for_id(focus_id, &cx.focus_handles) From b5cfaa99fcb6ef1e684ed457b27e4002580911e5 Mon Sep 17 00:00:00 2001 From: aditya sharma Date: Tue, 9 Jun 2026 21:34:35 -0700 Subject: [PATCH 3/3] feat(gpui): add cross-platform rounded blur overlay with backdrop brush support Implement HostBackdropBrush-style blur across Windows (WinComp), Linux (Wayland/kwin), and macOS (NSVisualEffectView) backends. Includes rounded_blur_overlay example demonstrating the feature. --- crates/gpui/examples/rounded_blur_overlay.rs | 80 +++++ crates/gpui/src/platform.rs | 47 ++- crates/gpui_linux/src/linux/wayland/window.rs | 161 +++++++++- crates/gpui_macos/src/window.rs | 114 +++++-- crates/gpui_windows/Cargo.toml | 7 +- .../gpui_windows/src/direct_manipulation.rs | 2 +- crates/gpui_windows/src/directx_renderer.rs | 296 +++++++++++++++++- crates/gpui_windows/src/events.rs | 14 + crates/gpui_windows/src/util.rs | 2 +- crates/gpui_windows/src/window.rs | 70 ++++- 10 files changed, 745 insertions(+), 48 deletions(-) create mode 100644 crates/gpui/examples/rounded_blur_overlay.rs diff --git a/crates/gpui/examples/rounded_blur_overlay.rs b/crates/gpui/examples/rounded_blur_overlay.rs new file mode 100644 index 0000000..e716be2 --- /dev/null +++ b/crates/gpui/examples/rounded_blur_overlay.rs @@ -0,0 +1,80 @@ +//! Rounded native blur overlay. +//! +//! Shows a pill-shaped overlay window whose background is blurred by the OS +//! compositor, with the blur clipped to a rounded rectangle. This is created +//! with `cx.open_overlay_surface` + `OverlaySurfaceOptions` using +//! `WindowBackgroundAppearance::Blurred { corner_radius }`. +//! +//! The window is 360x72 logical px and the corner radius is 36 (half the +//! height), which turns the rounded rect into a perfect pill. +//! +//! Platform degradation matrix for `Blurred { corner_radius }`: +//! - Windows: acrylic accent clipped to a rounded HWND region. GDI regions are +//! aliased, so the region edge looks jagged; the root view is rendered with a +//! matching radius to cover it (see comment below). +//! - macOS 12+: NSVisualEffectView masked to the rounded rect. macOS < 12 has no +//! mask API, so it degrades to unmasked (rectangular) window-background blur. +//! - Linux Wayland: KDE blur protocol with a region approximating the rounded +//! rect; compositors without blur support degrade to plain transparency. +//! - X11 / web: degrade to plain transparency (no blur, no artifact). +//! +//! Quit with Ctrl+C. + +use gpui::{ + App, Context, OverlayInputMode, OverlaySurfaceOptions, Window, WindowBackgroundAppearance, + WindowBounds, div, hsla, prelude::*, px, rgb, rgba, size, +}; +use gpui_platform::application; + +/// Window size: the corner radius is half the height so the rounded rect is a pill. +const PILL_WIDTH: gpui::Pixels = px(360.0); +const PILL_HEIGHT: gpui::Pixels = px(72.0); +const CORNER_RADIUS: gpui::Pixels = px(36.0); + +struct RoundedBlurOverlay; + +impl Render for RoundedBlurOverlay { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + .size_full() + // The element radius MUST exactly match the window `corner_radius`. + // On Windows the compositor blur is clipped by an aliased GDI region; + // drawing our own rounded content at the same radius hides that jagged + // region edge behind the crisp, anti-aliased element border. + .rounded(CORNER_RADIUS) + // Semi-transparent fill lets the blurred desktop show through. + .bg(rgba(0x00000040)) + .border_1() + .border_color(hsla(0.0, 0.0, 1.0, 0.20)) + .flex() + .items_center() + .justify_center() + .text_color(rgb(0xffffff)) + .child("Rounded native blur") + } +} + +fn main() { + application().run(|cx: &mut App| { + let bounds = WindowBounds::centered(size(PILL_WIDTH, PILL_HEIGHT), cx); + + cx.open_overlay_surface( + OverlaySurfaceOptions { + window_bounds: Some(bounds), + window_background: WindowBackgroundAppearance::Blurred { + corner_radius: CORNER_RADIUS, + }, + show: true, + focus: true, + has_shadow: Some(false), + // Interactive so the overlay can be clicked/focused (not click-through). + input_mode: OverlayInputMode::Interactive, + ..Default::default() + }, + |_, cx| cx.new(|_| RoundedBlurOverlay), + ) + .unwrap(); + + cx.activate(true); + }); +} diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index a306fc1..283baf8 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -1356,6 +1356,10 @@ pub struct OverlaySurfaceOptions { /// Whether the overlay should be focused when created. pub focus: bool, /// The appearance of the overlay background. + /// + /// Use `WindowBackgroundAppearance::Blurred { corner_radius }` to enable + /// native compositor blur clipped to a rounded rect — ideal for pill-shaped + /// overlays that should not show a rectangular blur gutter behind them. pub window_background: WindowBackgroundAppearance, /// Whether platform shadow should be enabled. pub has_shadow: Option, @@ -1608,10 +1612,25 @@ pub enum WindowBackgroundAppearance { Opaque, /// Plain alpha transparency. Transparent, - /// Transparency, but the contents behind the window are blurred. + /// Plain alpha transparency with the desktop/window content behind the + /// window blurred by the OS compositor. + /// + /// `corner_radius` (logical pixels) clips the native blur to a rounded + /// rectangle so small pill-shaped overlays do not show a rectangular blur + /// gutter; `px(0.)` keeps the full rectangular window blur (legacy behavior). /// - /// Not always supported. - Blurred, + /// Platform support: + /// - Windows: acrylic accent, clipped via a rounded HWND region (GDI regions + /// are aliased; render your content rounded at the same radius to hide the edge). + /// - macOS 12+: NSVisualEffectView with a rounded mask image; macOS < 12 + /// degrades to unmasked window-background blur. + /// - Linux Wayland: KDE blur protocol with a region approximating the rounded + /// rect; compositors without blur support degrade to plain transparency. + /// - X11 / web: degrades to plain transparency (no blur, no artifact). + Blurred { + /// Corner radius of the blur clip region, in logical pixels. `px(0.)` = rectangular. + corner_radius: Pixels, + }, /// The Mica backdrop material, supported on Windows 11. MicaBackdrop, /// The Mica Alt backdrop material, supported on Windows 11. @@ -2182,3 +2201,25 @@ impl From for ClipboardString { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn overlay_surface_options_preserve_blurred_rounded_background() { + let options = OverlaySurfaceOptions { + window_background: WindowBackgroundAppearance::Blurred { + corner_radius: px(12.), + }, + ..Default::default() + }; + let window_options = options.into_window_options(); + assert_eq!( + window_options.window_background, + WindowBackgroundAppearance::Blurred { + corner_radius: px(12.) + } + ); + } +} diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index 5c45d3d..dbf663b 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -969,7 +969,7 @@ impl WaylandWindowStatePtr { } pub fn set_size_and_scale(&self, size: Option>, scale: Option) { - let (size, scale) = { + let (size, scale, needs_blur_update) = { let mut state = self.state.borrow_mut(); if size.is_none_or(|size| size == state.bounds.size) && scale.is_none_or(|scale| scale == state.scale) @@ -985,7 +985,13 @@ impl WaylandWindowStatePtr { state.update_accesskit_window_bounds(); let device_bounds = state.bounds.to_device_pixels(state.scale); state.renderer.update_drawable_size(device_bounds.size); - (state.bounds.size, state.scale) + // A rounded blur region depends on the window size, so it must be + // rebuilt on resize. Capture the flag before dropping the borrow. + let needs_blur_update = matches!( + state.background_appearance, + WindowBackgroundAppearance::Blurred { corner_radius } if corner_radius > px(0.) + ); + (state.bounds.size, state.scale, needs_blur_update) }; if let Some(ref mut fun) = self.callbacks.borrow_mut().resize { @@ -999,6 +1005,10 @@ impl WaylandWindowStatePtr { .set_destination(f32::from(size.width) as i32, f32::from(size.height) as i32); } } + + if needs_blur_update { + update_window(self.state.borrow_mut()); + } } pub fn resize(&self, size: Size) { @@ -1534,6 +1544,39 @@ impl PlatformWindow for WaylandWindow { } } +/// Approximates a rounded rectangle as horizontal bands for building a +/// `wl_region`. Returns `(x, y, width, height)` rects in surface-local +/// logical coordinates. `radius` is clamped to `min(width, height) / 2`; +/// radius 0 yields a single full rect. +pub(crate) fn rounded_rect_region_bands( + width: i32, + height: i32, + radius: i32, +) -> Vec<(i32, i32, i32, i32)> { + if width <= 0 || height <= 0 { + return Vec::new(); + } + let r = radius.clamp(0, width.min(height) / 2); + if r == 0 { + return vec![(0, 0, width, height)]; + } + + let rf = r as f64; + let mut bands = Vec::with_capacity((2 * r + 1) as usize); + for y in 0..r { + // Sample the band's vertical center to pick a representative inset. + let dy = rf - y as f64 - 0.5; + let inset = (rf - (rf * rf - dy * dy).sqrt()).round() as i32; + let band_width = width - 2 * inset; + // Top band, then its mirror at the bottom. + bands.push((inset, y, band_width, 1)); + bands.push((inset, height - 1 - y, band_width, 1)); + } + // Middle slab between the two rounded ends. + bands.push((0, r, width, height - 2 * r)); + bands +} + fn update_window(mut state: RefMut) { let opaque = !state.is_transparent(); @@ -1580,17 +1623,38 @@ fn update_window(mut state: RefMut) { state.surface.set_input_region(Some(&input_region)); if let Some(ref blur_manager) = state.globals.blur_manager { - if state.background_appearance == WindowBackgroundAppearance::Blurred { - if state.blur.is_none() { - let blur = blur_manager.create(&state.surface, &state.globals.qh, ()); - state.blur = Some(blur); + match state.background_appearance { + WindowBackgroundAppearance::Blurred { corner_radius } => { + if state.blur.is_none() { + let blur = blur_manager.create(&state.surface, &state.globals.qh, ()); + state.blur = Some(blur); + } + // Clip the blur to a rounded rect approximated by horizontal + // bands, using the same logical window size as the opaque region. + let bounds = state.window_bounds.map(|v| f32::from(v) as i32); + let radius = f32::from(corner_radius) as i32; + let region = state + .globals + .compositor + .create_region(&state.globals.qh, ()); + for (x, y, w, h) in + rounded_rect_region_bands(bounds.size.width, bounds.size.height, radius) + { + region.add(x, y, w, h); + } + let blur = state.blur.as_ref().unwrap(); + // Always set the region explicitly so a radius change clears any + // stale region; a null region would mean "whole surface" to KWin. + blur.set_region(Some(®ion)); + blur.commit(); + region.destroy(); } - state.blur.as_ref().unwrap().commit(); - } else { - // It probably doesn't hurt to clear the blur for opaque windows - blur_manager.unset(&state.surface); - if let Some(b) = state.blur.take() { - b.release() + _ => { + // It probably doesn't hurt to clear the blur for opaque windows + blur_manager.unset(&state.surface); + if let Some(b) = state.blur.take() { + b.release() + } } } } @@ -1675,3 +1739,76 @@ fn inset_by_tiling(mut bounds: Bounds, inset: Pixels, tiling: Tiling) -> bounds } + +#[cfg(test)] +mod tests { + use super::rounded_rect_region_bands; + use std::collections::HashMap; + + #[test] + fn radius_zero_is_single_full_rect() { + assert_eq!(rounded_rect_region_bands(120, 80, 0), vec![(0, 0, 120, 80)]); + } + + #[test] + fn degenerate_sizes_yield_empty() { + assert!(rounded_rect_region_bands(0, 80, 10).is_empty()); + assert!(rounded_rect_region_bands(120, 0, 10).is_empty()); + assert!(rounded_rect_region_bands(-5, 80, 10).is_empty()); + } + + #[test] + fn band_count_is_two_r_plus_one() { + let (w, h, r) = (200, 200, 30); + assert!(r < w.min(h) / 2); + let bands = rounded_rect_region_bands(w, h, r); + assert_eq!(bands.len() as i32, 2 * r + 1); + } + + #[test] + fn top_bottom_symmetry() { + let (w, h, r) = (200, 120, 25); + let bands = rounded_rect_region_bands(w, h, r); + // Map each band's y to its (x, width). + let by_y: HashMap = + bands.iter().map(|&(x, y, bw, _)| (y, (x, bw))).collect(); + for y in 0..r { + let top = by_y[&y]; + let bottom = by_y[&(h - 1 - y)]; + assert_eq!(top, bottom, "rows {y} and {} differ", h - 1 - y); + } + } + + #[test] + fn no_negative_widths_and_insets_in_range() { + let (w, h, r) = (200, 120, 25); + let bands = rounded_rect_region_bands(w, h, r); + for &(x, _y, bw, bh) in &bands { + assert!(bw >= 0, "negative width {bw}"); + assert!(bh >= 0, "negative height {bh}"); + // x is the inset for the rounded rows; must stay within [0, r]. + assert!(x >= 0 && x <= r, "inset {x} out of range"); + } + } + + #[test] + fn oversized_radius_clamps() { + // min(width, height) / 2 == 25, so radius 100 behaves as radius 25. + assert_eq!( + rounded_rect_region_bands(200, 50, 100), + rounded_rect_region_bands(200, 50, 25) + ); + } + + #[test] + fn insets_are_monotonic() { + let (w, h, r) = (200, 120, 25); + let bands = rounded_rect_region_bands(w, h, r); + let by_y: HashMap = bands.iter().map(|&(x, y, _, _)| (y, x)).collect(); + // Inset is widest at the very edge (y = 0) and shrinks toward the slab. + let first = by_y[&0]; + let last = by_y[&(r - 1)]; + assert!(first >= last, "inset(0)={first} < inset(r-1)={last}"); + assert!(last >= 0); + } +} diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 60dfe11..6f48b8d 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -417,6 +417,7 @@ struct MacWindowState { native_window: id, native_view: NonNull, blurred_view: Option, + blurred_view_corner_radius: Option, background_appearance: WindowBackgroundAppearance, display_link: Option, renderer: renderer::Renderer, @@ -739,6 +740,7 @@ impl MacWindow { native_window, native_view: NonNull::new_unchecked(native_view), blurred_view: None, + blurred_view_corner_radius: None, background_appearance: WindowBackgroundAppearance::Opaque, display_link: None, renderer: renderer::new_renderer( @@ -1423,7 +1425,12 @@ impl PlatformWindow for MacWindow { // Whether `-[NSVisualEffectView respondsToSelector:@selector(_updateProxyLayer)]`. // On macOS Catalina/Big Sur `NSVisualEffectView` doesn’t own concrete sublayers // but uses a `CAProxyLayer`. Use the legacy WindowServer API. - let blur_radius = if background_appearance == WindowBackgroundAppearance::Blurred { + // Any rounded `corner_radius` is ignored here: the CGS window blur can't be + // masked (it follows the window alpha), so it degrades to a rectangular blur. + let blur_radius = if matches!( + background_appearance, + WindowBackgroundAppearance::Blurred { .. } + ) { 80 } else { 0 @@ -1435,25 +1442,45 @@ impl PlatformWindow for MacWindow { // On newer macOS `NSVisualEffectView` manages the effect layer directly. Using it // could have a better performance (it downsamples the backdrop) and more control // over the effect layer. - if background_appearance != WindowBackgroundAppearance::Blurred { - if let Some(blur_view) = this.blurred_view { - NSView::removeFromSuperview(blur_view); - this.blurred_view = None; - } - } else if this.blurred_view.is_none() { - let content_view = this.native_window.contentView(); - let frame = NSView::bounds(content_view); - let mut blur_view: id = msg_send![BLURRED_VIEW_CLASS, alloc]; - blur_view = NSView::initWithFrame_(blur_view, frame); - blur_view.setAutoresizingMask_(NSViewWidthSizable | NSViewHeightSizable); + if let WindowBackgroundAppearance::Blurred { corner_radius } = background_appearance + { + let blur_view = if let Some(blur_view) = this.blurred_view { + blur_view + } else { + let content_view = this.native_window.contentView(); + let frame = NSView::bounds(content_view); + let mut blur_view: id = msg_send![BLURRED_VIEW_CLASS, alloc]; + blur_view = NSView::initWithFrame_(blur_view, frame); + blur_view.setAutoresizingMask_(NSViewWidthSizable | NSViewHeightSizable); - let _: () = msg_send![ - content_view, - addSubview: blur_view - positioned: NSWindowOrderingMode::NSWindowBelow - relativeTo: nil - ]; - this.blurred_view = Some(blur_view.autorelease()); + let _: () = msg_send![ + content_view, + addSubview: blur_view + positioned: NSWindowOrderingMode::NSWindowBelow + relativeTo: nil + ]; + let blur_view = blur_view.autorelease(); + this.blurred_view = Some(blur_view); + blur_view + }; + + // The mask image is set once; the view's width/height autoresizing stretches + // its 9-slice cap insets to fit, so no resize hook is needed. Rebuild only + // when the radius actually changes. + if corner_radius > px(0.) { + if this.blurred_view_corner_radius != Some(corner_radius) { + let mask = rounded_rect_mask_image(corner_radius.to_f64()); + let _: () = msg_send![blur_view, setMaskImage: mask]; + this.blurred_view_corner_radius = Some(corner_radius); + } + } else if this.blurred_view_corner_radius.is_some() { + let _: () = msg_send![blur_view, setMaskImage: nil]; + this.blurred_view_corner_radius = None; + } + } else if let Some(blur_view) = this.blurred_view { + NSView::removeFromSuperview(blur_view); + this.blurred_view = None; + this.blurred_view_corner_radius = None; } } } @@ -2832,6 +2859,55 @@ unsafe fn display_id_for_screen(screen: id) -> CGDirectDisplayID { } } +// `NSEdgeInsets` is not exported by the `cocoa` crate, so mirror AppKit's declaration. +// Fields are `CGFloat` (f64 on 64-bit) in the order top/left/bottom/right. +#[repr(C)] +struct NSEdgeInsets { + top: f64, + left: f64, + bottom: f64, + right: f64, +} + +/// Builds a resizable (9-slice) rounded-rect mask image for +/// `NSVisualEffectView.maskImage`. Radius is in points (== logical pixels). +unsafe fn rounded_rect_mask_image(radius: f64) -> id { + unsafe { + let size = NSSize::new(radius * 2.0 + 1.0, radius * 2.0 + 1.0); + let handler = ConcreteBlock::new(move |rect: NSRect| -> BOOL { + let path: id = msg_send![ + class!(NSBezierPath), + bezierPathWithRoundedRect: rect + xRadius: radius + yRadius: radius + ]; + let color: id = msg_send![class!(NSColor), blackColor]; + let _: () = msg_send![color, set]; + let _: () = msg_send![path, fill]; + YES + }); + let handler = handler.copy(); + let image: id = msg_send![ + class!(NSImage), + imageWithSize: size + flipped: NO + drawingHandler: handler + ]; + let _: () = msg_send![ + image, + setCapInsets: NSEdgeInsets { + top: radius, + left: radius, + bottom: radius, + right: radius, + } + ]; + // NSImageResizingModeStretch = 1 + let _: () = msg_send![image, setResizingMode: 1i64]; + image + } +} + extern "C" fn blurred_view_init_with_frame(this: &Object, _: Sel, frame: NSRect) -> id { unsafe { let view = msg_send![super(this, class!(NSVisualEffectView)), initWithFrame: frame]; diff --git a/crates/gpui_windows/Cargo.toml b/crates/gpui_windows/Cargo.toml index f5a93e7..bfb404a 100644 --- a/crates/gpui_windows/Cargo.toml +++ b/crates/gpui_windows/Cargo.toml @@ -35,7 +35,12 @@ raw-window-handle = "0.6" smallvec.workspace = true util.workspace = true uuid.workspace = true -windows.workspace = true +windows = { workspace = true, features = [ + "UI_Composition", + "UI_Composition_Desktop", + "System", + "Win32_System_WinRT_Composition", +] } windows-core.workspace = true windows-numerics = "0.2" windows-registry = "0.5" diff --git a/crates/gpui_windows/src/direct_manipulation.rs b/crates/gpui_windows/src/direct_manipulation.rs index 2d6dac9..6935ef7 100644 --- a/crates/gpui_windows/src/direct_manipulation.rs +++ b/crates/gpui_windows/src/direct_manipulation.rs @@ -1,9 +1,9 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; +use ::util::ResultExt; use anyhow::Result; use gpui::*; -use ::util::ResultExt; use windows::Win32::{ Foundation::*, Graphics::{DirectManipulation::*, Gdi::*}, diff --git a/crates/gpui_windows/src/directx_renderer.rs b/crates/gpui_windows/src/directx_renderer.rs index 8920cc0..2040188 100644 --- a/crates/gpui_windows/src/directx_renderer.rs +++ b/crates/gpui_windows/src/directx_renderer.rs @@ -8,6 +8,11 @@ use std::{ use ::util::ResultExt; use anyhow::{Context, Result}; use windows::{ + System::DispatcherQueueController, + UI::Composition::{ + CompositionRoundedRectangleGeometry, Compositor, ContainerVisual, + Desktop::DesktopWindowTarget, SpriteVisual, + }, Win32::{ Foundation::HWND, Graphics::{ @@ -17,9 +22,15 @@ use windows::{ DirectWrite::*, Dxgi::{Common::*, *}, }, + System::WinRT::{ + Composition::{ICompositorDesktopInterop, ICompositorInterop}, + CreateDispatcherQueueController, DQTAT_COM_NONE, DQTYPE_THREAD_CURRENT, + DispatcherQueueOptions, + }, }, core::{IUnknown, Interface}, }; +use windows_numerics::Vector2; use crate::directx_renderer::shader_resources::{RawShaderBytes, ShaderModule, ShaderTarget}; use crate::*; @@ -49,6 +60,16 @@ pub(crate) struct DirectXRenderer { globals: DirectXGlobalElements, pipelines: DirectXRenderPipelines, direct_composition: Option, + /// Windows.UI.Composition tree used for the rounded host-backdrop blur mode. + /// When `Some`, `direct_composition` is `None` (only one composition target + /// may exist per HWND) and the swap chain is presented through this tree. + rounded_backdrop: Option, + /// Logical (DIP) corner radius for the rounded backdrop, retained so the + /// device-pixel radius can be recomputed on resize / DPI change. + rounded_backdrop_radius: Option, + /// Last scale factor applied to the rounded backdrop, retained so the tree + /// can be rebuilt with the correct device-pixel radius after device loss. + rounded_backdrop_scale: f32, font_info: &'static FontInfo, width: u32, @@ -236,6 +257,9 @@ impl DirectXRenderer { globals, pipelines, direct_composition, + rounded_backdrop: None, + rounded_backdrop_radius: None, + rounded_backdrop_scale: 1.0, font_info: Self::get_font_info(), width: 1, height: 1, @@ -390,7 +414,11 @@ impl DirectXRenderer { } fn handle_device_lost_impl(&mut self, directx_devices: &DirectXDevices) -> Result<()> { - let disable_direct_composition = self.direct_composition.is_none(); + let rounded_active = self.rounded_backdrop.is_some(); + // The rounded backdrop mode still relies on a composition swap chain even + // though it has no `DirectComposition` target, so don't disable DComp + // resources when it is active. + let disable_direct_composition = self.direct_composition.is_none() && !rounded_active; unsafe { #[cfg(debug_assertions)] @@ -412,6 +440,7 @@ impl DirectXRenderer { } self.direct_composition.take(); + self.rounded_backdrop.take(); self.devices.take(); } @@ -430,7 +459,7 @@ impl DirectXRenderer { let pipelines = DirectXRenderPipelines::new(&devices.device) .context("Creating DirectXRenderPipelines")?; - let direct_composition = if disable_direct_composition { + let direct_composition = if disable_direct_composition || rounded_active { None } else { let mut composition = @@ -438,6 +467,21 @@ impl DirectXRenderer { composition.set_swap_chain(&resources.swap_chain)?; Some(composition) }; + let rounded_backdrop = if rounded_active { + let device_radius = + self.rounded_backdrop_radius.unwrap_or(0.0) * self.rounded_backdrop_scale; + RoundedBackdrop::new( + self.hwnd, + &resources.swap_chain, + self.width, + self.height, + device_radius, + ) + .context("Rebuilding rounded backdrop after device lost") + .log_err() + } else { + None + }; self.atlas .handle_device_lost(&devices.device, &devices.device_context); @@ -452,6 +496,7 @@ impl DirectXRenderer { self.globals = globals; self.pipelines = pipelines; self.direct_composition = direct_composition; + self.rounded_backdrop = rounded_backdrop; self.skip_draws = true; Ok(()) } @@ -658,6 +703,12 @@ impl DirectXRenderer { } pub(crate) fn update_transparency(&mut self, transparent: bool) { + // The rounded backdrop mode already composes the swap chain with per-pixel + // alpha through its own Windows.UI.Composition target; never create a + // competing DirectComposition target for the same HWND. + if self.rounded_backdrop.is_some() { + return; + } if !transparent || self.direct_composition.is_some() { return; } @@ -667,6 +718,102 @@ impl DirectXRenderer { .log_err(); } + /// Enables or disables the rounded host-backdrop blur mode. + /// + /// `Some(logical_radius)` builds (or updates) a Windows.UI.Composition tree + /// for this HWND: a backdrop `SpriteVisual` painted with a host-backdrop + /// brush and an antialiased rounded-rectangle clip, plus a content + /// `SpriteVisual` whose brush is a composition surface wrapping the existing + /// swap chain (clipped to the same rounded rect). Because only one + /// composition target may exist per HWND, the legacy `DirectComposition` + /// target is torn down while this mode is active. + /// + /// `None` tears the tree down and restores the plain `DirectComposition` + /// transparency path (if it was previously in use). + pub(crate) fn set_rounded_backdrop_blur( + &mut self, + logical_radius: Option, + scale_factor: f32, + ) -> Result<()> { + match logical_radius { + Some(radius) => { + self.rounded_backdrop_radius = Some(radius); + self.rounded_backdrop_scale = scale_factor; + let device_radius = radius * scale_factor; + + // Only one composition target per HWND: drop the DComp target. + self.direct_composition.take(); + + if let Some(backdrop) = self.rounded_backdrop.as_ref() { + backdrop.update_geometry(self.width, self.height, device_radius)?; + } else { + let swap_chain = self + .resources + .as_ref() + .context("resources missing")? + .swap_chain + .clone(); + let backdrop = RoundedBackdrop::new( + self.hwnd, + &swap_chain, + self.width, + self.height, + device_radius, + ) + .context("Creating rounded backdrop")?; + self.rounded_backdrop = Some(backdrop); + } + } + None => { + self.rounded_backdrop_radius = None; + if self.rounded_backdrop.take().is_some() { + // We tore down the DComp target when entering rounded mode; + // rebuild it so plain transparency keeps working. + self.rebuild_direct_composition() + .context("Restoring DirectComposition after rounded backdrop")?; + } + } + } + Ok(()) + } + + /// Recomputes the rounded backdrop clip geometry for the current device size + /// and scale factor. Called on resize / DPI change. No-op unless rounded + /// mode is active. + pub(crate) fn update_rounded_backdrop(&mut self, scale_factor: f32) -> Result<()> { + let Some(radius) = self.rounded_backdrop_radius else { + return Ok(()); + }; + self.rounded_backdrop_scale = scale_factor; + let device_radius = radius * scale_factor; + let (width, height) = (self.width, self.height); + if let Some(backdrop) = self.rounded_backdrop.as_ref() { + backdrop.update_geometry(width, height, device_radius)?; + } + Ok(()) + } + + fn rebuild_direct_composition(&mut self) -> Result<()> { + let devices = self.devices.as_ref().context("devices missing")?; + let dxgi_device = devices + .dxgi_device + .as_ref() + .context("dxgi device missing for DirectComposition")?; + let swap_chain = self + .resources + .as_ref() + .context("resources missing")? + .swap_chain + .clone(); + let mut composition = + DirectComposition::new(dxgi_device, self.hwnd).context("Creating DirectComposition")?; + composition + .set_swap_chain(&swap_chain) + .context("Setting swap chain for DirectComposition")?; + self.direct_composition = Some(composition); + Ok(()) + } + fn enable_direct_composition_for_transparency(&mut self) -> Result<()> { let devices = self.devices.as_ref().context("devices missing")?; let dxgi_device = devices @@ -1841,6 +1988,151 @@ impl DirectComposition { } } +thread_local! { + /// A `DispatcherQueue` must exist on the current thread before a + /// `Compositor` can be constructed. We create one lazily per thread and keep + /// it alive for the life of the thread (windows run their message loop on the + /// same thread, which pumps the queue). + static DISPATCHER_QUEUE_CONTROLLER: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +/// Ensures a `DispatcherQueue` exists for the current thread so a `Compositor` +/// can be created. Non-fatal: if a queue already exists (e.g. created elsewhere) +/// the call fails and we proceed, since `Compositor::new` will still succeed. +fn ensure_dispatcher_queue() { + DISPATCHER_QUEUE_CONTROLLER.with(|cell| { + let mut cell = cell.borrow_mut(); + if cell.is_some() { + return; + } + let options = DispatcherQueueOptions { + dwSize: std::mem::size_of::() as u32, + threadType: DQTYPE_THREAD_CURRENT, + apartmentType: DQTAT_COM_NONE, + }; + match unsafe { CreateDispatcherQueueController(options) } { + Ok(controller) => *cell = Some(controller), + Err(e) => { + log::warn!( + "CreateDispatcherQueueController failed (a queue may already exist): {e}" + ) + } + } + }); +} + +/// A Windows.UI.Composition tree that hosts the swap chain content above a +/// host-backdrop blur, both clipped to an antialiased rounded rectangle. +struct RoundedBackdrop { + // Held to keep the composition tree alive; not otherwise read. + _compositor: Compositor, + _target: DesktopWindowTarget, + _root: ContainerVisual, + _backdrop_visual: SpriteVisual, + _content_visual: SpriteVisual, + geometry: CompositionRoundedRectangleGeometry, +} + +impl RoundedBackdrop { + fn new( + hwnd: HWND, + swap_chain: &IDXGISwapChain1, + width: u32, + height: u32, + device_radius: f32, + ) -> Result { + ensure_dispatcher_queue(); + + let compositor = Compositor::new().context("Creating WinComp Compositor")?; + + let desktop_interop: ICompositorDesktopInterop = compositor + .cast() + .context("Casting Compositor to ICompositorDesktopInterop")?; + let target = unsafe { desktop_interop.CreateDesktopWindowTarget(hwnd, false) } + .context("Creating DesktopWindowTarget")?; + + let root = compositor + .CreateContainerVisual() + .context("Creating root ContainerVisual")?; + root.SetRelativeSizeAdjustment(Vector2 { X: 1.0, Y: 1.0 })?; + target.SetRoot(&root)?; + + // Shared rounded-rect geometry in physical pixels (DesktopWindowTarget + // visuals are not DPI-scaled). + let geometry = compositor + .CreateRoundedRectangleGeometry() + .context("Creating rounded rectangle geometry")?; + geometry.SetSize(Vector2 { + X: width as f32, + Y: height as f32, + })?; + geometry.SetCornerRadius(Vector2 { + X: device_radius, + Y: device_radius, + })?; + + let backdrop_clip = compositor + .CreateGeometricClipWithGeometry(&geometry) + .context("Creating backdrop geometric clip")?; + let content_clip = compositor + .CreateGeometricClipWithGeometry(&geometry) + .context("Creating content geometric clip")?; + + // Backdrop visual: host-backdrop blur clipped to the rounded rect. + let backdrop_visual = compositor + .CreateSpriteVisual() + .context("Creating backdrop SpriteVisual")?; + backdrop_visual.SetRelativeSizeAdjustment(Vector2 { X: 1.0, Y: 1.0 })?; + let host_brush = compositor + .CreateHostBackdropBrush() + .context("Creating host backdrop brush")?; + backdrop_visual.SetBrush(&host_brush)?; + backdrop_visual.SetClip(&backdrop_clip)?; + + // Content visual: the GPUI swap chain, also clipped to the rounded rect. + let content_visual = compositor + .CreateSpriteVisual() + .context("Creating content SpriteVisual")?; + content_visual.SetRelativeSizeAdjustment(Vector2 { X: 1.0, Y: 1.0 })?; + let surface_interop: ICompositorInterop = compositor + .cast() + .context("Casting Compositor to ICompositorInterop")?; + let surface = unsafe { surface_interop.CreateCompositionSurfaceForSwapChain(swap_chain) } + .context("Creating composition surface for swap chain")?; + let surface_brush = compositor + .CreateSurfaceBrushWithSurface(&surface) + .context("Creating surface brush")?; + content_visual.SetBrush(&surface_brush)?; + content_visual.SetClip(&content_clip)?; + + let children = root.Children()?; + children.InsertAtTop(&backdrop_visual)?; + children.InsertAtTop(&content_visual)?; + + Ok(Self { + _compositor: compositor, + _target: target, + _root: root, + _backdrop_visual: backdrop_visual, + _content_visual: content_visual, + geometry, + }) + } + + fn update_geometry(&self, width: u32, height: u32, device_radius: f32) -> Result<()> { + self.geometry.SetSize(Vector2 { + X: width as f32, + Y: height as f32, + })?; + self.geometry.SetCornerRadius(Vector2 { + X: device_radius, + Y: device_radius, + })?; + Ok(()) + } +} + impl DirectXGlobalElements { pub fn new(device: &ID3D11Device) -> Result { let global_params_buffer = unsafe { diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index 53a96da..3cb961a 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -222,6 +222,20 @@ impl WindowsWindowInner { callback(new_logical_size, scale_factor); self.state.callbacks.resize.set(Some(callback)); } + // Re-clip the rounded host-backdrop blur to the new size. This covers + // WM_SIZE and DPI changes (which round-trip through WM_SIZE or call this + // directly). The renderer recomputes the device-pixel corner radius from + // the current scale factor. + if let WindowBackgroundAppearance::Blurred { corner_radius } = + self.state.background_appearance.get() + && corner_radius > px(0.) + { + self.state + .renderer + .borrow_mut() + .update_rounded_backdrop(scale_factor) + .log_err(); + } } fn handle_size_move_loop(&self, handle: HWND) -> Option { diff --git a/crates/gpui_windows/src/util.rs b/crates/gpui_windows/src/util.rs index ac211e2..fe5093d 100644 --- a/crates/gpui_windows/src/util.rs +++ b/crates/gpui_windows/src/util.rs @@ -1,7 +1,7 @@ use std::sync::OnceLock; -use anyhow::Context; use ::util::ResultExt; +use anyhow::Context; use windows::{ UI::{ Color, diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 40b4b87..86f0da5 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -891,9 +891,15 @@ impl PlatformWindow for WindowsWindow { fn set_background_appearance(&self, background_appearance: WindowBackgroundAppearance) { self.state.background_appearance.set(background_appearance); let hwnd = self.0.hwnd; + let scale_factor = self.state.scale_factor.get(); let opaque = matches!(background_appearance, WindowBackgroundAppearance::Opaque); let corner_preference = match background_appearance { WindowBackgroundAppearance::Transparent => DWMWCP_DONOTROUND, + // We clip the acrylic blur to our own rounded region, so let Windows + // leave the frame square. Radius 0 keeps the legacy default rounding. + WindowBackgroundAppearance::Blurred { corner_radius } if corner_radius > px(0.) => { + DWMWCP_DONOTROUND + } _ => DWMWCP_DEFAULT, }; unsafe { @@ -907,32 +913,76 @@ impl PlatformWindow for WindowsWindow { // using Dwm APIs for Mica and MicaAlt backdrops. // others follow the set_window_composition_attribute approach + let mut renderer = self.state.renderer.borrow_mut(); match background_appearance { WindowBackgroundAppearance::Opaque => { set_window_composition_attribute(hwnd, None, 0); + renderer + .set_rounded_backdrop_blur(None, scale_factor) + .log_err(); } WindowBackgroundAppearance::Transparent => { set_window_composition_attribute(hwnd, Some((0, 0, 0, 0)), 2); // Ensure Windows 11 system backdrop is disabled for transparent overlays. dwm_set_window_composition_attribute(hwnd, 1); + renderer + .set_rounded_backdrop_blur(None, scale_factor) + .log_err(); } - WindowBackgroundAppearance::Blurred => { - set_window_composition_attribute(hwnd, Some((0, 0, 0, 0)), 4); + WindowBackgroundAppearance::Blurred { corner_radius } => { + if corner_radius > px(0.) { + // Documented enabler: DWM only feeds the host backdrop that + // `CreateHostBackdropBrush` samples when DWMWA_USE_HOSTBACKDROPBRUSH + // is set on the top-level HWND. + let use_host_backdrop: BOOL = true.into(); + unsafe { + DwmSetWindowAttribute( + hwnd, + DWMWA_USE_HOSTBACKDROPBRUSH, + &use_host_backdrop as *const _ as *const _, + std::mem::size_of::() as u32, + ) + } + .log_err(); + // Belt-and-suspenders: the undocumented accent state 5 + // (ACCENT_ENABLE_HOSTBACKDROP) also nudges DWM to provide the + // host backdrop on builds where the attribute alone is not + // enough. The rounded clip is applied by the + // Windows.UI.Composition tree in the renderer, so no GDI window + // region is used here. + // + // Note: when the OS "Transparency effects" setting is off, or + // under battery saver / Remote Desktop, HostBackdropBrush + // renders its fallback and the pill shows transparent (not + // blurred). That degradation is expected and needs no code. + set_window_composition_attribute(hwnd, Some((0, 0, 0, 0)), 5); + renderer + .set_rounded_backdrop_blur(Some(f32::from(corner_radius)), scale_factor) + .log_err(); + } else { + set_window_composition_attribute(hwnd, Some((0, 0, 0, 0)), 4); + renderer + .set_rounded_backdrop_blur(None, scale_factor) + .log_err(); + } } WindowBackgroundAppearance::MicaBackdrop => { // DWMSBT_MAINWINDOW => MicaBase dwm_set_window_composition_attribute(hwnd, 2); + renderer + .set_rounded_backdrop_blur(None, scale_factor) + .log_err(); } WindowBackgroundAppearance::MicaAltBackdrop => { // DWMSBT_TABBEDWINDOW => MicaAlt dwm_set_window_composition_attribute(hwnd, 4); + renderer + .set_rounded_backdrop_blur(None, scale_factor) + .log_err(); } } - self.state - .renderer - .borrow_mut() - .update_transparency(!opaque); + renderer.update_transparency(!opaque); } fn set_has_shadow(&self, has_shadow: bool) { @@ -1671,13 +1721,15 @@ fn set_window_composition_attribute(hwnd: HWND, color: Option, state: u32 let set_window_composition_attribute: SetWindowCompositionAttributeType = std::mem::transmute(GetProcAddress(user32, func_name)); let mut color = color.unwrap_or_default(); - let is_acrylic = state == 4; - if is_acrylic && color.3 == 0 { + // state 4 = ACCENT_ENABLE_ACRYLICBLURBEHIND, 5 = ACCENT_ENABLE_HOSTBACKDROP. + let is_blur = state == 4 || state == 5; + // Acrylic needs a non-zero gradient alpha to tint; host backdrop does not. + if state == 4 && color.3 == 0 { color.3 = 1; } let accent = AccentPolicy { accent_state: state, - accent_flags: if is_acrylic { 0 } else { 2 }, + accent_flags: if is_blur { 0 } else { 2 }, gradient_color: (color.0 as u32) | ((color.1 as u32) << 8) | ((color.2 as u32) << 16)