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 browser chrome UI (Phase 17)

Add a browser chrome bar above the web page viewport with navigation
buttons (back, forward, reload), an editable address bar, and keyboard
shortcuts. This transforms the project from a page viewer into a
navigable browser.

Features:
- Chrome bar rendered at top of window (40px), web content below
- Back/Forward/Reload buttons with enabled/disabled state
- Address bar displays current URL, click to focus and edit
- Enter in address bar navigates to the typed URL
- Cmd+L focuses address bar and selects all text
- Cmd+R reloads current page
- Cmd+[/] navigate back/forward (existing)
- Escape cancels address bar editing and restores URL
- Full text editing: cursor movement, selection, copy/cut/paste
- Chrome rendering independent of web content CSS
- All event coordinates adjusted for chrome offset

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+1208 -27
+1208 -27
crates/browser/src/main.rs
··· 13 13 use we_browser::navigation_history::NavigationHistory; 14 14 use we_browser::script_loader::execute_page_scripts; 15 15 use we_css::parser::Stylesheet; 16 + use we_css::values::Color; 16 17 use we_dom::{Document, NodeData, NodeId}; 17 18 use we_html::parse_html; 18 19 use we_image::pixel::Image; 19 20 use we_layout::layout; 21 + use we_layout::TextLine; 20 22 use we_net::referrer::ReferrerPolicy; 21 23 use we_platform::appkit; 22 24 use we_platform::cg::BitmapContext; 23 25 use we_platform::metal::ClearColor; 24 - use we_render::{build_display_list_with_page_scroll, RenderBackend, ScrollState}; 25 - use we_style::computed::resolve_styles; 26 + use we_render::{build_display_list_with_page_scroll, PaintCommand, RenderBackend, ScrollState}; 27 + use we_style::computed::{resolve_styles, TextDecoration}; 26 28 use we_svg::{render_svg, svg_intrinsic_size}; 27 29 use we_text::font::{self, Font, FontRegistry}; 28 30 use we_url::Url; 29 31 30 32 // --------------------------------------------------------------------------- 33 + // Browser chrome constants 34 + // --------------------------------------------------------------------------- 35 + 36 + /// Height of the browser chrome bar in pixels. 37 + const CHROME_HEIGHT: f32 = 40.0; 38 + /// Font size for chrome UI text. 39 + const CHROME_FONT_SIZE: f32 = 13.0; 40 + /// Padding inside the address bar. 41 + const CHROME_PADDING: f32 = 8.0; 42 + /// Width of each navigation button. 43 + const CHROME_BUTTON_WIDTH: f32 = 32.0; 44 + /// Height of each navigation button. 45 + const CHROME_BUTTON_HEIGHT: f32 = 28.0; 46 + /// Gap between buttons. 47 + const CHROME_BUTTON_GAP: f32 = 4.0; 48 + /// Left margin for chrome content. 49 + const CHROME_LEFT_MARGIN: f32 = 8.0; 50 + /// Right margin for chrome content. 51 + const CHROME_RIGHT_MARGIN: f32 = 8.0; 52 + 53 + // --------------------------------------------------------------------------- 31 54 // Page state: holds everything needed to re-render without re-fetching 32 55 // --------------------------------------------------------------------------- 33 56 ··· 43 66 base_url: Url, 44 67 } 45 68 69 + // --------------------------------------------------------------------------- 70 + // Browser chrome state 71 + // --------------------------------------------------------------------------- 72 + 73 + /// State for the browser chrome UI (address bar, navigation buttons). 74 + struct ChromeState { 75 + /// Whether the address bar is focused (being edited). 76 + focused: bool, 77 + /// The text currently displayed/edited in the address bar. 78 + text: String, 79 + /// Cursor byte position within `text`. 80 + cursor: usize, 81 + /// Selection anchor byte position (equal to cursor if no selection). 82 + selection_anchor: usize, 83 + /// Whether a page is currently loading. 84 + loading: bool, 85 + } 86 + 87 + impl ChromeState { 88 + fn new(url: &str) -> Self { 89 + Self { 90 + focused: false, 91 + text: url.to_string(), 92 + cursor: 0, 93 + selection_anchor: 0, 94 + loading: false, 95 + } 96 + } 97 + 98 + /// Update the displayed URL (when navigation completes). 99 + fn set_url(&mut self, url: &str) { 100 + if !self.focused { 101 + self.text = url.to_string(); 102 + self.cursor = 0; 103 + self.selection_anchor = 0; 104 + } 105 + } 106 + 107 + /// Focus the address bar and select all text. 108 + fn focus_and_select_all(&mut self) { 109 + self.focused = true; 110 + self.cursor = self.text.len(); 111 + self.selection_anchor = 0; 112 + } 113 + 114 + /// Cancel editing and restore the URL. 115 + fn cancel(&mut self, current_url: &str) { 116 + self.focused = false; 117 + self.text = current_url.to_string(); 118 + self.cursor = 0; 119 + self.selection_anchor = 0; 120 + } 121 + 122 + /// Whether there is a text selection. 123 + fn has_selection(&self) -> bool { 124 + self.cursor != self.selection_anchor 125 + } 126 + 127 + /// Returns the selection range (start, end) in byte offsets. 128 + fn selection_range(&self) -> (usize, usize) { 129 + if self.cursor < self.selection_anchor { 130 + (self.cursor, self.selection_anchor) 131 + } else { 132 + (self.selection_anchor, self.cursor) 133 + } 134 + } 135 + 136 + /// Delete the selected text and collapse cursor. 137 + fn delete_selection(&mut self) { 138 + if !self.has_selection() { 139 + return; 140 + } 141 + let (start, end) = self.selection_range(); 142 + self.text.drain(start..end); 143 + self.cursor = start; 144 + self.selection_anchor = start; 145 + } 146 + 147 + /// Insert text at the cursor, replacing any selection. 148 + fn insert(&mut self, s: &str) { 149 + self.delete_selection(); 150 + self.text.insert_str(self.cursor, s); 151 + self.cursor += s.len(); 152 + self.selection_anchor = self.cursor; 153 + } 154 + 155 + /// Delete one character before the cursor. 156 + fn delete_backward(&mut self) { 157 + if self.has_selection() { 158 + self.delete_selection(); 159 + return; 160 + } 161 + if self.cursor == 0 { 162 + return; 163 + } 164 + // Find the previous character boundary. 165 + let prev = self.text[..self.cursor] 166 + .char_indices() 167 + .next_back() 168 + .map(|(i, _)| i) 169 + .unwrap_or(0); 170 + self.text.drain(prev..self.cursor); 171 + self.cursor = prev; 172 + self.selection_anchor = prev; 173 + } 174 + 175 + /// Delete one character after the cursor. 176 + fn delete_forward(&mut self) { 177 + if self.has_selection() { 178 + self.delete_selection(); 179 + return; 180 + } 181 + if self.cursor >= self.text.len() { 182 + return; 183 + } 184 + let next = self.text[self.cursor..] 185 + .char_indices() 186 + .nth(1) 187 + .map(|(i, _)| self.cursor + i) 188 + .unwrap_or(self.text.len()); 189 + self.text.drain(self.cursor..next); 190 + } 191 + 192 + /// Move cursor left by one character. 193 + fn move_left(&mut self) { 194 + if self.has_selection() { 195 + let (start, _) = self.selection_range(); 196 + self.cursor = start; 197 + self.selection_anchor = start; 198 + return; 199 + } 200 + if self.cursor > 0 { 201 + self.cursor = self.text[..self.cursor] 202 + .char_indices() 203 + .next_back() 204 + .map(|(i, _)| i) 205 + .unwrap_or(0); 206 + self.selection_anchor = self.cursor; 207 + } 208 + } 209 + 210 + /// Move cursor right by one character. 211 + fn move_right(&mut self) { 212 + if self.has_selection() { 213 + let (_, end) = self.selection_range(); 214 + self.cursor = end; 215 + self.selection_anchor = end; 216 + return; 217 + } 218 + if self.cursor < self.text.len() { 219 + self.cursor = self.text[self.cursor..] 220 + .char_indices() 221 + .nth(1) 222 + .map(|(i, _)| self.cursor + i) 223 + .unwrap_or(self.text.len()); 224 + self.selection_anchor = self.cursor; 225 + } 226 + } 227 + 228 + /// Move cursor to the beginning. 229 + fn move_home(&mut self) { 230 + self.cursor = 0; 231 + self.selection_anchor = 0; 232 + } 233 + 234 + /// Move cursor to the end. 235 + fn move_end(&mut self) { 236 + self.cursor = self.text.len(); 237 + self.selection_anchor = self.text.len(); 238 + } 239 + 240 + /// Select all text. 241 + fn select_all(&mut self) { 242 + self.selection_anchor = 0; 243 + self.cursor = self.text.len(); 244 + } 245 + } 246 + 46 247 /// Browser state kept in thread-local storage so the resize handler can 47 248 /// access it. All AppKit callbacks run on the main thread. 48 249 struct BrowserState { ··· 60 261 scroll_offsets: HashMap<NodeId, (f32, f32)>, 61 262 /// Session navigation history (back/forward stack). 62 263 history: NavigationHistory, 264 + /// Browser chrome UI state (address bar, buttons). 265 + chrome: ChromeState, 63 266 } 64 267 65 268 /// The view kind: either Metal-backed or software bitmap. ··· 168 371 } 169 372 } 170 373 374 + /// Build a display list for the browser chrome UI (address bar, nav buttons). 375 + fn build_chrome_display_list( 376 + chrome: &ChromeState, 377 + history: &NavigationHistory, 378 + _font: &Font, 379 + viewport_width: f32, 380 + ) -> Vec<PaintCommand> { 381 + let mut list = Vec::new(); 382 + 383 + // Chrome background bar. 384 + list.push(PaintCommand::FillRect { 385 + x: 0.0, 386 + y: 0.0, 387 + width: viewport_width, 388 + height: CHROME_HEIGHT, 389 + color: Color::rgb(242, 242, 247), 390 + }); 391 + 392 + // Bottom border of chrome bar. 393 + list.push(PaintCommand::FillRect { 394 + x: 0.0, 395 + y: CHROME_HEIGHT - 1.0, 396 + width: viewport_width, 397 + height: 1.0, 398 + color: Color::rgb(200, 200, 200), 399 + }); 400 + 401 + let button_y = (CHROME_HEIGHT - CHROME_BUTTON_HEIGHT) / 2.0; 402 + let mut x = CHROME_LEFT_MARGIN; 403 + 404 + // -- Back button -- 405 + let can_back = history.can_go_back(); 406 + let back_color = if can_back { 407 + Color::rgb(60, 60, 60) 408 + } else { 409 + Color::rgb(180, 180, 180) 410 + }; 411 + // Button background. 412 + list.push(PaintCommand::FillRect { 413 + x, 414 + y: button_y, 415 + width: CHROME_BUTTON_WIDTH, 416 + height: CHROME_BUTTON_HEIGHT, 417 + color: Color::rgb(228, 228, 233), 418 + }); 419 + // Back arrow "←" rendered as text. 420 + list.push(PaintCommand::DrawGlyphs { 421 + line: TextLine { 422 + text: "\u{2190}".to_string(), // ← 423 + x: x + 8.0, 424 + y: button_y + 4.0, 425 + width: CHROME_BUTTON_WIDTH, 426 + font_size: CHROME_FONT_SIZE + 2.0, 427 + color: back_color, 428 + text_decoration: TextDecoration::None, 429 + background_color: Color::new(0, 0, 0, 0), 430 + }, 431 + font_size: CHROME_FONT_SIZE + 2.0, 432 + color: back_color, 433 + }); 434 + 435 + x += CHROME_BUTTON_WIDTH + CHROME_BUTTON_GAP; 436 + 437 + // -- Forward button -- 438 + let can_forward = history.can_go_forward(); 439 + let fwd_color = if can_forward { 440 + Color::rgb(60, 60, 60) 441 + } else { 442 + Color::rgb(180, 180, 180) 443 + }; 444 + list.push(PaintCommand::FillRect { 445 + x, 446 + y: button_y, 447 + width: CHROME_BUTTON_WIDTH, 448 + height: CHROME_BUTTON_HEIGHT, 449 + color: Color::rgb(228, 228, 233), 450 + }); 451 + list.push(PaintCommand::DrawGlyphs { 452 + line: TextLine { 453 + text: "\u{2192}".to_string(), // → 454 + x: x + 8.0, 455 + y: button_y + 4.0, 456 + width: CHROME_BUTTON_WIDTH, 457 + font_size: CHROME_FONT_SIZE + 2.0, 458 + color: fwd_color, 459 + text_decoration: TextDecoration::None, 460 + background_color: Color::new(0, 0, 0, 0), 461 + }, 462 + font_size: CHROME_FONT_SIZE + 2.0, 463 + color: fwd_color, 464 + }); 465 + 466 + x += CHROME_BUTTON_WIDTH + CHROME_BUTTON_GAP; 467 + 468 + // -- Reload button -- 469 + let reload_color = Color::rgb(60, 60, 60); 470 + list.push(PaintCommand::FillRect { 471 + x, 472 + y: button_y, 473 + width: CHROME_BUTTON_WIDTH, 474 + height: CHROME_BUTTON_HEIGHT, 475 + color: Color::rgb(228, 228, 233), 476 + }); 477 + let reload_label = if chrome.loading { 478 + "\u{2715}" // ✕ (stop) 479 + } else { 480 + "\u{21BB}" // ↻ (reload) 481 + }; 482 + list.push(PaintCommand::DrawGlyphs { 483 + line: TextLine { 484 + text: reload_label.to_string(), 485 + x: x + 8.0, 486 + y: button_y + 4.0, 487 + width: CHROME_BUTTON_WIDTH, 488 + font_size: CHROME_FONT_SIZE + 2.0, 489 + color: reload_color, 490 + text_decoration: TextDecoration::None, 491 + background_color: Color::new(0, 0, 0, 0), 492 + }, 493 + font_size: CHROME_FONT_SIZE + 2.0, 494 + color: reload_color, 495 + }); 496 + 497 + x += CHROME_BUTTON_WIDTH + CHROME_BUTTON_GAP + 4.0; 498 + 499 + // -- Address bar -- 500 + let addr_bar_x = x; 501 + let addr_bar_width = viewport_width - addr_bar_x - CHROME_RIGHT_MARGIN; 502 + let addr_bar_y = button_y; 503 + let addr_bar_height = CHROME_BUTTON_HEIGHT; 504 + 505 + // Address bar background (white, with border). 506 + list.push(PaintCommand::FillRect { 507 + x: addr_bar_x, 508 + y: addr_bar_y, 509 + width: addr_bar_width, 510 + height: addr_bar_height, 511 + color: Color::rgb(255, 255, 255), 512 + }); 513 + // Top border. 514 + list.push(PaintCommand::FillRect { 515 + x: addr_bar_x, 516 + y: addr_bar_y, 517 + width: addr_bar_width, 518 + height: 1.0, 519 + color: Color::rgb(190, 190, 195), 520 + }); 521 + // Bottom border. 522 + list.push(PaintCommand::FillRect { 523 + x: addr_bar_x, 524 + y: addr_bar_y + addr_bar_height - 1.0, 525 + width: addr_bar_width, 526 + height: 1.0, 527 + color: Color::rgb(190, 190, 195), 528 + }); 529 + // Left border. 530 + list.push(PaintCommand::FillRect { 531 + x: addr_bar_x, 532 + y: addr_bar_y, 533 + width: 1.0, 534 + height: addr_bar_height, 535 + color: Color::rgb(190, 190, 195), 536 + }); 537 + // Right border. 538 + list.push(PaintCommand::FillRect { 539 + x: addr_bar_x + addr_bar_width - 1.0, 540 + y: addr_bar_y, 541 + width: 1.0, 542 + height: addr_bar_height, 543 + color: Color::rgb(190, 190, 195), 544 + }); 545 + 546 + // Focused highlight border. 547 + if chrome.focused { 548 + // Blue highlight around address bar. 549 + let hl = Color::rgb(0, 122, 255); 550 + list.push(PaintCommand::FillRect { 551 + x: addr_bar_x - 1.0, 552 + y: addr_bar_y - 1.0, 553 + width: addr_bar_width + 2.0, 554 + height: 1.0, 555 + color: hl, 556 + }); 557 + list.push(PaintCommand::FillRect { 558 + x: addr_bar_x - 1.0, 559 + y: addr_bar_y + addr_bar_height, 560 + width: addr_bar_width + 2.0, 561 + height: 1.0, 562 + color: hl, 563 + }); 564 + list.push(PaintCommand::FillRect { 565 + x: addr_bar_x - 1.0, 566 + y: addr_bar_y - 1.0, 567 + width: 1.0, 568 + height: addr_bar_height + 2.0, 569 + color: hl, 570 + }); 571 + list.push(PaintCommand::FillRect { 572 + x: addr_bar_x + addr_bar_width, 573 + y: addr_bar_y - 1.0, 574 + width: 1.0, 575 + height: addr_bar_height + 2.0, 576 + color: hl, 577 + }); 578 + } 579 + 580 + // Address bar text, clipped to the bar interior. 581 + let text_x = addr_bar_x + CHROME_PADDING; 582 + let text_y = addr_bar_y + (addr_bar_height - CHROME_FONT_SIZE) / 2.0; 583 + let clip_x = addr_bar_x + 1.0; 584 + let clip_width = addr_bar_width - 2.0; 585 + 586 + list.push(PaintCommand::PushClip { 587 + x: clip_x, 588 + y: addr_bar_y + 1.0, 589 + width: clip_width, 590 + height: addr_bar_height - 2.0, 591 + }); 592 + 593 + // Draw selection highlight if there is one. 594 + if chrome.focused && chrome.has_selection() { 595 + let (sel_start, sel_end) = chrome.selection_range(); 596 + let char_width = CHROME_FONT_SIZE * 0.6; 597 + let start_chars = chrome.text[..sel_start].chars().count(); 598 + let end_chars = chrome.text[..sel_end].chars().count(); 599 + let sel_x = text_x + start_chars as f32 * char_width; 600 + let sel_w = (end_chars - start_chars) as f32 * char_width; 601 + list.push(PaintCommand::FillRect { 602 + x: sel_x, 603 + y: addr_bar_y + 2.0, 604 + width: sel_w, 605 + height: addr_bar_height - 4.0, 606 + color: Color::new(0, 122, 255, 60), 607 + }); 608 + } 609 + 610 + // Use proper text measurement for cursor position. 611 + let text_color = Color::rgb(30, 30, 30); 612 + list.push(PaintCommand::DrawGlyphs { 613 + line: TextLine { 614 + text: chrome.text.clone(), 615 + x: text_x, 616 + y: text_y, 617 + width: clip_width, 618 + font_size: CHROME_FONT_SIZE, 619 + color: text_color, 620 + text_decoration: TextDecoration::None, 621 + background_color: Color::new(0, 0, 0, 0), 622 + }, 623 + font_size: CHROME_FONT_SIZE, 624 + color: text_color, 625 + }); 626 + 627 + // Draw cursor when focused. 628 + if chrome.focused { 629 + let char_width = CHROME_FONT_SIZE * 0.6; 630 + let cursor_chars = chrome.text[..chrome.cursor].chars().count(); 631 + let cursor_x = text_x + cursor_chars as f32 * char_width; 632 + list.push(PaintCommand::FillRect { 633 + x: cursor_x, 634 + y: addr_bar_y + 3.0, 635 + width: 1.0, 636 + height: addr_bar_height - 6.0, 637 + color: Color::rgb(0, 0, 0), 638 + }); 639 + } 640 + 641 + list.push(PaintCommand::PopClip); 642 + 643 + list 644 + } 645 + 646 + /// Compute the x coordinate where the address bar starts. 647 + fn chrome_address_bar_x() -> f32 { 648 + CHROME_LEFT_MARGIN 649 + + CHROME_BUTTON_WIDTH 650 + + CHROME_BUTTON_GAP 651 + + CHROME_BUTTON_WIDTH 652 + + CHROME_BUTTON_GAP 653 + + CHROME_BUTTON_WIDTH 654 + + CHROME_BUTTON_GAP 655 + + 4.0 656 + } 657 + 658 + /// Hit-test the chrome area. Returns a `ChromeHit` describing what was clicked. 659 + enum ChromeHit { 660 + Back, 661 + Forward, 662 + Reload, 663 + AddressBar { local_x: f32 }, 664 + None, 665 + } 666 + 667 + fn hit_test_chrome(x: f32, y: f32, viewport_width: f32) -> ChromeHit { 668 + if y >= CHROME_HEIGHT { 669 + return ChromeHit::None; 670 + } 671 + let button_y = (CHROME_HEIGHT - CHROME_BUTTON_HEIGHT) / 2.0; 672 + 673 + let mut bx = CHROME_LEFT_MARGIN; 674 + 675 + // Back button. 676 + if x >= bx 677 + && x < bx + CHROME_BUTTON_WIDTH 678 + && y >= button_y 679 + && y < button_y + CHROME_BUTTON_HEIGHT 680 + { 681 + return ChromeHit::Back; 682 + } 683 + bx += CHROME_BUTTON_WIDTH + CHROME_BUTTON_GAP; 684 + 685 + // Forward button. 686 + if x >= bx 687 + && x < bx + CHROME_BUTTON_WIDTH 688 + && y >= button_y 689 + && y < button_y + CHROME_BUTTON_HEIGHT 690 + { 691 + return ChromeHit::Forward; 692 + } 693 + bx += CHROME_BUTTON_WIDTH + CHROME_BUTTON_GAP; 694 + 695 + // Reload button. 696 + if x >= bx 697 + && x < bx + CHROME_BUTTON_WIDTH 698 + && y >= button_y 699 + && y < button_y + CHROME_BUTTON_HEIGHT 700 + { 701 + return ChromeHit::Reload; 702 + } 703 + 704 + // Address bar. 705 + let addr_bar_x = chrome_address_bar_x(); 706 + let addr_bar_width = viewport_width - addr_bar_x - CHROME_RIGHT_MARGIN; 707 + if x >= addr_bar_x 708 + && x < addr_bar_x + addr_bar_width 709 + && y >= button_y 710 + && y < button_y + CHROME_BUTTON_HEIGHT 711 + { 712 + return ChromeHit::AddressBar { 713 + local_x: x - addr_bar_x - CHROME_PADDING, 714 + }; 715 + } 716 + 717 + ChromeHit::None 718 + } 719 + 171 720 /// Unified render pipeline: resolve styles → layout → build display list → 172 721 /// dispatch to active backend. 173 722 /// 723 + /// The chrome bar is rendered at the top, and web content is offset below it. 174 724 /// Returns the total content height for scroll clamping. 175 725 #[allow(clippy::too_many_arguments)] 176 726 fn render_page( ··· 183 733 viewport_height: f32, 184 734 page_scroll_y: f32, 185 735 scroll_offsets: &ScrollState, 736 + chrome: &ChromeState, 737 + history: &NavigationHistory, 186 738 ) -> f32 { 187 739 let width = viewport_width as u32; 188 740 let height = viewport_height as u32; ··· 190 742 return 0.0; 191 743 } 192 744 745 + // The web content viewport is the window minus the chrome bar. 746 + let content_viewport_height = (viewport_height - CHROME_HEIGHT).max(0.0); 747 + 193 748 // Resolve computed styles from DOM + stylesheet. 194 749 let styled = match resolve_styles( 195 750 &page.doc, 196 751 std::slice::from_ref(&page.stylesheet), 197 - (viewport_width, viewport_height), 752 + (viewport_width, content_viewport_height), 198 753 ) { 199 754 Some(s) => s, 200 755 None => return 0.0, ··· 211 766 let svg_images = rasterize_svgs(&page.doc, font); 212 767 let refs = image_refs(&page.images, &svg_images); 213 768 214 - // Layout. 769 + // Layout with the reduced viewport height (below chrome). 215 770 let tree = layout( 216 771 &styled, 217 772 &page.doc, 218 773 viewport_width, 219 - viewport_height, 774 + content_viewport_height, 220 775 font, 221 776 &sizes, 222 777 ); 223 778 224 - // Build display list with scroll state. 225 - let display_list = build_display_list_with_page_scroll(&tree, page_scroll_y, scroll_offsets); 779 + // Build web content display list. Shift content down by CHROME_HEIGHT 780 + // by subtracting it from page_scroll_y (the builder applies -scroll_y as offset). 781 + let web_display_list = 782 + build_display_list_with_page_scroll(&tree, page_scroll_y - CHROME_HEIGHT, scroll_offsets); 783 + 784 + // Build chrome display list. 785 + let chrome_display_list = build_chrome_display_list(chrome, history, font, viewport_width); 786 + 787 + // Combine: chrome first (background), then clipped web content, then chrome overlay. 788 + let mut display_list = 789 + Vec::with_capacity(chrome_display_list.len() + web_display_list.len() + 2); 790 + 791 + // Clip web content to below the chrome bar. 792 + display_list.push(PaintCommand::PushClip { 793 + x: 0.0, 794 + y: CHROME_HEIGHT, 795 + width: viewport_width, 796 + height: content_viewport_height, 797 + }); 798 + display_list.extend(web_display_list); 799 + display_list.push(PaintCommand::PopClip); 800 + 801 + // Chrome on top (painted last so it overlaps any web content that might leak). 802 + display_list.extend(chrome_display_list); 226 803 227 804 // Dispatch to the active backend. 228 805 match (backend, view) { ··· 297 874 height as f32, 298 875 state.page_scroll_y, 299 876 &state.scroll_offsets, 877 + &state.chrome, 878 + &state.history, 300 879 ); 301 880 state.content_height = content_height; 302 881 303 - // Clamp scroll position after resize. 304 - let max_scroll = (state.content_height - height as f32).max(0.0); 882 + // Clamp scroll position after resize (content viewport excludes chrome). 883 + let content_viewport = (height as f32 - CHROME_HEIGHT).max(0.0); 884 + let max_scroll = (state.content_height - content_viewport).max(0.0); 305 885 state.page_scroll_y = state.page_scroll_y.clamp(0.0, max_scroll); 306 886 307 887 // Software path: update the bitmap view. ··· 331 911 const KEY_CODE_C: u16 = 8; 332 912 const KEY_CODE_V: u16 = 9; 333 913 const KEY_CODE_X: u16 = 7; 914 + const KEY_CODE_L: u16 = 37; 915 + const KEY_CODE_R: u16 = 15; 334 916 const KEY_CODE_LBRACKET: u16 = 33; // [ key 335 917 const KEY_CODE_RBRACKET: u16 = 30; // ] key 336 918 ··· 590 1172 state.page = new_page; 591 1173 state.page_scroll_y = 0.0; 592 1174 state.scroll_offsets.clear(); 1175 + 1176 + // Update chrome address bar with new URL. 1177 + state.chrome.set_url(&state.page.base_url.serialize()); 1178 + 593 1179 rerender(state); 594 1180 595 1181 // Process any History API commands queued during page script execution. ··· 729 1315 // We need to find the select element's screen position from layout. 730 1316 let viewport_width = state.bitmap.width() as f32; 731 1317 let viewport_height = state.bitmap.height() as f32; 1318 + let content_viewport_height = (viewport_height - CHROME_HEIGHT).max(0.0); 732 1319 733 1320 let styled = we_style::computed::resolve_styles( 734 1321 &state.page.doc, 735 1322 std::slice::from_ref(&state.page.stylesheet), 736 - (viewport_width, viewport_height), 1323 + (viewport_width, content_viewport_height), 737 1324 )?; 738 1325 739 1326 let mut img_sizes = image_sizes(&state.page.images); ··· 745 1332 &styled, 746 1333 &state.page.doc, 747 1334 viewport_width, 748 - viewport_height, 1335 + content_viewport_height, 749 1336 &state.font, 750 1337 &img_sizes, 751 1338 ); ··· 829 1416 } 830 1417 831 1418 /// Re-render the page and mark the view as needing display. 1419 + /// Handle a click in the browser chrome area. 1420 + fn handle_chrome_click(state: &mut BrowserState, x: f32, y: f32, viewport_width: f32) { 1421 + match hit_test_chrome(x, y, viewport_width) { 1422 + ChromeHit::Back => { 1423 + if state.chrome.focused { 1424 + state.chrome.cancel(&state.page.base_url.serialize()); 1425 + } 1426 + navigate_history(state, -1); 1427 + } 1428 + ChromeHit::Forward => { 1429 + if state.chrome.focused { 1430 + state.chrome.cancel(&state.page.base_url.serialize()); 1431 + } 1432 + navigate_history(state, 1); 1433 + } 1434 + ChromeHit::Reload => { 1435 + if state.chrome.focused { 1436 + state.chrome.cancel(&state.page.base_url.serialize()); 1437 + } 1438 + reload_current_page(state); 1439 + } 1440 + ChromeHit::AddressBar { local_x } => { 1441 + if !state.chrome.focused { 1442 + // First click focuses and selects all. 1443 + state.chrome.focus_and_select_all(); 1444 + } else { 1445 + // Subsequent clicks position cursor. 1446 + let char_width = CHROME_FONT_SIZE * 0.6; 1447 + let char_idx = if char_width > 0.0 { 1448 + (local_x / char_width).round() as usize 1449 + } else { 1450 + 0 1451 + }; 1452 + let byte_pos = chrome_char_to_byte(&state.chrome.text, char_idx); 1453 + state.chrome.cursor = byte_pos; 1454 + state.chrome.selection_anchor = byte_pos; 1455 + } 1456 + // Blur any focused web content element. 1457 + state.page.doc.close_all_selects(); 1458 + state.page.doc.set_active_element(None, false); 1459 + rerender(state); 1460 + } 1461 + ChromeHit::None => { 1462 + // Clicked in chrome but not on any control — unfocus address bar. 1463 + if state.chrome.focused { 1464 + state.chrome.cancel(&state.page.base_url.serialize()); 1465 + rerender(state); 1466 + } 1467 + } 1468 + } 1469 + } 1470 + 1471 + /// Convert a character index to a byte offset in a string. 1472 + fn chrome_char_to_byte(text: &str, char_idx: usize) -> usize { 1473 + text.char_indices() 1474 + .nth(char_idx) 1475 + .map(|(i, _)| i) 1476 + .unwrap_or(text.len()) 1477 + } 1478 + 1479 + /// Navigate to a URL string from the address bar. 1480 + fn navigate_to_address(state: &mut BrowserState, input: &str) { 1481 + let input = input.trim(); 1482 + if input.is_empty() { 1483 + return; 1484 + } 1485 + 1486 + // If it looks like a URL (has scheme or dots), use it directly. 1487 + // Otherwise, prepend https://. 1488 + let url_str = if input.contains("://") || input.starts_with("about:") { 1489 + input.to_string() 1490 + } else { 1491 + format!("https://{input}") 1492 + }; 1493 + 1494 + state.chrome.focused = false; 1495 + navigate_to_link(state, &url_str); 1496 + } 1497 + 1498 + /// Reload the current page. 1499 + fn reload_current_page(state: &mut BrowserState) { 1500 + let url = state.page.base_url.clone(); 1501 + let url_str = url.serialize(); 1502 + navigate_to_link(state, &url_str); 1503 + } 1504 + 832 1505 fn rerender(state: &mut BrowserState) { 833 1506 let viewport_width = state.bitmap.width() as f32; 834 1507 let viewport_height = state.bitmap.height() as f32; ··· 842 1515 viewport_height, 843 1516 state.page_scroll_y, 844 1517 &state.scroll_offsets, 1518 + &state.chrome, 1519 + &state.history, 845 1520 ); 846 1521 state.content_height = content_height; 847 1522 if let ViewKind::Bitmap(bitmap_view) = &state.view { ··· 849 1524 } 850 1525 } 851 1526 1527 + /// Returns true if the chrome address bar is currently focused. 1528 + fn is_chrome_focused() -> bool { 1529 + STATE.with(|state| { 1530 + state 1531 + .borrow() 1532 + .as_ref() 1533 + .map(|s| s.chrome.focused) 1534 + .unwrap_or(false) 1535 + }) 1536 + } 1537 + 1538 + /// Handle keyboard input directed at the browser chrome address bar. 1539 + fn handle_chrome_key( 1540 + state: &mut BrowserState, 1541 + key_code: u16, 1542 + chars: &str, 1543 + mods: appkit::KeyModifiers, 1544 + ) { 1545 + match key_code { 1546 + KEY_CODE_ESCAPE => { 1547 + // Cancel editing and restore URL. 1548 + state.chrome.cancel(&state.page.base_url.serialize()); 1549 + rerender(state); 1550 + } 1551 + KEY_CODE_RETURN => { 1552 + // Navigate to the entered URL. 1553 + let text = state.chrome.text.clone(); 1554 + navigate_to_address(state, &text); 1555 + } 1556 + KEY_CODE_DELETE => { 1557 + if mods.command { 1558 + // Cmd+Backspace: delete to beginning of line. 1559 + let (start, _) = state.chrome.selection_range(); 1560 + let cursor = if state.chrome.has_selection() { 1561 + start 1562 + } else { 1563 + state.chrome.cursor 1564 + }; 1565 + state.chrome.selection_anchor = 0; 1566 + state.chrome.cursor = cursor; 1567 + state.chrome.delete_selection(); 1568 + } else { 1569 + state.chrome.delete_backward(); 1570 + } 1571 + rerender(state); 1572 + } 1573 + KEY_CODE_FORWARD_DELETE => { 1574 + state.chrome.delete_forward(); 1575 + rerender(state); 1576 + } 1577 + KEY_CODE_LEFT => { 1578 + if mods.command { 1579 + state.chrome.move_home(); 1580 + } else { 1581 + state.chrome.move_left(); 1582 + } 1583 + rerender(state); 1584 + } 1585 + KEY_CODE_RIGHT => { 1586 + if mods.command { 1587 + state.chrome.move_end(); 1588 + } else { 1589 + state.chrome.move_right(); 1590 + } 1591 + rerender(state); 1592 + } 1593 + KEY_CODE_HOME => { 1594 + state.chrome.move_home(); 1595 + rerender(state); 1596 + } 1597 + KEY_CODE_END => { 1598 + state.chrome.move_end(); 1599 + rerender(state); 1600 + } 1601 + _ if mods.command && key_code == KEY_CODE_A => { 1602 + state.chrome.select_all(); 1603 + rerender(state); 1604 + } 1605 + _ if mods.command && key_code == KEY_CODE_C => { 1606 + // Copy selection to clipboard. 1607 + if state.chrome.has_selection() { 1608 + let (start, end) = state.chrome.selection_range(); 1609 + let text = &state.chrome.text[start..end]; 1610 + appkit::clipboard_set_string(text); 1611 + } 1612 + } 1613 + _ if mods.command && key_code == KEY_CODE_V => { 1614 + // Paste from clipboard. 1615 + if let Some(text) = appkit::clipboard_get_string() { 1616 + // Strip newlines for single-line address bar. 1617 + let clean: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect(); 1618 + state.chrome.insert(&clean); 1619 + rerender(state); 1620 + } 1621 + } 1622 + _ if mods.command && key_code == KEY_CODE_X => { 1623 + // Cut selection. 1624 + if state.chrome.has_selection() { 1625 + let (start, end) = state.chrome.selection_range(); 1626 + let text = state.chrome.text[start..end].to_string(); 1627 + appkit::clipboard_set_string(&text); 1628 + state.chrome.delete_selection(); 1629 + rerender(state); 1630 + } 1631 + } 1632 + _ => { 1633 + // Insert printable characters (ignore control characters). 1634 + if !mods.command && !mods.control && !chars.is_empty() { 1635 + let first = chars.chars().next().unwrap_or('\0'); 1636 + if !first.is_control() { 1637 + state.chrome.insert(chars); 1638 + rerender(state); 1639 + } 1640 + } 1641 + } 1642 + } 1643 + } 1644 + 852 1645 /// Called by the platform crate on key-down events. 853 1646 fn handle_key_down(key_code: u16, chars: &str, mods: appkit::KeyModifiers) { 1647 + // Cmd+L = focus address bar. 1648 + if mods.command && key_code == KEY_CODE_L { 1649 + STATE.with(|state| { 1650 + let mut state = state.borrow_mut(); 1651 + if let Some(state) = state.as_mut() { 1652 + state.chrome.focus_and_select_all(); 1653 + state.page.doc.close_all_selects(); 1654 + state.page.doc.set_active_element(None, false); 1655 + rerender(state); 1656 + } 1657 + }); 1658 + return; 1659 + } 1660 + 1661 + // Cmd+R = reload current page. 1662 + if mods.command && key_code == KEY_CODE_R { 1663 + STATE.with(|state| { 1664 + let mut state = state.borrow_mut(); 1665 + if let Some(state) = state.as_mut() { 1666 + if state.chrome.focused { 1667 + state.chrome.cancel(&state.page.base_url.serialize()); 1668 + } 1669 + reload_current_page(state); 1670 + } 1671 + }); 1672 + return; 1673 + } 1674 + 1675 + // When the address bar is focused, route keyboard input there. 1676 + if is_chrome_focused() { 1677 + STATE.with(|state| { 1678 + let mut state = state.borrow_mut(); 1679 + if let Some(state) = state.as_mut() { 1680 + handle_chrome_key(state, key_code, chars, mods); 1681 + } 1682 + }); 1683 + return; 1684 + } 1685 + 854 1686 if key_code == KEY_CODE_TAB { 855 1687 handle_tab(mods.shift); 856 1688 return; ··· 1351 2183 }; 1352 2184 1353 2185 let view_x = x as f32; 1354 - let view_y = y as f32 + state.page_scroll_y; 2186 + let view_y_raw = y as f32; 2187 + 2188 + // Check if click is in the chrome area. 2189 + let viewport_width = state.bitmap.width() as f32; 2190 + if view_y_raw < CHROME_HEIGHT { 2191 + handle_chrome_click(state, view_x, view_y_raw, viewport_width); 2192 + return; 2193 + } 2194 + 2195 + // Click is in web content area. Adjust y for content coordinate space: 2196 + // subtract chrome height to get content-relative y, then add scroll offset. 2197 + let content_y = view_y_raw - CHROME_HEIGHT; 2198 + let view_y = content_y + state.page_scroll_y; 2199 + 2200 + // Unfocus address bar when clicking on web content. 2201 + if state.chrome.focused { 2202 + state.chrome.cancel(&state.page.base_url.serialize()); 2203 + } 1355 2204 1356 2205 // First, check if an open dropdown was clicked. 1357 2206 if let Some(open_select) = find_open_select(&state.page.doc) { ··· 1368 2217 } 1369 2218 1370 2219 // Hit-test: find the form control element at (view_x, view_y). 1371 - let viewport_width = state.bitmap.width() as f32; 1372 2220 let viewport_height = state.bitmap.height() as f32; 2221 + let content_viewport_height = (viewport_height - CHROME_HEIGHT).max(0.0); 1373 2222 1374 2223 let styled = match we_style::computed::resolve_styles( 1375 2224 &state.page.doc, 1376 2225 std::slice::from_ref(&state.page.stylesheet), 1377 - (viewport_width, viewport_height), 2226 + (viewport_width, content_viewport_height), 1378 2227 ) { 1379 2228 Some(s) => s, 1380 2229 None => return, ··· 1389 2238 &styled, 1390 2239 &state.page.doc, 1391 2240 viewport_width, 1392 - viewport_height, 2241 + content_viewport_height, 1393 2242 &state.font, 1394 2243 &img_sizes, 1395 2244 ); ··· 1502 2351 // Find the select's screen position to determine which option was clicked. 1503 2352 let viewport_width = state.bitmap.width() as f32; 1504 2353 let viewport_height = state.bitmap.height() as f32; 2354 + let content_viewport_height = (viewport_height - CHROME_HEIGHT).max(0.0); 1505 2355 1506 2356 let styled = match we_style::computed::resolve_styles( 1507 2357 &state.page.doc, 1508 2358 std::slice::from_ref(&state.page.stylesheet), 1509 - (viewport_width, viewport_height), 2359 + (viewport_width, content_viewport_height), 1510 2360 ) { 1511 2361 Some(s) => s, 1512 2362 None => return, ··· 1521 2371 &styled, 1522 2372 &state.page.doc, 1523 2373 viewport_width, 1524 - viewport_height, 2374 + content_viewport_height, 1525 2375 &state.font, 1526 2376 &img_sizes, 1527 2377 ); ··· 1572 2422 Some(s) => s, 1573 2423 None => return, 1574 2424 }; 2425 + 2426 + // Ignore drags in the chrome area. 2427 + if (y as f32) < CHROME_HEIGHT { 2428 + return; 2429 + } 1575 2430 1576 2431 let focused = match state.page.doc.active_element() { 1577 2432 Some(n) if is_text_editable(&state.page.doc, n) => n, ··· 1579 2434 }; 1580 2435 1581 2436 let view_x = x as f32; 1582 - let view_y = y as f32 + state.page_scroll_y; 2437 + let content_y = y as f32 - CHROME_HEIGHT; 2438 + let view_y = content_y + state.page_scroll_y; 1583 2439 let viewport_width = state.bitmap.width() as f32; 1584 2440 let viewport_height = state.bitmap.height() as f32; 2441 + let content_viewport_height = (viewport_height - CHROME_HEIGHT).max(0.0); 1585 2442 1586 2443 let styled = match we_style::computed::resolve_styles( 1587 2444 &state.page.doc, 1588 2445 std::slice::from_ref(&state.page.stylesheet), 1589 - (viewport_width, viewport_height), 2446 + (viewport_width, content_viewport_height), 1590 2447 ) { 1591 2448 Some(s) => s, 1592 2449 None => return, ··· 1601 2458 &styled, 1602 2459 &state.page.doc, 1603 2460 viewport_width, 1604 - viewport_height, 2461 + content_viewport_height, 1605 2462 &state.font, 1606 2463 &img_sizes, 1607 2464 ); ··· 1795 2652 fn compute_element_scroll_y(state: &BrowserState, target: NodeId) -> Option<f32> { 1796 2653 let viewport_width = state.bitmap.width() as f32; 1797 2654 let viewport_height = state.bitmap.height() as f32; 2655 + let content_viewport_height = (viewport_height - CHROME_HEIGHT).max(0.0); 1798 2656 1799 2657 let styled = resolve_styles( 1800 2658 &state.page.doc, 1801 2659 std::slice::from_ref(&state.page.stylesheet), 1802 - (viewport_width, viewport_height), 2660 + (viewport_width, content_viewport_height), 1803 2661 )?; 1804 2662 1805 2663 let mut sizes = image_sizes(&state.page.images); ··· 1811 2669 &styled, 1812 2670 &state.page.doc, 1813 2671 viewport_width, 1814 - viewport_height, 2672 + content_viewport_height, 1815 2673 &state.font, 1816 2674 &sizes, 1817 2675 ); ··· 1852 2710 } else if let Some(target) = find_fragment_target(&state.page.doc, fragment) { 1853 2711 if let Some(y) = compute_element_scroll_y(state, target) { 1854 2712 let viewport_height = state.bitmap.height() as f32; 1855 - let max_scroll = (state.content_height - viewport_height).max(0.0); 2713 + let content_viewport = (viewport_height - CHROME_HEIGHT).max(0.0); 2714 + let max_scroll = (state.content_height - content_viewport).max(0.0); 1856 2715 state.page_scroll_y = y.clamp(0.0, max_scroll); 1857 2716 } 1858 2717 } 1859 2718 // If no matching element, don't scroll (per spec). 2719 + 2720 + // Update chrome address bar. 2721 + state.chrome.set_url(&state.page.base_url.serialize()); 1860 2722 1861 2723 rerender(state); 1862 2724 ··· 1931 2793 state.page = new_page; 1932 2794 state.page_scroll_y = 0.0; 1933 2795 state.scroll_offsets.clear(); 2796 + 2797 + // Update chrome address bar with new URL. 2798 + state.chrome.set_url(&state.page.base_url.serialize()); 2799 + 1934 2800 rerender(state); 1935 2801 1936 2802 // Process any History API commands queued during page script execution. ··· 1960 2826 let new_url_str = entry.url.serialize(); 1961 2827 state.page.base_url = entry.url; 1962 2828 state.page_scroll_y = entry.scroll_y; 2829 + state.chrome.set_url(&state.page.base_url.serialize()); 1963 2830 rerender(state); 1964 2831 eprintln!("[we] hashchange: oldURL={old_url_str}, newURL={new_url_str}"); 1965 2832 return; ··· 1979 2846 state.page = new_page; 1980 2847 state.page_scroll_y = entry.scroll_y; 1981 2848 state.scroll_offsets.clear(); 2849 + state.chrome.set_url(&state.page.base_url.serialize()); 1982 2850 rerender(state); 1983 2851 1984 2852 // Process any History API commands queued during page script execution. ··· 2060 2928 2061 2929 let viewport_width = state.bitmap.width() as f32; 2062 2930 let viewport_height = state.bitmap.height() as f32; 2063 - let max_scroll = (state.content_height - viewport_height).max(0.0); 2931 + let content_viewport = (viewport_height - CHROME_HEIGHT).max(0.0); 2932 + let max_scroll = (state.content_height - content_viewport).max(0.0); 2064 2933 2065 2934 // Apply scroll delta (negative dy = scroll down). 2066 2935 state.page_scroll_y = (state.page_scroll_y - dy as f32).clamp(0.0, max_scroll); ··· 2075 2944 viewport_height, 2076 2945 state.page_scroll_y, 2077 2946 &state.scroll_offsets, 2947 + &state.chrome, 2948 + &state.history, 2078 2949 ); 2079 2950 state.content_height = content_height; 2080 2951 ··· 2675 3546 ViewKind::Bitmap(bitmap_view) 2676 3547 }; 2677 3548 3549 + // Initialise chrome state with the initial URL. 3550 + let chrome = ChromeState::new(&page.base_url.serialize()); 3551 + 3552 + // Initialise session history with the first page's URL. 3553 + let history = NavigationHistory::new(page.base_url.clone()); 3554 + 2678 3555 // Initial render. 2679 3556 let content_height = render_page( 2680 3557 &page, ··· 2686 3563 600.0, 2687 3564 0.0, 2688 3565 &scroll_offsets, 3566 + &chrome, 3567 + &history, 2689 3568 ); 2690 3569 2691 - // Initialise session history with the first page's URL. 2692 - let history = NavigationHistory::new(page.base_url.clone()); 2693 - 2694 3570 // Store state for the resize handler. 2695 3571 STATE.with(|state| { 2696 3572 *state.borrow_mut() = Some(BrowserState { ··· 2703 3579 content_height, 2704 3580 scroll_offsets, 2705 3581 history, 3582 + chrome, 2706 3583 }); 2707 3584 }); 2708 3585 ··· 3134 4011 let loaded = error_page("Something went wrong"); 3135 4012 assert!(loaded.text.contains("Something went wrong")); 3136 4013 assert_eq!(loaded.base_url.serialize(), "about:blank"); 4014 + } 4015 + 4016 + // ----------------------------------------------------------------------- 4017 + // ChromeState tests 4018 + // ----------------------------------------------------------------------- 4019 + 4020 + #[test] 4021 + fn chrome_state_new() { 4022 + let chrome = ChromeState::new("https://example.com/"); 4023 + assert_eq!(chrome.text, "https://example.com/"); 4024 + assert!(!chrome.focused); 4025 + assert_eq!(chrome.cursor, 0); 4026 + assert_eq!(chrome.selection_anchor, 0); 4027 + } 4028 + 4029 + #[test] 4030 + fn chrome_state_focus_and_select_all() { 4031 + let mut chrome = ChromeState::new("https://example.com/"); 4032 + chrome.focus_and_select_all(); 4033 + assert!(chrome.focused); 4034 + assert_eq!(chrome.selection_anchor, 0); 4035 + assert_eq!(chrome.cursor, chrome.text.len()); 4036 + assert!(chrome.has_selection()); 4037 + } 4038 + 4039 + #[test] 4040 + fn chrome_state_cancel_restores_url() { 4041 + let mut chrome = ChromeState::new("https://example.com/"); 4042 + chrome.focus_and_select_all(); 4043 + chrome.insert("https://other.com/"); 4044 + assert_eq!(chrome.text, "https://other.com/"); 4045 + 4046 + chrome.cancel("https://example.com/"); 4047 + assert!(!chrome.focused); 4048 + assert_eq!(chrome.text, "https://example.com/"); 4049 + } 4050 + 4051 + #[test] 4052 + fn chrome_state_insert_replaces_selection() { 4053 + let mut chrome = ChromeState::new("https://example.com/"); 4054 + chrome.focus_and_select_all(); 4055 + chrome.insert("hello"); 4056 + assert_eq!(chrome.text, "hello"); 4057 + assert_eq!(chrome.cursor, 5); 4058 + assert!(!chrome.has_selection()); 4059 + } 4060 + 4061 + #[test] 4062 + fn chrome_state_delete_backward() { 4063 + let mut chrome = ChromeState::new("abc"); 4064 + chrome.cursor = 3; 4065 + chrome.selection_anchor = 3; 4066 + chrome.delete_backward(); 4067 + assert_eq!(chrome.text, "ab"); 4068 + assert_eq!(chrome.cursor, 2); 4069 + } 4070 + 4071 + #[test] 4072 + fn chrome_state_delete_backward_at_start() { 4073 + let mut chrome = ChromeState::new("abc"); 4074 + chrome.cursor = 0; 4075 + chrome.selection_anchor = 0; 4076 + chrome.delete_backward(); 4077 + assert_eq!(chrome.text, "abc"); // No change. 4078 + } 4079 + 4080 + #[test] 4081 + fn chrome_state_delete_forward() { 4082 + let mut chrome = ChromeState::new("abc"); 4083 + chrome.cursor = 0; 4084 + chrome.selection_anchor = 0; 4085 + chrome.delete_forward(); 4086 + assert_eq!(chrome.text, "bc"); 4087 + assert_eq!(chrome.cursor, 0); 4088 + } 4089 + 4090 + #[test] 4091 + fn chrome_state_delete_forward_at_end() { 4092 + let mut chrome = ChromeState::new("abc"); 4093 + chrome.cursor = 3; 4094 + chrome.selection_anchor = 3; 4095 + chrome.delete_forward(); 4096 + assert_eq!(chrome.text, "abc"); // No change. 4097 + } 4098 + 4099 + #[test] 4100 + fn chrome_state_move_left_right() { 4101 + let mut chrome = ChromeState::new("abc"); 4102 + chrome.cursor = 1; 4103 + chrome.selection_anchor = 1; 4104 + 4105 + chrome.move_right(); 4106 + assert_eq!(chrome.cursor, 2); 4107 + 4108 + chrome.move_left(); 4109 + assert_eq!(chrome.cursor, 1); 4110 + } 4111 + 4112 + #[test] 4113 + fn chrome_state_move_left_collapses_selection() { 4114 + let mut chrome = ChromeState::new("abcdef"); 4115 + chrome.selection_anchor = 1; 4116 + chrome.cursor = 4; 4117 + assert!(chrome.has_selection()); 4118 + 4119 + chrome.move_left(); 4120 + assert_eq!(chrome.cursor, 1); // Collapses to start. 4121 + assert!(!chrome.has_selection()); 4122 + } 4123 + 4124 + #[test] 4125 + fn chrome_state_move_right_collapses_selection() { 4126 + let mut chrome = ChromeState::new("abcdef"); 4127 + chrome.selection_anchor = 1; 4128 + chrome.cursor = 4; 4129 + 4130 + chrome.move_right(); 4131 + assert_eq!(chrome.cursor, 4); // Collapses to end. 4132 + assert!(!chrome.has_selection()); 4133 + } 4134 + 4135 + #[test] 4136 + fn chrome_state_move_home_end() { 4137 + let mut chrome = ChromeState::new("hello"); 4138 + chrome.cursor = 3; 4139 + chrome.selection_anchor = 3; 4140 + 4141 + chrome.move_home(); 4142 + assert_eq!(chrome.cursor, 0); 4143 + 4144 + chrome.move_end(); 4145 + assert_eq!(chrome.cursor, 5); 4146 + } 4147 + 4148 + #[test] 4149 + fn chrome_state_select_all() { 4150 + let mut chrome = ChromeState::new("hello"); 4151 + chrome.select_all(); 4152 + assert_eq!(chrome.selection_anchor, 0); 4153 + assert_eq!(chrome.cursor, 5); 4154 + assert!(chrome.has_selection()); 4155 + } 4156 + 4157 + #[test] 4158 + fn chrome_state_selection_range() { 4159 + let mut chrome = ChromeState::new("abcdef"); 4160 + chrome.selection_anchor = 4; 4161 + chrome.cursor = 1; 4162 + let (start, end) = chrome.selection_range(); 4163 + assert_eq!(start, 1); 4164 + assert_eq!(end, 4); 4165 + } 4166 + 4167 + #[test] 4168 + fn chrome_state_delete_selection() { 4169 + let mut chrome = ChromeState::new("abcdef"); 4170 + chrome.selection_anchor = 1; 4171 + chrome.cursor = 4; 4172 + chrome.delete_selection(); 4173 + assert_eq!(chrome.text, "aef"); 4174 + assert_eq!(chrome.cursor, 1); 4175 + assert!(!chrome.has_selection()); 4176 + } 4177 + 4178 + #[test] 4179 + fn chrome_state_set_url_when_not_focused() { 4180 + let mut chrome = ChromeState::new("https://old.com/"); 4181 + chrome.set_url("https://new.com/"); 4182 + assert_eq!(chrome.text, "https://new.com/"); 4183 + } 4184 + 4185 + #[test] 4186 + fn chrome_state_set_url_ignored_when_focused() { 4187 + let mut chrome = ChromeState::new("https://old.com/"); 4188 + chrome.focused = true; 4189 + chrome.set_url("https://new.com/"); 4190 + assert_eq!(chrome.text, "https://old.com/"); // Unchanged. 4191 + } 4192 + 4193 + #[test] 4194 + fn chrome_state_multibyte_chars() { 4195 + let mut chrome = ChromeState::new("café"); 4196 + assert_eq!(chrome.text.len(), 5); // 'é' is 2 bytes in UTF-8. 4197 + chrome.cursor = 5; 4198 + chrome.selection_anchor = 5; 4199 + chrome.delete_backward(); 4200 + assert_eq!(chrome.text, "caf"); 4201 + assert_eq!(chrome.cursor, 3); 4202 + } 4203 + 4204 + // ----------------------------------------------------------------------- 4205 + // Chrome hit-test tests 4206 + // ----------------------------------------------------------------------- 4207 + 4208 + #[test] 4209 + fn hit_test_chrome_back_button() { 4210 + let x = CHROME_LEFT_MARGIN + CHROME_BUTTON_WIDTH / 2.0; 4211 + let y = CHROME_HEIGHT / 2.0; 4212 + assert!(matches!(hit_test_chrome(x, y, 800.0), ChromeHit::Back)); 4213 + } 4214 + 4215 + #[test] 4216 + fn hit_test_chrome_forward_button() { 4217 + let x = CHROME_LEFT_MARGIN 4218 + + CHROME_BUTTON_WIDTH 4219 + + CHROME_BUTTON_GAP 4220 + + CHROME_BUTTON_WIDTH / 2.0; 4221 + let y = CHROME_HEIGHT / 2.0; 4222 + assert!(matches!(hit_test_chrome(x, y, 800.0), ChromeHit::Forward)); 4223 + } 4224 + 4225 + #[test] 4226 + fn hit_test_chrome_reload_button() { 4227 + let x = CHROME_LEFT_MARGIN 4228 + + CHROME_BUTTON_WIDTH 4229 + + CHROME_BUTTON_GAP 4230 + + CHROME_BUTTON_WIDTH 4231 + + CHROME_BUTTON_GAP 4232 + + CHROME_BUTTON_WIDTH / 2.0; 4233 + let y = CHROME_HEIGHT / 2.0; 4234 + assert!(matches!(hit_test_chrome(x, y, 800.0), ChromeHit::Reload)); 4235 + } 4236 + 4237 + #[test] 4238 + fn hit_test_chrome_address_bar() { 4239 + let addr_x = chrome_address_bar_x(); 4240 + let x = addr_x + 50.0; 4241 + let y = CHROME_HEIGHT / 2.0; 4242 + assert!(matches!( 4243 + hit_test_chrome(x, y, 800.0), 4244 + ChromeHit::AddressBar { .. } 4245 + )); 4246 + } 4247 + 4248 + #[test] 4249 + fn hit_test_chrome_below_bar_returns_none() { 4250 + assert!(matches!( 4251 + hit_test_chrome(100.0, CHROME_HEIGHT + 1.0, 800.0), 4252 + ChromeHit::None 4253 + )); 4254 + } 4255 + 4256 + // ----------------------------------------------------------------------- 4257 + // chrome_char_to_byte tests 4258 + // ----------------------------------------------------------------------- 4259 + 4260 + #[test] 4261 + fn char_to_byte_ascii() { 4262 + assert_eq!(chrome_char_to_byte("hello", 0), 0); 4263 + assert_eq!(chrome_char_to_byte("hello", 3), 3); 4264 + assert_eq!(chrome_char_to_byte("hello", 5), 5); 4265 + assert_eq!(chrome_char_to_byte("hello", 10), 5); // Beyond end. 4266 + } 4267 + 4268 + #[test] 4269 + fn char_to_byte_multibyte() { 4270 + let s = "café"; 4271 + assert_eq!(chrome_char_to_byte(s, 0), 0); 4272 + assert_eq!(chrome_char_to_byte(s, 3), 3); // 'é' starts at byte 3. 4273 + assert_eq!(chrome_char_to_byte(s, 4), 5); // Past 'é' (2 bytes). 4274 + } 4275 + 4276 + // ----------------------------------------------------------------------- 4277 + // Chrome display list tests 4278 + // ----------------------------------------------------------------------- 4279 + 4280 + #[test] 4281 + fn chrome_display_list_contains_fill_rects() { 4282 + let chrome = ChromeState::new("https://example.com/"); 4283 + let history = NavigationHistory::new(Url::parse("https://example.com/").unwrap()); 4284 + let font_path = std::path::Path::new("/System/Library/Fonts/Geneva.ttf"); 4285 + if !font_path.exists() { 4286 + return; // Skip if font not available (e.g., CI). 4287 + } 4288 + let font = Font::from_file(font_path).unwrap(); 4289 + let list = build_chrome_display_list(&chrome, &history, &font, 800.0); 4290 + 4291 + // Should contain at least the chrome background rect. 4292 + let has_bg = list.iter().any(|cmd| { 4293 + matches!(cmd, 4294 + PaintCommand::FillRect { x, y, width, height, .. } 4295 + if *x == 0.0 && *y == 0.0 && *width == 800.0 && *height == CHROME_HEIGHT 4296 + ) 4297 + }); 4298 + assert!(has_bg, "Chrome display list should have background rect"); 4299 + } 4300 + 4301 + #[test] 4302 + fn chrome_display_list_has_text() { 4303 + let chrome = ChromeState::new("https://test.com/"); 4304 + let history = NavigationHistory::new(Url::parse("https://test.com/").unwrap()); 4305 + let font_path = std::path::Path::new("/System/Library/Fonts/Geneva.ttf"); 4306 + if !font_path.exists() { 4307 + return; 4308 + } 4309 + let font = Font::from_file(font_path).unwrap(); 4310 + let list = build_chrome_display_list(&chrome, &history, &font, 800.0); 4311 + 4312 + let has_url_text = list.iter().any(|cmd| { 4313 + matches!(cmd, 4314 + PaintCommand::DrawGlyphs { line, .. } if line.text == "https://test.com/" 4315 + ) 4316 + }); 4317 + assert!(has_url_text, "Chrome should render the URL text"); 3137 4318 } 3138 4319 }