Files
ladybird/Libraries/LibWeb/Painting/PaintableWithLines.cpp
Andreas Kling c550301a04 LibWeb: Allow hit testing visible children of hidden elements
Previously, hit testing would return early for elements with
visibility: hidden, which prevented their visible children from being
hit. Now we traverse children even for hidden elements, allowing visible
descendants to be hit while still preventing the hidden elements
themselves from being hit.

The key changes:
- PaintableBox::hit_test() and PaintableWithLines::hit_test() no longer
  return early for hidden elements, but still skip chrome hit testing
  and the final hit result for them
- hit_test_fragments() now checks is_visible() on each fragment's
  paintable to skip hidden text

This matches the CSS specification where visibility is inherited but
children can override it with visibility: visible.
2026-01-25 10:55:30 +01:00

578 lines
28 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Copyright (c) 2022-2023, Andreas Kling <andreas@ladybird.org>
* Copyright (c) 2022-2025, Sam Atkins <sam@ladybird.org>
* Copyright (c) 2024-2025, Aliaksandr Kalenik <kalenik.aliaksandr@gmail.com>
* Copyright (c) 2025, Jelle Raaijmakers <jelle@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibGfx/Font/Font.h>
#include <LibWeb/CSS/SystemColor.h>
#include <LibWeb/DOM/Document.h>
#include <LibWeb/DOM/Position.h>
#include <LibWeb/DOM/Range.h>
#include <LibWeb/HTML/FormAssociatedElement.h>
#include <LibWeb/HTML/Navigable.h>
#include <LibWeb/Layout/BlockContainer.h>
#include <LibWeb/Layout/InlineNode.h>
#include <LibWeb/Painting/DisplayListRecorder.h>
#include <LibWeb/Painting/PaintableWithLines.h>
#include <LibWeb/Painting/ShadowPainting.h>
#include <LibWeb/Painting/TextPaintable.h>
#include <LibWeb/Painting/ViewportPaintable.h>
#include <LibWeb/Selection/Selection.h>
namespace Web::Painting {
GC_DEFINE_ALLOCATOR(PaintableWithLines);
static void paint_text_decoration(DisplayListRecordingContext&, TextPaintable const&, PaintableFragment const&);
static Gfx::Path build_triangle_wave_path(Gfx::IntPoint from, Gfx::IntPoint to, float amplitude);
static void paint_cursor_if_needed(DisplayListRecordingContext&, TextPaintable const&, PaintableFragment const&);
static void paint_text_fragment(DisplayListRecordingContext&, TextPaintable const&, PaintableFragment const&, PaintPhase);
GC::Ref<PaintableWithLines> PaintableWithLines::create(Layout::BlockContainer const& block_container)
{
return block_container.heap().allocate<PaintableWithLines>(block_container);
}
GC::Ref<PaintableWithLines> PaintableWithLines::create(Layout::InlineNode const& inline_node, size_t line_index)
{
return inline_node.heap().allocate<PaintableWithLines>(inline_node, line_index);
}
PaintableWithLines::PaintableWithLines(Layout::BlockContainer const& layout_box)
: PaintableBox(layout_box)
{
}
PaintableWithLines::PaintableWithLines(Layout::InlineNode const& inline_node, size_t line_index)
: PaintableBox(inline_node)
, m_line_index(line_index)
{
}
PaintableWithLines::~PaintableWithLines()
{
}
void PaintableWithLines::reset_for_relayout()
{
PaintableBox::reset_for_relayout();
m_fragments.clear();
}
void PaintableWithLines::paint_text_fragment_debug_highlight(DisplayListRecordingContext& context, PaintableFragment const& fragment)
{
auto fragment_absolute_rect = fragment.absolute_rect();
auto fragment_absolute_device_rect = context.enclosing_device_rect(fragment_absolute_rect);
context.display_list_recorder().draw_rect(fragment_absolute_device_rect.to_type<int>(), Color::Green);
auto baseline_start = context.rounded_device_point(fragment_absolute_rect.top_left().translated(0, fragment.baseline())).to_type<int>();
auto baseline_end = context.rounded_device_point(fragment_absolute_rect.top_right().translated(-1, fragment.baseline())).to_type<int>();
context.display_list_recorder().draw_line(baseline_start, baseline_end, Color::Red);
}
TraversalDecision PaintableWithLines::hit_test(CSSPixelPoint position, HitTestType type, Function<TraversalDecision(HitTestResult)> const& callback) const
{
auto const is_visible = computed_values().visibility() == CSS::Visibility::Visible;
// TextCursor hit testing mode should be able to place cursor in contenteditable elements even if they are empty
if (m_fragments.is_empty()
&& !has_children()
&& type == HitTestType::TextCursor
&& layout_node().dom_node()
&& layout_node().dom_node()->is_editable()
&& is_visible
&& visible_for_hit_testing()) {
HitTestResult const hit_test_result {
.paintable = const_cast<PaintableWithLines&>(*this),
.index_in_node = 0,
.vertical_distance = 0,
.horizontal_distance = 0,
};
if (callback(hit_test_result) == TraversalDecision::Break)
return TraversalDecision::Break;
}
if (!layout_node().children_are_inline())
return PaintableBox::hit_test(position, type, callback);
// Only hit test chrome for visible elements.
if (is_visible) {
if (hit_test_chrome(position, callback) == TraversalDecision::Break)
return TraversalDecision::Break;
}
if (hit_test_children(position, type, callback) == TraversalDecision::Break)
return TraversalDecision::Break;
// Hidden elements and elements with pointer-events: none shouldn't be hit.
if (!is_visible || !visible_for_hit_testing())
return TraversalDecision::Continue;
auto const& viewport_paintable = *document().paintable();
auto const& scroll_state = viewport_paintable.scroll_state_snapshot();
Optional<CSSPixelPoint> local_position;
if (auto state = accumulated_visual_context())
local_position = state->transform_point_for_hit_test(position, scroll_state);
else
local_position = position;
if (!local_position.has_value())
return TraversalDecision::Continue;
if (hit_test_fragments(position, local_position.value(), type, callback) == TraversalDecision::Break)
return TraversalDecision::Break;
if (!stacking_context() && is_visible && (!layout_node().is_anonymous() || is_positioned())
&& absolute_border_box_rect().contains(local_position.value())) {
if (callback(HitTestResult { const_cast<PaintableWithLines&>(*this) }) == TraversalDecision::Break)
return TraversalDecision::Break;
}
return TraversalDecision::Continue;
}
TraversalDecision PaintableWithLines::hit_test_fragments(CSSPixelPoint position, CSSPixelPoint local_position, HitTestType type, Function<TraversalDecision(HitTestResult)> const& callback) const
{
for (auto const& fragment : fragments()) {
if (fragment.paintable().has_stacking_context() || !fragment.paintable().is_visible() || !fragment.paintable().visible_for_hit_testing())
continue;
auto fragment_absolute_rect = fragment.absolute_rect();
if (fragment_absolute_rect.contains(local_position)) {
if (fragment.paintable().hit_test(position, type, callback) == TraversalDecision::Break)
return TraversalDecision::Break;
HitTestResult hit_test_result { const_cast<Paintable&>(fragment.paintable()), fragment.index_in_node_for_point(local_position), 0, 0 };
if (callback(hit_test_result) == TraversalDecision::Break)
return TraversalDecision::Break;
} else if (type == HitTestType::TextCursor) {
auto const* common_ancestor_parent = [&]() -> DOM::Node const* {
auto selection = document().get_selection();
if (!selection)
return nullptr;
auto range = selection->range();
if (!range)
return nullptr;
auto common_ancestor = range->common_ancestor_container();
if (common_ancestor->parent())
return common_ancestor->parent();
return common_ancestor;
}();
auto const* fragment_dom_node = fragment.layout_node().dom_node();
if (common_ancestor_parent && fragment_dom_node && common_ancestor_parent->is_ancestor_of(*fragment_dom_node)) {
// If we reached this point, the position is not within the fragment. However, the fragment start or end might be
// the place to place the cursor. To determine the best place, we first find the closest fragment horizontally to
// the cursor. If we could not find one, then find for the closest vertically above the cursor.
// If we knew the direction of selection, we would look above if selecting upward.
if (fragment_absolute_rect.bottom() - 1 <= local_position.y()) { // fully below the fragment
HitTestResult hit_test_result {
.paintable = const_cast<Paintable&>(fragment.paintable()),
.index_in_node = fragment.start_offset() + fragment.length_in_code_units(),
.vertical_distance = local_position.y() - fragment_absolute_rect.bottom(),
};
if (callback(hit_test_result) == TraversalDecision::Break)
return TraversalDecision::Break;
} else if (fragment_absolute_rect.top() <= local_position.y()) { // vertically within the fragment
if (local_position.x() < fragment_absolute_rect.left()) {
HitTestResult hit_test_result {
.paintable = const_cast<Paintable&>(fragment.paintable()),
.index_in_node = fragment.start_offset(),
.vertical_distance = 0,
.horizontal_distance = fragment_absolute_rect.left() - local_position.x(),
};
if (callback(hit_test_result) == TraversalDecision::Break)
return TraversalDecision::Break;
} else if (local_position.x() > fragment_absolute_rect.right()) {
HitTestResult hit_test_result {
.paintable = const_cast<Paintable&>(fragment.paintable()),
.index_in_node = fragment.start_offset() + fragment.length_in_code_units(),
.vertical_distance = 0,
.horizontal_distance = local_position.x() - fragment_absolute_rect.right(),
};
if (callback(hit_test_result) == TraversalDecision::Break)
return TraversalDecision::Break;
}
}
}
}
}
return TraversalDecision::Continue;
}
void PaintableWithLines::resolve_paint_properties()
{
Base::resolve_paint_properties();
auto const& layout_node = this->layout_node();
for (auto& fragment : fragments()) {
if (!fragment.m_layout_node->is_text_node())
continue;
auto const& text_node = static_cast<Layout::TextNode const&>(*fragment.m_layout_node);
auto const& font = fragment.m_layout_node->first_available_font();
auto const glyph_height = CSSPixels::nearest_value_for(font.pixel_size());
auto const css_line_thickness = [&] {
auto const& thickness = text_node.computed_values().text_decoration_thickness();
return thickness.value.visit(
[glyph_height](CSS::TextDecorationThickness::Auto) {
// The UA chooses an appropriate thickness for text decoration lines; see below.
// https://drafts.csswg.org/css-text-decor-4/#valdef-text-decoration-thickness-auto
return max(glyph_height.scaled(0.1), 1);
},
[glyph_height](CSS::TextDecorationThickness::FromFont) {
// If the first available font has metrics indicating a preferred underline width, use that width,
// otherwise behaves as auto.
// https://drafts.csswg.org/css-text-decor-4/#valdef-text-decoration-thickness-from-font
// FIXME: Implement this properly.
return max(glyph_height.scaled(0.1), 1);
},
[&](CSS::LengthPercentage const& length_percentage) {
auto resolved_length = length_percentage.resolved(text_node, CSS::Length(1, CSS::LengthUnit::Em).to_px(text_node)).to_px(*fragment.m_layout_node);
return max(resolved_length, 1);
});
}();
fragment.set_text_decoration_thickness(css_line_thickness);
auto const& text_shadow = text_node.computed_values().text_shadow();
Vector<ShadowData> resolved_shadow_data;
if (!text_shadow.is_empty()) {
resolved_shadow_data.ensure_capacity(text_shadow.size());
for (auto const& layer : text_shadow) {
resolved_shadow_data.empend(
layer.color,
layer.offset_x.to_px(layout_node),
layer.offset_y.to_px(layout_node),
layer.blur_radius.to_px(layout_node),
layer.spread_distance.to_px(layout_node),
ShadowPlacement::Outer);
}
}
fragment.set_shadows(move(resolved_shadow_data));
}
}
void PaintableWithLines::paint(DisplayListRecordingContext& context, PaintPhase phase) const
{
if (!is_visible())
return;
PaintableBox::paint(context, phase);
context.display_list_recorder().set_accumulated_visual_context(accumulated_visual_context_for_descendants());
// Text shadows
// This is yet another loop, but done here because all shadows should appear under all text.
// So, we paint the shadows before painting any text.
// FIXME: Find a smarter way to do this?
if (phase == PaintPhase::Foreground) {
for (auto& fragment : fragments())
paint_text_shadow(context, fragment, fragment.shadows());
}
for (auto const& fragment : m_fragments) {
if (is<TextPaintable>(fragment.paintable()))
paint_text_fragment(context, static_cast<TextPaintable const&>(fragment.paintable()), fragment, phase);
}
}
void paint_text_fragment(DisplayListRecordingContext& context, TextPaintable const& paintable, PaintableFragment const& fragment, PaintPhase phase)
{
if (!paintable.is_visible())
return;
auto& painter = context.display_list_recorder();
if (phase == PaintPhase::Foreground) {
auto fragment_absolute_rect = fragment.absolute_rect();
auto fragment_enclosing_device_rect = context.enclosing_device_rect(fragment_absolute_rect).to_type<int>();
if (context.should_show_line_box_borders())
PaintableWithLines::paint_text_fragment_debug_highlight(context, fragment);
auto glyph_run = fragment.glyph_run();
if (!glyph_run)
return;
auto selection_rect = context.enclosing_device_rect(fragment.selection_rect()).to_type<int>();
if (!selection_rect.is_empty())
painter.fill_rect(selection_rect, CSS::SystemColor::highlight(paintable.computed_values().color_scheme()));
auto scale = context.device_pixels_per_css_pixel();
auto baseline_start = Gfx::FloatPoint {
fragment_absolute_rect.x().to_float(),
fragment_absolute_rect.y().to_float() + fragment.baseline().to_float(),
} * scale;
painter.draw_glyph_run(baseline_start, *glyph_run, paintable.computed_values().webkit_text_fill_color(), fragment_enclosing_device_rect, scale, fragment.orientation());
paint_text_decoration(context, paintable, fragment);
paint_cursor_if_needed(context, paintable, fragment);
}
}
void paint_cursor_if_needed(DisplayListRecordingContext& context, TextPaintable const& paintable, PaintableFragment const& fragment)
{
auto const& document = paintable.document();
auto const& navigable = *document.navigable();
if (!navigable.is_focused())
return;
if (!document.cursor_blink_state())
return;
auto cursor_position = document.cursor_position();
if (!cursor_position)
return;
if (cursor_position->node() != paintable.dom_node())
return;
// NOTE: This checks if the cursor is before the start or after the end of the fragment. If it is at the end, after all text, it should still be painted.
if (cursor_position->offset() < (unsigned)fragment.start_offset() || cursor_position->offset() > (unsigned)(fragment.start_offset() + fragment.length_in_code_units()))
return;
auto active_element = document.active_element();
auto active_element_is_editable = false;
if (auto* text_control = as_if<HTML::FormAssociatedTextControlElement>(active_element))
active_element_is_editable = text_control->is_mutable();
auto dom_node = fragment.layout_node().dom_node();
if (!dom_node || (!dom_node->is_editable() && !active_element_is_editable))
return;
auto caret_color = paintable.computed_values().caret_color();
if (caret_color.alpha() == 0)
return;
auto cursor_rect = fragment.range_rect(paintable.selection_state(), cursor_position->offset(), cursor_position->offset());
VERIFY(cursor_rect.width() == 1);
auto cursor_device_rect = context.rounded_device_rect(cursor_rect).to_type<int>();
context.display_list_recorder().fill_rect(cursor_device_rect, caret_color);
}
void paint_text_decoration(DisplayListRecordingContext& context, TextPaintable const& paintable, PaintableFragment const& fragment)
{
auto& recorder = context.display_list_recorder();
auto& font = fragment.layout_node().first_available_font();
auto fragment_box = fragment.absolute_rect();
CSSPixels glyph_height = CSSPixels::nearest_value_for(font.pixel_size());
auto baseline = fragment.baseline();
auto line_color = paintable.computed_values().text_decoration_color();
auto line_style = paintable.computed_values().text_decoration_style();
auto device_line_thickness = context.rounded_device_pixels(fragment.text_decoration_thickness());
auto text_decoration_lines = paintable.computed_values().text_decoration_line();
auto text_underline_offset = paintable.computed_values().text_underline_offset();
auto text_underline_position = paintable.computed_values().text_underline_position();
for (auto line : text_decoration_lines) {
DevicePixelPoint line_start_point {};
DevicePixelPoint line_end_point {};
if (line == CSS::TextDecorationLine::SpellingError) {
// https://drafts.csswg.org/css-text-decor-4/#valdef-text-decoration-line-spelling-error
// This value indicates the type of text decoration used by the user agent to highlight spelling mistakes.
// Its appearance is UA-defined, and may be platform-dependent. It is often rendered as a red wavy underline.
line_color = Color::Red;
device_line_thickness = context.rounded_device_pixels(1);
line_style = CSS::TextDecorationStyle::Wavy;
line = CSS::TextDecorationLine::Underline;
// https://drafts.csswg.org/css-text-decor-4/#underline-offset
// When the value of the text-decoration-line property is either spelling-error or grammar-error, the UA
// must ignore the value of text-underline-position.
text_underline_offset = CSS::InitialValues::text_underline_offset();
} else if (line == CSS::TextDecorationLine::GrammarError) {
// https://drafts.csswg.org/css-text-decor-4/#valdef-text-decoration-line-grammar-error
// This value indicates the type of text decoration used by the user agent to highlight grammar mistakes.
// Its appearance is UA defined, and may be platform-dependent. It is often rendered as a green wavy underline.
line_color = Color::DarkGreen;
device_line_thickness = context.rounded_device_pixels(1);
line_style = CSS::TextDecorationStyle::Wavy;
line = CSS::TextDecorationLine::Underline;
// https://drafts.csswg.org/css-text-decor-4/#underline-offset
// When the value of the text-decoration-line property is either spelling-error or grammar-error, the UA
// must ignore the value of text-underline-position.
text_underline_offset = CSS::InitialValues::text_underline_offset();
}
switch (line) {
case CSS::TextDecorationLine::None:
return;
case CSS::TextDecorationLine::Underline: {
// https://drafts.csswg.org/css-text-decor-4/#text-underline-position-property
auto underline_position_without_offset = [&]() {
// FIXME: Support text-decoration: underline on vertical text
switch (text_underline_position.horizontal) {
case Web::CSS::TextUnderlinePositionHorizontal::Auto:
// The user agent may use any algorithm to determine the underlines position; however it must be
// placed at or under the alphabetic baseline.
// Spec Note: It is suggested that the default underline position be close to the alphabetic
// baseline,
// FIXME: unless that would either cross subscripted (or otherwise lowered) text or draw over
// glyphs from Asian scripts such as Han or Tibetan for which an alphabetic underline is
// too high: in such cases, shifting the underline lower or aligning to the em box edge
// as described for under may be more appropriate.
return fragment.baseline();
case Web::CSS::TextUnderlinePositionHorizontal::FromFont:
// FIXME: If the first available font has metrics indicating a preferred underline offset, use that
// offset, otherwise behaves as auto.
return fragment.baseline();
case Web::CSS::TextUnderlinePositionHorizontal::Under:
// The underline is positioned under the elements text content. In this case the underline usually
// does not cross the descenders. (This is sometimes called “accounting” underline.)
return fragment.baseline() + CSSPixels { font.pixel_metrics().descent };
}
VERIFY_NOT_REACHED();
}();
line_start_point = context.rounded_device_point(fragment_box.top_left().translated(0, underline_position_without_offset + text_underline_offset));
line_end_point = context.rounded_device_point(fragment_box.top_right().translated(0, underline_position_without_offset + text_underline_offset));
break;
}
case CSS::TextDecorationLine::Overline:
line_start_point = context.rounded_device_point(fragment_box.top_left().translated(0, baseline - glyph_height));
line_end_point = context.rounded_device_point(fragment_box.top_right().translated(0, baseline - glyph_height));
break;
case CSS::TextDecorationLine::LineThrough: {
auto x_height = font.x_height();
line_start_point = context.rounded_device_point(fragment_box.top_left().translated(0, baseline - x_height * CSSPixels(0.5f)));
line_end_point = context.rounded_device_point(fragment_box.top_right().translated(0, baseline - x_height * CSSPixels(0.5f)));
break;
}
case CSS::TextDecorationLine::Blink:
// Conforming user agents may simply not blink the text
return;
case CSS::TextDecorationLine::SpellingError:
case CSS::TextDecorationLine::GrammarError:
// Handled above.
VERIFY_NOT_REACHED();
}
switch (line_style) {
case CSS::TextDecorationStyle::Solid:
recorder.draw_line(line_start_point.to_type<int>(), line_end_point.to_type<int>(), line_color, device_line_thickness.value(), Gfx::LineStyle::Solid);
break;
case CSS::TextDecorationStyle::Double:
switch (line) {
case CSS::TextDecorationLine::Underline:
break;
case CSS::TextDecorationLine::Overline:
line_start_point.translate_by(0, -device_line_thickness - context.rounded_device_pixels(1));
line_end_point.translate_by(0, -device_line_thickness - context.rounded_device_pixels(1));
break;
case CSS::TextDecorationLine::LineThrough:
line_start_point.translate_by(0, -device_line_thickness / 2);
line_end_point.translate_by(0, -device_line_thickness / 2);
break;
default:
VERIFY_NOT_REACHED();
}
recorder.draw_line(line_start_point.to_type<int>(), line_end_point.to_type<int>(), line_color, device_line_thickness.value());
recorder.draw_line(line_start_point.translated(0, device_line_thickness + 1).to_type<int>(), line_end_point.translated(0, device_line_thickness + 1).to_type<int>(), line_color, device_line_thickness.value());
break;
case CSS::TextDecorationStyle::Dashed:
recorder.draw_line(line_start_point.to_type<int>(), line_end_point.to_type<int>(), line_color, device_line_thickness.value(), Gfx::LineStyle::Dashed);
break;
case CSS::TextDecorationStyle::Dotted:
recorder.draw_line(line_start_point.to_type<int>(), line_end_point.to_type<int>(), line_color, device_line_thickness.value(), Gfx::LineStyle::Dotted);
break;
case CSS::TextDecorationStyle::Wavy:
auto amplitude = device_line_thickness.value() * 3;
switch (line) {
case CSS::TextDecorationLine::Underline:
line_start_point.translate_by(0, device_line_thickness + context.rounded_device_pixels(1));
line_end_point.translate_by(0, device_line_thickness + context.rounded_device_pixels(1));
break;
case CSS::TextDecorationLine::Overline:
line_start_point.translate_by(0, -device_line_thickness - context.rounded_device_pixels(1));
line_end_point.translate_by(0, -device_line_thickness - context.rounded_device_pixels(1));
break;
case CSS::TextDecorationLine::LineThrough:
line_start_point.translate_by(0, -device_line_thickness / 2);
line_end_point.translate_by(0, -device_line_thickness / 2);
break;
default:
VERIFY_NOT_REACHED();
}
recorder.stroke_path({
.cap_style = Gfx::Path::CapStyle::Round,
.join_style = Gfx::Path::JoinStyle::Round,
.miter_limit = 0,
.dash_array = {},
.dash_offset = 0,
.path = build_triangle_wave_path(line_start_point.to_type<int>(), line_end_point.to_type<int>(), amplitude),
.paint_style_or_color = line_color,
.thickness = static_cast<float>(device_line_thickness.value()),
});
break;
}
}
}
Gfx::Path build_triangle_wave_path(Gfx::IntPoint from, Gfx::IntPoint to, float amplitude)
{
Gfx::Path path;
if (from.y() != to.y()) {
dbgln("FIXME: Support more than horizontal waves");
return path;
}
path.move_to(from.to_type<float>());
float const wavelength = amplitude * 2.0f;
float const half_wavelength = amplitude;
float const quarter_wavelength = amplitude / 2.0f;
auto position = from.to_type<float>();
auto remaining = abs(to.x() - position.x());
while (remaining > wavelength) {
// Draw a whole wave
path.line_to({ position.x() + quarter_wavelength, position.y() - quarter_wavelength });
path.line_to({ position.x() + quarter_wavelength + half_wavelength, position.y() + quarter_wavelength });
path.line_to({ position.x() + wavelength, (float)position.y() });
position.translate_by({ wavelength, 0 });
remaining = abs(to.x() - position.x());
}
// Up
if (remaining > quarter_wavelength) {
path.line_to({ position.x() + quarter_wavelength, position.y() - quarter_wavelength });
position.translate_by({ quarter_wavelength, 0 });
remaining = abs(to.x() - position.x());
} else if (remaining >= 1) {
auto fraction = remaining / quarter_wavelength;
path.line_to({ position.x() + (fraction * quarter_wavelength), position.y() - (fraction * quarter_wavelength) });
remaining = 0;
}
// Down
if (remaining > half_wavelength) {
path.line_to({ position.x() + half_wavelength, position.y() + quarter_wavelength });
position.translate_by(half_wavelength, 0);
remaining = abs(to.x() - position.x());
} else if (remaining >= 1) {
auto fraction = remaining / half_wavelength;
path.line_to({ position.x() + (fraction * half_wavelength), position.y() - quarter_wavelength + (fraction * half_wavelength) });
remaining = 0;
}
// Back to middle
if (remaining >= 1) {
auto fraction = remaining / quarter_wavelength;
path.line_to({ position.x() + (fraction * quarter_wavelength), position.y() + ((1 - fraction) * quarter_wavelength) });
}
return path;
}
} // namespace Web::Painting