we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement focus management and tab navigation (Phase 16)

Add focus tracking, tab-order navigation, focus-related CSS pseudo-classes,
focus ring rendering, and JS DOM APIs for focus management.

- DOM: activeElement tracking, is_focusable(), compute_tab_order()
- CSS: :focus, :focus-visible, :focus-within, :disabled, :enabled, :checked
- Layout: focused field on FormControlInfo
- Render: blue focus ring around focused form controls
- Platform: key event handler (keyDown: dispatch)
- Browser: Tab/Shift+Tab cycles through focusable elements
- JS: document.activeElement, element.focus(), element.blur(), element.tabIndex

+764 -20
+83 -1
crates/browser/src/main.rs
··· 302 302 }); 303 303 } 304 304 305 + /// macOS key code for the Tab key. 306 + const KEY_CODE_TAB: u16 = 48; 307 + 308 + /// Called by the platform crate on key-down events. 309 + fn handle_key_down(key_code: u16, _chars: &str, shift: bool) { 310 + if key_code == KEY_CODE_TAB { 311 + handle_tab(shift); 312 + } 313 + } 314 + 315 + /// Advance (or reverse) focus through the document's tab order. 316 + fn handle_tab(shift: bool) { 317 + STATE.with(|state| { 318 + let mut state = state.borrow_mut(); 319 + let state = match state.as_mut() { 320 + Some(s) => s, 321 + None => return, 322 + }; 323 + 324 + let tab_order = state.page.doc.compute_tab_order(); 325 + if tab_order.is_empty() { 326 + return; 327 + } 328 + 329 + let current = state.page.doc.active_element(); 330 + let next = match current { 331 + Some(cur) => { 332 + if let Some(pos) = tab_order.iter().position(|&id| id == cur) { 333 + if shift { 334 + if pos == 0 { 335 + tab_order[tab_order.len() - 1] 336 + } else { 337 + tab_order[pos - 1] 338 + } 339 + } else if pos + 1 >= tab_order.len() { 340 + tab_order[0] 341 + } else { 342 + tab_order[pos + 1] 343 + } 344 + } else { 345 + // Current focus not in tab order; go to first/last. 346 + if shift { 347 + tab_order[tab_order.len() - 1] 348 + } else { 349 + tab_order[0] 350 + } 351 + } 352 + } 353 + None => { 354 + if shift { 355 + tab_order[tab_order.len() - 1] 356 + } else { 357 + tab_order[0] 358 + } 359 + } 360 + }; 361 + 362 + state.page.doc.set_active_element(Some(next), true); 363 + 364 + // Re-render to show focus ring. 365 + let viewport_width = state.bitmap.width() as f32; 366 + let viewport_height = state.bitmap.height() as f32; 367 + let content_height = render_page( 368 + &state.page, 369 + &state.font, 370 + &mut state.backend, 371 + &state.view, 372 + &mut state.bitmap, 373 + viewport_width, 374 + viewport_height, 375 + state.page_scroll_y, 376 + &state.scroll_offsets, 377 + ); 378 + state.content_height = content_height; 379 + 380 + if let ViewKind::Bitmap(bitmap_view) = &state.view { 381 + bitmap_view.set_needs_display(); 382 + } 383 + }); 384 + } 385 + 305 386 /// Called by the platform crate on scroll wheel events. 306 387 fn handle_scroll(_dx: f64, dy: f64, _mouse_x: f64, _mouse_y: f64) { 307 388 STATE.with(|state| { ··· 618 699 }); 619 700 }); 620 701 621 - // Register resize and scroll handlers. 702 + // Register resize, scroll, and key handlers. 622 703 appkit::set_resize_handler(handle_resize); 623 704 appkit::set_scroll_handler(handle_scroll); 705 + appkit::set_key_handler(handle_key_down); 624 706 625 707 window.make_key_and_order_front(); 626 708 app.activate();
+330
crates/dom/src/lib.rs
··· 70 70 pub struct Document { 71 71 nodes: Vec<Node>, 72 72 root: NodeId, 73 + /// The currently focused element, if any. 74 + active_element: Option<NodeId>, 75 + /// Whether focus was set via keyboard navigation (for `:focus-visible`). 76 + focus_visible: bool, 73 77 } 74 78 75 79 impl fmt::Debug for Document { ··· 95 99 Document { 96 100 nodes: vec![root_node], 97 101 root: NodeId(0), 102 + active_element: None, 103 + focus_visible: false, 98 104 } 99 105 } 100 106 ··· 518 524 child = self.nodes[id.0].next_sibling; 519 525 } 520 526 None 527 + } 528 + 529 + // --- Focus management --- 530 + 531 + /// Returns the currently focused element, or `None` if nothing is focused. 532 + pub fn active_element(&self) -> Option<NodeId> { 533 + self.active_element 534 + } 535 + 536 + /// Returns whether the current focus was set via keyboard navigation. 537 + pub fn is_focus_visible(&self) -> bool { 538 + self.focus_visible 539 + } 540 + 541 + /// Set the active (focused) element. Pass `None` to blur all. 542 + /// `keyboard` indicates whether focus was set via keyboard (Tab) navigation, 543 + /// which controls the `:focus-visible` pseudo-class. 544 + pub fn set_active_element(&mut self, node: Option<NodeId>, keyboard: bool) { 545 + self.active_element = node; 546 + self.focus_visible = keyboard; 547 + } 548 + 549 + /// Returns true if `node` is an ancestor of the currently focused element. 550 + pub fn is_focus_within(&self, node: NodeId) -> bool { 551 + let focused = match self.active_element { 552 + Some(f) => f, 553 + None => return false, 554 + }; 555 + // Walk up from the focused element's parent looking for `node`. 556 + let mut current = self.parent(focused); 557 + while let Some(id) = current { 558 + if id == node { 559 + return true; 560 + } 561 + current = self.parent(id); 562 + } 563 + false 564 + } 565 + 566 + /// Returns true if the element can receive focus. 567 + /// 568 + /// Naturally focusable: form controls (not disabled, not hidden), `<a>` with href. 569 + /// Also focusable: any element with a `tabindex` attribute. 570 + pub fn is_focusable(&self, node: NodeId) -> bool { 571 + if !matches!(self.node_data(node), NodeData::Element { .. }) { 572 + return false; 573 + } 574 + // Disabled elements are not focusable. 575 + if self.get_attribute(node, "disabled").is_some() { 576 + return false; 577 + } 578 + // Hidden inputs are not focusable. 579 + if self.tag_name(node) == Some("input") 580 + && self 581 + .get_attribute(node, "type") 582 + .map(|t| t.eq_ignore_ascii_case("hidden")) 583 + .unwrap_or(false) 584 + { 585 + return false; 586 + } 587 + // Elements with tabindex are always focusable (even tabindex="-1"). 588 + if self.get_attribute(node, "tabindex").is_some() { 589 + return true; 590 + } 591 + // Naturally focusable elements. 592 + match self.tag_name(node) { 593 + Some("input" | "select" | "textarea" | "button") => true, 594 + Some("a") => self.get_attribute(node, "href").is_some(), 595 + _ => false, 596 + } 597 + } 598 + 599 + /// Compute the tab order: focusable elements sorted by tabindex then DOM order. 600 + /// 601 + /// Elements with positive tabindex come first (ascending), then elements 602 + /// with tabindex=0 or naturally focusable elements in DOM order. 603 + /// Elements with tabindex=-1 are excluded (not in tab order). 604 + pub fn compute_tab_order(&self) -> Vec<NodeId> { 605 + let mut positive: Vec<(i32, usize, NodeId)> = Vec::new(); 606 + let mut zero_or_natural: Vec<(usize, NodeId)> = Vec::new(); 607 + let mut dom_order = 0usize; 608 + 609 + self.collect_tab_order(self.root, &mut dom_order, &mut positive, &mut zero_or_natural); 610 + 611 + // Sort positive tabindex by (tabindex, dom_order). 612 + positive.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1))); 613 + 614 + let mut result: Vec<NodeId> = positive.iter().map(|&(_, _, id)| id).collect(); 615 + result.extend(zero_or_natural.iter().map(|&(_, id)| id)); 616 + result 617 + } 618 + 619 + fn collect_tab_order( 620 + &self, 621 + node: NodeId, 622 + dom_order: &mut usize, 623 + positive: &mut Vec<(i32, usize, NodeId)>, 624 + zero_or_natural: &mut Vec<(usize, NodeId)>, 625 + ) { 626 + if self.is_focusable(node) { 627 + let tabindex = self 628 + .get_attribute(node, "tabindex") 629 + .and_then(|v| v.parse::<i32>().ok()); 630 + 631 + let order = *dom_order; 632 + *dom_order += 1; 633 + 634 + match tabindex { 635 + Some(ti) if ti < 0 => { 636 + // tabindex=-1: focusable via script but not in tab order. 637 + } 638 + Some(ti) if ti > 0 => { 639 + positive.push((ti, order, node)); 640 + } 641 + _ => { 642 + // tabindex=0 or naturally focusable (no tabindex attr). 643 + zero_or_natural.push((order, node)); 644 + } 645 + } 646 + } else { 647 + *dom_order += 1; 648 + } 649 + 650 + let mut child = self.nodes[node.0].first_child; 651 + while let Some(c) = child { 652 + self.collect_tab_order(c, dom_order, positive, zero_or_natural); 653 + child = self.nodes[c.0].next_sibling; 654 + } 521 655 } 522 656 523 657 /// Estimated heap bytes used by the DOM tree. ··· 1193 1327 assert_eq!(doc.get_element_by_id("container"), Some(div)); 1194 1328 assert_eq!(doc.get_element_by_id("email"), Some(input)); 1195 1329 assert_eq!(doc.get_element_by_id("nonexistent"), None); 1330 + } 1331 + 1332 + // --- Focus management tests --- 1333 + 1334 + #[test] 1335 + fn active_element_initially_none() { 1336 + let doc = Document::new(); 1337 + assert_eq!(doc.active_element(), None); 1338 + } 1339 + 1340 + #[test] 1341 + fn set_active_element() { 1342 + let mut doc = Document::new(); 1343 + let root = doc.root(); 1344 + let input = doc.create_element("input"); 1345 + doc.append_child(root, input); 1346 + 1347 + doc.set_active_element(Some(input), false); 1348 + assert_eq!(doc.active_element(), Some(input)); 1349 + assert!(!doc.is_focus_visible()); 1350 + 1351 + doc.set_active_element(Some(input), true); 1352 + assert_eq!(doc.active_element(), Some(input)); 1353 + assert!(doc.is_focus_visible()); 1354 + 1355 + doc.set_active_element(None, false); 1356 + assert_eq!(doc.active_element(), None); 1357 + } 1358 + 1359 + #[test] 1360 + fn is_focusable_form_controls() { 1361 + let mut doc = Document::new(); 1362 + let root = doc.root(); 1363 + 1364 + let input = doc.create_element("input"); 1365 + let select = doc.create_element("select"); 1366 + let textarea = doc.create_element("textarea"); 1367 + let button = doc.create_element("button"); 1368 + let div = doc.create_element("div"); 1369 + 1370 + doc.append_child(root, input); 1371 + doc.append_child(root, select); 1372 + doc.append_child(root, textarea); 1373 + doc.append_child(root, button); 1374 + doc.append_child(root, div); 1375 + 1376 + assert!(doc.is_focusable(input)); 1377 + assert!(doc.is_focusable(select)); 1378 + assert!(doc.is_focusable(textarea)); 1379 + assert!(doc.is_focusable(button)); 1380 + assert!(!doc.is_focusable(div)); // plain div not focusable 1381 + } 1382 + 1383 + #[test] 1384 + fn is_focusable_disabled_not_focusable() { 1385 + let mut doc = Document::new(); 1386 + let root = doc.root(); 1387 + let input = doc.create_element("input"); 1388 + doc.set_attribute(input, "disabled", ""); 1389 + doc.append_child(root, input); 1390 + 1391 + assert!(!doc.is_focusable(input)); 1392 + } 1393 + 1394 + #[test] 1395 + fn is_focusable_hidden_input_not_focusable() { 1396 + let mut doc = Document::new(); 1397 + let root = doc.root(); 1398 + let input = doc.create_element("input"); 1399 + doc.set_attribute(input, "type", "hidden"); 1400 + doc.append_child(root, input); 1401 + 1402 + assert!(!doc.is_focusable(input)); 1403 + } 1404 + 1405 + #[test] 1406 + fn is_focusable_anchor_with_href() { 1407 + let mut doc = Document::new(); 1408 + let root = doc.root(); 1409 + 1410 + let a = doc.create_element("a"); 1411 + doc.set_attribute(a, "href", "https://example.com"); 1412 + doc.append_child(root, a); 1413 + 1414 + let a_no_href = doc.create_element("a"); 1415 + doc.append_child(root, a_no_href); 1416 + 1417 + assert!(doc.is_focusable(a)); 1418 + assert!(!doc.is_focusable(a_no_href)); 1419 + } 1420 + 1421 + #[test] 1422 + fn is_focusable_tabindex() { 1423 + let mut doc = Document::new(); 1424 + let root = doc.root(); 1425 + 1426 + let div = doc.create_element("div"); 1427 + doc.set_attribute(div, "tabindex", "0"); 1428 + doc.append_child(root, div); 1429 + 1430 + let span = doc.create_element("span"); 1431 + doc.set_attribute(span, "tabindex", "-1"); 1432 + doc.append_child(root, span); 1433 + 1434 + assert!(doc.is_focusable(div)); 1435 + assert!(doc.is_focusable(span)); // tabindex=-1 is focusable, just not in tab order 1436 + } 1437 + 1438 + #[test] 1439 + fn focus_within() { 1440 + let mut doc = Document::new(); 1441 + let root = doc.root(); 1442 + let form = doc.create_element("form"); 1443 + let div = doc.create_element("div"); 1444 + let input = doc.create_element("input"); 1445 + 1446 + doc.append_child(root, form); 1447 + doc.append_child(form, div); 1448 + doc.append_child(div, input); 1449 + 1450 + doc.set_active_element(Some(input), false); 1451 + 1452 + assert!(doc.is_focus_within(div)); 1453 + assert!(doc.is_focus_within(form)); 1454 + assert!(!doc.is_focus_within(input)); // focus_within excludes the element itself 1455 + } 1456 + 1457 + #[test] 1458 + fn compute_tab_order_basic() { 1459 + let mut doc = Document::new(); 1460 + let root = doc.root(); 1461 + let body = doc.create_element("body"); 1462 + let input1 = doc.create_element("input"); 1463 + let input2 = doc.create_element("input"); 1464 + let input3 = doc.create_element("input"); 1465 + 1466 + doc.append_child(root, body); 1467 + doc.append_child(body, input1); 1468 + doc.append_child(body, input2); 1469 + doc.append_child(body, input3); 1470 + 1471 + let order = doc.compute_tab_order(); 1472 + assert_eq!(order, vec![input1, input2, input3]); 1473 + } 1474 + 1475 + #[test] 1476 + fn compute_tab_order_positive_tabindex_first() { 1477 + let mut doc = Document::new(); 1478 + let root = doc.root(); 1479 + let body = doc.create_element("body"); 1480 + let input_a = doc.create_element("input"); // natural, tabindex=0 1481 + let input_b = doc.create_element("input"); 1482 + doc.set_attribute(input_b, "tabindex", "2"); 1483 + let input_c = doc.create_element("input"); 1484 + doc.set_attribute(input_c, "tabindex", "1"); 1485 + 1486 + doc.append_child(root, body); 1487 + doc.append_child(body, input_a); 1488 + doc.append_child(body, input_b); 1489 + doc.append_child(body, input_c); 1490 + 1491 + let order = doc.compute_tab_order(); 1492 + // Positive tabindex first (ascending), then natural/zero. 1493 + assert_eq!(order, vec![input_c, input_b, input_a]); 1494 + } 1495 + 1496 + #[test] 1497 + fn compute_tab_order_negative_tabindex_excluded() { 1498 + let mut doc = Document::new(); 1499 + let root = doc.root(); 1500 + let body = doc.create_element("body"); 1501 + let input1 = doc.create_element("input"); 1502 + let div = doc.create_element("div"); 1503 + doc.set_attribute(div, "tabindex", "-1"); 1504 + let input2 = doc.create_element("input"); 1505 + 1506 + doc.append_child(root, body); 1507 + doc.append_child(body, input1); 1508 + doc.append_child(body, div); 1509 + doc.append_child(body, input2); 1510 + 1511 + let order = doc.compute_tab_order(); 1512 + // div with tabindex=-1 should be excluded from tab order. 1513 + assert_eq!(order, vec![input1, input2]); 1514 + // But div is still focusable (via script). 1515 + assert!(doc.is_focusable(div)); 1516 + } 1517 + 1518 + #[test] 1519 + fn text_nodes_not_focusable() { 1520 + let mut doc = Document::new(); 1521 + let root = doc.root(); 1522 + let text = doc.create_text("hello"); 1523 + doc.append_child(root, text); 1524 + 1525 + assert!(!doc.is_focusable(text)); 1196 1526 } 1197 1527 }
+92 -12
crates/js/src/dom_bridge.rs
··· 626 626 ("setAttribute", element_set_attribute), 627 627 ("removeAttribute", element_remove_attribute), 628 628 ("hasAttribute", element_has_attribute), 629 + ("focus", element_focus), 630 + ("blur", element_blur), 629 631 ]; 630 632 for &(name, callback) in element_methods { 631 633 let func = make_native(gc, name, callback); ··· 931 933 Ok(Value::Boolean(has)) 932 934 } 933 935 936 + // ── Focus management ──────────────────────────────────────────────── 937 + 938 + /// Native `element.focus()` — set focus on the element. 939 + fn element_focus(_args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 940 + let bridge = ctx 941 + .dom_bridge 942 + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; 943 + let node_id = match &ctx.this { 944 + Value::Object(r) => get_node_id(ctx.gc, ctx.shapes, *r), 945 + _ => None, 946 + } 947 + .ok_or_else(|| RuntimeError::type_error("focus called on non-element"))?; 948 + 949 + let focusable = bridge.document.borrow().is_focusable(node_id); 950 + if focusable { 951 + bridge 952 + .document 953 + .borrow_mut() 954 + .set_active_element(Some(node_id), false); 955 + } 956 + Ok(Value::Undefined) 957 + } 958 + 959 + /// Native `element.blur()` — remove focus from the element. 960 + fn element_blur(_args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 961 + let bridge = ctx 962 + .dom_bridge 963 + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; 964 + let node_id = match &ctx.this { 965 + Value::Object(r) => get_node_id(ctx.gc, ctx.shapes, *r), 966 + _ => None, 967 + } 968 + .ok_or_else(|| RuntimeError::type_error("blur called on non-element"))?; 969 + 970 + let is_active = bridge.document.borrow().active_element() == Some(node_id); 971 + if is_active { 972 + bridge 973 + .document 974 + .borrow_mut() 975 + .set_active_element(None, false); 976 + } 977 + Ok(Value::Undefined) 978 + } 979 + 934 980 // ── HTML serialization ────────────────────────────────────────────── 935 981 936 982 /// Serialize the children of a node to HTML (for innerHTML getter). ··· 1231 1277 } 1232 1278 } 1233 1279 1280 + // ── Focus-related properties ──────────── 1281 + "tabIndex" => { 1282 + if !matches!(doc.node_data(node_id), NodeData::Element { .. }) { 1283 + return None; 1284 + } 1285 + let ti = doc 1286 + .get_attribute(node_id, "tabindex") 1287 + .and_then(|v| v.parse::<i32>().ok()) 1288 + .unwrap_or_else(|| { 1289 + // Naturally focusable elements default to 0, others to -1. 1290 + if doc.is_focusable(node_id) { 1291 + 0 1292 + } else { 1293 + -1 1294 + } 1295 + }); 1296 + Some(Value::Number(ti as f64)) 1297 + } 1298 + 1234 1299 // ── classList ──────────────────────────── 1235 1300 "classList" => { 1236 1301 if !matches!(doc.node_data(node_id), NodeData::Element { .. }) { ··· 1276 1341 1277 1342 /// Resolve a dynamic property on the `document` object itself (not a node wrapper). 1278 1343 /// 1279 - /// Currently handles `document.cookie`. 1344 + /// Handles `document.cookie` and `document.activeElement`. 1280 1345 pub fn resolve_document_get( 1281 - gc: &Gc<HeapObject>, 1282 - shapes: &ShapeTable, 1346 + gc: &mut Gc<HeapObject>, 1347 + shapes: &mut ShapeTable, 1283 1348 bridge: &Rc<DomBridge>, 1284 1349 gc_ref: GcRef, 1285 1350 key: &str, 1286 1351 ) -> Option<Value> { 1287 - if key != "cookie" { 1288 - return None; 1289 - } 1290 1352 if !is_document_object(gc, shapes, gc_ref) { 1291 1353 return None; 1292 1354 } 1293 1355 1294 - let url = bridge.document_url.borrow(); 1295 - let cookie_str = match url.as_ref() { 1296 - Some(u) => bridge.cookie_jar.borrow_mut().document_cookie_get(u), 1297 - None => String::new(), 1298 - }; 1299 - Some(Value::String(cookie_str)) 1356 + match key { 1357 + "cookie" => { 1358 + let url = bridge.document_url.borrow(); 1359 + let cookie_str = match url.as_ref() { 1360 + Some(u) => bridge.cookie_jar.borrow_mut().document_cookie_get(u), 1361 + None => String::new(), 1362 + }; 1363 + Some(Value::String(cookie_str)) 1364 + } 1365 + "activeElement" => { 1366 + let active = bridge.document.borrow().active_element(); 1367 + match active { 1368 + Some(node_id) => { 1369 + let wrapper = get_or_create_wrapper(node_id, gc, shapes, bridge, None); 1370 + Some(Value::Object(wrapper)) 1371 + } 1372 + None => { 1373 + // Per spec, activeElement returns <body> or null. 1374 + Some(Value::Null) 1375 + } 1376 + } 1377 + } 1378 + _ => None, 1379 + } 1300 1380 } 1301 1381 1302 1382 /// Handle a property set on the `document` object (e.g. `document.cookie = "..."`)`.
+2 -2
crates/js/src/vm.rs
··· 2658 2658 { 2659 2659 return Some(val); 2660 2660 } 2661 - // Try document-level dynamic properties (e.g. document.cookie). 2662 - crate::dom_bridge::resolve_document_get(&self.gc, &self.shapes, &bridge, gc_ref, key) 2661 + // Try document-level dynamic properties (e.g. document.cookie, document.activeElement). 2662 + crate::dom_bridge::resolve_document_get(&mut self.gc, &mut self.shapes, &bridge, gc_ref, key) 2663 2663 } 2664 2664 2665 2665 /// Handle a DOM property set on a wrapper object.
+13
crates/layout/src/lib.rs
··· 103 103 pub checked: bool, 104 104 /// Whether the control is disabled. 105 105 pub disabled: bool, 106 + /// Whether the control currently has focus. 107 + pub focused: bool, 106 108 } 107 109 108 110 /// A box in the layout tree with dimensions and child boxes. ··· 647 649 /// Returns `None` for non-form-control elements and hidden inputs. 648 650 fn build_form_control_info(node: NodeId, doc: &Document) -> Option<FormControlInfo> { 649 651 let tag = doc.tag_name(node)?; 652 + let focused = doc.active_element() == Some(node); 650 653 match tag { 651 654 "input" => { 652 655 let input_type = doc.get_attribute(node, "type").unwrap_or("text"); ··· 659 662 value: String::new(), 660 663 checked, 661 664 disabled, 665 + focused, 662 666 }), 663 667 "radio" => Some(FormControlInfo { 664 668 control_type: FormControlType::Radio, 665 669 value: String::new(), 666 670 checked, 667 671 disabled, 672 + focused, 668 673 }), 669 674 "submit" => { 670 675 let value = doc ··· 676 681 value, 677 682 checked: false, 678 683 disabled, 684 + focused, 679 685 }) 680 686 } 681 687 "reset" => { ··· 688 694 value, 689 695 checked: false, 690 696 disabled, 697 + focused, 691 698 }) 692 699 } 693 700 "button" => { ··· 697 704 value, 698 705 checked: false, 699 706 disabled, 707 + focused, 700 708 }) 701 709 } 702 710 "password" => { ··· 706 714 value, 707 715 checked: false, 708 716 disabled, 717 + focused, 709 718 }) 710 719 } 711 720 // text, email, url, search, tel, number, etc. ··· 716 725 value, 717 726 checked: false, 718 727 disabled, 728 + focused, 719 729 }) 720 730 } 721 731 } ··· 728 738 value, 729 739 checked: false, 730 740 disabled, 741 + focused, 731 742 }) 732 743 } 733 744 "select" => { ··· 739 750 value, 740 751 checked: false, 741 752 disabled, 753 + focused, 742 754 }) 743 755 } 744 756 "button" => { ··· 754 766 value, 755 767 checked: false, 756 768 disabled, 769 + focused, 757 770 }) 758 771 } 759 772 _ => None,
+28 -2
crates/platform/src/appkit.rs
··· 259 259 true 260 260 } 261 261 262 - /// `keyDown:` — log key character and keyCode to stdout. 262 + /// `keyDown:` — dispatch key event to registered handler. 263 263 extern "C" fn view_key_down(_this: *mut c_void, _sel: *mut c_void, event: *mut c_void) { 264 264 let chars: *mut c_void = msg_send![event, characters]; 265 265 if chars.is_null() { ··· 271 271 } 272 272 let c_str = unsafe { CStr::from_ptr(utf8) }; 273 273 let key_code: u16 = msg_send![event, keyCode]; 274 + let modifier_flags: u64 = msg_send![event, modifierFlags]; 275 + let shift = (modifier_flags & 0x20000) != 0; // NSEventModifierFlagShift 274 276 if let Ok(s) = c_str.to_str() { 275 - println!("keyDown: '{}' (keyCode: {})", s, key_code); 277 + // SAFETY: We are on the main thread (AppKit event loop). 278 + unsafe { 279 + if let Some(handler) = KEY_HANDLER { 280 + handler(key_code, s, shift); 281 + } 282 + } 276 283 } 277 284 } 278 285 ··· 792 799 // SAFETY: Called from the main thread before `app.run()`. 793 800 unsafe { 794 801 SCROLL_HANDLER = Some(handler); 802 + } 803 + } 804 + 805 + /// Global key-down callback, called from `keyDown:` with the key code, 806 + /// character string, and whether the shift key was held. 807 + /// 808 + /// # Safety 809 + /// 810 + /// Accessed only from the main thread (the AppKit event loop). 811 + static mut KEY_HANDLER: Option<fn(u16, &str, bool)> = None; 812 + 813 + /// Register a function to be called when a key-down event occurs. 814 + /// 815 + /// The handler receives `(key_code, characters, shift_held)`. 816 + /// Only one handler can be active at a time. 817 + pub fn set_key_handler(handler: fn(u16, &str, bool)) { 818 + // SAFETY: Called from the main thread before `app.run()`. 819 + unsafe { 820 + KEY_HANDLER = Some(handler); 795 821 } 796 822 } 797 823
+58
crates/render/src/lib.rs
··· 699 699 b: 0, 700 700 a: 255, 701 701 }; 702 + /// Focus ring color (blue outline, matching typical browser defaults). 703 + const FC_FOCUS_RING_COLOR: Color = Color { 704 + r: 0, 705 + g: 95, 706 + b: 204, 707 + a: 255, 708 + }; 702 709 703 710 /// Paint a form control with native-style appearance. 704 711 fn paint_form_control( ··· 728 735 paint_select(layout_box, fc, list, tx, ty); 729 736 } 730 737 } 738 + 739 + // Paint focus ring around focused controls. 740 + if fc.focused { 741 + paint_focus_ring(layout_box, list, tx, ty); 742 + } 743 + } 744 + 745 + /// Paint a focus ring (blue outline) around a focused form control. 746 + fn paint_focus_ring(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { 747 + let bb = border_box(layout_box); 748 + // The focus ring sits 1px outside the border box. 749 + let offset = 1.0f32; 750 + let thickness = 2.0f32; 751 + let x = bb.x + tx - offset; 752 + let y = bb.y + ty - offset; 753 + let w = bb.width + offset * 2.0; 754 + let h = bb.height + offset * 2.0; 755 + let color = FC_FOCUS_RING_COLOR; 756 + 757 + // Top 758 + list.push(PaintCommand::FillRect { 759 + x, 760 + y, 761 + width: w, 762 + height: thickness, 763 + color, 764 + }); 765 + // Bottom 766 + list.push(PaintCommand::FillRect { 767 + x, 768 + y: y + h - thickness, 769 + width: w, 770 + height: thickness, 771 + color, 772 + }); 773 + // Left 774 + list.push(PaintCommand::FillRect { 775 + x, 776 + y, 777 + width: thickness, 778 + height: h, 779 + color, 780 + }); 781 + // Right 782 + list.push(PaintCommand::FillRect { 783 + x: x + w - thickness, 784 + y, 785 + width: thickness, 786 + height: h, 787 + color, 788 + }); 731 789 } 732 790 733 791 /// Paint a text input or password field: inset border, white background, value text.
+158 -3
crates/style/src/matching.rs
··· 197 197 .map(|val| val == id) 198 198 .unwrap_or(false), 199 199 SimpleSelector::Attribute(attr_sel) => matches_attribute(doc, node, attr_sel), 200 - SimpleSelector::PseudoClass(_name) => { 201 - // Pseudo-classes are not yet implemented; always return false. 202 - false 200 + SimpleSelector::PseudoClass(name) => matches_pseudo_class(doc, node, name), 201 + } 202 + } 203 + 204 + /// Check if a pseudo-class matches an element. 205 + fn matches_pseudo_class(doc: &Document, node: NodeId, name: &str) -> bool { 206 + match name { 207 + "focus" => doc.active_element() == Some(node), 208 + "focus-visible" => doc.active_element() == Some(node) && doc.is_focus_visible(), 209 + "focus-within" => { 210 + doc.active_element() == Some(node) || doc.is_focus_within(node) 211 + } 212 + "disabled" => doc.get_attribute(node, "disabled").is_some(), 213 + "enabled" => { 214 + matches!( 215 + doc.tag_name(node), 216 + Some("input" | "select" | "textarea" | "button") 217 + ) && doc.get_attribute(node, "disabled").is_none() 203 218 } 219 + "checked" => { 220 + doc.get_attribute(node, "checked").is_some() 221 + } 222 + _ => false, 204 223 } 205 224 } 206 225 ··· 841 860 Rule::Style(r) => r.selectors.selectors[0].clone(), 842 861 _ => panic!("expected style rule"), 843 862 } 863 + } 864 + 865 + // ----------------------------------------------------------------------- 866 + // Pseudo-class: :focus 867 + // ----------------------------------------------------------------------- 868 + 869 + #[test] 870 + fn focus_pseudo_class_matches_focused_element() { 871 + let mut doc = Document::new(); 872 + let root = doc.root(); 873 + let body = doc.create_element("body"); 874 + let input = doc.create_element("input"); 875 + doc.append_child(root, body); 876 + doc.append_child(body, input); 877 + 878 + let sel = parse_first_selector(":focus {}"); 879 + 880 + // Not focused → no match. 881 + assert!(!matches_selector(&doc, input, &sel)); 882 + 883 + // Focus the input → matches. 884 + doc.set_active_element(Some(input), false); 885 + assert!(matches_selector(&doc, input, &sel)); 886 + 887 + // Other element → no match. 888 + assert!(!matches_selector(&doc, body, &sel)); 889 + } 890 + 891 + // ----------------------------------------------------------------------- 892 + // Pseudo-class: :focus-visible 893 + // ----------------------------------------------------------------------- 894 + 895 + #[test] 896 + fn focus_visible_pseudo_class() { 897 + let mut doc = Document::new(); 898 + let root = doc.root(); 899 + let input = doc.create_element("input"); 900 + doc.append_child(root, input); 901 + 902 + let sel = parse_first_selector(":focus-visible {}"); 903 + 904 + // Focus via script (not keyboard) → :focus-visible doesn't match. 905 + doc.set_active_element(Some(input), false); 906 + assert!(!matches_selector(&doc, input, &sel)); 907 + 908 + // Focus via keyboard → :focus-visible matches. 909 + doc.set_active_element(Some(input), true); 910 + assert!(matches_selector(&doc, input, &sel)); 911 + } 912 + 913 + // ----------------------------------------------------------------------- 914 + // Pseudo-class: :focus-within 915 + // ----------------------------------------------------------------------- 916 + 917 + #[test] 918 + fn focus_within_pseudo_class() { 919 + let mut doc = Document::new(); 920 + let root = doc.root(); 921 + let form = doc.create_element("form"); 922 + let input = doc.create_element("input"); 923 + doc.append_child(root, form); 924 + doc.append_child(form, input); 925 + 926 + let sel = parse_first_selector(":focus-within {}"); 927 + 928 + // Nothing focused → no match. 929 + assert!(!matches_selector(&doc, form, &sel)); 930 + 931 + // Focus input → form matches :focus-within. 932 + doc.set_active_element(Some(input), false); 933 + assert!(matches_selector(&doc, form, &sel)); 934 + 935 + // Input itself also matches :focus-within (since it is the focused element). 936 + assert!(matches_selector(&doc, input, &sel)); 937 + } 938 + 939 + // ----------------------------------------------------------------------- 940 + // Pseudo-class: :disabled / :enabled / :checked 941 + // ----------------------------------------------------------------------- 942 + 943 + #[test] 944 + fn disabled_enabled_pseudo_classes() { 945 + let mut doc = Document::new(); 946 + let root = doc.root(); 947 + let input = doc.create_element("input"); 948 + let disabled_input = doc.create_element("input"); 949 + doc.set_attribute(disabled_input, "disabled", ""); 950 + doc.append_child(root, input); 951 + doc.append_child(root, disabled_input); 952 + 953 + let enabled_sel = parse_first_selector(":enabled {}"); 954 + let disabled_sel = parse_first_selector(":disabled {}"); 955 + 956 + assert!(matches_selector(&doc, input, &enabled_sel)); 957 + assert!(!matches_selector(&doc, input, &disabled_sel)); 958 + 959 + assert!(!matches_selector(&doc, disabled_input, &enabled_sel)); 960 + assert!(matches_selector(&doc, disabled_input, &disabled_sel)); 961 + } 962 + 963 + #[test] 964 + fn checked_pseudo_class() { 965 + let mut doc = Document::new(); 966 + let root = doc.root(); 967 + let checkbox = doc.create_element("input"); 968 + doc.set_attribute(checkbox, "type", "checkbox"); 969 + doc.set_attribute(checkbox, "checked", ""); 970 + doc.append_child(root, checkbox); 971 + 972 + let unchecked = doc.create_element("input"); 973 + doc.set_attribute(unchecked, "type", "checkbox"); 974 + doc.append_child(root, unchecked); 975 + 976 + let sel = parse_first_selector(":checked {}"); 977 + assert!(matches_selector(&doc, checkbox, &sel)); 978 + assert!(!matches_selector(&doc, unchecked, &sel)); 979 + } 980 + 981 + // ----------------------------------------------------------------------- 982 + // Compound selector with :focus 983 + // ----------------------------------------------------------------------- 984 + 985 + #[test] 986 + fn compound_with_focus() { 987 + let mut doc = Document::new(); 988 + let root = doc.root(); 989 + let input = doc.create_element("input"); 990 + doc.set_attribute(input, "class", "search"); 991 + doc.append_child(root, input); 992 + 993 + let sel = parse_first_selector("input.search:focus {}"); 994 + 995 + assert!(!matches_selector(&doc, input, &sel)); 996 + 997 + doc.set_active_element(Some(input), false); 998 + assert!(matches_selector(&doc, input, &sel)); 844 999 } 845 1000 }