fix(shell): clip native_surface overlay to its panel's corner radius

The web canvas is a GPUI `native_surface` element, which on macOS
attaches an `NSView` (with a Metal-backed `CALayer`) as an AppKit
overlay on top of the GPUI render layer. GPUI's `overflow: hidden`
clips its own children but cannot reach into AppKit, so the canvas
painted square corners on top of the main panel's rounded edge —
the white surface visibly overshot the panel's rounded bottom.

Thread a per-corner radius through the GPUI patch:

- `Window::sync_native_surface` and the `PlatformWindow` trait carry
  a `Corners<Pixels>`.
- The macOS implementation sets `CALayer.cornerRadius` to the maximum
  requested radius and `maskedCorners` to the bitmask of corners that
  actually requested rounding (GPUI is Y-down while NSView is Y-up,
  so a GPUI "top" maps to a CALayer "MaxY" corner — comment locks
  the mapping). `setMasksToBounds: YES` lets Core Animation actually
  honor the corner radius for layered content.
- The `NativeSurface` element resolves all four corner radii from its
  own `StyleRefinement` (so callers attach `.rounded_bl(...)` /
  `.rounded_br(...)` exactly as on any other GPUI element).

Application side wires the right radius per context:

- The main canvas asks for `RADIUS_CARD` (18 px) on its bottom corners
  so it follows the surrounding panel's rounded bottom edge while
  the top stays flush against the toolbar.
- A split pane's canvas asks for the pane's own `SPLIT_PANE_RADIUS`
  (10 px) so the canvas hugs the rounded frame the pane already paints.

`render_failed_web_surface` doesn't need rounding wiring — the error
page is a GPUI div and is clipped normally by the surrounding
`overflow: hidden`.
This commit is contained in:
2026-05-18 15:07:53 -04:00
parent 8aa9ddaeb2
commit 3678db60b9
8 changed files with 142 additions and 20 deletions
+3 -2
View File
@@ -40,7 +40,7 @@ use ely_browser_core::BrowserSnapshot;
use ely_design_system::colors;
use ely_domain::{ArchivedTab, BrowserTab, TabState};
use gpui::{
AnyElement, Context, InteractiveElement, IntoElement, ParentElement, SharedString,
AnyElement, Context, InteractiveElement, IntoElement, ParentElement, Pixels, SharedString,
StatefulInteractiveElement, Styled, div, px, rgb,
};
use gpui_component::{IconName, StyledExt, scroll::ScrollableElement};
@@ -54,6 +54,7 @@ impl ElyShell {
&mut self,
tab: &BrowserTab,
snapshot: &BrowserSnapshot,
bottom_corner_radius: Pixels,
cx: &mut Context<Self>,
) -> AnyElement {
if tab.state() == &TabState::Crashed {
@@ -145,7 +146,7 @@ impl ElyShell {
render_settings_shell(snapshot, "ely://settings/sync", content, cx)
}
url if super::web_surface::is_external_web_url(url) => {
self.render_external_web_canvas(tab, snapshot, cx)
self.render_external_web_canvas(tab, snapshot, bottom_corner_radius, cx)
}
_ => render_default_page(tab),
}
+15 -4
View File
@@ -16,6 +16,13 @@ use super::chrome::{
use super::{ElyShell, ShellState};
use crate::SplitRight;
/// Bottom-corner radius applied to a split pane's canvas overlay so it
/// follows the pane's rounded frame. Kept in lock-step with the
/// `.rounded(px(10.0))` used in `render_pane`; bumping one without the
/// other re-introduces the panel-bg sliver between the canvas and the
/// pane's rounded edge.
const SPLIT_PANE_RADIUS: f32 = 10.0;
impl ElyShell {
pub(super) fn render_content_area(
&mut self,
@@ -24,11 +31,11 @@ impl ElyShell {
cx: &mut Context<Self>,
) -> AnyElement {
let Some(layout) = active_split_layout(snapshot, active_tab) else {
return self.render_web_canvas(active_tab, snapshot, cx);
return self.render_web_canvas(active_tab, snapshot, px(spacing::RADIUS_CARD), cx);
};
if layout.pane_count() < 2 {
return self.render_web_canvas(active_tab, snapshot, cx);
return self.render_web_canvas(active_tab, snapshot, px(spacing::RADIUS_CARD), cx);
}
self.render_split_canvas(snapshot, active_tab, layout, cx)
@@ -126,7 +133,7 @@ impl ElyShell {
.collect::<Vec<_>>();
if panes.len() < 2 {
return self.render_web_canvas(active_tab, snapshot, cx);
return self.render_web_canvas(active_tab, snapshot, px(spacing::RADIUS_CARD), cx);
}
let body = div()
@@ -217,7 +224,11 @@ impl ElyShell {
.child(div().flex_1().min_h_0().overflow_hidden().child(if compact_canvas {
render_compact_split_canvas(tab)
} else {
self.render_web_canvas(tab, snapshot, cx)
// Split pane wraps the canvas in a `rounded(SPLIT_PANE_RADIUS)`
// container with its own header — the canvas's bottom
// corners follow the pane's rounded edge, top stays
// flush against the pane header.
self.render_web_canvas(tab, snapshot, px(SPLIT_PANE_RADIUS), cx)
}))
.into_any_element()
}
@@ -22,6 +22,7 @@ impl ElyShell {
&mut self,
tab: &BrowserTab,
snapshot: &BrowserSnapshot,
bottom_corner_radius: Pixels,
cx: &mut Context<Self>,
) -> AnyElement {
let state_entity = cx.entity().clone();
@@ -31,16 +32,16 @@ impl ElyShell {
match self.web_surfaces.state(tab.id()) {
Some(WebSurfaceState::Ready(frame)) => {
render_ready_web_surface(frame, tab, state_entity)
render_ready_web_surface(frame, tab, state_entity, bottom_corner_radius)
}
Some(WebSurfaceState::Failed { message, .. }) => {
render_failed_web_surface(tab, message.as_str(), state_entity)
}
Some(WebSurfaceState::Loading { previous_frame: Some(frame), .. }) => {
render_ready_web_surface(frame, tab, state_entity)
render_ready_web_surface(frame, tab, state_entity, bottom_corner_radius)
}
Some(WebSurfaceState::Loading { previous_frame: None, .. }) | None => {
render_loading_web_surface(tab, state_entity)
render_loading_web_surface(tab, state_entity, bottom_corner_radius)
}
}
}
+25 -4
View File
@@ -1,7 +1,7 @@
use ely_domain::{BrowserTab, TabId};
use gpui::{
AnyElement, App, ElementId, Entity, InteractiveElement, IntoElement, MouseButton,
ParentElement, Styled, Window, canvas, div, native_surface, px, rgb,
ParentElement, Pixels, Styled, Window, canvas, div, native_surface, px, rgb,
};
use super::{
@@ -14,15 +14,25 @@ pub(super) fn render_ready_web_surface(
_frame: &WebSurfaceFrame,
tab: &BrowserTab,
state_entity: Entity<ElyShell>,
bottom_corner_radius: Pixels,
) -> AnyElement {
render_web_surface(tab, state_entity.clone(), render_native_web_surface(tab, state_entity))
render_web_surface(
tab,
state_entity.clone(),
render_native_web_surface(tab, state_entity, bottom_corner_radius),
)
}
pub(super) fn render_loading_web_surface(
tab: &BrowserTab,
state_entity: Entity<ElyShell>,
bottom_corner_radius: Pixels,
) -> AnyElement {
render_web_surface(tab, state_entity.clone(), render_native_web_surface(tab, state_entity))
render_web_surface(
tab,
state_entity.clone(),
render_native_web_surface(tab, state_entity, bottom_corner_radius),
)
}
pub(super) fn render_failed_web_surface(
@@ -75,9 +85,18 @@ fn render_web_surface(
.into_any_element()
}
fn render_native_web_surface(tab: &BrowserTab, state_entity: Entity<ElyShell>) -> impl IntoElement {
fn render_native_web_surface(
tab: &BrowserTab,
state_entity: Entity<ElyShell>,
bottom_corner_radius: Pixels,
) -> impl IntoElement {
let tab_id = tab.id().clone();
let element_id = ElementId::Name(format!("web-surface-{}", tab_id.as_str()).into());
// `bottom_corner_radius` is wired through the GPUI `native_surface`
// patch to the AppKit overlay's `CALayer.cornerRadius`, so the
// canvas follows the same rounded edge as its containing panel
// instead of painting past it. The top corners stay flat because
// the topbar / pane header sits flush above the canvas.
native_surface(element_id, move |surface, bounds, window: &mut Window, cx: &mut App| {
let scale_factor = window.scale_factor();
state_entity.update(cx, |shell, cx| {
@@ -85,6 +104,8 @@ fn render_native_web_surface(tab: &BrowserTab, state_entity: Entity<ElyShell>) -
});
})
.size_full()
.rounded_bl(bottom_corner_radius)
.rounded_br(bottom_corner_radius)
}
fn render_input_overlay(