LibWeb: Integrate clip-path into AccumulatedVisualContext

Previously, clip-path was applied only during painting in
StackingContext::paint(), which meant hit testing did not respect
clip-path boundaries. Clicks outside the visible clipped region but
inside the element's bounding box would incorrectly register as hits.

By moving clip-path into AccumulatedVisualContext, it becomes part of
the same system that handles transforms, clips, and scroll offsets for
both painting and hit testing, ensuring consistent behavior.
This commit is contained in:
Aliaksandr Kalenik 2026-01-16 03:56:34 +01:00 committed by Jelle Raaijmakers
parent 795639fd2b
commit 98afd82491
Notes: github-actions[bot] 2026-01-16 12:40:14 +00:00
13 changed files with 52 additions and 54 deletions

View file

@ -61,6 +61,14 @@ Optional<CSSPixelPoint> AccumulatedVisualContext::transform_point_for_hit_test(C
if (!clip.rect.contains(point_in_document))
return {};
return point;
},
[&](ClipPathData const& clip_path) -> Optional<CSSPixelPoint> {
auto point_in_document = current_to_document.map(point.to_type<float>()).to_type<CSSPixels>();
if (!clip_path.bounding_rect.contains(point_in_document))
return {};
if (!clip_path.path.contains(point_in_document.to_type<float>(), clip_path.fill_rule))
return {};
return point;
});
if (!result.has_value())
@ -94,6 +102,10 @@ void AccumulatedVisualContext::dump(StringBuilder& builder) const
auto const& corner_radii = clip.corner_radii;
builder.appendff(" radii=({},{},{},{})", corner_radii.top_left.horizontal_radius, corner_radii.top_right.horizontal_radius, corner_radii.bottom_right.horizontal_radius, corner_radii.bottom_left.horizontal_radius);
}
},
[&](ClipPathData const& clip_path) {
auto const& rect = clip_path.bounding_rect;
builder.appendff("clip_path=[bounds: {},{} {}x{}]", rect.x().to_float(), rect.y().to_float(), rect.width().to_float(), rect.height().to_float());
});
}

View file

@ -9,6 +9,8 @@
#include <AK/AtomicRefCounted.h>
#include <AK/Variant.h>
#include <LibGfx/Matrix4x4.h>
#include <LibGfx/Path.h>
#include <LibGfx/WindingRule.h>
#include <LibWeb/Painting/BorderRadiiData.h>
#include <LibWeb/Painting/ScrollState.h>
@ -50,7 +52,13 @@ struct PerspectiveData {
Gfx::FloatMatrix4x4 matrix;
};
using VisualContextData = Variant<ScrollData, ClipData, TransformData, PerspectiveData>;
struct ClipPathData {
Gfx::Path path;
CSSPixelRect bounding_rect;
Gfx::WindingRule fill_rule;
};
using VisualContextData = Variant<ScrollData, ClipData, TransformData, PerspectiveData, ClipPathData>;
class AccumulatedVisualContext : public AtomicRefCounted<AccumulatedVisualContext> {
public:
@ -63,6 +71,7 @@ public:
bool is_clip() const { return m_data.has<ClipData>(); }
bool is_transform() const { return m_data.has<TransformData>(); }
bool is_perspective() const { return m_data.has<PerspectiveData>(); }
bool is_clip_path() const { return m_data.has<ClipPathData>(); }
size_t depth() const { return m_depth; }
size_t id() const { return m_id; }

View file

@ -125,6 +125,10 @@ void DisplayListPlayer::execute_impl(DisplayList& display_list, ScrollStateSnaps
add_rounded_rect_clip({ .corner_radii = corner_radii, .border_rect = device_rect, .corner_clip = CornerClip::Outside });
else
add_clip_rect({ .rect = device_rect });
},
[&](ClipPathData const& clip_path) {
auto transformed_path = clip_path.path.copy_transformed(Gfx::AffineTransform {}.set_scale(static_cast<float>(device_pixels_per_css_pixel), static_cast<float>(device_pixels_per_css_pixel)));
add_clip_path(transformed_path);
});
};
@ -194,7 +198,6 @@ void DisplayListPlayer::execute_impl(DisplayList& display_list, ScrollStateSnaps
else HANDLE_COMMAND(DrawScaledImmutableBitmap, draw_scaled_immutable_bitmap)
else HANDLE_COMMAND(DrawRepeatedImmutableBitmap, draw_repeated_immutable_bitmap)
else HANDLE_COMMAND(AddClipRect, add_clip_rect)
else HANDLE_COMMAND(AddClipPath, add_clip_path)
else HANDLE_COMMAND(Save, save)
else HANDLE_COMMAND(SaveLayer, save_layer)
else HANDLE_COMMAND(Restore, restore)

View file

@ -45,7 +45,6 @@ private:
virtual void restore(Restore const&) = 0;
virtual void translate(Translate const&) = 0;
virtual void add_clip_rect(AddClipRect const&) = 0;
virtual void add_clip_path(AddClipPath const&) = 0;
virtual void paint_linear_gradient(PaintLinearGradient const&) = 0;
virtual void paint_radial_gradient(PaintRadialGradient const&) = 0;
virtual void paint_conic_gradient(PaintConicGradient const&) = 0;
@ -69,6 +68,8 @@ private:
virtual void apply_mask_bitmap(ApplyMaskBitmap const&) = 0;
virtual bool would_be_fully_clipped_by_painter(Gfx::IntRect) const = 0;
virtual void add_clip_path(Gfx::Path const&) = 0;
Vector<NonnullRefPtr<Gfx::PaintingSurface>, 1> m_surfaces;
};

View file

@ -72,11 +72,6 @@ void AddClipRect::dump(StringBuilder& builder) const
builder.appendff(" rect={}", rect);
}
void AddClipPath::dump(StringBuilder& builder) const
{
builder.appendff(" bounding_rect={}", bounding_rectangle);
}
void PaintLinearGradient::dump(StringBuilder& builder) const
{
builder.appendff(" rect={}", gradient_rect);

View file

@ -141,17 +141,6 @@ struct AddClipRect {
void dump(StringBuilder&) const;
};
struct AddClipPath {
static constexpr StringView command_name = "AddClipPath"sv;
Gfx::Path path;
Gfx::IntRect bounding_rectangle;
[[nodiscard]] Gfx::IntRect bounding_rect() const { return bounding_rectangle; }
bool is_clip_or_mask() const { return true; }
void dump(StringBuilder&) const;
};
struct PaintLinearGradient {
static constexpr StringView command_name = "PaintLinearGradient"sv;
@ -418,7 +407,6 @@ using DisplayListCommand = Variant<
Restore,
Translate,
AddClipRect,
AddClipPath,
PaintLinearGradient,
PaintRadialGradient,
PaintConicGradient,

View file

@ -201,12 +201,6 @@ void DisplayListPlayerSkia::add_clip_rect(AddClipRect const& command)
canvas.clipRect(to_skia_rect(rect), true);
}
void DisplayListPlayerSkia::add_clip_path(AddClipPath const& command)
{
auto& canvas = surface().canvas();
canvas.clipPath(to_skia_path(command.path), true);
}
void DisplayListPlayerSkia::save(Save const&)
{
auto& canvas = surface().canvas();
@ -1016,6 +1010,12 @@ void DisplayListPlayerSkia::apply_mask_bitmap(ApplyMaskBitmap const& command)
canvas.clipShader(builder.makeShader());
}
void DisplayListPlayerSkia::add_clip_path(Gfx::Path const& path)
{
auto& canvas = surface().canvas();
canvas.clipPath(to_skia_path(path), true);
}
bool DisplayListPlayerSkia::would_be_fully_clipped_by_painter(Gfx::IntRect rect) const
{
return surface().canvas().quickReject(to_skia_rect(rect));

View file

@ -29,7 +29,6 @@ private:
void draw_scaled_immutable_bitmap(DrawScaledImmutableBitmap const&) override;
void draw_repeated_immutable_bitmap(DrawRepeatedImmutableBitmap const&) override;
void add_clip_rect(AddClipRect const&) override;
void add_clip_path(AddClipPath const&) override;
void save(Save const&) override;
void save_layer(SaveLayer const&) override;
void restore(Restore const&) override;
@ -56,6 +55,8 @@ private:
void apply_transform(ApplyTransform const&) override;
void apply_mask_bitmap(ApplyMaskBitmap const&) override;
void add_clip_path(Gfx::Path const&) override;
bool would_be_fully_clipped_by_painter(Gfx::IntRect) const override;
RefPtr<Gfx::SkiaBackendContext> m_context;

View file

@ -256,11 +256,6 @@ void DisplayListRecorder::add_clip_rect(Gfx::IntRect const& rect)
APPEND(AddClipRect { rect });
}
void DisplayListRecorder::add_clip_path(Gfx::Path const& path, Gfx::IntRect bounding_rect)
{
APPEND(AddClipPath { .path = path, .bounding_rectangle = bounding_rect });
}
void DisplayListRecorder::translate(Gfx::IntPoint delta)
{
APPEND(Translate { delta });

View file

@ -83,7 +83,6 @@ public:
void draw_glyph_run(Gfx::FloatPoint baseline_start, Gfx::GlyphRun const& glyph_run, Color color, Gfx::IntRect const& rect, double scale, Gfx::Orientation);
void add_clip_rect(Gfx::IntRect const& rect);
void add_clip_path(Gfx::Path const& path, Gfx::IntRect bounding_rect);
void translate(Gfx::IntPoint delta);

View file

@ -311,24 +311,11 @@ void StackingContext::paint(DisplayListRecordingContext& context) const
auto compositing_and_blending_operator = mix_blend_mode_to_compositing_and_blending_operator(computed_values.mix_blend_mode());
bool isolate = computed_values.isolation() == CSS::Isolation::Isolate;
Optional<Gfx::Path> clip_path;
Gfx::IntRect clip_path_bounding_rect;
if (auto cp = computed_values.clip_path(); cp.has_value() && cp->is_basic_shape()) {
auto const& masking_area = paintable_box().get_masking_area();
auto const& basic_shape = cp->basic_shape();
auto path = basic_shape.to_path(*masking_area, paintable_box().layout_node());
auto device_pixel_scale = context.device_pixels_per_css_pixel();
auto source_paintable_rect = context.enclosing_device_rect(paintable_box().absolute_paint_rect()).to_type<int>();
clip_path = path.copy_transformed(Gfx::AffineTransform {}.set_scale(device_pixel_scale, device_pixel_scale).set_translation(source_paintable_rect.location().to_type<float>()));
clip_path_bounding_rect = source_paintable_rect;
}
auto mask_image = computed_values.mask_image();
Optional<Gfx::Filter> resolved_filter;
if (computed_values.filter().has_filters())
resolved_filter = paintable_box().resolve_filter(context, computed_values.filter());
bool needs_clip_path = clip_path.has_value();
bool needs_opacity_layer = opacity != 1.0f || isolate;
bool needs_blend_layer = compositing_and_blending_operator != Gfx::CompositingAndBlendingOperator::Normal;
bool needs_stacking_layer = needs_opacity_layer || needs_blend_layer;
@ -341,18 +328,12 @@ void StackingContext::paint(DisplayListRecordingContext& context) const
context.display_list_recorder().set_accumulated_visual_context(stacking_state);
if (needs_clip_path) {
context.display_list_recorder().save();
context.display_list_recorder().add_clip_path(*clip_path, clip_path_bounding_rect);
restore_count++;
}
if (needs_stacking_layer || resolved_filter.has_value()) {
context.display_list_recorder().apply_effects(opacity, compositing_and_blending_operator, resolved_filter);
restore_count++;
}
if (needs_to_save_state && !needs_stacking_layer && !needs_clip_path && !resolved_filter.has_value()) {
if (needs_to_save_state && !needs_stacking_layer && !resolved_filter.has_value()) {
context.display_list_recorder().save();
restore_count++;
}

View file

@ -182,6 +182,20 @@ void ViewportPaintable::assign_accumulated_visual_contexts()
if (auto css_clip = paintable_box.get_clip_rect(); css_clip.has_value())
own_state = append_node(own_state, ClipData { effective_css_clip_rect(*css_clip), {} });
if (auto const& clip_path = paintable_box.computed_values().clip_path(); clip_path.has_value() && clip_path->is_basic_shape()) {
if (auto masking_area = paintable_box.get_masking_area(); masking_area.has_value()) {
auto reference_box = CSSPixelRect { {}, masking_area->size() };
auto const& basic_shape = clip_path->basic_shape();
auto path = basic_shape.to_path(reference_box, paintable_box.layout_node());
path.offset(masking_area->top_left().template to_type<float>());
auto fill_rule = basic_shape.basic_shape().visit(
[](CSS::Polygon const& polygon) { return polygon.fill_rule; },
[](CSS::Path const& path) { return path.fill_rule; },
[](auto const&) { return Gfx::WindingRule::Nonzero; });
own_state = append_node(own_state, ClipPathData { move(path), *masking_area, fill_rule });
}
}
paintable_box.set_accumulated_visual_context(own_state);
// Build state for descendants: own state + perspective + clip + scroll.

View file

@ -1,3 +1,3 @@
Inside circle: PASS
Outside circle (in bbox): FAIL
Outside circle (in bbox): PASS
Inside circle edge: PASS