LibWeb: Use themed text selection colors

Use the page palette for default selection backgrounds instead of a
hardcoded blue color. Tint opaque selection backgrounds before painting.
Selected images stay visible through the overlay.

Use SelectionText for default selected text and HighlightText. Authored
::selection colors still take precedence. On macOS, use a light
translucent color matching other engines.

Update selection and system-color baselines for the themed colors. Add
macOS-specific screenshot baselines for tests whose expectations depend
on system colors.
This commit is contained in:
Andreas Kling 2026-05-29 11:21:04 +02:00 committed by Andreas Kling
parent faf7e4c720
commit 3746fbf7f4
37 changed files with 351 additions and 26 deletions

View file

@ -87,6 +87,39 @@ Color gray_text(PreferredColorScheme)
return Color(128, 128, 128);
}
Color transform_selection_background_color(Color color)
{
if (color.alpha() < 255)
return color;
constexpr int start_alpha = 153; // 60%
constexpr int end_alpha = 204; // 80%
constexpr int alpha_increment = 17;
auto blend_component = [](u8 component, int alpha) {
auto white_blend = 255 - alpha;
return (static_cast<int>(component) - white_blend) * 255 / alpha;
};
Color result;
for (int alpha = start_alpha; alpha <= end_alpha; alpha += alpha_increment) {
auto red = blend_component(color.red(), alpha);
auto green = blend_component(color.green(), alpha);
auto blue = blend_component(color.blue(), alpha);
result = Color(
static_cast<u8>(clamp(red, 0, 255)),
static_cast<u8>(clamp(green, 0, 255)),
static_cast<u8>(clamp(blue, 0, 255)),
static_cast<u8>(alpha));
if (red >= 0 && green >= 0 && blue >= 0)
break;
}
return result;
}
Color highlight(PreferredColorScheme)
{
return Color(61, 174, 233, 128);

View file

@ -24,6 +24,7 @@ Color canvas_text(PreferredColorScheme);
Color field(PreferredColorScheme);
Color field_text(PreferredColorScheme);
Color gray_text(PreferredColorScheme);
Color transform_selection_background_color(Color);
WEB_API Color highlight(PreferredColorScheme);
WEB_API Color highlight_text(PreferredColorScheme);
Color link_text(PreferredColorScheme);

View file

@ -7,6 +7,7 @@
*/
#include <LibWeb/CSS/ComputedProperties.h>
#include <LibWeb/CSS/StyleValues/ColorSchemeStyleValue.h>
#include <LibWeb/CSS/SystemColor.h>
#include <LibWeb/DOM/Document.h>
#include <LibWeb/DOM/Element.h>
@ -15,6 +16,7 @@
#include <LibWeb/Layout/Node.h>
#include <LibWeb/Layout/TextNode.h>
#include <LibWeb/Layout/TextOffsetMapping.h>
#include <LibWeb/Page/Page.h>
#include <LibWeb/Painting/DisplayListRecorder.h>
#include <LibWeb/Painting/DisplayListRecordingContext.h>
#include <LibWeb/Painting/Paintable.h>
@ -281,19 +283,40 @@ Painting::BorderRadiiData normalize_border_radii_data(Layout::Node const& node,
// fill-color, stroke-width, and CSS custom properties.
Paintable::SelectionStyle Paintable::selection_style() const
{
auto color_scheme = computed_values().color_scheme();
SelectionStyle default_style { CSS::SystemColor::highlight(color_scheme), {}, {}, {} };
auto default_style_for_color_scheme = [&](CSS::PreferredColorScheme color_scheme, bool use_palette_for_normal_color_scheme = true) {
auto palette = document().page().palette();
auto palette_color_scheme = palette.is_dark() ? CSS::PreferredColorScheme::Dark : CSS::PreferredColorScheme::Light;
if (color_scheme == palette_color_scheme || use_palette_for_normal_color_scheme) {
return SelectionStyle {
CSS::SystemColor::transform_selection_background_color(palette.selection()),
palette.selection_text(),
{},
{},
};
}
return SelectionStyle {
CSS::SystemColor::transform_selection_background_color(CSS::SystemColor::highlight(color_scheme)),
CSS::SystemColor::highlight_text(color_scheme),
{},
{},
};
};
// For text nodes, check the parent element since text nodes don't have computed properties.
auto node = dom_node();
if (!node)
return default_style;
return default_style_for_color_scheme(computed_values().color_scheme());
DOM::Element const* element = as_if<DOM::Element>(*node);
if (!element)
element = node->parent_element();
if (!element)
return default_style;
return default_style_for_color_scheme(computed_values().color_scheme());
auto color_scheme_is_normal = element->computed_properties()->property(CSS::PropertyID::ColorScheme).as_color_scheme().schemes().is_empty();
auto use_palette_for_normal_color_scheme = color_scheme_is_normal && !document().supported_color_schemes().has_value();
auto default_style = default_style_for_color_scheme(computed_values().color_scheme(), use_palette_for_normal_color_scheme);
auto style_from_element = [&](DOM::Element const& element) -> Optional<SelectionStyle> {
auto element_layout_node = element.layout_node();

View file

@ -9,13 +9,10 @@ p {
.selected {
background-color: red;
}
.selected-default {
background: Highlight;
}
</style>
<div>
<p><span>P</span><span class="selected-default">ar</span><span class="selected">tial start</span></p>
<p><span>P</span><span class="selected">artial start</span></p>
<p><span class="selected">Full selection</span></p>
<p><span class="selected">Partial </span><span class="selected-default">en</span><span>d</span></p>
<p><span class="selected">Partial en</span><span>d</span></p>
</div>

View file

@ -9,17 +9,14 @@ p {
.selected {
color: green;
}
.selected-default {
background: Highlight;
}
.transparent-selected {
color: transparent;
}
</style>
<div>
<p><span>P</span><span class="selected-default">ar</span><span class="selected">tial start</span></p>
<p><span>P</span><span class="selected">artial start</span></p>
<p><span class="selected">Full selection</span></p>
<p><span class="selected">Partial </span><span class="selected-default">end</span></p>
<p><span class="selected">Partial end</span></p>
<p class="transparent-selected">Invisible when selected</p>
</div>

View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<style>
p {
font-size: 20px;
line-height: 1.5;
margin: 10px 0;
}
.selected-light {
background: rgba(101, 2, 0, 0.8);
color: white;
}
.selected-dark {
background: rgba(61, 174, 233, 0.5);
color: rgb(20, 20, 20);
}
</style>
<p><span class="selected-light">Light scheme</span><br><span class="selected-dark">Dark scheme</span></p>

View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<style>
p {
font-size: 20px;
line-height: 1.5;
margin: 10px 0;
}
.selected {
background: rgba(61, 174, 233, 0.5);
color: rgb(20, 20, 20);
}
</style>
<p><span class="selected">Forced dark scheme</span></p>

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<style>
td {
font-size: 20px;
line-height: 1.5;
}
.selected {
background: rgba(101, 2, 0, 0.8);
color: white;
}
</style>
<table><tr><td><span class="selected">Default link selection</span></td></tr></table>

View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<meta name="color-scheme" content="dark">
<style>
p {
font-size: 20px;
line-height: 1.5;
margin: 10px 0;
}
.selected {
background: rgba(61, 174, 233, 0.5);
color: rgb(20, 20, 20);
}
</style>
<p><span class="selected">Meta dark scheme</span></p>

View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<style>
p {
font-size: 20px;
line-height: 1.5;
margin: 10px 0;
}
.selected {
background: rgba(101, 2, 0, 0.8);
color: white;
}
</style>
<p><span class="selected">Normal color scheme</span></p>

View file

@ -16,14 +16,10 @@ p {
background-color: blue;
color: yellow;
}
.selected-default {
background: Highlight;
}
</style>
<div>
<p><span class="selected-bg">Background only</span></p>
<p><span class="selected-color">Color only</span></p>
<p><span class="selected-both">Both</span></p>
<p><span class="selected-default">Default</span></p>
</div>

View file

@ -0,0 +1,10 @@
<!DOCTYPE html>
<style>
.image {
display: inline-block;
width: 50px;
height: 50px;
background-color: red;
}
</style>
<span class="image"></span>

View file

@ -1,7 +1,7 @@
<!DOCTYPE html>
<style>
img { width: 50px; height: 50px; vertical-align: middle; }
.selected { background: rgba(61, 174, 233, 0.5); }
.selected { background: rgba(101, 2, 0, 0.8); color: white; }
.img-wrapper {
display: inline-block;
position: relative;
@ -10,7 +10,7 @@ img { width: 50px; height: 50px; vertical-align: middle; }
.img-wrapper .overlay {
position: absolute;
inset: 0;
background: rgba(61, 174, 233, 0.5);
background: rgba(101, 2, 0, 0.8);
}
</style>
<p><span class="selected">before image </span><span class="img-wrapper"><img src="../data/smiley.png"><span class="overlay"></span></span><span class="selected"> after image</span></p>

View file

@ -7,7 +7,8 @@ p {
margin: 10px 0;
}
.styled::selection {
.styled::selection,
.styled *::selection {
background-color: red;
}
</style>

View file

@ -7,11 +7,13 @@ p {
margin: 10px 0;
}
.styled::selection {
.styled::selection,
.styled *::selection {
background-color: transparent;
color: green;
}
.transparent::selection {
.transparent::selection,
.transparent *::selection {
background-color: transparent;
color: transparent;
}

View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<link rel="match" href="../expected/selection-default-color-scheme-ref.html">
<style>
p {
font-size: 20px;
line-height: 1.5;
margin: 10px 0;
}
.light {
color-scheme: light;
}
.dark {
color-scheme: dark;
}
</style>
<p id="container"><span class="light">Light scheme</span><br><span class="dark">Dark scheme</span></p>
<script>
const range = document.createRange();
range.selectNodeContents(document.getElementById("container"));
getSelection().addRange(range);
</script>

View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<link rel="match" href="../expected/selection-default-forced-dark-color-scheme-ref.html">
<style>
p {
color-scheme: dark;
font-size: 20px;
line-height: 1.5;
margin: 10px 0;
}
</style>
<p id="target">Forced dark scheme</p>
<script>
addEventListener("pagehide", () => {
internals.setPreferredColorScheme("auto");
});
internals.setPreferredColorScheme("dark");
const range = document.createRange();
range.selectNodeContents(document.getElementById("target"));
getSelection().addRange(range);
</script>

View file

@ -0,0 +1,28 @@
<!DOCTYPE html>
<link rel="match" href="../expected/selection-default-link-ref.html">
<style>
body,
td {
color: #828282;
}
td {
font-size: 20px;
line-height: 1.5;
}
a:link {
color: #000000;
text-decoration: none;
}
a:visited {
color: #828282;
text-decoration: none;
}
</style>
<table><tr><td id="target"><a href="#">Default link selection</a></td></tr></table>
<script>
const range = document.createRange();
range.selectNodeContents(document.getElementById("target"));
getSelection().addRange(range);
</script>

View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<meta name="color-scheme" content="dark">
<link rel="match" href="../expected/selection-default-meta-dark-color-scheme-ref.html">
<style>
p {
font-size: 20px;
line-height: 1.5;
margin: 10px 0;
}
</style>
<p id="target">Meta dark scheme</p>
<script>
const range = document.createRange();
range.selectNodeContents(document.getElementById("target"));
getSelection().addRange(range);
</script>

View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<link rel="match" href="../expected/selection-default-normal-forced-dark-color-scheme-ref.html">
<style>
p {
font-size: 20px;
line-height: 1.5;
margin: 10px 0;
}
</style>
<p id="target">Normal color scheme</p>
<script>
addEventListener("pagehide", () => {
internals.setPreferredColorScheme("auto");
});
internals.setPreferredColorScheme("dark");
const range = document.createRange();
range.selectNodeContents(document.getElementById("target"));
getSelection().addRange(range);
</script>

View file

@ -26,7 +26,6 @@ p {
<p class="bg-only">Background only</p>
<p class="color-only">Color only</p>
<p class="both">Both</p>
<p>Default</p>
</div>
<script>

View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<link rel="match" href="../expected/selection-with-custom-image-background-ref.html" />
<style>
img {
width: 50px;
height: 50px;
}
img::selection {
background-color: red;
}
</style>
<img id="selected-image" src="../data/smiley.png">
<script>
const range = document.createRange();
range.selectNode(document.getElementById("selected-image"));
getSelection().addRange(range);
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

View file

@ -1,5 +1,11 @@
<!DOCTYPE html>
<meta name="fuzzy" content="maxDifference=0-1;totalPixels=0-260">
<style>
::selection {
background-color: rgba(61, 174, 233, 0.5);
color: black;
}
</style>
<div id="start">not selected <span>sel</span><span id="end">ected<span> and not selected</span></span></div>
<script>
let range = document.createRange();

View file

@ -1,5 +1,11 @@
<!DOCTYPE html>
<meta name="fuzzy" content="maxDifference=0-1;totalPixels=0-339">
<style>
::selection {
background-color: rgba(61, 174, 233, 0.5);
color: black;
}
</style>
<!-- The space after the start node, as well as "NOT SELECTED" must not be selected. -->
<span id="end">End Node <span id="start">Start Node</span> </span>NOT SELECTED
<script>

View file

@ -1,5 +1,11 @@
<!DOCTYPE html>
<meta name="fuzzy" content="maxDifference=0-1;totalPixels=0-417">
<style>
::selection {
background-color: rgba(61, 174, 233, 0.5);
color: black;
}
</style>
<!-- here the end node contains the start node, as well as some further selected content and some non-selected content. -->
<span id="end">End Node <span id="start">Start</span> <span>Node</span> </span>NOT SELECTED
<script>

View file

@ -1,5 +1,11 @@
<!DOCTYPE html>
<meta name="fuzzy" content="maxDifference=0-1;totalPixels=0-339">
<style>
::selection {
background-color: rgba(61, 174, 233, 0.5);
color: black;
}
</style>
<!-- The text after the end node must not be selected. -->
<span id="end">End Node <span id="start">Start Node</span></span> NOT SELECTED
<script>

View file

@ -3,6 +3,10 @@
<style>
body { margin: 0; }
#container { width: 100px; }
::selection {
background-color: rgba(61, 174, 233, 0.5);
color: black;
}
</style>
<!-- Test that trailing whitespace at end of wrapped lines is included in selection highlight. -->
<div id="container"><span id="text">one two three four</span></div>

View file

@ -0,0 +1,2 @@
Forced dark: true
Reset to auto: false

View file

@ -0,0 +1,2 @@
Light HighlightText: rgb(255, 255, 255)
Dark HighlightText: rgb(20, 20, 20)

View file

@ -8,7 +8,7 @@ SaveLayer@0
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[8,8 784x50]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[8,8 784x50]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[8,8 168.54688x50]
FillRect@1 rect=[69,-17 47x100] color=rgba(61, 174, 233, 0.5)
FillRect@1 rect=[69,-17 47x100] color=rgba(101, 2, 0, 0.8)
Save@1
AddClipRect@1 rect=[8,-17 61x100]
DrawGlyphRun@1 rect=[8,8 169x50] translation=[8,62.984375] color=rgb(0, 0, 0)
@ -17,7 +17,7 @@ SaveLayer@0
DrawLine@1 from=[57,70] to=[67,70] color=rgb(0, 0, 0) thickness=10
Save@1
AddClipRect@1 rect=[69,-17 47x100]
DrawGlyphRun@1 rect=[8,8 169x50] translation=[8,62.984375] color=rgb(0, 0, 0)
DrawGlyphRun@1 rect=[8,8 169x50] translation=[8,62.984375] color=rgb(255, 255, 255)
Restore@1
DrawLine@1 from=[109,70] to=[116,70] color=rgb(0, 0, 0) thickness=10
Save@1

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<script src="../include.js"></script>
<script>
test(() => {
internals.setPreferredColorScheme("dark");
internals.updateStyle();
println(`Forced dark: ${matchMedia("(prefers-color-scheme: dark)").matches}`);
internals.setPreferredColorScheme("auto");
internals.updateStyle();
println(`Reset to auto: ${matchMedia("(prefers-color-scheme: dark)").matches}`);
});
</script>

View file

@ -0,0 +1,10 @@
<!DOCTYPE html>
<script src="../include.js"></script>
<div id="light" style="color-scheme: light; color: HighlightText"></div>
<div id="dark" style="color-scheme: dark; color: HighlightText"></div>
<script>
test(() => {
println(`Light HighlightText: ${getComputedStyle(document.getElementById("light")).color}`);
println(`Dark HighlightText: ${getComputedStyle(document.getElementById("dark")).color}`);
});
</script>

View file

@ -39,6 +39,10 @@ Core::AnonymousBuffer create_system_palette()
auto palette = Gfx::Palette(move(palette_impl));
palette.set_flag(Gfx::FlagRole::IsDark, is_dark);
palette.set_color(Gfx::ColorRole::Accent, ns_color_to_gfx_color([NSColor controlAccentColor]));
palette.set_color(Gfx::ColorRole::Selection, Gfx::Color(128, 188, 254, 153));
palette.set_color(Gfx::ColorRole::SelectionText, Gfx::Color::Black);
palette.set_color(Gfx::ColorRole::InactiveSelection, ns_color_to_gfx_color([NSColor unemphasizedSelectedTextBackgroundColor]));
palette.set_color(Gfx::ColorRole::InactiveSelectionText, ns_color_to_gfx_color([NSColor unemphasizedSelectedTextColor]));
// FIXME: There are more system colors we currently don't use (https://developer.apple.com/documentation/appkit/nscolor/3000782-controlaccentcolor?language=objc)
return theme;