script: Ensure that leaving the WebView sets the cursor back to the default cursor (#38759)

This changes makes a variety of changes to ensure that the cursor is set
back to the default cursor when it leaves the `WebView`:

1. Display list updates can come after a mouse leaves the `WebView`, so
   when refreshing the cursor after the update, base the updated cursor
   on the last hovered location in the `DocumentEventHandler`, rather
   than the compositor. This allows us to catch when the last hovered
   position is `None` (ie the cursor has left the `WebView`).
2. When handling `MouseLeftViewport` events for the cursor leaving the
   entire WebView, properly set the
   MouseLeftViewport::focus_moving_to_another_iframe` on the input event
   passed to the script thread.
3. When moving out of the `WebView` entirely, explicitly ask the
   embedder to set the cursor back to the default.

Testing: This change adds a unit test verifying this behavior.
Fixes: #38710.

Signed-off-by: Martin Robinson <mrobinson@igalia.com>
This commit is contained in:
Martin Robinson 2025-08-22 00:49:56 -07:00 committed by GitHub
parent 66adf2bf9f
commit 4784ff0375
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 164 additions and 90 deletions

View file

@ -1601,7 +1601,6 @@ impl IOCompositor {
.constellation_sender .constellation_sender
.send(EmbedderToConstellationMessage::RefreshCursor( .send(EmbedderToConstellationMessage::RefreshCursor(
hit_test_result.pipeline_id, hit_test_result.pipeline_id,
hit_test_result.point_in_viewport,
)) ))
{ {
warn!("Sending event to constellation failed ({:?}).", error); warn!("Sending event to constellation failed ({:?}).", error);

View file

@ -136,8 +136,8 @@ use embedder_traits::{
MouseButton, MouseButtonAction, MouseButtonEvent, Theme, ViewportDetails, WebDriverCommandMsg, MouseButton, MouseButtonAction, MouseButtonEvent, Theme, ViewportDetails, WebDriverCommandMsg,
WebDriverCommandResponse, WebDriverLoadStatus, WebDriverScriptCommand, WebDriverCommandResponse, WebDriverLoadStatus, WebDriverScriptCommand,
}; };
use euclid::Size2D;
use euclid::default::Size2D as UntypedSize2D; use euclid::default::Size2D as UntypedSize2D;
use euclid::{Point2D, Size2D};
use fonts::SystemFontServiceProxy; use fonts::SystemFontServiceProxy;
use ipc_channel::Error as IpcError; use ipc_channel::Error as IpcError;
use ipc_channel::ipc::{self, IpcReceiver, IpcSender}; use ipc_channel::ipc::{self, IpcReceiver, IpcSender};
@ -164,7 +164,6 @@ use servo_config::prefs::{self, PrefValue};
use servo_config::{opts, pref}; use servo_config::{opts, pref};
use servo_rand::{Rng, ServoRng, SliceRandom, random}; use servo_rand::{Rng, ServoRng, SliceRandom, random};
use servo_url::{Host, ImmutableOrigin, ServoUrl}; use servo_url::{Host, ImmutableOrigin, ServoUrl};
use style_traits::CSSPixel;
#[cfg(feature = "webgpu")] #[cfg(feature = "webgpu")]
use webgpu::swapchain::WGPUImageMap; use webgpu::swapchain::WGPUImageMap;
#[cfg(feature = "webgpu")] #[cfg(feature = "webgpu")]
@ -1465,8 +1464,8 @@ where
EmbedderToConstellationMessage::ForwardInputEvent(webview_id, event, hit_test) => { EmbedderToConstellationMessage::ForwardInputEvent(webview_id, event, hit_test) => {
self.forward_input_event(webview_id, event, hit_test); self.forward_input_event(webview_id, event, hit_test);
}, },
EmbedderToConstellationMessage::RefreshCursor(pipeline_id, point) => { EmbedderToConstellationMessage::RefreshCursor(pipeline_id) => {
self.handle_refresh_cursor(pipeline_id, point) self.handle_refresh_cursor(pipeline_id)
}, },
EmbedderToConstellationMessage::ToggleProfiler(rate, max_duration) => { EmbedderToConstellationMessage::ToggleProfiler(rate, max_duration) => {
for background_monitor_control_sender in &self.background_monitor_control_senders { for background_monitor_control_sender in &self.background_monitor_control_senders {
@ -3440,14 +3439,14 @@ where
} }
#[servo_tracing::instrument(skip_all)] #[servo_tracing::instrument(skip_all)]
fn handle_refresh_cursor(&self, pipeline_id: PipelineId, point: Point2D<f32, CSSPixel>) { fn handle_refresh_cursor(&self, pipeline_id: PipelineId) {
let Some(pipeline) = self.pipelines.get(&pipeline_id) else { let Some(pipeline) = self.pipelines.get(&pipeline_id) else {
return; return;
}; };
if let Err(error) = pipeline if let Err(error) = pipeline
.event_loop .event_loop
.send(ScriptThreadMessage::RefreshCursor(pipeline_id, point)) .send(ScriptThreadMessage::RefreshCursor(pipeline_id))
{ {
warn!("Could not send RefreshCursor message to pipeline: {error:?}"); warn!("Could not send RefreshCursor message to pipeline: {error:?}");
} }

View file

@ -98,45 +98,46 @@ impl ConstellationWebView {
return; return;
}; };
let mut update_hovered_browsing_context = |newly_hovered_browsing_context_id| { let mut update_hovered_browsing_context =
let old_hovered_context_id = std::mem::replace( |newly_hovered_browsing_context_id, focus_moving_to_another_iframe: bool| {
&mut self.hovered_browsing_context_id, let old_hovered_context_id = std::mem::replace(
newly_hovered_browsing_context_id, &mut self.hovered_browsing_context_id,
); newly_hovered_browsing_context_id,
if old_hovered_context_id == newly_hovered_browsing_context_id { );
return; if old_hovered_context_id == newly_hovered_browsing_context_id {
} return;
let Some(old_hovered_context_id) = old_hovered_context_id else { }
return; let Some(old_hovered_context_id) = old_hovered_context_id else {
}; return;
let Some(pipeline) = browsing_contexts };
.get(&old_hovered_context_id) let Some(pipeline) = browsing_contexts
.and_then(|browsing_context| pipelines.get(&browsing_context.pipeline_id)) .get(&old_hovered_context_id)
else { .and_then(|browsing_context| pipelines.get(&browsing_context.pipeline_id))
return; else {
}; return;
};
let mut synthetic_mouse_leave_event = event.clone(); let mut synthetic_mouse_leave_event = event.clone();
synthetic_mouse_leave_event.event = synthetic_mouse_leave_event.event =
InputEvent::MouseLeftViewport(MouseLeftViewportEvent { InputEvent::MouseLeftViewport(MouseLeftViewportEvent {
focus_moving_to_another_iframe: true, focus_moving_to_another_iframe,
}); });
let _ = pipeline let _ = pipeline
.event_loop .event_loop
.send(ScriptThreadMessage::SendInputEvent( .send(ScriptThreadMessage::SendInputEvent(
pipeline.id, pipeline.id,
synthetic_mouse_leave_event, synthetic_mouse_leave_event,
)); ));
}; };
if let InputEvent::MouseLeftViewport(_) = &event.event { if let InputEvent::MouseLeftViewport(_) = &event.event {
update_hovered_browsing_context(None); update_hovered_browsing_context(None, false);
return; return;
} }
if let InputEvent::MouseMove(_) = &event.event { if let InputEvent::MouseMove(_) = &event.event {
update_hovered_browsing_context(Some(pipeline.browsing_context_id)); update_hovered_browsing_context(Some(pipeline.browsing_context_id), true);
self.last_mouse_move_point = event self.last_mouse_move_point = event
.hit_test_result .hit_test_result
.as_ref() .as_ref()

View file

@ -79,7 +79,7 @@ pub(crate) struct DocumentEventHandler {
current_hover_target: MutNullableDom<Element>, current_hover_target: MutNullableDom<Element>,
/// The most recent mouse movement point, used for processing `mouseleave` events. /// The most recent mouse movement point, used for processing `mouseleave` events.
#[no_trace] #[no_trace]
most_recent_mousemove_point: Point2D<f32, CSSPixel>, most_recent_mousemove_point: Cell<Option<Point2D<f32, CSSPixel>>>,
/// The currently set [`Cursor`] or `None` if the `Document` isn't being hovered /// The currently set [`Cursor`] or `None` if the `Document` isn't being hovered
/// by the cursor. /// by the cursor.
#[no_trace] #[no_trace]
@ -212,13 +212,15 @@ impl DocumentEventHandler {
})); }));
} }
pub(crate) fn set_cursor(&self, cursor: Cursor) { pub(crate) fn set_cursor(&self, cursor: Option<Cursor>) {
if Some(cursor) == self.current_cursor.get() { if cursor == self.current_cursor.get() {
return; return;
} }
self.current_cursor.set(Some(cursor)); self.current_cursor.set(cursor);
self.window self.window.send_to_embedder(EmbedderMsg::SetCursor(
.send_to_embedder(EmbedderMsg::SetCursor(self.window.webview_id(), cursor)); self.window.webview_id(),
cursor.unwrap_or_default(),
));
} }
fn handle_mouse_left_viewport_event( fn handle_mouse_left_viewport_event(
@ -238,8 +240,9 @@ impl DocumentEventHandler {
} }
if let Some(hit_test_result) = self if let Some(hit_test_result) = self
.window .most_recent_mousemove_point
.hit_test_from_point_in_viewport(self.most_recent_mousemove_point) .get()
.and_then(|point| self.window.hit_test_from_point_in_viewport(point))
{ {
MouseEvent::new_simple( MouseEvent::new_simple(
&self.window, &self.window,
@ -263,15 +266,23 @@ impl DocumentEventHandler {
} }
} }
self.current_cursor.set(None); // We do not want to always inform the embedder that cursor has been set to the
self.current_hover_target.set(None); // default cursor, in order to avoid a timing issue when moving between `<iframe>`
// elements. There is currently no way to control which `SetCursor` message will
// If focus is moving to another frame, it will decide what the new status text is, but if // reach the embedder first. This is safer when leaving the `WebView` entirely.
// this mouse leave event is leaving the WebView entirely, then clear it.
if !mouse_leave_event.focus_moving_to_another_iframe { if !mouse_leave_event.focus_moving_to_another_iframe {
// If focus is moving to another frame, it will decide what the new status
// text is, but if this mouse leave event is leaving the WebView entirely,
// then clear it.
self.window self.window
.send_to_embedder(EmbedderMsg::Status(self.window.webview_id(), None)); .send_to_embedder(EmbedderMsg::Status(self.window.webview_id(), None));
self.set_cursor(None);
} else {
self.current_cursor.set(None);
} }
self.current_hover_target.set(None);
self.most_recent_mousemove_point.set(None);
} }
fn handle_mouse_enter_leave_event( fn handle_mouse_enter_leave_event(
@ -336,7 +347,7 @@ impl DocumentEventHandler {
}; };
// Update the cursor when the mouse moves, if it has changed. // Update the cursor when the mouse moves, if it has changed.
self.set_cursor(hit_test_result.cursor); self.set_cursor(Some(hit_test_result.cursor));
let Some(new_target) = hit_test_result let Some(new_target) = hit_test_result
.node .node
@ -451,6 +462,8 @@ impl DocumentEventHandler {
.fire(new_target.upcast(), can_gc); .fire(new_target.upcast(), can_gc);
self.update_current_hover_target_and_status(Some(new_target)); self.update_current_hover_target_and_status(Some(new_target));
self.most_recent_mousemove_point
.set(Some(hit_test_result.point_in_frame));
} }
fn update_current_hover_target_and_status(&self, new_hover_target: Option<DomRoot<Element>>) { fn update_current_hover_target_and_status(&self, new_hover_target: Option<DomRoot<Element>>) {
@ -502,6 +515,21 @@ impl DocumentEventHandler {
} }
} }
pub(crate) fn handle_refresh_cursor(&self) {
let Some(most_recent_mousemove_point) = self.most_recent_mousemove_point.get() else {
return;
};
let Some(hit_test_result) = self
.window
.hit_test_from_point_in_viewport(most_recent_mousemove_point)
else {
return;
};
self.set_cursor(Some(hit_test_result.cursor));
}
/// <https://w3c.github.io/uievents/#mouseevent-algorithms> /// <https://w3c.github.io/uievents/#mouseevent-algorithms>
/// Handles native mouse down, mouse up, mouse click. /// Handles native mouse down, mouse up, mouse click.
fn handle_native_mouse_button_event( fn handle_native_mouse_button_event(

View file

@ -3067,22 +3067,6 @@ impl Window {
node.dirty(NodeDamage::Other); node.dirty(NodeDamage::Other);
} }
} }
pub fn handle_refresh_cursor(&self, cursor_position: Point2D<f32, CSSPixel>) {
let layout = self.layout.borrow();
layout.ensure_stacking_context_tree(self.viewport_details.get());
let Some(hit_test_result) = layout
.query_elements_from_point(cursor_position.cast_unit(), ElementsFromPointFlags::empty())
.into_iter()
.nth(0)
else {
return;
};
self.Document()
.event_handler()
.set_cursor(hit_test_result.cursor);
}
} }
impl Window { impl Window {

View file

@ -91,7 +91,6 @@ use script_traits::{
use servo_config::{opts, prefs}; use servo_config::{opts, prefs};
use servo_url::{ImmutableOrigin, MutableOrigin, ServoUrl}; use servo_url::{ImmutableOrigin, MutableOrigin, ServoUrl};
use style::thread_state::{self, ThreadState}; use style::thread_state::{self, ThreadState};
use style_traits::CSSPixel;
use stylo_atoms::Atom; use stylo_atoms::Atom;
use timers::{TimerEventRequest, TimerId, TimerScheduler}; use timers::{TimerEventRequest, TimerId, TimerScheduler};
use url::Position; use url::Position;
@ -1884,8 +1883,8 @@ impl ScriptThread {
); );
} }
}, },
ScriptThreadMessage::RefreshCursor(pipeline_id, cursor_position) => { ScriptThreadMessage::RefreshCursor(pipeline_id) => {
self.handle_refresh_cursor(pipeline_id, cursor_position); self.handle_refresh_cursor(pipeline_id);
}, },
ScriptThreadMessage::PreferencesUpdated(updates) => { ScriptThreadMessage::PreferencesUpdated(updates) => {
let mut current_preferences = prefs::get().clone(); let mut current_preferences = prefs::get().clone();
@ -3996,15 +3995,11 @@ impl ScriptThread {
)); ));
} }
fn handle_refresh_cursor( fn handle_refresh_cursor(&self, pipeline_id: PipelineId) {
&self, let Some(document) = self.documents.borrow().find_document(pipeline_id) else {
pipeline_id: PipelineId,
cursor_position: Point2D<f32, CSSPixel>,
) {
let Some(window) = self.documents.borrow().find_window(pipeline_id) else {
return; return;
}; };
window.handle_refresh_cursor(cursor_position); document.event_handler().handle_refresh_cursor();
} }
} }

View file

@ -57,6 +57,8 @@ pub(crate) fn run_test(
pub struct ServoTest { pub struct ServoTest {
servo: Servo, servo: Servo,
#[allow(dead_code)]
pub rendering_context: Rc<dyn RenderingContext>,
} }
impl Drop for ServoTest { impl Drop for ServoTest {
@ -100,7 +102,10 @@ impl ServoTest {
.event_loop_waker(Box::new(EventLoopWakerImpl(user_event_triggered))); .event_loop_waker(Box::new(EventLoopWakerImpl(user_event_triggered)));
let builder = customize(builder); let builder = customize(builder);
let servo = builder.build(); let servo = builder.build();
Self { servo } Self {
servo,
rendering_context,
}
} }
pub fn servo(&self) -> &Servo { pub fn servo(&self) -> &Servo {
@ -136,11 +141,15 @@ impl ServoTest {
#[derive(Default)] #[derive(Default)]
pub(crate) struct WebViewDelegateImpl { pub(crate) struct WebViewDelegateImpl {
pub(crate) url_changed: Cell<bool>, pub(crate) url_changed: Cell<bool>,
pub(crate) cursor_changed: Cell<bool>,
pub(crate) new_frame_ready: Cell<bool>,
} }
impl WebViewDelegateImpl { impl WebViewDelegateImpl {
pub(crate) fn reset(&self) { pub(crate) fn reset(&self) {
self.url_changed.set(false); self.url_changed.set(false);
self.cursor_changed.set(false);
self.new_frame_ready.set(false);
} }
} }
@ -148,6 +157,15 @@ impl WebViewDelegate for WebViewDelegateImpl {
fn notify_url_changed(&self, _webview: servo::WebView, _url: url::Url) { fn notify_url_changed(&self, _webview: servo::WebView, _url: url::Url) {
self.url_changed.set(true); self.url_changed.set(true);
} }
fn notify_cursor_changed(&self, _webview: WebView, _: servo::Cursor) {
self.cursor_changed.set(true);
}
fn notify_new_frame_ready(&self, webview: WebView) {
self.new_frame_ready.set(true);
webview.paint();
}
} }
pub(crate) fn evaluate_javascript( pub(crate) fn evaluate_javascript(

View file

@ -15,7 +15,11 @@ use std::rc::Rc;
use anyhow::ensure; use anyhow::ensure;
use common::{ServoTest, WebViewDelegateImpl, evaluate_javascript, run_api_tests}; use common::{ServoTest, WebViewDelegateImpl, evaluate_javascript, run_api_tests};
use servo::{JSValue, JavaScriptEvaluationError, Theme, WebViewBuilder}; use euclid::Point2D;
use servo::{
Cursor, InputEvent, JSValue, JavaScriptEvaluationError, LoadStatus, MouseLeftViewportEvent,
MouseMoveEvent, Theme, WebViewBuilder,
};
use url::Url; use url::Url;
fn test_create_webview(servo_test: &ServoTest) -> Result<(), anyhow::Error> { fn test_create_webview(servo_test: &ServoTest) -> Result<(), anyhow::Error> {
@ -141,9 +145,54 @@ fn test_theme_change(servo_test: &ServoTest) -> Result<(), anyhow::Error> {
Ok(()) Ok(())
} }
fn test_cursor_change(servo_test: &ServoTest) -> Result<(), anyhow::Error> {
let delegate = Rc::new(WebViewDelegateImpl::default());
let webview = WebViewBuilder::new(servo_test.servo())
.delegate(delegate.clone())
.url(
Url::parse(
"data:text/html,<!DOCTYPE html><style> html { cursor: crosshair; margin: 0}</style><body>hello</body>",
)
.unwrap(),
)
.build();
webview.focus();
webview.show(true);
webview.move_resize(servo_test.rendering_context.size2d().to_f32().into());
let load_webview = webview.clone();
let _ = servo_test.spin(move || Ok(load_webview.load_status() != LoadStatus::Complete));
// Wait for at least one frame after the load completes.
delegate.reset();
let captured_delegate = delegate.clone();
servo_test.spin(move || Ok(!captured_delegate.new_frame_ready.get()))?;
webview.notify_input_event(InputEvent::MouseMove(MouseMoveEvent::new(Point2D::new(
10., 10.,
))));
let captured_delegate = delegate.clone();
servo_test.spin(move || Ok(!captured_delegate.cursor_changed.get()))?;
ensure!(webview.cursor() == Cursor::Crosshair);
delegate.reset();
webview.notify_input_event(InputEvent::MouseLeftViewport(
MouseLeftViewportEvent::default(),
));
let captured_delegate = delegate.clone();
servo_test.spin(move || Ok(!captured_delegate.cursor_changed.get()))?;
ensure!(webview.cursor() == Cursor::Default);
Ok(())
}
fn main() { fn main() {
run_api_tests!( run_api_tests!(
test_create_webview, test_create_webview,
test_cursor_change,
test_evaluate_javascript_basic, test_evaluate_javascript_basic,
test_evaluate_javascript_panic, test_evaluate_javascript_panic,
test_theme_change, test_theme_change,

View file

@ -22,7 +22,6 @@ use embedder_traits::{
CompositorHitTestResult, FocusId, InputEvent, JavaScriptEvaluationId, MediaSessionActionType, CompositorHitTestResult, FocusId, InputEvent, JavaScriptEvaluationId, MediaSessionActionType,
Theme, TraversalId, ViewportDetails, WebDriverCommandMsg, WebDriverCommandResponse, Theme, TraversalId, ViewportDetails, WebDriverCommandMsg, WebDriverCommandResponse,
}; };
use euclid::Point2D;
pub use from_script_message::*; pub use from_script_message::*;
use ipc_channel::ipc::IpcSender; use ipc_channel::ipc::IpcSender;
use malloc_size_of_derive::MallocSizeOf; use malloc_size_of_derive::MallocSizeOf;
@ -32,7 +31,6 @@ use servo_config::prefs::PrefValue;
use servo_url::{ImmutableOrigin, ServoUrl}; use servo_url::{ImmutableOrigin, ServoUrl};
pub use structured_data::*; pub use structured_data::*;
use strum_macros::IntoStaticStr; use strum_macros::IntoStaticStr;
use style_traits::CSSPixel;
use webrender_api::units::LayoutVector2D; use webrender_api::units::LayoutVector2D;
use webrender_api::{ExternalScrollId, ImageKey}; use webrender_api::{ExternalScrollId, ImageKey};
@ -78,9 +76,10 @@ pub enum EmbedderToConstellationMessage {
BlurWebView, BlurWebView,
/// Forward an input event to an appropriate ScriptTask. /// Forward an input event to an appropriate ScriptTask.
ForwardInputEvent(WebViewId, InputEvent, Option<CompositorHitTestResult>), ForwardInputEvent(WebViewId, InputEvent, Option<CompositorHitTestResult>),
/// Request that the given pipeline do a hit test at the location and reset the /// Request that the given pipeline refresh the cursor by doing a hit test at the most
/// cursor accordingly. This happens after a display list update is rendered. /// recently hovered cursor position and resetting the cursor. This happens after a
RefreshCursor(PipelineId, Point2D<f32, CSSPixel>), /// display list update is rendered.
RefreshCursor(PipelineId),
/// Enable the sampling profiler, with a given sampling rate and max total sampling duration. /// Enable the sampling profiler, with a given sampling rate and max total sampling duration.
ToggleProfiler(Duration, Duration), ToggleProfiler(Duration, Duration),
/// Request to exit from fullscreen mode /// Request to exit from fullscreen mode

View file

@ -55,9 +55,10 @@ pub enum ShutdownState {
/// A cursor for the window. This is different from a CSS cursor (see /// A cursor for the window. This is different from a CSS cursor (see
/// `CursorKind`) in that it has no `Auto` value. /// `CursorKind`) in that it has no `Auto` value.
#[repr(u8)] #[repr(u8)]
#[derive(Clone, Copy, Debug, Deserialize, Eq, MallocSizeOf, PartialEq, Serialize)] #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, MallocSizeOf, PartialEq, Serialize)]
pub enum Cursor { pub enum Cursor {
None, None,
#[default]
Default, Default,
Pointer, Pointer,
ContextMenu, ContextMenu,

View file

@ -32,7 +32,7 @@ use embedder_traits::{
CompositorHitTestResult, FocusSequenceNumber, InputEvent, JavaScriptEvaluationId, CompositorHitTestResult, FocusSequenceNumber, InputEvent, JavaScriptEvaluationId,
MediaSessionActionType, Theme, ViewportDetails, WebDriverScriptCommand, MediaSessionActionType, Theme, ViewportDetails, WebDriverScriptCommand,
}; };
use euclid::{Point2D, Rect, Scale, Size2D, UnknownUnit}; use euclid::{Rect, Scale, Size2D, UnknownUnit};
use ipc_channel::ipc::{IpcReceiver, IpcSender}; use ipc_channel::ipc::{IpcReceiver, IpcSender};
use keyboard_types::Modifiers; use keyboard_types::Modifiers;
use malloc_size_of_derive::MallocSizeOf; use malloc_size_of_derive::MallocSizeOf;
@ -147,9 +147,10 @@ pub enum ScriptThreadMessage {
ExitScriptThread, ExitScriptThread,
/// Sends a DOM event. /// Sends a DOM event.
SendInputEvent(PipelineId, ConstellationInputEvent), SendInputEvent(PipelineId, ConstellationInputEvent),
/// Ask that the given pipeline refreshes the cursor (after a display list render) based /// Request that the given pipeline refresh the cursor by doing a hit test at the most
/// on the hit test at the given point. /// recently hovered cursor position and resetting the cursor. This happens after a
RefreshCursor(PipelineId, Point2D<f32, CSSPixel>), /// display list update is rendered.
RefreshCursor(PipelineId),
/// Notifies script of the viewport. /// Notifies script of the viewport.
Viewport(PipelineId, Rect<f32, UnknownUnit>), Viewport(PipelineId, Rect<f32, UnknownUnit>),
/// Requests that the script thread immediately send the constellation the title of a pipeline. /// Requests that the script thread immediately send the constellation the title of a pipeline.