Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ license = "MIT OR Apache-2.0"
repository = "https://github.com/pathscale/ps-observability"

[workspace.dependencies]
blitz-control-protocol = { version = "^0.5", path = "crates/blitz-control-protocol" }
blitz-control-protocol = { version = "^0.5.1", path = "crates/blitz-control-protocol" }
endpoint-libs = { version = "^3", default-features = false, features = ["agent-control"] }
serde = { version = "^1", features = ["derive"] }
serde_json = "^1"
Expand Down
8 changes: 4 additions & 4 deletions crates/blitz-control-protocol/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "blitz-control-protocol"
description = "The Blitz agent-control and diagnostics surface: one vocabulary, one core, two transports"
version = "0.5.0"
version = "0.5.1"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
Expand Down Expand Up @@ -73,7 +73,7 @@ serde_json.workspace = true
schemars.workspace = true

# The core, and nothing else, reaches for these.
blitz-dom = { package = "ps-blitz-dom", version = "^0.4.8", optional = true }
blitz-dom = { package = "ps-blitz-dom", version = "^0.4.9", optional = true }
# Naming stylo directly: the computed-style reader and the visibility check read
# `style::values::computed`, and blitz-dom does not re-export it. The version is
# the one blitz-dom resolves, so cargo unifies them instead of putting two
Expand All @@ -83,8 +83,8 @@ style = { version = "0.20.0", package = "stylo", optional = true }
# `yeslogic-fontconfig-sys` system library, so enabling it here would decide
# that every consumer needs one installed, including a headless one that reads
# no font catalogue. An application that wants the machine's fonts asks itself.
blitz-script = { package = "ps-blitz-script", version = "^0.4.8", optional = true }
blitz-traits = { package = "ps-blitz-traits", version = "^0.4.8", optional = true }
blitz-script = { package = "ps-blitz-script", version = "^0.4.9", optional = true }
blitz-traits = { package = "ps-blitz-traits", version = "^0.4.9", optional = true }
keyboard-types = { version = "0.7", optional = true }

# Capture. The CPU renderer on purpose: a capture must not need a GPU, a
Expand Down
225 changes: 191 additions & 34 deletions crates/blitz-control-protocol/src/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ use crate::{
use blitz_dom::Document;
use blitz_script::ScriptDocument;
use blitz_traits::events::{
BlitzKeyEvent, BlitzPointerEvent, BlitzPointerId, DomEvent, DomEventData, KeyState,
MouseEventButton, MouseEventButtons, Point, PointerCoords, PointerDetails, UiEvent,
BlitzKeyEvent, BlitzPointerEvent, BlitzPointerId, KeyState, MouseEventButton,
MouseEventButtons, Point, PointerCoords, PointerDetails, UiEvent,
};
#[cfg(feature = "capture")]
use blitz_traits::node_id::NodeId;
Expand Down Expand Up @@ -171,25 +171,16 @@ pub(crate) fn capture_document_with_surface(
),
Some(id) => {
let inner = script_document.inner();
let node = inner
.get_node(NodeId::from_u64(id))
let rect = inner
.get_client_bounding_rect(NodeId::from_u64(id))
.ok_or_else(|| debug_error("unknownNode", &format!("no node {id}")))?;
let layout = node.final_layout();
let position = node.absolute_position(0.0, 0.0);
if layout.size.width <= 0.0 || layout.size.height <= 0.0 {
if rect.width <= 0.0 || rect.height <= 0.0 {
return Err(debug_error(
"captureEmpty",
&format!("node {id} has a zero-sized box, so there is nothing to capture"),
));
}
let box_ = (
f64::from(position.x),
f64::from(position.y),
f64::from(layout.size.width),
f64::from(layout.size.height),
);
drop(inner);
box_
(rect.x, rect.y, rect.width, rect.height)
}
};

Expand Down Expand Up @@ -343,6 +334,8 @@ pub fn snapshot_document(
value,
enabled: element_attr(element, "disabled").is_none()
&& element_attr(element, "aria-disabled") != Some("true"),
focusable: focuses_on_click(element),
viewport_fixed: viewport_fixed_descendant(&inner, id, layout_node_limit),
visible,
selected: semantic_selected(element, &inner, id),
bounds: rect.and_then(|rect| {
Expand Down Expand Up @@ -1214,6 +1207,38 @@ pub(crate) fn layout_chain_is_valid(
false
}

/// Whether layout places this node inside a fixed box anchored to the window.
///
/// Fixed boxes retain their DOM ancestry, but Blitz reparents their layout box
/// to the root element. Following layout parents therefore covers every child
/// in the fixed surface and still excludes CSS fixed boxes whose transformed
/// ancestor makes them locally positioned.
pub(crate) fn viewport_fixed_descendant(
document: &blitz_dom::BaseDocument,
node_id: blitz_dom::NodeId,
node_limit: usize,
) -> bool {
use style::properties::generated::longhands::position::computed_value::T as Position;

let root_element = document.root_element().id;
let mut current = Some(node_id);
for _ in 0..=node_limit {
let Some(id) = current else { return false };
let Some(node) = document.get_node(id) else {
return false;
};
if node
.primary_styles()
.is_some_and(|styles| styles.clone_position() == Position::Fixed)
&& node.layout_parent.get() == Some(root_element)
{
return true;
}
current = node.layout_parent.get();
}
false
}

/// Activate the node the caller selected, without asking hit-testing to select
/// it a second time from a screen coordinate.
///
Expand Down Expand Up @@ -1249,13 +1274,17 @@ pub fn hover_agent_node(
document: &mut ScriptDocument,
node_id: u64,
) -> Result<(f32, f32), DebugError> {
let position = resolve_agent_node(document, node_id)?.1;
document.handle_ui_event(UiEvent::PointerMove(pointer_event(
position,
MouseEventButton::Main,
MouseEventButtons::default(),
KeyboardModifiers::empty(),
)));
let (node_id, position) = resolve_agent_node(document, node_id)?;
document.handle_pointer_move_to_node(
pointer_event_for_document(
document,
position,
MouseEventButton::Main,
MouseEventButtons::default(),
KeyboardModifiers::empty(),
),
node_id,
);
Ok(position)
}

Expand Down Expand Up @@ -1351,6 +1380,14 @@ pub fn inspect_document(
return control_error("unknownNode", "the requested root node does not exist");
}
let focused_node = inner.get_focussed_node_id().map(|id| id.as_u64());
let viewport = inner.viewport();
let viewport_scale = viewport.scale_f64();
let viewport_bounds = Some([
0.0,
0.0,
viewport.window_size.0 as f64 / viewport_scale,
viewport.window_size.1 as f64 / viewport_scale,
]);
// Built over the whole document even when a subtree was asked for: a label
// is frequently a sibling of the control rather than a descendant of the
// node the caller rooted at.
Expand Down Expand Up @@ -1401,6 +1438,8 @@ pub fn inspect_document(
name: text,
value: None,
enabled: true,
focusable: false,
viewport_fixed: false,
visible: candidate.visible,
selected: false,
bounds,
Expand All @@ -1423,6 +1462,8 @@ pub fn inspect_document(
value,
enabled: element_attr(element, "disabled").is_none()
&& element_attr(element, "aria-disabled") != Some("true"),
focusable: focuses_on_click(element),
viewport_fixed: viewport_fixed_descendant(&inner, id, node_limit),
visible,
selected: semantic_selected(element, &inner, id),
bounds,
Expand All @@ -1434,6 +1475,7 @@ pub fn inspect_document(
revision,
active_window: Some("blitz-main".into()),
focused_node,
viewport: viewport_bounds,
nodes,
})
}
Expand Down Expand Up @@ -1627,6 +1669,38 @@ pub(crate) fn pointer_event(
}
}

/// Build a pointer event from viewport coordinates without losing document
/// coordinates after scrolling.
///
/// Agent input is expressed in the same client-space coordinates as semantic
/// bounds. DOM `clientX/Y` keep those values, while `pageX/Y` include the
/// document's current viewport scroll. Treating both as the client position
/// makes hit testing and component pointer math miss after `ScrollIntoView`.
pub(crate) fn pointer_event_for_document(
document: &ScriptDocument,
position: (f32, f32),
button: MouseEventButton,
buttons: MouseEventButtons,
modifiers: KeyboardModifiers,
) -> BlitzPointerEvent {
let mut event = pointer_event(position, button, buttons, modifiers);
let scroll = document.inner().viewport_scroll();
event.coords.page_x += scroll.x as f32;
event.coords.page_y += scroll.y as f32;
event
}

pub(crate) fn pointer_coords_for_document(
document: &ScriptDocument,
position: (f32, f32),
) -> PointerCoords {
let mut coords = pointer_coords(position);
let scroll = document.inner().viewport_scroll();
coords.page_x += scroll.x as f32;
coords.page_y += scroll.y as f32;
coords
}

pub(crate) struct SemanticCandidate {
pub(crate) id: blitz_dom::NodeId,
pub(crate) parent: Option<blitz_dom::NodeId>,
Expand Down Expand Up @@ -2031,32 +2105,39 @@ pub(crate) fn activate_agent_node(
.is_some_and(focuses_on_click);

for _ in 0..count {
let down = pointer_event(
let movement = pointer_event_for_document(
document,
position,
MouseEventButton::Main,
MouseEventButtons::default(),
KeyboardModifiers::empty(),
);
let down = pointer_event_for_document(
document,
position,
MouseEventButton::Main,
MouseEventButtons::Primary,
KeyboardModifiers::empty(),
);
let up = pointer_event(
let up = pointer_event_for_document(
document,
position,
MouseEventButton::Main,
MouseEventButtons::default(),
KeyboardModifiers::empty(),
);
for data in [
DomEventData::PointerDown(down.clone()),
DomEventData::MouseDown(down),
DomEventData::PointerUp(up.clone()),
DomEventData::MouseUp(up.clone()),
DomEventData::Click(up),
for event in [
UiEvent::PointerMove(movement),
UiEvent::PointerDown(down),
UiEvent::PointerUp(up),
] {
// A mousedown handler can deliberately replace its own control.
// The action already happened; later phases have no surviving
// target and must not be retargeted to whatever took its place.
// A pointer handler can deliberately replace its own control. The
// action already happened; later phases have no surviving target
// and must not be retargeted to whatever took its place.
if document.inner().get_node(node_id).is_none() {
break;
}
document.dispatch_dom_event(DomEvent::new(node_id, data));
document.handle_ui_event_to_node(event, node_id);
}
if focusable && document.inner().get_node(node_id).is_some() {
document.inner_mut().set_focus_to(node_id);
Expand Down Expand Up @@ -2152,6 +2233,19 @@ mod tests {
"unexpected error: {error:?}"
);
}

#[test]
fn pointer_coordinates_keep_client_and_page_space_distinct_after_scroll() {
let mut document = document();
document
.inner_mut()
.set_viewport_scroll(blitz_dom::Point { x: 17.0, y: 600.0 });

let coords = pointer_coords_for_document(&document, (24.0, 32.0));

assert_eq!((coords.client_x, coords.client_y), (24.0, 32.0));
assert_eq!((coords.page_x, coords.page_y), (41.0, 632.0));
}
}

/// What the semantic tree says about one small document.
Expand Down Expand Up @@ -2264,6 +2358,27 @@ mod semantic_tests {
);
}

#[test]
fn fixed_boxes_are_identified_as_viewport_anchored() {
let nodes = tree(
"<main style='height:1600px'><button id='flow'>Flow</button>\
<section style='position:fixed;top:16px;left:16px'>\
<button id='fixed'>Fixed</button>\
</section></main>",
);
let flow = nodes
.iter()
.find(|node| node.dom_id.as_deref() == Some("flow"))
.expect("flow button is inspected");
let fixed = nodes
.iter()
.find(|node| node.dom_id.as_deref() == Some("fixed"))
.expect("fixed button is inspected");

assert!(!flow.viewport_fixed);
assert!(fixed.viewport_fixed);
}

#[test]
fn a_text_run_refuses_a_click_rather_than_panicking() {
// Text runs are new ids in a tree a harness drives by id, so the
Expand Down Expand Up @@ -3447,6 +3562,44 @@ mod runtime_tests {
assert_eq!(node_rgba, expected);
}

#[cfg(feature = "capture")]
#[test]
fn fixed_node_capture_uses_viewport_coordinates_after_document_scroll() {
use blitz_traits::shell::{ColorScheme, Viewport};

let mut document = ScriptDocument::from_html(
"<body style='margin:0;height:1600px'>\
<textarea id='target' style='position:fixed;left:24px;top:32px;width:240px;height:96px;background:#18202a'>theme css</textarea>\
</body>",
DocumentConfig::default(),
);
document
.inner_mut()
.set_viewport(Viewport::new(320, 200, 1.0, ColorScheme::Dark));
document.inner_mut().resolve(0.0);
document
.inner_mut()
.set_viewport_scroll(blitz_dom::Point { x: 0.0, y: 600.0 });
let target = document.inner().query_selector("#target").unwrap().unwrap();
let expected = document
.inner()
.get_client_bounding_rect(target)
.expect("the fixed textarea is laid out");

let image = capture_document(
&mut document,
crate::CaptureRequest {
node_id: Some(target.as_u64()),
scale: 1.0,
},
)
.unwrap();

assert!(expected.width > 0.0 && expected.height > 1.0);
assert_eq!(image.width, expected.width.round() as u32);
assert_eq!(image.height, expected.height.round() as u32);
}

#[cfg(feature = "capture")]
#[test]
fn diagnostic_layout_reports_scroll_state_without_script_evaluation() {
Expand Down Expand Up @@ -3483,6 +3636,8 @@ mod runtime_tests {
name: "Scrollable region".into(),
value: None,
enabled: true,
focusable: false,
viewport_fixed: false,
visible: true,
selected: false,
bounds: Some([0.0, 0.0, 100.0, 100.0]),
Expand Down Expand Up @@ -3517,6 +3672,8 @@ mod runtime_tests {
name: "Readable".into(),
value: None,
enabled: true,
focusable: false,
viewport_fixed: false,
visible: true,
selected: false,
bounds: Some([0.0, 0.0, 100.0, 24.0]),
Expand Down
Loading
Loading