markdown: Fix code block scrollbar track leaking past rounded corners (#64978)

When hovering a horizontally scrollable code block, the scrollbar track
was painted with square corners. Since GPUI content masks are
rectangular, the parent's rounded border didn't clip it, so a small
corner of the track showed outside the code block's rounded border.

This adds `Scrollbars::track_corner_radius` to round the track's outer
corners, and markdown code blocks now pass their inner corner radius
(corner radius minus border width).

Before:
<img width="400" alt="CleanShot 2026-09-30 at 3 57 50 PM@2x"
src="https://github.com/user-attachments/assets/a90372cf-c668-4469-8dea-058a795888be"
/>

After:
<img width="300" alt="CleanShot 2026-09-30 at 4 00 08 PM@2x"
src="https://github.com/user-attachments/assets/cf6c384e-3c93-4a79-9479-d390974c4b67"
/>

Release Notes:

- Fixed a visual artifact at the corners of scrollable code blocks when
hovered.
This commit is contained in:
Smit Barmase 2026-10-07 17:36:14 +00:00 • committed by GitHub
parent a34a062bb7
commit da0a7a78cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 157 additions and 54 deletions

View file

@ -2819,10 +2819,26 @@ impl Element for MarkdownElement {
match (&self.code_block_renderer, is_indented) {
(CodeBlockRenderer::Default { .. }, _) | (_, true) => {
// This is a parent container that we can position the copy button inside.
let parent_container =
div().group("code_block").relative().w_full();
let mut parent_container = div()
.group("code_block")
.relative()
.w_full()
.when(scroll_handle.is_some(), |this| this.rounded_lg());
let mut parent_container: AnyDiv = if let Some(scroll_handle) =
if let CodeBlockRenderer::Default { border: true, .. } =
&self.code_block_renderer
{
parent_container = parent_container
.rounded_md()
.border_1()
.border_color(cx.theme().colors().border_variant);
}
// Styles must be final before `custom_scrollbars`, which reads the
// corner radii and borders to round the scrollbar track.
parent_container.style().refine(&self.style.code_block);
let parent_container: AnyDiv = if let Some(scroll_handle) =
scroll_handle.as_ref()
{
let scrollbars = Scrollbars::new(ScrollAxes::Horizontal)
@ -2834,24 +2850,18 @@ impl Element for MarkdownElement {
)
.notify_content();
let mut parent_container = parent_container
.custom_scrollbars(scrollbars, window, cx);
// Keep the code block's padding over the space reserved for the scrollbar track.
parent_container
.rounded_lg()
.custom_scrollbars(scrollbars, window, cx)
.into()
.style()
.padding
.refine(&self.style.code_block.padding);
parent_container.into()
} else {
parent_container.into()
};
if let CodeBlockRenderer::Default { border: true, .. } =
&self.code_block_renderer
{
parent_container = parent_container
.rounded_md()
.border_1()
.border_color(cx.theme().colors().border_variant);
}
parent_container.style().refine(&self.style.code_block);
builder.push_div(parent_container, range, markdown_end);
let code_block = div()

View file

@ -7,9 +7,9 @@ use gpui::{
Entity, EntityId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement,
IntoElement, IsZero, LayoutId, ListState, MouseButton, MouseDownEvent, MouseMoveEvent,
MouseUpEvent, ParentElement, Pixels, Point, Position, Render, ScrollHandle, ScrollWheelEvent,
Size, Stateful, StatefulInteractiveElement, Style, Styled, Task, UniformListDecoration,
UniformListScrollHandle, Window, ease_in_out, prelude::FluentBuilder as _, px, quad, relative,
size,
Size, Stateful, StatefulInteractiveElement, Style, StyleRefinement, Styled, Task,
UniformListDecoration, UniformListScrollHandle, Window, ease_in_out, fill,
prelude::FluentBuilder as _, px, quad, relative, size,
};
use gpui_util::ResultExt;
use smallvec::SmallVec;
@ -17,7 +17,10 @@ use theme::ActiveTheme as _;
use std::ops::Range;
use crate::scrollbars::{ScrollbarAutoHide, ScrollbarVisibility, ShowScrollbar};
use crate::{
scrollbars::{ScrollbarAutoHide, ScrollbarVisibility, ShowScrollbar},
utils::inner_corner_radius,
};
const SCROLLBAR_HIDE_DELAY_INTERVAL: Duration = Duration::from_secs(1);
const SCROLLBAR_HIDE_DURATION: Duration = Duration::from_millis(400);
@ -68,6 +71,7 @@ pub mod scrollbars {
fn get_scrollbar_state<T>(
mut config: Scrollbars<T>,
parent_style: &StyleRefinement,
caller_location: &'static std::panic::Location,
window: &mut Window,
cx: &mut App,
@ -77,17 +81,21 @@ where
{
let element_id = config.id.take().unwrap_or_else(|| caller_location.into());
let track_color = config.track_color;
let track_corner_radii = inner_corner_radii(parent_style, window.rem_size());
let has_border = config.border;
let reveal_policy = config.reveal_policy;
let state = window.use_keyed_state(element_id, cx, |_, cx| {
let parent_id = cx.entity_id();
ScrollbarStateWrapper(cx.new(|cx| ScrollbarState::new_from_config(config, parent_id, cx)))
ScrollbarStateWrapper(
cx.new(|cx| ScrollbarState::new_from_config(config, track_corner_radii, parent_id, cx)),
)
});
state.update(cx, |state, cx| {
state.0.update(cx, |state, _cx| {
state.update_colors(track_color, has_border);
state.track_corner_radii = track_corner_radii;
state.reveal_policy = reveal_policy;
})
});
@ -97,6 +105,8 @@ where
pub trait WithScrollbar: Sized {
type Output;
/// The scrollbar tracks are rounded to fit the element's corner radii and borders,
/// which are read when this is called, so set them beforehand.
fn custom_scrollbars<T>(
self,
config: Scrollbars<T>,
@ -147,7 +157,7 @@ impl WithScrollbar for Stateful<Div> {
#[track_caller]
fn custom_scrollbars<T>(
self,
mut self,
config: Scrollbars<T>,
window: &mut Window,
cx: &mut App,
@ -155,11 +165,14 @@ impl WithScrollbar for Stateful<Div> {
where
T: ScrollableHandle,
{
render_scrollbar(
get_scrollbar_state(config, std::panic::Location::caller(), window, cx),
self,
let scrollbar = get_scrollbar_state(
config,
self.style(),
std::panic::Location::caller(),
window,
cx,
)
);
render_scrollbar(scrollbar, self, cx)
}
}
@ -168,7 +181,7 @@ impl WithScrollbar for Div {
#[track_caller]
fn custom_scrollbars<T>(
self,
mut self,
config: Scrollbars<T>,
window: &mut Window,
cx: &mut App,
@ -176,7 +189,13 @@ impl WithScrollbar for Div {
where
T: ScrollableHandle,
{
let scrollbar = get_scrollbar_state(config, std::panic::Location::caller(), window, cx);
let scrollbar = get_scrollbar_state(
config,
self.style(),
std::panic::Location::caller(),
window,
cx,
);
// We know this ID stays consistent as long as the element is rendered for
// consecutive frames, which is sufficient for our use case here
let scrollbar_entity_id = scrollbar.entity_id();
@ -189,6 +208,33 @@ impl WithScrollbar for Div {
}
}
/// Content masks are rectangular, so the parent's rounded corners don't clip the track.
/// Rounding the track by the parent's inner radii keeps it from showing past the parent's border.
fn inner_corner_radii(style: &StyleRefinement, rem_size: Pixels) -> Corners<Pixels> {
let to_pixels =
|length: Option<gpui::AbsoluteLength>| length.unwrap_or_default().to_pixels(rem_size);
let radii = &style.corner_radii;
let borders = &style.border_widths;
let top = to_pixels(borders.top);
let right = to_pixels(borders.right);
let bottom = to_pixels(borders.bottom);
let left = to_pixels(borders.left);
let inner = |radius, border_a: Pixels, border_b: Pixels| {
inner_corner_radius(
to_pixels(radius),
border_a.max(border_b),
Pixels::ZERO,
Pixels::ZERO,
)
};
Corners {
top_left: inner(radii.top_left, top, left),
top_right: inner(radii.top_right, top, right),
bottom_right: inner(radii.bottom_right, bottom, right),
bottom_left: inner(radii.bottom_left, bottom, left),
}
}
fn render_scrollbar<T>(
scrollbar: Entity<ScrollbarStateWrapper<T>>,
div: Stateful<Div>,
@ -649,6 +695,7 @@ struct ScrollbarState<T: ScrollableHandle = ScrollHandle> {
get_visibility: fn(&App) -> ShowScrollbar,
visibility: Point<ReservedSpace>,
track_color: Option<TrackColors>,
track_corner_radii: Corners<Pixels>,
reveal_policy: ScrollbarRevealPolicy,
show_state: VisibilityState,
style: ScrollbarStyle,
@ -658,7 +705,12 @@ struct ScrollbarState<T: ScrollableHandle = ScrollHandle> {
}
impl<T: ScrollableHandle> ScrollbarState<T> {
fn new_from_config(config: Scrollbars<T>, parent_id: EntityId, cx: &mut Context<Self>) -> Self {
fn new_from_config(
config: Scrollbars<T>,
track_corner_radii: Corners<Pixels>,
parent_id: EntityId,
cx: &mut Context<Self>,
) -> Self {
let (manually_added, scroll_handle) = match config.scrollable_handle {
Handle::Tracked(handle) => (true, handle),
Handle::Untracked(func) => (false, func()),
@ -675,6 +727,7 @@ impl<T: ScrollableHandle> ScrollbarState<T> {
background: color,
has_border: config.border,
}),
track_corner_radii,
show_behavior,
get_visibility: config.get_visibility,
style: config.style.unwrap_or_default(),
@ -1421,6 +1474,7 @@ impl<T: ScrollableHandle> Element for ScrollbarElement<T> {
let state = self.state.read(cx);
let thumb_state = &state.thumb_state;
let style = state.style;
let track_corner_radii = state.track_corner_radii;
if thumb_state.is_dragging() {
capture_phase = DispatchPhase::Capture;
@ -1475,33 +1529,72 @@ impl<T: ScrollableHandle> Element for ScrollbarElement<T> {
track_color.fade_out(fade);
}
let border_edges = has_border
.then(|| match axis {
ScrollbarAxis::Horizontal => Edges {
top: BORDER_WIDTH,
..Default::default()
},
ScrollbarAxis::Vertical => Edges {
left: BORDER_WIDTH,
..Default::default()
},
})
.unwrap_or_default();
let border_color = if has_border {
cx.theme().colors().border_variant.opacity(0.6)
} else {
Hsla::transparent_black()
let track_corners = match axis {
ScrollbarAxis::Horizontal => Corners {
bottom_left: track_corner_radii.bottom_left,
bottom_right: track_corner_radii.bottom_right,
..Default::default()
},
ScrollbarAxis::Vertical => Corners {
top_right: track_corner_radii.top_right,
bottom_right: track_corner_radii.bottom_right,
..Default::default()
},
};
window.paint_quad(quad(
*track_bounds,
Corners::default(),
track_color,
border_edges,
border_color,
BorderStyle::Solid,
));
// Quad radii are clamped to half the quad's size, so a thin track
// can't follow a larger parent curve. Paint a quad thick enough
// to hold the full radii and crop it back to the track.
let largest_radius = track_corners
.top_left
.max(track_corners.top_right)
.max(track_corners.bottom_right)
.max(track_corners.bottom_left);
let extension = (largest_radius * 2.
- track_bounds.size.along(axis.invert()))
.max(Pixels::ZERO);
let painted_bounds = track_bounds.extend(match axis {
ScrollbarAxis::Horizontal => Edges {
top: extension,
..Default::default()
},
ScrollbarAxis::Vertical => Edges {
left: extension,
..Default::default()
},
});
window.with_content_mask(
Some(ContentMask {
bounds: *track_bounds,
}),
|window| {
window.paint_quad(fill(painted_bounds, track_color).corner_radii(
track_corners.clamp_radii_for_quad_size(painted_bounds.size),
));
},
);
// Painted separately because the crop would remove a border on the extended quad.
if has_border {
window.paint_quad(quad(
*track_bounds,
Corners::default(),
Hsla::transparent_black(),
match axis {
ScrollbarAxis::Horizontal => Edges {
top: BORDER_WIDTH,
..Default::default()
},
ScrollbarAxis::Vertical => Edges {
left: BORDER_WIDTH,
..Default::default()
},
},
cx.theme().colors().border_variant.opacity(0.6),
BorderStyle::Solid,
));
}
}
window.paint_quad(quad(