mirror of
https://github.com/servo/servo.git
synced 2025-06-06 16:45:39 +00:00
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:
parent
e6595619e1
commit
e62aecb103
9 changed files with 172 additions and 11 deletions
|
@ -5,7 +5,7 @@
|
|||
use std::cell::{OnceCell, RefCell};
|
||||
use std::sync::Arc;
|
||||
|
||||
use app_units::Au;
|
||||
use app_units::{AU_PER_PX, Au};
|
||||
use base::WebRenderEpochToU16;
|
||||
use base::id::ScrollTreeNodeId;
|
||||
use compositing_traits::display_list::{AxesScrollSensitivity, CompositorDisplayListInfo};
|
||||
|
@ -13,6 +13,7 @@ use embedder_traits::Cursor;
|
|||
use euclid::{Point2D, SideOffsets2D, Size2D, UnknownUnit};
|
||||
use fonts::GlyphStore;
|
||||
use gradient::WebRenderGradient;
|
||||
use range::Range as ServoRange;
|
||||
use servo_geometry::MaxRect;
|
||||
use style::Zero;
|
||||
use style::color::{AbsoluteColor, ColorSpace};
|
||||
|
@ -68,6 +69,7 @@ pub struct WebRenderImageInfo {
|
|||
// webrender's `ItemTag` is private.
|
||||
type ItemTag = (u64, u16);
|
||||
type HitInfo = Option<ItemTag>;
|
||||
const INSERTION_POINT_LOGICAL_WIDTH: Au = Au(AU_PER_PX);
|
||||
|
||||
/// Where the information that's used to build display lists is stored. This
|
||||
/// includes both a [wr::DisplayListBuilder] for building up WebRender-specific
|
||||
|
@ -388,6 +390,7 @@ impl Fragment {
|
|||
&fragment.glyphs,
|
||||
baseline_origin,
|
||||
fragment.justification_adjustment,
|
||||
!fragment.has_selection(),
|
||||
);
|
||||
if glyphs.is_empty() {
|
||||
return;
|
||||
|
@ -424,7 +427,6 @@ impl Fragment {
|
|||
);
|
||||
}
|
||||
|
||||
// Underline.
|
||||
if fragment
|
||||
.text_decoration_line
|
||||
.contains(TextDecorationLine::UNDERLINE)
|
||||
|
@ -435,7 +437,6 @@ impl Fragment {
|
|||
self.build_display_list_for_text_decoration(fragment, builder, &rect, &color);
|
||||
}
|
||||
|
||||
// Overline.
|
||||
if fragment
|
||||
.text_decoration_line
|
||||
.contains(TextDecorationLine::OVERLINE)
|
||||
|
@ -445,7 +446,70 @@ impl Fragment {
|
|||
self.build_display_list_for_text_decoration(fragment, builder, &rect, &color);
|
||||
}
|
||||
|
||||
// Text.
|
||||
// TODO: This caret/text selection implementation currently does not account for vertical text
|
||||
// and RTL text properly.
|
||||
if let Some(range) = fragment.selection_range {
|
||||
let baseline_origin = rect.origin;
|
||||
if !range.is_empty() {
|
||||
let start = glyphs_advance_by_index(
|
||||
&fragment.glyphs,
|
||||
range.begin(),
|
||||
baseline_origin,
|
||||
fragment.justification_adjustment,
|
||||
);
|
||||
|
||||
let end = glyphs_advance_by_index(
|
||||
&fragment.glyphs,
|
||||
range.end(),
|
||||
baseline_origin,
|
||||
fragment.justification_adjustment,
|
||||
);
|
||||
|
||||
let selection_rect = LayoutRect::new(
|
||||
Point2D::new(start.x.to_f32_px(), containing_block.min_y().to_f32_px()),
|
||||
Point2D::new(end.x.to_f32_px(), containing_block.max_y().to_f32_px()),
|
||||
);
|
||||
if let Some(selection_color) = fragment
|
||||
.selected_style
|
||||
.clone_background_color()
|
||||
.as_absolute()
|
||||
{
|
||||
let selection_common =
|
||||
builder.common_properties(selection_rect, &fragment.parent_style);
|
||||
builder.wr().push_rect(
|
||||
&selection_common,
|
||||
selection_rect,
|
||||
rgba(*selection_color),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let insertion_point = glyphs_advance_by_index(
|
||||
&fragment.glyphs,
|
||||
range.begin(),
|
||||
baseline_origin,
|
||||
fragment.justification_adjustment,
|
||||
);
|
||||
|
||||
let insertion_point_rect = LayoutRect::new(
|
||||
Point2D::new(
|
||||
insertion_point.x.to_f32_px(),
|
||||
containing_block.min_y().to_f32_px(),
|
||||
),
|
||||
Point2D::new(
|
||||
insertion_point.x.to_f32_px() + INSERTION_POINT_LOGICAL_WIDTH.to_f32_px(),
|
||||
containing_block.max_y().to_f32_px(),
|
||||
),
|
||||
);
|
||||
let insertion_point_common =
|
||||
builder.common_properties(insertion_point_rect, &fragment.parent_style);
|
||||
// TODO: The color of the caret is currently hardcoded to the text color.
|
||||
// We should be retrieving the caret color from the style properly.
|
||||
builder
|
||||
.wr()
|
||||
.push_rect(&insertion_point_common, insertion_point_rect, rgba(color));
|
||||
}
|
||||
}
|
||||
|
||||
builder.wr().push_text(
|
||||
&common,
|
||||
rect.to_webrender(),
|
||||
|
@ -455,7 +519,6 @@ impl Fragment {
|
|||
None,
|
||||
);
|
||||
|
||||
// Line-through.
|
||||
if fragment
|
||||
.text_decoration_line
|
||||
.contains(TextDecorationLine::LINE_THROUGH)
|
||||
|
@ -1130,6 +1193,7 @@ fn glyphs(
|
|||
glyph_runs: &[Arc<GlyphStore>],
|
||||
mut baseline_origin: PhysicalPoint<Au>,
|
||||
justification_adjustment: Au,
|
||||
ignore_whitespace: bool,
|
||||
) -> Vec<wr::GlyphInstance> {
|
||||
use fonts_traits::ByteIndex;
|
||||
use range::Range;
|
||||
|
@ -1137,7 +1201,7 @@ fn glyphs(
|
|||
let mut glyphs = vec![];
|
||||
for run in glyph_runs {
|
||||
for glyph in run.iter_glyphs_for_byte_range(&Range::new(ByteIndex(0), run.len())) {
|
||||
if !run.is_whitespace() {
|
||||
if !run.is_whitespace() || !ignore_whitespace {
|
||||
let glyph_offset = glyph.offset().unwrap_or(Point2D::zero());
|
||||
let point = units::LayoutPoint::new(
|
||||
baseline_origin.x.to_f32_px() + glyph_offset.x.to_f32_px(),
|
||||
|
@ -1159,6 +1223,26 @@ fn glyphs(
|
|||
glyphs
|
||||
}
|
||||
|
||||
// TODO: This implementation has not been tested against `TextFragment` with mutiple runs.
|
||||
// It is possible that the `glyphs` function above will need to be modified to
|
||||
// handle multiple runs correctly.
|
||||
fn glyphs_advance_by_index(
|
||||
glyph_runs: &[Arc<GlyphStore>],
|
||||
index: fonts_traits::ByteIndex,
|
||||
baseline_origin: PhysicalPoint<Au>,
|
||||
justification_adjustment: Au,
|
||||
) -> PhysicalPoint<Au> {
|
||||
let mut point = baseline_origin;
|
||||
for run in glyph_runs {
|
||||
let total_advance = run.advance_for_byte_range(
|
||||
&ServoRange::new(fonts::ByteIndex(0), index),
|
||||
justification_adjustment,
|
||||
);
|
||||
point.x += total_advance;
|
||||
}
|
||||
point
|
||||
}
|
||||
|
||||
fn cursor(kind: CursorKind, auto_cursor: Cursor) -> Cursor {
|
||||
match kind {
|
||||
CursorKind::Auto => auto_cursor,
|
||||
|
|
|
@ -5,7 +5,9 @@
|
|||
use std::borrow::Cow;
|
||||
use std::iter::FusedIterator;
|
||||
|
||||
use fonts::ByteIndex;
|
||||
use html5ever::{LocalName, local_name};
|
||||
use range::Range;
|
||||
use script_layout_interface::wrapper_traits::{ThreadSafeLayoutElement, ThreadSafeLayoutNode};
|
||||
use script_layout_interface::{LayoutElementType, LayoutNodeType};
|
||||
use selectors::Element as SelectorsElement;
|
||||
|
@ -74,6 +76,14 @@ impl<'dom, Node: NodeExt<'dom>> NodeAndStyleInfo<Node> {
|
|||
style,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn get_selected_style(&self) -> ServoArc<ComputedValues> {
|
||||
self.node.to_threadsafe().selected_style()
|
||||
}
|
||||
|
||||
pub(crate) fn get_selection_range(&self) -> Option<Range<ByteIndex>> {
|
||||
self.node.to_threadsafe().selection()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'dom, Node> From<&NodeAndStyleInfo<Node>> for BaseFragmentInfo
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
))));
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,7 +7,8 @@ use std::sync::Arc;
|
|||
use app_units::Au;
|
||||
use base::id::PipelineId;
|
||||
use base::print_tree::PrintTree;
|
||||
use fonts::{FontMetrics, GlyphStore};
|
||||
use fonts::{ByteIndex, FontMetrics, GlyphStore};
|
||||
use range::Range as ServoRange;
|
||||
use servo_arc::Arc as ServoArc;
|
||||
use style::Zero;
|
||||
use style::properties::ComputedValues;
|
||||
|
@ -71,6 +72,8 @@ pub(crate) struct TextFragment {
|
|||
|
||||
/// Extra space to add for each justification opportunity.
|
||||
pub justification_adjustment: Au,
|
||||
pub selection_range: Option<ServoRange<ByteIndex>>,
|
||||
pub selected_style: ServoArc<ComputedValues>,
|
||||
}
|
||||
|
||||
pub(crate) struct ImageFragment {
|
||||
|
@ -221,6 +224,10 @@ impl TextFragment {
|
|||
self.rect,
|
||||
));
|
||||
}
|
||||
|
||||
pub fn has_selection(&self) -> bool {
|
||||
self.selection_range.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageFragment {
|
||||
|
|
|
@ -212,7 +212,7 @@ pub trait ThreadSafeLayoutNode<'dom>: Clone + Copy + Debug + NodeInfo + PartialE
|
|||
|
||||
fn node_text_content(self) -> Cow<'dom, str>;
|
||||
|
||||
/// If the insertion point is within this node, returns it. Otherwise, returns `None`.
|
||||
/// If selection intersects this node, return it. Otherwise, returns `None`.
|
||||
fn selection(&self) -> Option<Range<ByteIndex>>;
|
||||
|
||||
/// If this is an image element, returns its URL. If this is not an image element, fails.
|
||||
|
|
2
tests/wpt/meta/css/css-overflow/text-overflow-ellipsis-editing-input.html.ini
vendored
Normal file
2
tests/wpt/meta/css/css-overflow/text-overflow-ellipsis-editing-input.html.ini
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
[text-overflow-ellipsis-editing-input.html]
|
||||
expected: FAIL
|
Loading…
Add table
Add a link
Reference in a new issue