feat: Implement display for text selection and caret (#35830)

This PR introduces an initial, straightforward implementation for
displaying text selection and the caret.

This is achieved by passing the selection range and insertion point
index down to `TextFragment`, along with the starting offset of each
`TextFragment` to determine the proper range for displaying the caret
and text selection. Additionally, the `selected_style` was passed into
`TextFragment` to specify the background color.

During the final build phase, although whitespace is typically ignored
when constructing glyphs, we still need to retrieve it to render both
the caret and text selection at the correct location. This ensures that
whitespace is not overlooked when the `TextFragment` contains an
insertion point or selection range.

There are several improvements yet to be made, including:

- The caret is static and does not flash.
- The caret is not rendered when the input field is empty. (I suppose
there should be an easy fix somewhere but I haven't found it yet)

**Working Examples**

macOS


https://github.com/user-attachments/assets/f3622cbe-9fa6-40c0-b2d8-b3a8f9842c28

Windows


https://github.com/user-attachments/assets/9b008a0d-0011-4c76-a2e2-0e35869a216c

Linux

[Screencast from 03-07-2025 11_05_41
AM.webm](https://github.com/user-attachments/assets/09a311ad-f975-4450-a66c-b20be525a5ed)



---
- [x] `./mach build -d` does not report any errors
- [x] `./mach test-tidy` does not report any errors
- [x] These changes fix part of #33237 (But the cursor isn't blinking
yet)
- [x] These changes do not require tests because there's no behavior
change

Signed-off-by: DK Liao <dklassic@gmail.com>
This commit is contained in:
DK Liao 2025-04-10 23:40:38 +09:00 committed by GitHub
parent e6595619e1
commit e62aecb103
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 172 additions and 11 deletions

View file

@ -245,6 +245,9 @@ impl InlineFormattingContextBuilder {
return;
}
let selection_range = info.get_selection_range();
let selected_style = info.get_selected_style();
if let Some(last_character) = new_text.chars().next_back() {
self.on_word_boundary = last_character.is_whitespace();
self.last_inline_box_ended_with_collapsible_white_space =
@ -264,7 +267,13 @@ impl InlineFormattingContextBuilder {
self.inline_items
.push(ArcRefCell::new(InlineItem::TextRun(ArcRefCell::new(
TextRun::new(info.into(), info.style.clone(), new_range),
TextRun::new(
info.into(),
info.style.clone(),
new_range,
selection_range,
selected_style,
),
))));
}

View file

@ -4,8 +4,9 @@
use app_units::Au;
use bitflags::bitflags;
use fonts::{FontMetrics, GlyphStore};
use fonts::{ByteIndex, FontMetrics, GlyphStore};
use itertools::Either;
use range::Range;
use servo_arc::Arc;
use style::Zero;
use style::computed_values::position::T as Position;
@ -576,6 +577,8 @@ impl LineItemLayout<'_, '_> {
glyphs: text_item.text,
text_decoration_line: text_item.text_decoration_line,
justification_adjustment: self.justification_adjustment,
selection_range: text_item.selection_range,
selected_style: text_item.selected_style,
})),
content_rect,
));
@ -768,6 +771,8 @@ pub(super) struct TextRunLineItem {
pub text_decoration_line: TextDecorationLine,
/// The BiDi level of this [`TextRunLineItem`] to enable reordering.
pub bidi_level: Level,
pub selection_range: Option<Range<ByteIndex>>,
pub selected_style: Arc<ComputedValues>,
}
impl TextRunLineItem {

View file

@ -81,13 +81,14 @@ use std::rc::Rc;
use app_units::{Au, MAX_AU};
use bitflags::bitflags;
use construct::InlineFormattingContextBuilder;
use fonts::{FontMetrics, GlyphStore};
use fonts::{ByteIndex, FontMetrics, GlyphStore};
use inline_box::{InlineBox, InlineBoxContainerState, InlineBoxIdentifier, InlineBoxes};
use line::{
AbsolutelyPositionedLineItem, AtomicLineItem, FloatLineItem, LineItem, LineItemLayout,
TextRunLineItem,
};
use line_breaker::LineBreaker;
use range::Range;
use servo_arc::Arc;
use style::Zero;
use style::computed_values::text_wrap_mode::T as TextWrapMode;
@ -1288,6 +1289,7 @@ impl InlineFormattingContextLayout<'_> {
text_run: &TextRun,
font_index: usize,
bidi_level: Level,
range: range::Range<ByteIndex>,
) {
let inline_advance = glyph_store.total_advance();
let flags = if glyph_store.is_whitespace() {
@ -1344,6 +1346,33 @@ impl InlineFormattingContextLayout<'_> {
_ => {},
}
let selection_range = if let Some(selection) = &text_run.selection_range {
let intersection = selection.intersect(&range);
if intersection.is_empty() {
let insertion_point_index = selection.begin();
// We only allow the caret to be shown in the start of the fragment if it is the first fragment.
// Otherwise this will cause duplicate caret, especially apparent when encountered line break.
if insertion_point_index >= range.begin() &&
insertion_point_index <= range.end() &&
(range.begin() != insertion_point_index || range.begin().0 == 0)
{
Some(Range::new(
insertion_point_index - range.begin(),
ByteIndex(0),
))
} else {
None
}
} else {
Some(Range::new(
intersection.begin() - range.begin(),
intersection.length(),
))
}
} else {
None
};
self.push_line_item_to_unbreakable_segment(LineItem::TextRun(
current_inline_box_identifier,
TextRunLineItem {
@ -1354,6 +1383,8 @@ impl InlineFormattingContextLayout<'_> {
font_key: ifc_font_info.key,
text_decoration_line: self.current_inline_container_state().text_decoration_line,
bidi_level,
selection_range,
selected_style: text_run.selected_style.clone(),
},
));
}

View file

@ -46,6 +46,8 @@ pub(crate) struct TextRun {
/// The text of this [`TextRun`] with a font selected, broken into unbreakable
/// segments, and shaped.
pub shaped_text: Vec<TextRunSegment>,
pub selection_range: Option<ServoRange<ByteIndex>>,
pub selected_style: Arc<ComputedValues>,
}
// There are two reasons why we might want to break at the start:
@ -140,6 +142,7 @@ impl TextRunSegment {
soft_wrap_policy = SegmentStartSoftWrapPolicy::Force;
}
let mut byte_processed = ByteIndex(0);
for (run_index, run) in self.runs.iter().enumerate() {
ifc.possibly_flush_deferred_forced_line_break();
@ -147,6 +150,7 @@ impl TextRunSegment {
// see any content. We don't line break immediately, because we'd like to finish processing
// any ongoing inline boxes before ending the line.
if run.is_single_preserved_newline() {
byte_processed = byte_processed + run.range.length();
ifc.defer_forced_line_break();
continue;
}
@ -160,7 +164,12 @@ impl TextRunSegment {
text_run,
self.font_index,
self.bidi_level,
ServoRange::<ByteIndex>::new(
byte_processed + ByteIndex(self.range.start as isize),
run.range.length(),
),
);
byte_processed = byte_processed + run.range.length();
}
}
@ -327,12 +336,16 @@ impl TextRun {
base_fragment_info: BaseFragmentInfo,
parent_style: Arc<ComputedValues>,
text_range: Range<usize>,
selection_range: Option<ServoRange<ByteIndex>>,
selected_style: Arc<ComputedValues>,
) -> Self {
Self {
base_fragment_info,
parent_style,
text_range,
shaped_text: Vec::new(),
selection_range,
selected_style,
}
}