From 5a7531bbff164c6e7f4a8191c618aa815f3b8a9e Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Fri, 19 Jun 2026 11:59:57 +0200 Subject: [PATCH] Add CSS cursor property support and native cursor display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the `cursor` CSS property end-to-end so the browser shows the correct pointer shape — pointer over links, I-beam over text and text inputs, and any author-chosen cursor — closing isu issue 373. - style: parse the full standard `cursor` keyword set (plus `url(...)` fallback lists) into a new inherited `Cursor` computed value; seed it through inheritance and add UA rules (`a[href]` -> pointer, text inputs -> text, push buttons/checkboxes -> default). - layout: carry the computed `cursor` on each `LayoutBox`. - browser: resolve the cursor under the pointer via the shared hit-test (`resolve_cursor_at_point`, with `auto` resolving to text over rendered text and the arrow elsewhere), cache the laid-out tree so hovering does not relayout, and drive `NSCursor` on `mouseMoved:` (incl. I-beam over the address bar and pointer over nav buttons). - platform: NSCursor FFI (`set_cursor`/`CursorKind`) and a mouse-moved handler hook. - e2e: new `assert_cursor_at ` command exercising the production hit-test + cursor resolution, with scenario `cursor.we` and page `57_cursor.html`. Unit tests in style and hit_test. Co-Authored-By: Claude Opus 4.8 --- .isu/issues.json | 2 +- CLAUDE.md | 1 + crates/browser/src/hit_test.rs | 133 ++++++++++++++- crates/browser/src/main.rs | 122 +++++++++++++- crates/e2e/pages/57_cursor.html | 29 ++++ crates/e2e/scenarios/cursor.we | 42 +++++ crates/e2e/src/render.rs | 44 +++++ crates/e2e/src/scenario.rs | 44 +++++ crates/layout/src/lib.rs | 6 +- crates/platform/src/appkit.rs | 94 ++++++++++- crates/style/src/computed.rs | 283 ++++++++++++++++++++++++++++++++ 11 files changed, 786 insertions(+), 14 deletions(-) create mode 100644 crates/e2e/pages/57_cursor.html create mode 100644 crates/e2e/scenarios/cursor.we diff --git a/.isu/issues.json b/.isu/issues.json index 8e1a73d..0d6412e 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -4565,7 +4565,7 @@ "labels": [], "assigned": [], "author": "piefev", - "state": "open", + "state": "closed", "created_at": "2026-06-05T05:32:39Z" }, { diff --git a/CLAUDE.md b/CLAUDE.md index edc11e1..ff8c85e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -217,6 +217,7 @@ Line-based, one command per line. `#` starts a comment. | `assert_dom_contains ""` | Fail if the DOM dump lacks ``. | | `assert_console_contains ""` | Fail if the console capture lacks ``. | | `assert_link_at ` | Resolve the link a native click on the first element matching `` would follow — finds the element's rendered center, then runs the production hit-test + ancestor-`` walk (the same path a real mouse click takes) — and fail unless the resolved `href` contains ``. Because `#` is the comment delimiter, write an ID match as `[id=foo]`. | +| `assert_cursor_at ` | Resolve the cursor a pointer hovering the center of the first element matching `` would display — finds the element's rendered center, then runs the production hit-test + `cursor` resolution (the same path the browser shell takes on `mouseMoved:`) — and fail unless the resolved CSS keyword equals `` (e.g. `pointer`, `text`, `default`, `move`). Because `#` is the comment delimiter, write an ID match as `[id=foo]`. | | `assert_screenshot_matches [--tolerance N] [--max-diff-pct P]` | Fail if `` differs from the golden `` by more than `--max-diff-pct` percent of pixels (default `0.1`), where a pixel counts as differing when any RGBA channel exceeds `--tolerance` (default `4`). On failure writes a magenta-highlighted diff image at `.diff.png`. `` is resolved relative to `--out-dir`; `` is resolved relative to the scenario file. | The JS VM stays alive across commands within a single page session, so diff --git a/crates/browser/src/hit_test.rs b/crates/browser/src/hit_test.rs index 480e779..490ee34 100644 --- a/crates/browser/src/hit_test.rs +++ b/crates/browser/src/hit_test.rs @@ -15,7 +15,7 @@ use we_dom::{Document, NodeId}; use we_layout::{BoxType, LayoutBox}; -use we_style::computed::Overflow; +use we_style::computed::{Cursor, Overflow}; /// The absolute border-box rectangle `(x, y, w, h)` of a layout box. fn border_box(layout_box: &LayoutBox) -> (f32, f32, f32, f32) { @@ -75,6 +75,62 @@ pub fn hit_test_any_element(layout_box: &LayoutBox, x: f32, y: f32) -> Option Option<&LayoutBox> { + let (bx, by, bw, bh) = border_box(layout_box); + let inside = x >= bx && x < bx + bw && y >= by && y < by + bh; + + if layout_box.overflow != Overflow::Visible && !inside { + return None; + } + + for child in layout_box.children.iter().rev() { + if let Some(hit) = hit_test_box(child, x, y) { + return Some(hit); + } + } + + if inside && !matches!(layout_box.box_type, BoxType::Anonymous) { + Some(layout_box) + } else { + None + } +} + +/// Resolve the `auto` cursor keyword to a concrete cursor given the hit +/// context. `auto` over rendered text becomes the text (I-beam) cursor; over +/// anything else it is the default arrow. Every explicit keyword is honored +/// verbatim (so e.g. an ``'s inherited `pointer` wins even over its text). +pub fn effective_cursor(cursor: Cursor, over_text: bool) -> Cursor { + match cursor { + Cursor::Auto => { + if over_text { + Cursor::Text + } else { + Cursor::Default + } + } + other => other, + } +} + +/// Resolve the cursor a pointer at absolute `(x, y)` should display: hit-test +/// for the topmost box, read its computed `cursor`, and apply the `auto` +/// context heuristic. Returns [`Cursor::Default`] when the point hits nothing. +pub fn resolve_cursor_at_point(root: &LayoutBox, x: f32, y: f32) -> Cursor { + match hit_test_box(root, x, y) { + Some(b) => effective_cursor(b.cursor, matches!(b.box_type, BoxType::TextRun { .. })), + None => Cursor::Default, + } +} + /// Walk up the DOM from `node` looking for an ancestor `` element with an /// `href` attribute. Returns the anchor node and its raw href value. pub fn find_ancestor_anchor(doc: &Document, node: NodeId) -> Option<(NodeId, &str)> { @@ -215,4 +271,79 @@ mod tests { Some("/go") ); } + + /// Build a text-run box at an absolute `rect` carrying a specific cursor. + fn text_run(node: usize, x: f32, y: f32, w: f32, h: f32, cursor: Cursor) -> LayoutBox { + let style = ComputedStyle { + cursor, + ..ComputedStyle::default() + }; + let mut b = LayoutBox::from_style( + BoxType::TextRun { + node: NodeId::from_index(node), + text: "hi".into(), + }, + &style, + ); + b.rect = Rect { + x, + y, + width: w, + height: h, + }; + b + } + + #[test] + fn effective_cursor_resolves_auto_by_context() { + assert_eq!(effective_cursor(Cursor::Auto, true), Cursor::Text); + assert_eq!(effective_cursor(Cursor::Auto, false), Cursor::Default); + // Explicit keywords are always honored, even over text. + assert_eq!(effective_cursor(Cursor::Pointer, true), Cursor::Pointer); + assert_eq!(effective_cursor(Cursor::Move, false), Cursor::Move); + } + + #[test] + fn resolve_cursor_explicit_value_wins_over_text_heuristic() { + // A text run that inherited `pointer` (e.g. inside an ) shows pointer, + // not the I-beam the auto-over-text heuristic would pick. + let mut root = block(0, 0.0, 0.0, 800.0, 600.0); + let link_text = text_run(1, 0.0, 0.0, 100.0, 20.0, Cursor::Pointer); + root.children.push(link_text); + assert_eq!(resolve_cursor_at_point(&root, 50.0, 10.0), Cursor::Pointer); + } + + #[test] + fn resolve_cursor_auto_over_text_is_ibeam() { + let mut root = block(0, 0.0, 0.0, 800.0, 600.0); + let body_text = text_run(1, 0.0, 0.0, 100.0, 20.0, Cursor::Auto); + root.children.push(body_text); + assert_eq!(resolve_cursor_at_point(&root, 50.0, 10.0), Cursor::Text); + } + + #[test] + fn resolve_cursor_auto_over_block_is_default() { + // A plain block (no text) with the initial `auto` cursor → arrow. + let root = block(0, 0.0, 0.0, 800.0, 600.0); + assert_eq!(resolve_cursor_at_point(&root, 50.0, 50.0), Cursor::Default); + // A miss also yields the default arrow. + assert_eq!( + resolve_cursor_at_point(&root, 5000.0, 5000.0), + Cursor::Default + ); + } + + #[test] + fn resolve_cursor_reads_explicit_block_cursor() { + let mut root = block(0, 0.0, 0.0, 800.0, 600.0); + let mut widget = block(1, 100.0, 100.0, 50.0, 50.0); + widget.cursor = Cursor::NotAllowed; + root.children.push(widget); + assert_eq!( + resolve_cursor_at_point(&root, 120.0, 120.0), + Cursor::NotAllowed + ); + // Outside the widget, the root block's auto resolves to default. + assert_eq!(resolve_cursor_at_point(&root, 10.0, 10.0), Cursor::Default); + } } diff --git a/crates/browser/src/main.rs b/crates/browser/src/main.rs index aa2aad8..b85a0e6 100644 --- a/crates/browser/src/main.rs +++ b/crates/browser/src/main.rs @@ -398,6 +398,11 @@ struct BrowserState { history: NavigationHistory, /// Browser chrome UI state (address bar, buttons). chrome: ChromeState, + /// The most recently laid-out web-content tree, cached so pointer moves can + /// update the native cursor without re-running style + layout each time. + last_layout: Option, + /// The cursor most recently applied, to skip redundant `NSCursor` calls. + last_cursor: appkit::CursorKind, } thread_local! { @@ -725,7 +730,9 @@ fn build_chrome_layout( /// submit to the Metal GPU compositor. /// /// The chrome bar is rendered at the top, and web content is offset below it. -/// Returns the total content height for scroll clamping. +/// Returns the laid-out web-content tree; the caller reads +/// `tree.root.content_height` for scroll clamping and may cache the tree for +/// pointer hit-testing (cursor updates) between renders. #[allow(clippy::too_many_arguments)] fn render_page( page: &PageState, @@ -738,9 +745,9 @@ fn render_page( scroll_offsets: &ScrollState, chrome: &ChromeState, history: &NavigationHistory, -) -> f32 { +) -> (f32, Option) { if viewport_width <= 0.0 || viewport_height <= 0.0 { - return 0.0; + return (0.0, None); } // The web content viewport is the window minus the chrome bar. @@ -753,7 +760,7 @@ fn render_page( (viewport_width, content_viewport_height), ) { Some(s) => s, - None => return 0.0, + None => return (0.0, None), }; // Build image maps for layout (sizes) and render (pixel data). @@ -866,7 +873,8 @@ fn render_page( viewport_height, ); - tree.root.content_height + let content_height = tree.root.content_height; + (content_height, Some(tree)) } /// Called by the platform crate when the window is resized. @@ -897,7 +905,7 @@ fn handle_resize(width: f64, height: f64) { state.viewport_width = w; state.viewport_height = h; - let content_height = render_page( + let (content_height, tree) = render_page( &state.page, &state.font, &mut state.backend, @@ -910,6 +918,7 @@ fn handle_resize(width: f64, height: f64) { &state.history, ); state.content_height = content_height; + state.last_layout = tree; // Clamp scroll position after resize (content viewport excludes chrome). let content_viewport = (height as f32 - CHROME_HEIGHT).max(0.0); @@ -1545,7 +1554,7 @@ fn reload_current_page(state: &mut BrowserState) { fn rerender(state: &mut BrowserState) { let viewport_width = state.viewport_width as f32; let viewport_height = state.viewport_height as f32; - let content_height = render_page( + let (content_height, tree) = render_page( &state.page, &state.font, &mut state.backend, @@ -1558,6 +1567,7 @@ fn rerender(state: &mut BrowserState) { &state.history, ); state.content_height = content_height; + state.last_layout = tree; } /// Returns true if the chrome address bar is currently focused. @@ -2531,6 +2541,96 @@ fn handle_mouse_dragged(x: f64, y: f64) { }); } +/// Handle passive mouse-moved events: choose the cursor for whatever sits +/// under the pointer and apply it via `NSCursor`. Reuses the cached layout tree +/// so hovering never triggers a restyle/relayout, and skips the native call +/// when the cursor would not change. +fn handle_mouse_moved(x: f64, y: f64) { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let state = match state.as_mut() { + Some(s) => s, + None => return, + }; + + let view_x = x as f32; + let view_y_raw = y as f32; + + let kind = if view_y_raw < CHROME_HEIGHT { + // Over the browser chrome: I-beam in the address bar, pointer over + // the nav buttons, arrow elsewhere. + let viewport_width = state.viewport_width as f32; + match hit_test_chrome( + &state.chrome, + &state.history, + &state.font, + view_x, + view_y_raw, + viewport_width, + ) { + ChromeHit::AddressBar { .. } => appkit::CursorKind::Text, + ChromeHit::Back | ChromeHit::Forward | ChromeHit::Reload => { + appkit::CursorKind::Pointer + } + ChromeHit::None => appkit::CursorKind::Arrow, + } + } else if let Some(tree) = state.last_layout.as_ref() { + // Over web content: hit-test the cached tree in content coordinates + // (mirrors handle_mouse_down's coordinate mapping). + let view_y = (view_y_raw - CHROME_HEIGHT) + state.page_scroll_y; + cursor_to_platform(we_browser::hit_test::resolve_cursor_at_point( + &tree.root, view_x, view_y, + )) + } else { + appkit::CursorKind::Arrow + }; + + if kind != state.last_cursor { + state.last_cursor = kind; + appkit::set_cursor(kind); + } + }); +} + +/// Map a resolved CSS `cursor` value to the closest native cursor shape. +/// Keywords with no public `NSCursor` equivalent (`wait`, `help`, `progress`, +/// `move`, the diagonal resizes, `zoom-*`, …) fall back to the default arrow. +fn cursor_to_platform(cursor: we_style::computed::Cursor) -> appkit::CursorKind { + use we_style::computed::Cursor; + match cursor { + Cursor::Auto | Cursor::Default => appkit::CursorKind::Arrow, + Cursor::Pointer => appkit::CursorKind::Pointer, + Cursor::Text | Cursor::VerticalText => appkit::CursorKind::Text, + Cursor::Crosshair | Cursor::Cell => appkit::CursorKind::Crosshair, + Cursor::Grab => appkit::CursorKind::OpenHand, + Cursor::Grabbing => appkit::CursorKind::ClosedHand, + Cursor::NotAllowed | Cursor::NoDrop => appkit::CursorKind::NotAllowed, + Cursor::EResize | Cursor::WResize | Cursor::EwResize | Cursor::ColResize => { + appkit::CursorKind::ResizeLeftRight + } + Cursor::NResize | Cursor::SResize | Cursor::NsResize | Cursor::RowResize => { + appkit::CursorKind::ResizeUpDown + } + Cursor::ContextMenu => appkit::CursorKind::ContextMenu, + Cursor::Copy => appkit::CursorKind::Copy, + Cursor::Alias => appkit::CursorKind::Alias, + Cursor::None + | Cursor::Help + | Cursor::Progress + | Cursor::Wait + | Cursor::Move + | Cursor::NeResize + | Cursor::NwResize + | Cursor::SeResize + | Cursor::SwResize + | Cursor::NeswResize + | Cursor::NwseResize + | Cursor::AllScroll + | Cursor::ZoomIn + | Cursor::ZoomOut => appkit::CursorKind::Arrow, + } +} + /// Convert a character index to a byte offset in a string. fn char_to_byte_offset(s: &str, char_idx: usize) -> usize { s.char_indices() @@ -2994,7 +3094,7 @@ fn handle_scroll(_dx: f64, dy: f64, _mouse_x: f64, _mouse_y: f64) { // Apply scroll delta (negative dy = scroll down). state.page_scroll_y = (state.page_scroll_y - dy as f32).clamp(0.0, max_scroll); - let content_height = render_page( + let (content_height, tree) = render_page( &state.page, &state.font, &mut state.backend, @@ -3007,6 +3107,7 @@ fn handle_scroll(_dx: f64, dy: f64, _mouse_x: f64, _mouse_y: f64) { &state.history, ); state.content_height = content_height; + state.last_layout = tree; }); } @@ -3776,7 +3877,7 @@ fn main() { let history = NavigationHistory::new(page.base_url.clone()); // Initial render. - let content_height = render_page( + let (content_height, tree) = render_page( &page, &font, &mut backend, @@ -3803,6 +3904,8 @@ fn main() { scroll_offsets, history, chrome, + last_layout: tree, + last_cursor: appkit::CursorKind::Arrow, }); }); @@ -3816,6 +3919,7 @@ fn main() { appkit::set_key_handler(handle_key_down); appkit::set_mouse_down_handler(handle_mouse_down); appkit::set_mouse_dragged_handler(handle_mouse_dragged); + appkit::set_mouse_moved_handler(handle_mouse_moved); window.make_key_and_order_front(); app.activate(); diff --git a/crates/e2e/pages/57_cursor.html b/crates/e2e/pages/57_cursor.html new file mode 100644 index 0000000..e906a70 --- /dev/null +++ b/crates/e2e/pages/57_cursor.html @@ -0,0 +1,29 @@ + + + + +Cursor resolution + + + + Home link + + +
move me
+
blocked
+
grab me
+
crosshair
+
url fallback
+
+

Lead ordinary body text tail.

+ + diff --git a/crates/e2e/scenarios/cursor.we b/crates/e2e/scenarios/cursor.we new file mode 100644 index 0000000..1cffd13 --- /dev/null +++ b/crates/e2e/scenarios/cursor.we @@ -0,0 +1,42 @@ +# Coverage for isu issue 373: the engine resolves the CSS `cursor` property +# (and the UA-stylesheet link/text defaults) so the browser shell can show the +# right native cursor under the pointer. +# +# `assert_cursor_at ` finds the rendered center of the +# matched element and runs the production hit-test + cursor resolution — the +# same path the real browser shell takes on `mouseMoved:` — then checks the +# resolved CSS keyword. + +viewport 800 600 +goto crates/e2e/pages/57_cursor.html + +# UA stylesheet: links get `pointer`, inherited into inline children. +assert_cursor_at [id=home] pointer +assert_cursor_at [id=home-label] pointer + +# UA stylesheet: text inputs get `text`, push buttons stay the default arrow. +assert_cursor_at [id=field] text +assert_cursor_at [id=go] default + +# Author `cursor` declarations are honored verbatim (and inherited onto the +# element's own text run). +assert_cursor_at [id=moved] move +assert_cursor_at [id=blocked] not-allowed +assert_cursor_at [id=grabber] grab +assert_cursor_at [id=crosshair] crosshair + +# `cursor: url(...), pointer` — the custom image is not realized, so the +# trailing keyword fallback wins. +assert_cursor_at [id=fallback] pointer + +# `cursor: auto` resolution: over an empty block (and the empty area of a wide +# paragraph) it is the default arrow; directly over rendered body text it is +# the text (I-beam) cursor. +assert_cursor_at [id=plain-block] default +assert_cursor_at [id=paragraph] default +assert_cursor_at [id=bodytext] text + +# Exercise the page interactively: clicking the link still resolves correctly, +# and the cursor over it stays a pointer afterwards. +click [id=home] +assert_cursor_at [id=home] pointer diff --git a/crates/e2e/src/render.rs b/crates/e2e/src/render.rs index d8b4aa8..dd40686 100644 --- a/crates/e2e/src/render.rs +++ b/crates/e2e/src/render.rs @@ -293,6 +293,50 @@ impl Session { )) } + /// Resolve the cursor a pointer hovering the center of the element matching + /// `selector` would display, as a CSS keyword (e.g. `"pointer"`, `"text"`, + /// `"default"`). Runs the same production hit-test + cursor resolution the + /// real browser shell uses on `mouseMoved:`. Returns `Err` when the + /// selector matches no element or the matched element has no layout box. + pub fn resolve_cursor_at_selector(&self, selector: &str) -> Result { + let doc = self + .vm + .borrow_document() + .ok_or("document still attached after scripts")?; + + let list = we_css::parser::Parser::parse_selectors(selector); + let node = (0..doc.len()) + .map(NodeId::from_index) + .find(|&id| { + matches!(doc.node_data(id), NodeData::Element { .. }) + && we_style::matching::matches_selector_list(&doc, id, &list) + }) + .ok_or_else(|| format!("no element matched selector {selector}"))?; + + let viewport_w = self.width as f32; + let viewport_h = self.height as f32; + let styled = resolve_styles( + &doc, + std::slice::from_ref(&self.stylesheet), + (viewport_w, viewport_h), + ) + .ok_or("style resolution failed")?; + + let mut sizes = image_sizes(&self.images); + let svg_sizes = collect_svg_sizes(&doc); + sizes.extend(svg_sizes); + we_browser::iframe_loader::collect_iframe_sizes(&doc, &mut sizes); + let tree = layout(&styled, &doc, viewport_w, viewport_h, &self.font, &sizes); + + let (cx, cy) = we_browser::hit_test::element_center(&tree.root, node) + .ok_or_else(|| format!("no layout box for selector {selector}"))?; + Ok( + we_browser::hit_test::resolve_cursor_at_point(&tree.root, cx, cy) + .as_keyword() + .to_string(), + ) + } + /// Plain-text dump of the live document tree. pub fn dom_dump(&self) -> String { let doc = self diff --git a/crates/e2e/src/scenario.rs b/crates/e2e/src/scenario.rs index 18851ff..2477a19 100644 --- a/crates/e2e/src/scenario.rs +++ b/crates/e2e/src/scenario.rs @@ -29,6 +29,12 @@ //! # element matched by would follow //! # (production hit-test + ancestor-anchor walk) //! # and fail unless its href contains +//! assert_cursor_at +//! # resolve the cursor a pointer hovering the +//! # center of the element matched by +//! # would show (production hit-test + cursor +//! # resolution) and fail unless it equals +//! # (e.g. pointer, text, default) //! assert_screenshot_matches [--tolerance N] [--max-diff-pct P] //! # fail scenario if rendered PNG differs from //! # expected (golden) PNG by more than the @@ -103,6 +109,10 @@ pub enum Cmd { selector: String, expected: String, }, + AssertCursorAt { + selector: String, + expected: String, + }, ExpectNoPanics, PanicNow(String), PerfBudgetWallMs(u64), @@ -276,6 +286,17 @@ fn parse_line(line: &str) -> Result { expected: unquote(expected), }) } + "assert_cursor_at" => { + let (selector, expected) = split_first_token(rest) + .ok_or("assert_cursor_at: expected ")?; + if expected.is_empty() { + return Err("assert_cursor_at: missing expected cursor keyword".into()); + } + Ok(Cmd::AssertCursorAt { + selector: unquote(selector), + expected: unquote(expected), + }) + } "assert_dom_contains" => Ok(Cmd::AssertDomContains(unquote(rest))), "assert_console_contains" => Ok(Cmd::AssertConsoleContains(unquote(rest))), "assert_screenshot_matches" => parse_assert_screenshot_matches(rest), @@ -579,6 +600,9 @@ fn cmd_label(cmd: &Cmd) -> String { Cmd::AssertLinkAt { selector, expected } => { format!("assert_link_at {selector} {expected:?}") } + Cmd::AssertCursorAt { selector, expected } => { + format!("assert_cursor_at {selector} {expected:?}") + } Cmd::ExpectNoPanics => "expect_no_panics".into(), Cmd::PanicNow(_) => "panic_now".into(), Cmd::PerfBudgetWallMs(n) => format!("perf_budget wall_ms {n}"), @@ -908,6 +932,26 @@ fn execute_cmd( steps.push((lineno, false, "assert_link_at: no page loaded".into())); } }, + Cmd::AssertCursorAt { selector, expected } => match current { + Some(session) => match session.resolve_cursor_at_selector(selector) { + Ok(cursor) if cursor == *expected => { + steps.push((lineno, true, format!("cursor at {selector} -> {cursor}"))); + } + Ok(cursor) => { + steps.push(( + lineno, + false, + format!("cursor at {selector} -> {cursor}; expected {expected:?}"), + )); + } + Err(e) => { + steps.push((lineno, false, format!("assert_cursor_at {selector}: {e}"))); + } + }, + None => { + steps.push((lineno, false, "assert_cursor_at: no page loaded".into())); + } + }, Cmd::AssertConsoleContains(needle) => match current { Some(session) => { if session.console.joined().contains(needle.as_str()) { diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 79cb103..36815d2 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -10,7 +10,7 @@ use we_css::values::Color; use we_dom::{Document, NodeData, NodeId}; use we_style::computed::{ AlignContent, AlignItems, AlignSelf, BackgroundRepeat, BackgroundSize, BorderCollapse, - BorderStyle, BoxSizing, Clear, ComputedStyle, Display, FlexDirection, FlexWrap, Float, + BorderStyle, BoxSizing, Clear, ComputedStyle, Cursor, Display, FlexDirection, FlexWrap, Float, FontStyle, GridAutoFlow, GridPlacement, GridTrackSize, JustifyContent, JustifyItems, JustifySelf, LengthOrAuto, Overflow, Position, StyledNode, TextAlign, TextDecoration, TextOverflow, Visibility, WhiteSpace, WillChange, @@ -171,6 +171,9 @@ pub struct LayoutBox { pub italic: bool, /// CSS `white-space` property. pub white_space: WhiteSpace, + /// CSS `cursor` property (inherited). Read by pointer hit-testing to pick + /// the native cursor; `Auto` defers to the engine's context heuristic. + pub cursor: Cursor, /// CSS `text-overflow` property. pub text_overflow: TextOverflow, /// CSS `-webkit-line-clamp` / `line-clamp`: clamp inline content to at most @@ -314,6 +317,7 @@ impl LayoutBox { bold: style.font_weight.0 >= 600.0, italic: !matches!(style.font_style, FontStyle::Normal), white_space: style.white_space, + cursor: style.cursor, text_overflow: style.text_overflow, line_clamp: style.line_clamp, lines: Vec::new(), diff --git a/crates/platform/src/appkit.rs b/crates/platform/src/appkit.rs index e258303..c01f4f2 100644 --- a/crates/platform/src/appkit.rs +++ b/crates/platform/src/appkit.rs @@ -309,11 +309,18 @@ extern "C" fn view_mouse_up(this: *mut c_void, _sel: *mut c_void, event: *mut c_ msg_send![this, convertPoint: raw_loc, fromView: std::ptr::null_mut::()]; } -/// `mouseMoved:` — no-op (needed for `acceptsMouseMovedEvents`). +/// `mouseMoved:` — dispatch the pointer location to the registered handler so +/// the browser can update the native cursor as it hovers different elements. extern "C" fn view_mouse_moved(this: *mut c_void, _sel: *mut c_void, event: *mut c_void) { let raw_loc: NSPoint = msg_send![event, locationInWindow]; - let _loc: NSPoint = + let loc: NSPoint = msg_send![this, convertPoint: raw_loc, fromView: std::ptr::null_mut::()]; + // SAFETY: We are on the main thread (AppKit event loop). + unsafe { + if let Some(handler) = MOUSE_MOVED_HANDLER { + handler(loc.x, loc.y); + } + } } /// `mouseDragged:` — dispatch mouse-drag event to registered handler. @@ -965,6 +972,89 @@ pub fn set_mouse_dragged_handler(handler: fn(f64, f64)) { } } +/// Global mouse-moved callback, called from `mouseMoved:` with the current +/// pointer location in view coordinates. Used to update the native cursor as +/// the pointer hovers over different elements. +/// +/// # Safety +/// +/// Accessed only from the main thread (the AppKit event loop). +static mut MOUSE_MOVED_HANDLER: Option = None; + +/// Register a function to be called when the pointer moves over the view +/// (with no button pressed). The handler receives `(x, y)` in view +/// coordinates. Only one handler can be active at a time. +pub fn set_mouse_moved_handler(handler: fn(f64, f64)) { + // SAFETY: Called from the main thread before `app.run()`. + unsafe { + MOUSE_MOVED_HANDLER = Some(handler); + } +} + +// --------------------------------------------------------------------------- +// Native cursor (NSCursor) +// --------------------------------------------------------------------------- + +/// A native cursor shape, realized through `NSCursor`. CSS cursor keywords with +/// no public `NSCursor` equivalent (e.g. `wait`, `help`, `zoom-in`) are mapped +/// by the caller to the closest available shape, falling back to `Arrow`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CursorKind { + /// `arrowCursor` — the default arrow. + Arrow, + /// `pointingHandCursor` — CSS `pointer`. + Pointer, + /// `IBeamCursor` — CSS `text`. + Text, + /// `crosshairCursor` — CSS `crosshair`. + Crosshair, + /// `openHandCursor` — CSS `grab`. + OpenHand, + /// `closedHandCursor` — CSS `grabbing`. + ClosedHand, + /// `operationNotAllowedCursor` — CSS `not-allowed`. + NotAllowed, + /// `resizeLeftRightCursor` — CSS `ew-resize` / `col-resize`. + ResizeLeftRight, + /// `resizeUpDownCursor` — CSS `ns-resize` / `row-resize`. + ResizeUpDown, + /// `contextualMenuCursor` — CSS `context-menu`. + ContextMenu, + /// `dragCopyCursor` — CSS `copy`. + Copy, + /// `dragLinkCursor` — CSS `alias`. + Alias, +} + +/// Set the application's current cursor to `kind` via `NSCursor`. +/// +/// Each `NSCursor` class accessor returns a shared, autoreleased cursor object; +/// sending it `-set` makes it current. A no-op if the `NSCursor` class or the +/// requested cursor cannot be obtained. +pub fn set_cursor(kind: CursorKind) { + let Some(cls) = class!("NSCursor") else { + return; + }; + let cls = cls.as_ptr(); + let cursor: *mut c_void = match kind { + CursorKind::Arrow => msg_send![cls, arrowCursor], + CursorKind::Pointer => msg_send![cls, pointingHandCursor], + CursorKind::Text => msg_send![cls, IBeamCursor], + CursorKind::Crosshair => msg_send![cls, crosshairCursor], + CursorKind::OpenHand => msg_send![cls, openHandCursor], + CursorKind::ClosedHand => msg_send![cls, closedHandCursor], + CursorKind::NotAllowed => msg_send![cls, operationNotAllowedCursor], + CursorKind::ResizeLeftRight => msg_send![cls, resizeLeftRightCursor], + CursorKind::ResizeUpDown => msg_send![cls, resizeUpDownCursor], + CursorKind::ContextMenu => msg_send![cls, contextualMenuCursor], + CursorKind::Copy => msg_send![cls, dragCopyCursor], + CursorKind::Alias => msg_send![cls, dragLinkCursor], + }; + if !cursor.is_null() { + let _: () = msg_send![cursor, set]; + } +} + // --------------------------------------------------------------------------- // Window delegate for handling resize and close events // --------------------------------------------------------------------------- diff --git a/crates/style/src/computed.rs b/crates/style/src/computed.rs index 33344e6..47a08fc 100644 --- a/crates/style/src/computed.rs +++ b/crates/style/src/computed.rs @@ -329,6 +329,163 @@ pub enum Visibility { Collapse, } +// --------------------------------------------------------------------------- +// Cursor +// --------------------------------------------------------------------------- + +/// The CSS `cursor` property (inherited). Covers the standard keyword set from +/// the CSS Basic User Interface module. `Auto` lets the engine pick a cursor +/// from context (text over rendered text, otherwise the default arrow); every +/// other value is honored verbatim, mapped to the nearest native cursor by the +/// platform layer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Cursor { + #[default] + Auto, + Default, + None, + ContextMenu, + Help, + Pointer, + Progress, + Wait, + Cell, + Crosshair, + Text, + VerticalText, + Alias, + Copy, + Move, + NoDrop, + NotAllowed, + Grab, + Grabbing, + EResize, + NResize, + NeResize, + NwResize, + SResize, + SeResize, + SwResize, + WResize, + EwResize, + NsResize, + NeswResize, + NwseResize, + ColResize, + RowResize, + AllScroll, + ZoomIn, + ZoomOut, +} + +impl Cursor { + /// Parse a single `cursor` keyword (e.g. `pointer`, `not-allowed`). Returns + /// `None` for tokens that are not cursor keywords (e.g. a `url(...)` target + /// or a stray comma in a fallback list). + pub fn from_keyword(keyword: &str) -> Option { + let c = match keyword { + "auto" => Cursor::Auto, + "default" => Cursor::Default, + "none" => Cursor::None, + "context-menu" => Cursor::ContextMenu, + "help" => Cursor::Help, + "pointer" => Cursor::Pointer, + "progress" => Cursor::Progress, + "wait" => Cursor::Wait, + "cell" => Cursor::Cell, + "crosshair" => Cursor::Crosshair, + "text" => Cursor::Text, + "vertical-text" => Cursor::VerticalText, + "alias" => Cursor::Alias, + "copy" => Cursor::Copy, + "move" => Cursor::Move, + "no-drop" => Cursor::NoDrop, + "not-allowed" => Cursor::NotAllowed, + "grab" => Cursor::Grab, + "grabbing" => Cursor::Grabbing, + "e-resize" => Cursor::EResize, + "n-resize" => Cursor::NResize, + "ne-resize" => Cursor::NeResize, + "nw-resize" => Cursor::NwResize, + "s-resize" => Cursor::SResize, + "se-resize" => Cursor::SeResize, + "sw-resize" => Cursor::SwResize, + "w-resize" => Cursor::WResize, + "ew-resize" => Cursor::EwResize, + "ns-resize" => Cursor::NsResize, + "nesw-resize" => Cursor::NeswResize, + "nwse-resize" => Cursor::NwseResize, + "col-resize" => Cursor::ColResize, + "row-resize" => Cursor::RowResize, + "all-scroll" => Cursor::AllScroll, + "zoom-in" => Cursor::ZoomIn, + "zoom-out" => Cursor::ZoomOut, + _ => return None, + }; + Some(c) + } + + /// The canonical CSS keyword for this cursor (the inverse of + /// [`Cursor::from_keyword`]). Used by the e2e harness for assertions. + pub fn as_keyword(self) -> &'static str { + match self { + Cursor::Auto => "auto", + Cursor::Default => "default", + Cursor::None => "none", + Cursor::ContextMenu => "context-menu", + Cursor::Help => "help", + Cursor::Pointer => "pointer", + Cursor::Progress => "progress", + Cursor::Wait => "wait", + Cursor::Cell => "cell", + Cursor::Crosshair => "crosshair", + Cursor::Text => "text", + Cursor::VerticalText => "vertical-text", + Cursor::Alias => "alias", + Cursor::Copy => "copy", + Cursor::Move => "move", + Cursor::NoDrop => "no-drop", + Cursor::NotAllowed => "not-allowed", + Cursor::Grab => "grab", + Cursor::Grabbing => "grabbing", + Cursor::EResize => "e-resize", + Cursor::NResize => "n-resize", + Cursor::NeResize => "ne-resize", + Cursor::NwResize => "nw-resize", + Cursor::SResize => "s-resize", + Cursor::SeResize => "se-resize", + Cursor::SwResize => "sw-resize", + Cursor::WResize => "w-resize", + Cursor::EwResize => "ew-resize", + Cursor::NsResize => "ns-resize", + Cursor::NeswResize => "nesw-resize", + Cursor::NwseResize => "nwse-resize", + Cursor::ColResize => "col-resize", + Cursor::RowResize => "row-resize", + Cursor::AllScroll => "all-scroll", + Cursor::ZoomIn => "zoom-in", + Cursor::ZoomOut => "zoom-out", + } + } +} + +/// Resolve a parsed `cursor` declaration value to a [`Cursor`]. Handles the +/// `auto`/`none` keywords (which tokenize to dedicated `CssValue` variants), +/// plain keywords, and `url(...)`-prefixed fallback lists (the trailing +/// keyword is the mandatory fallback we honor, since custom image cursors are +/// not realized natively). Returns `None` for unrecognized values. +fn cursor_from_value(value: &CssValue) -> Option { + match value { + CssValue::Auto => Some(Cursor::Auto), + CssValue::None => Some(Cursor::None), + CssValue::Keyword(k) => Cursor::from_keyword(k), + // `cursor: url(a), url(b), pointer` — use the last keyword fallback. + CssValue::List(items) => items.iter().rev().find_map(cursor_from_value), + _ => None, + } +} + // --------------------------------------------------------------------------- // Flex enums // --------------------------------------------------------------------------- @@ -639,6 +796,9 @@ pub struct ComputedStyle { // Visibility (inherited) pub visibility: Visibility, + // Cursor (inherited) + pub cursor: Cursor, + // Flex container properties pub flex_direction: FlexDirection, pub flex_wrap: FlexWrap, @@ -762,6 +922,7 @@ impl Default for ComputedStyle { opacity: 1.0, will_change: WillChange::default(), visibility: Visibility::Visible, + cursor: Cursor::Auto, flex_direction: FlexDirection::Row, flex_wrap: FlexWrap::Nowrap, @@ -854,6 +1015,7 @@ fn is_inherited_property(property: &str) -> bool { | "line-height" | "visibility" | "white-space" + | "cursor" ) } @@ -1042,6 +1204,21 @@ th { font-weight: bold; text-align: center; } + +a[href] { + cursor: pointer; +} + +input, textarea { + cursor: text; +} + +button, select, summary, +input[type="button"], input[type="submit"], input[type="reset"], +input[type="checkbox"], input[type="radio"], input[type="file"], +input[type="image"], input[type="range"], input[type="color"] { + cursor: default; +} "#; // --------------------------------------------------------------------------- @@ -2036,6 +2213,13 @@ fn apply_property( }; } + // Cursor (inherited) + "cursor" => { + if let Some(c) = cursor_from_value(value) { + style.cursor = c; + } + } + // Flex container properties "flex-direction" => { style.flex_direction = match value { @@ -2755,6 +2939,7 @@ fn inherit_property(style: &mut ComputedStyle, property: &str, parent: &Computed "line-height" => style.line_height = parent.line_height, "visibility" => style.visibility = parent.visibility, "white-space" => style.white_space = parent.white_space, + "cursor" => style.cursor = parent.cursor, // Non-inherited properties: inherit from parent if explicitly requested "display" => style.display = parent.display, "margin-top" => style.margin_top = parent.margin_top, @@ -2886,6 +3071,7 @@ fn reset_property_to_initial(style: &mut ComputedStyle, property: &str) { "opacity" => style.opacity = initial.opacity, "will-change" => style.will_change = initial.will_change, "visibility" => style.visibility = initial.visibility, + "cursor" => style.cursor = initial.cursor, "flex-direction" => style.flex_direction = initial.flex_direction, "flex-wrap" => style.flex_wrap = initial.flex_wrap, "justify-content" => style.justify_content = initial.justify_content, @@ -3360,6 +3546,7 @@ fn compute_style_for_element_with_inputs( line_height: parent_style.line_height, white_space: parent_style.white_space, visibility: parent_style.visibility, + cursor: parent_style.cursor, custom_properties: parent_style.custom_properties.clone(), ..ComputedStyle::default() }; @@ -4891,6 +5078,102 @@ mod tests { assert_eq!(div_node.style.color, Color::rgb(0, 0, 0)); // default } + #[test] + fn cursor_keyword_parses_and_inherits() { + let (mut doc, _, _, body) = make_doc_with_body(); + let div = doc.create_element("div"); + let span = doc.create_element("span"); + doc.append_child(body, div); + doc.append_child(div, span); + + // `cursor` is an inherited property, so the descendant span sees `move`. + let ss = Parser::parse("div { cursor: move; }"); + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); + let body_node = &styled.children[0]; + let div_node = &body_node.children[0]; + let span_node = &div_node.children[0]; + + assert_eq!(div_node.style.cursor, Cursor::Move); + assert_eq!(span_node.style.cursor, Cursor::Move); + // The untouched body keeps the initial `auto`. + assert_eq!(body_node.style.cursor, Cursor::Auto); + } + + #[test] + fn cursor_hyphenated_keyword_and_url_fallback() { + let (mut doc, _, _, body) = make_doc_with_body(); + let a = doc.create_element("div"); + doc.set_attribute(a, "class", "blocked"); + let b = doc.create_element("div"); + doc.set_attribute(b, "class", "custom"); + doc.append_child(body, a); + doc.append_child(body, b); + + let ss = Parser::parse( + ".blocked { cursor: not-allowed; } \ + .custom { cursor: url(x.cur), pointer; }", + ); + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); + let body_node = &styled.children[0]; + assert_eq!(body_node.children[0].style.cursor, Cursor::NotAllowed); + // The custom-image cursor is not realized; the trailing keyword wins. + assert_eq!(body_node.children[1].style.cursor, Cursor::Pointer); + } + + #[test] + fn ua_stylesheet_link_and_form_cursors() { + let (mut doc, _, _, body) = make_doc_with_body(); + let a = doc.create_element("a"); + doc.set_attribute(a, "href", "/x"); + let label = doc.create_element("span"); + doc.append_child(body, a); + doc.append_child(a, label); + + let text_input = doc.create_element("input"); // default type=text + doc.append_child(body, text_input); + let checkbox = doc.create_element("input"); + doc.set_attribute(checkbox, "type", "checkbox"); + doc.append_child(body, checkbox); + let button = doc.create_element("button"); + doc.append_child(body, button); + + // No author stylesheet: only the UA rules apply. + let styled = resolve_styles(&doc, &[], (800.0, 600.0)).unwrap(); + let body_node = &styled.children[0]; + let a_node = &body_node.children[0]; + let label_node = &a_node.children[0]; + + assert_eq!(a_node.style.cursor, Cursor::Pointer); + // Inherited down into the anchor's inline content. + assert_eq!(label_node.style.cursor, Cursor::Pointer); + assert_eq!(body_node.children[1].style.cursor, Cursor::Text); + // The more-specific `input[type=checkbox]` rule overrides `input`. + assert_eq!(body_node.children[2].style.cursor, Cursor::Default); + assert_eq!(body_node.children[3].style.cursor, Cursor::Default); + } + + #[test] + fn cursor_from_keyword_roundtrips() { + for kw in [ + "auto", + "default", + "none", + "pointer", + "text", + "not-allowed", + "grab", + "grabbing", + "ew-resize", + "ns-resize", + "zoom-in", + ] { + let c = Cursor::from_keyword(kw).expect("known cursor keyword"); + assert_eq!(c.as_keyword(), kw); + } + assert_eq!(Cursor::from_keyword("definitely-not-a-cursor"), None); + assert_eq!(Cursor::from_keyword(","), None); + } + #[test] fn nested_var_in_fallback() { let (mut doc, _, _, body) = make_doc_with_body(); -- 2.51.2