devtools: Allow highlighting elements from the inspector (#35822)

This change connects the `HighlighterActor` from the devtools with the
document, which will draw a blue rectangle over any highlighted dom
node.



https://github.com/user-attachments/assets/571b2dab-497f-4102-9e55-517cdcc040ba




---
<!-- Thank you for contributing to Servo! Please replace each `[ ]` by
`[X]` when the step is complete, and replace `___` with appropriate
data: -->
- [X] `./mach build -d` does not report any errors
- [X] `./mach test-tidy` does not report any errors
- [X] These changes do not require tests because we don't have devtools
tests

Signed-off-by: Simon Wülker <simon.wuelker@arcor.de>
This commit is contained in:
Simon Wülker 2025-05-05 12:10:25 +02:00 committed by GitHub
parent 20f20a07f2
commit 8608e328a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 305 additions and 4 deletions

View file

@ -166,6 +166,8 @@ impl Actor for InspectorActor {
if self.highlighter.borrow().is_none() {
let highlighter_actor = HighlighterActor {
name: registry.new_name("highlighter"),
pipeline,
script_sender: self.script_chan.clone(),
};
let mut highlighter = self.highlighter.borrow_mut();
*highlighter = Some(highlighter_actor.name());

View file

@ -7,6 +7,9 @@
use std::net::TcpStream;
use base::id::PipelineId;
use devtools_traits::DevtoolScriptControlMsg;
use ipc_channel::ipc::IpcSender;
use serde::Serialize;
use serde_json::{self, Map, Value};
@ -21,6 +24,8 @@ pub struct HighlighterMsg {
pub struct HighlighterActor {
pub name: String,
pub script_sender: IpcSender<DevtoolScriptControlMsg>,
pub pipeline: PipelineId,
}
#[derive(Serialize)]
@ -41,14 +46,39 @@ impl Actor for HighlighterActor {
/// - `hide`: Disables highlighting for the selected node
fn handle_message(
&self,
_registry: &ActorRegistry,
registry: &ActorRegistry,
msg_type: &str,
_msg: &Map<String, Value>,
msg: &Map<String, Value>,
stream: &mut TcpStream,
_id: StreamId,
) -> Result<ActorMessageStatus, ()> {
Ok(match msg_type {
"show" => {
let Some(node_actor) = msg.get("node") else {
// TODO: send missing parameter error
return Ok(ActorMessageStatus::Ignored);
};
let Some(node_actor_name) = node_actor.as_str() else {
// TODO: send invalid parameter error
return Ok(ActorMessageStatus::Ignored);
};
if node_actor_name.starts_with("inspector") {
// TODO: For some reason, the client initially asks us to highlight
// the inspector? Investigate what this is supposed to mean.
let msg = ShowReply {
from: self.name(),
value: false,
};
let _ = stream.write_json_packet(&msg);
return Ok(ActorMessageStatus::Processed);
}
self.instruct_script_thread_to_highlight_node(
Some(node_actor_name.to_owned()),
registry,
);
let msg = ShowReply {
from: self.name(),
value: true,
@ -58,6 +88,8 @@ impl Actor for HighlighterActor {
},
"hide" => {
self.instruct_script_thread_to_highlight_node(None, registry);
let msg = EmptyReplyMsg { from: self.name() };
let _ = stream.write_json_packet(&msg);
ActorMessageStatus::Processed
@ -67,3 +99,19 @@ impl Actor for HighlighterActor {
})
}
}
impl HighlighterActor {
fn instruct_script_thread_to_highlight_node(
&self,
node_actor: Option<String>,
registry: &ActorRegistry,
) {
let node_id = node_actor.map(|node_actor| registry.actor_to_script(node_actor));
self.script_sender
.send(DevtoolScriptControlMsg::HighlightDomNode(
self.pipeline,
node_id,
))
.unwrap();
}
}

View file

@ -46,6 +46,9 @@ pub struct LayoutContext<'a> {
Arc<RwLock<FnvHashMap<(ServoUrl, UsePlaceholder), WebRenderImageInfo>>>,
pub node_image_animation_map: Arc<RwLock<FxHashMap<OpaqueNode, ImageAnimationState>>>,
/// The DOM node that is highlighted by the devtools inspector, if any
pub highlighted_dom_node: Option<OpaqueNode>,
}
pub enum ResolvedImage<'a> {

View file

@ -31,13 +31,15 @@ use style::values::generics::NonNegative;
use style::values::generics::rect::Rect;
use style::values::specified::text::TextDecorationLine;
use style::values::specified::ui::CursorKind;
use style_traits::CSSPixel;
use webrender_api::units::{DevicePixel, LayoutPixel, LayoutRect, LayoutSize};
use webrender_api::{
self as wr, BorderDetails, BoxShadowClipMode, ClipChainId, CommonItemProperties,
ImageRendering, NinePatchBorder, NinePatchBorderSource, units,
ImageRendering, NinePatchBorder, NinePatchBorderSource, SpatialId, units,
};
use wr::units::LayoutVector2D;
use crate::cell::ArcRefCell;
use crate::context::{LayoutContext, ResolvedImage};
pub use crate::display_list::conversions::ToWebRender;
use crate::display_list::stacking_context::StackingContextSection;
@ -161,10 +163,49 @@ pub(crate) struct DisplayListBuilder<'a> {
/// The [DisplayList] used to collect display list items and metadata.
pub display_list: &'a mut DisplayList,
/// Data about the fragments that are highlighted by the inspector, if any.
///
/// This data is collected during the traversal of the fragment tree and used
/// to paint the highlight at the very end.
inspector_highlight: Option<InspectorHighlight>,
}
struct InspectorHighlight {
/// The node that should be highlighted
tag: Tag,
/// Accumulates information about the fragments that belong to the highlighted node.
///
/// This information is collected as the fragment tree is traversed to build the
/// display list.
state: Option<HighlightTraversalState>,
}
struct HighlightTraversalState {
/// The smallest rectangle that fully encloses all fragments created by the highlighted
/// dom node, if any.
content_box: euclid::Rect<Au, CSSPixel>,
spatial_id: SpatialId,
clip_chain_id: ClipChainId,
/// When the highlighted fragment is a box fragment we remember the information
/// needed to paint padding, border and margin areas.
maybe_box_fragment: Option<ArcRefCell<BoxFragment>>,
}
impl InspectorHighlight {
fn for_node(node: OpaqueNode) -> Self {
Self {
tag: Tag::new(node),
state: None,
}
}
}
impl DisplayList {
/// Build the display list, returning true if it was contentful.
pub fn build(
&mut self,
context: &LayoutContext,
@ -180,8 +221,19 @@ impl DisplayList {
element_for_canvas_background: fragment_tree.canvas_background.from_element,
context,
display_list: self,
inspector_highlight: context
.highlighted_dom_node
.map(InspectorHighlight::for_node),
};
fragment_tree.build_display_list(&mut builder, root_stacking_context);
if let Some(highlight) = builder
.inspector_highlight
.take()
.and_then(|highlight| highlight.state)
{
builder.paint_dom_inspector_highlight(highlight);
}
}
}
@ -233,6 +285,150 @@ impl DisplayListBuilder<'_> {
self.display_list.compositor_info.epoch.as_u16(),
))
}
/// Draw highlights around the node that is currently hovered in the devtools.
fn paint_dom_inspector_highlight(&mut self, highlight: HighlightTraversalState) {
const CONTENT_BOX_HIGHLIGHT_COLOR: webrender_api::ColorF = webrender_api::ColorF {
r: 0.23,
g: 0.7,
b: 0.87,
a: 0.5,
};
const PADDING_BOX_HIGHLIGHT_COLOR: webrender_api::ColorF = webrender_api::ColorF {
r: 0.49,
g: 0.3,
b: 0.7,
a: 0.5,
};
const BORDER_BOX_HIGHLIGHT_COLOR: webrender_api::ColorF = webrender_api::ColorF {
r: 0.2,
g: 0.2,
b: 0.2,
a: 0.5,
};
const MARGIN_BOX_HIGHLIGHT_COLOR: webrender_api::ColorF = webrender_api::ColorF {
r: 1.,
g: 0.93,
b: 0.,
a: 0.5,
};
// Highlight content box
let content_box = highlight.content_box.to_webrender();
let properties = wr::CommonItemProperties {
clip_rect: content_box,
spatial_id: highlight.spatial_id,
clip_chain_id: highlight.clip_chain_id,
flags: wr::PrimitiveFlags::default(),
};
self.display_list
.wr
.push_rect(&properties, content_box, CONTENT_BOX_HIGHLIGHT_COLOR);
// Highlight margin, border and padding
if let Some(box_fragment) = highlight.maybe_box_fragment {
let mut paint_highlight =
|color: webrender_api::ColorF,
fragment_relative_bounds: PhysicalRect<Au>,
widths: webrender_api::units::LayoutSideOffsets| {
if widths.is_zero() {
return;
}
let bounds = box_fragment
.borrow()
.offset_by_containing_block(&fragment_relative_bounds)
.to_webrender();
// We paint each highlighted area as if it was a border for simplicity
let border_style = wr::BorderSide {
color,
style: webrender_api::BorderStyle::Solid,
};
let details = wr::BorderDetails::Normal(wr::NormalBorder {
top: border_style,
right: border_style,
bottom: border_style,
left: border_style,
radius: webrender_api::BorderRadius::default(),
do_aa: true,
});
let common = wr::CommonItemProperties {
clip_rect: bounds,
spatial_id: highlight.spatial_id,
clip_chain_id: highlight.clip_chain_id,
flags: wr::PrimitiveFlags::default(),
};
self.wr().push_border(&common, bounds, widths, details)
};
let box_fragment = box_fragment.borrow();
paint_highlight(
PADDING_BOX_HIGHLIGHT_COLOR,
box_fragment.padding_rect(),
box_fragment.padding.to_webrender(),
);
paint_highlight(
BORDER_BOX_HIGHLIGHT_COLOR,
box_fragment.border_rect(),
box_fragment.border.to_webrender(),
);
paint_highlight(
MARGIN_BOX_HIGHLIGHT_COLOR,
box_fragment.margin_rect(),
box_fragment.margin.to_webrender(),
);
}
}
}
impl InspectorHighlight {
fn register_fragment_of_highlighted_dom_node(
&mut self,
fragment: &Fragment,
spatial_id: SpatialId,
clip_chain_id: ClipChainId,
containing_block: &PhysicalRect<Au>,
) {
let state = self.state.get_or_insert(HighlightTraversalState {
content_box: euclid::Rect::zero(),
spatial_id,
clip_chain_id,
maybe_box_fragment: None,
});
// We expect all fragments generated by one node to be in the same scroll tree node and clip node
debug_assert_eq!(spatial_id, state.spatial_id);
if clip_chain_id != ClipChainId::INVALID && state.clip_chain_id != ClipChainId::INVALID {
debug_assert_eq!(
clip_chain_id, state.clip_chain_id,
"Fragments of the same node must either have no clip chain or the same one"
);
}
let fragment_relative_rect = match fragment {
Fragment::Box(fragment) | Fragment::Float(fragment) => {
state.maybe_box_fragment = Some(fragment.clone());
fragment.borrow().content_rect
},
Fragment::Positioning(fragment) => fragment.borrow().rect,
Fragment::Text(fragment) => fragment.borrow().rect,
Fragment::Image(image_fragment) => image_fragment.borrow().rect,
Fragment::AbsoluteOrFixedPositioned(_) => return,
Fragment::IFrame(iframe_fragment) => iframe_fragment.borrow().rect,
};
state.content_box = state
.content_box
.union(&fragment_relative_rect.translate(containing_block.origin.to_vector()));
}
}
impl Fragment {
@ -244,6 +440,17 @@ impl Fragment {
is_hit_test_for_scrollable_overflow: bool,
is_collapsed_table_borders: bool,
) {
if let Some(inspector_highlight) = &mut builder.inspector_highlight {
if self.tag() == Some(inspector_highlight.tag) {
inspector_highlight.register_fragment_of_highlighted_dom_node(
self,
builder.current_scroll_node_id.spatial_id,
builder.current_clip_chain_id,
containing_block,
);
}
}
match self {
Fragment::Box(box_fragment) | Fragment::Float(box_fragment) => {
let box_fragment = &*box_fragment.borrow();
@ -739,6 +946,7 @@ impl<'a> BuilderForBoxFragment<'a> {
{
return;
}
self.build_background(builder);
self.build_box_shadow(builder);
self.build_border(builder);

View file

@ -651,6 +651,7 @@ impl LayoutThread {
))),
iframe_sizes: Mutex::default(),
use_rayon: rayon_pool.is_some(),
highlighted_dom_node: reflow_request.highlighted_dom_node,
};
self.restyle_and_build_trees(

View file

@ -538,3 +538,21 @@ pub(crate) fn handle_get_css_database(reply: IpcSender<HashMap<String, CssDataba
.collect();
let _ = reply.send(database);
}
pub(crate) fn handle_highlight_dom_node(
documents: &DocumentCollection,
id: PipelineId,
node_id: Option<String>,
) {
let node = node_id.and_then(|node_id| {
let node = find_node_by_unique_id(documents, id, &node_id);
if node.is_none() {
log::warn!("Node id {node_id} for pipeline id {id} is not found",);
}
node
});
if let Some(window) = documents.find_window(id) {
window.Document().highlight_dom_node(node.as_deref());
}
}

View file

@ -565,6 +565,8 @@ pub(crate) struct Document {
/// The active keyboard modifiers for the WebView. This is updated when receiving any input event.
#[no_trace]
active_keyboard_modifiers: Cell<Modifiers>,
/// The node that is currently highlighted by the devtools
highlighted_dom_node: MutNullableDom<Node>,
}
#[allow(non_snake_case)]
@ -4222,6 +4224,7 @@ impl Document {
intersection_observer_task_queued: Cell::new(false),
intersection_observers: Default::default(),
active_keyboard_modifiers: Cell::new(Modifiers::empty()),
highlighted_dom_node: Default::default(),
}
}
@ -5213,6 +5216,14 @@ impl Document {
self.has_trustworthy_ancestor_origin.get() ||
self.origin().immutable().is_potentially_trustworthy()
}
pub(crate) fn highlight_dom_node(&self, node: Option<&Node>) {
self.highlighted_dom_node.set(node);
self.set_needs_paint(true);
}
pub(crate) fn highlighted_dom_node(&self) -> Option<DomRoot<Node>> {
self.highlighted_dom_node.get()
}
}
#[allow(non_snake_case)]

View file

@ -2142,6 +2142,8 @@ impl Window {
.or_else(|| document.GetDocumentElement())
.map(|root| root.upcast::<Node>().to_trusted_node_address());
let highlighted_dom_node = document.highlighted_dom_node().map(|node| node.to_opaque());
// Send new document and relevant styles to layout.
let reflow = ReflowRequest {
reflow_info: Reflow {
@ -2161,6 +2163,7 @@ impl Window {
.image_animation_manager_mut()
.take_image_animate_set(),
theme: self.theme.get(),
highlighted_dom_node,
};
let Some(results) = self.layout.borrow_mut().reflow(reflow) else {

View file

@ -2069,6 +2069,9 @@ impl ScriptThread {
None => warn!("Message sent to closed pipeline {}.", id),
}
},
DevtoolScriptControlMsg::HighlightDomNode(id, node_id) => {
devtools::handle_highlight_dom_node(&documents, id, node_id)
},
}
}

View file

@ -273,6 +273,8 @@ pub enum DevtoolScriptControlMsg {
GetCssDatabase(IpcSender<HashMap<String, CssDatabaseProperty>>),
/// Simulates a light or dark color scheme for the given pipeline
SimulateColorScheme(PipelineId, Theme),
/// Highlight the given DOM node
HighlightDomNode(PipelineId, Option<String>),
}
#[derive(Clone, Debug, Deserialize, Serialize)]

View file

@ -428,6 +428,8 @@ pub struct ReflowRequest {
pub node_to_image_animation_map: FxHashMap<OpaqueNode, ImageAnimationState>,
/// The theme for the window
pub theme: PrefersColorScheme,
/// The node highlighted by the devtools, if any
pub highlighted_dom_node: Option<OpaqueNode>,
}
/// A pending restyle.