diff --git a/.isu/issues.json b/.isu/issues.json index f5a57a1..9d8648e 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -1,5 +1,5 @@ { - "next_id": 309, + "next_id": 311, "issues": [ { "id": 1, @@ -3749,6 +3749,28 @@ "author": "piefev", "state": "open", "created_at": "2026-05-28T23:27:04Z" + }, + { + "id": 309, + "repo": "we", + "title": "fix pixelated/glitchy font rendering", + "body": null, + "labels": [], + "assigned": [], + "author": "piefev", + "state": "closed", + "created_at": "2026-05-29T12:16:44Z" + }, + { + "id": 310, + "repo": "we", + "title": "Render browser chrome as HTML/CSS/SVG; add border-radius + HiDPI/antialiased rendering", + "body": "Re-architect the toolbar (nav buttons + address bar) to render through the engine's own parse->style->layout->paint pipeline as an HTML/CSS document with inline SVG icons, instead of hand-built PaintCommands. Includes engine work: CSS border-radius; device-resolution (HiDPI) rendering; supersampled/antialiased SVG + scale-correct SVG text; SVG viewBox attribute case fix; GPU text-baseline fix; input vertical centering and glyph-accurate caret.", + "labels": [], + "assigned": [], + "author": "piefev", + "state": "closed", + "created_at": "2026-05-29T14:47:52Z" } ] } diff --git a/crates/browser/src/chrome_ui.rs b/crates/browser/src/chrome_ui.rs new file mode 100644 index 0000000..2052a6b --- /dev/null +++ b/crates/browser/src/chrome_ui.rs @@ -0,0 +1,177 @@ +//! Browser-chrome markup generation. +//! +//! The toolbar (navigation buttons + address bar) is rendered by the engine +//! itself: this module produces the HTML document and CSS for it, with inline +//! SVG icons. `main.rs` parses the document, seeds the address-bar `` +//! with the live edit state, lays it out, and composites it above the page — +//! so the browser's own UI exercises the same parse → style → layout → paint +//! pipeline as any web page. + +/// Toolbar height in CSS px. Mirrors the content offset applied to the page. +pub const CHROME_HEIGHT: f32 = 40.0; + +/// Font size (px) for the address-bar text. Kept in sync with the CSS below so +/// caret hit-testing can reshape the same glyphs. +pub const CHROME_FONT_SIZE: f32 = 14.0; + +/// Stable element ids for the interactive chrome controls. +pub const ID_BACK: &str = "back"; +pub const ID_FORWARD: &str = "forward"; +pub const ID_RELOAD: &str = "reload"; +pub const ID_URLBAR: &str = "urlbar"; + +/// Author stylesheet for the chrome document (macOS Tahoe-ish styling). The +/// UA stylesheet is added automatically by `resolve_styles`. +pub const CHROME_CSS: &str = "\ +html, body { margin: 0; padding: 0; } +#bar { + display: flex; + align-items: center; + height: 40px; + background: #f6f6f8; + padding: 0 8px; +} +.btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 28px; + margin-right: 4px; + border-radius: 8px; +} +#reload { margin-right: 8px; } +#urlbar { + flex-grow: 1; + height: 28px; + font-size: 14px; + color: #28282a; + background: #ffffff; + border: 1px solid rgba(0,0,0,0.22); + border-radius: 14px; + padding: 0 12px; +} +"; + +// Material-style filled icon path data (24x24 viewBox). +const PATH_BACK: &str = "M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"; +const PATH_FORWARD: &str = "M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"; +const PATH_STOP: &str = + "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 \ + 17.59 13.41 12z"; +const PATH_RELOAD: &str = "M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 \ + 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 \ + 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"; + +/// Enabled (dark) and disabled (dim) icon fill colors. +const ICON_ENABLED: &str = "#28282a"; +const ICON_DISABLED: &str = "#bcbcc0"; + +/// The faint pill behind an enabled nav button; disabled buttons show no pill. +const PILL_ENABLED: &str = "background: rgba(0,0,0,0.06);"; +const PILL_NONE: &str = "background: transparent;"; + +fn svg_icon(path: &str, fill: &str) -> String { + format!( + "\ +" + ) +} + +fn nav_button(id: &str, path: &str, enabled: bool) -> String { + let fill = if enabled { ICON_ENABLED } else { ICON_DISABLED }; + let pill = if enabled { PILL_ENABLED } else { PILL_NONE }; + format!( + "
{}
", + svg_icon(path, fill) + ) +} + +/// Escape a string for use inside a double-quoted HTML attribute value. +fn attr_escape(s: &str) -> String { + s.replace('&', "&") + .replace('"', """) + .replace('<', "<") +} + +/// Build the chrome document HTML for the given state. +/// +/// `loading` selects the reload vs. stop glyph; `can_back`/`can_forward` dim +/// the history buttons; `url` seeds the address-bar value (the live edit state +/// is layered on top via the input's `InputState` at render time, but the +/// attribute keeps the markup self-describing and testable). +pub fn chrome_html(url: &str, loading: bool, can_back: bool, can_forward: bool) -> String { + let back = nav_button(ID_BACK, PATH_BACK, can_back); + let forward = nav_button(ID_FORWARD, PATH_FORWARD, can_forward); + let reload_inner = if loading { + svg_icon(PATH_STOP, ICON_ENABLED) + } else { + svg_icon(PATH_RELOAD, ICON_ENABLED) + }; + let reload = format!( + "
{reload_inner}
" + ); + format!( + "
\ +{back}{forward}{reload}\ +
", + attr_escape(url), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn html_contains_controls_and_url() { + let html = chrome_html("https://example.com/", false, true, false); + assert!(html.contains("id=\"back\"")); + assert!(html.contains("id=\"forward\"")); + assert!(html.contains("id=\"reload\"")); + assert!(html.contains("id=\"urlbar\"")); + assert!(html.contains("value=\"https://example.com/\"")); + // Not loading → reload glyph, not the stop glyph. + assert!(html.contains(PATH_RELOAD)); + assert!(!html.contains(PATH_STOP)); + } + + #[test] + fn loading_shows_stop_glyph() { + let html = chrome_html("about:blank", true, false, false); + assert!(html.contains(PATH_STOP)); + assert!(!html.contains(PATH_RELOAD)); + } + + #[test] + fn disabled_buttons_have_no_pill() { + // Back disabled (transparent pill), reload always enabled (filled pill). + let html = chrome_html("about:blank", false, false, true); + // The back button div precedes its svg; check the transparent pill is present. + assert!(html.contains("background: transparent;")); + assert!(html.contains("background: rgba(0,0,0,0.06);")); + } + + /// Optional: set `WE_CHROME_HTML=/path.html` to dump a renderable chrome + /// document (CSS inlined as `"), + ); + std::fs::write(&out, doc).unwrap(); + } + + #[test] + fn url_is_attribute_escaped() { + let html = chrome_html("https://x/?a=1&b=\"2\"", false, true, true); + assert!(html.contains("a=1&b="2"")); + assert!(!html.contains("b=\"2\"")); + } +} diff --git a/crates/browser/src/lib.rs b/crates/browser/src/lib.rs index efa53b4..2058212 100644 --- a/crates/browser/src/lib.rs +++ b/crates/browser/src/lib.rs @@ -3,6 +3,7 @@ pub mod browsing_context; pub mod cache_storage; pub mod chrome; +pub mod chrome_ui; pub mod csp; pub mod css_loader; pub mod font_loader; diff --git a/crates/browser/src/main.rs b/crates/browser/src/main.rs index 1b67cd3..d7a01de 100644 --- a/crates/browser/src/main.rs +++ b/crates/browser/src/main.rs @@ -13,18 +13,16 @@ use we_browser::loader::{LoadError, Resource, ResourceLoader}; use we_browser::navigation_history::NavigationHistory; use we_browser::script_loader::{drain_event_loop_and_dynamic_scripts, execute_scripts_into_vm}; use we_css::parser::Stylesheet; -use we_css::values::Color; use we_dom::{Document, NodeData, NodeId}; use we_html::parse_html; use we_image::pixel::Image; use we_layout::layout; -use we_layout::TextLine; use we_net::referrer::ReferrerPolicy; use we_platform::appkit; use we_platform::metal::ClearColor; use we_render::{build_display_list_with_page_scroll, GpuRenderer, PaintCommand, ScrollState}; -use we_style::computed::{resolve_styles, TextDecoration}; -use we_svg::{render_svg, svg_intrinsic_size}; +use we_style::computed::resolve_styles; +use we_svg::{render_svg_scaled, svg_intrinsic_size}; use we_text::font::{self, Font, FontRegistry}; use we_url::Url; @@ -45,48 +43,13 @@ const DEFAULT_START_PAGE_HTML: &str = "\ \ "; -/// Height of the browser chrome bar in pixels. +/// Height of the browser chrome bar in pixels. Mirrors +/// [`we_browser::chrome_ui::CHROME_HEIGHT`]. const CHROME_HEIGHT: f32 = 40.0; -/// Font size for chrome UI text (URL bar contents). -const CHROME_FONT_SIZE: f32 = 13.0; -/// Font size for chrome icon glyphs (back/forward/reload arrows). -const CHROME_ICON_FONT_SIZE: f32 = CHROME_FONT_SIZE + 2.0; -/// Padding inside the address bar. -const CHROME_PADDING: f32 = 8.0; -/// Width of each navigation button. -const CHROME_BUTTON_WIDTH: f32 = 32.0; -/// Height of each navigation button. -const CHROME_BUTTON_HEIGHT: f32 = 28.0; -/// Gap between buttons. -const CHROME_BUTTON_GAP: f32 = 4.0; -/// Left margin for chrome content. -const CHROME_LEFT_MARGIN: f32 = 8.0; -/// Right margin for chrome content. -const CHROME_RIGHT_MARGIN: f32 = 8.0; -/// Corner radius for the soft pill behind each navigation button (macOS Tahoe -/// style rounded controls). -const CHROME_BUTTON_RADIUS: f32 = 8.0; - -// macOS Tahoe chrome palette. Controls are light, softly rounded, and lean on -// faint translucent tints rather than hard 1px borders. -/// Toolbar background fill. -const CHROME_BAR_BG: Color = Color::new(246, 246, 248, 255); -/// Hairline separating the toolbar from page content. -const CHROME_BAR_SEPARATOR: Color = Color::new(0, 0, 0, 20); -/// Faint pill behind an enabled navigation button. -const CHROME_BUTTON_BG: Color = Color::new(0, 0, 0, 13); -/// Glyph colour for an enabled navigation button. -const CHROME_ICON_ENABLED: Color = Color::new(40, 40, 42, 255); -/// Glyph colour for a disabled navigation button. -const CHROME_ICON_DISABLED: Color = Color::new(188, 188, 192, 255); -/// Address-bar field fill. -const CHROME_ADDR_BG: Color = Color::new(255, 255, 255, 255); -/// Address-bar hairline border when unfocused. -const CHROME_ADDR_BORDER: Color = Color::new(0, 0, 0, 30); -/// Accent colour for the focus ring, caret, and selection. -const CHROME_ACCENT: Color = Color::new(0, 122, 255, 255); -/// Address-bar text colour. -const CHROME_TEXT: Color = Color::new(40, 40, 42, 255); +/// Font size for the address-bar text; must match the `#urlbar` `font-size` in +/// [`we_browser::chrome_ui::CHROME_CSS`] so caret hit-testing reshapes the same +/// glyphs the engine paints. +const CHROME_FONT_SIZE: f32 = 14.0; // --------------------------------------------------------------------------- // Page state: holds everything needed to re-render without re-fetching @@ -515,10 +478,11 @@ fn collect_canvas_images(doc: &Document) -> HashMap { images } -/// Rasterize all SVG elements in the DOM to RGBA images. -fn rasterize_svgs(doc: &Document, font: &Font) -> HashMap { +/// Rasterize all SVG elements in the DOM to RGBA images at `scale` device +/// pixels per logical unit, so they stay crisp on Retina displays. +fn rasterize_svgs(doc: &Document, font: &Font, scale: f32) -> HashMap { let mut images = HashMap::new(); - collect_and_rasterize_svgs(doc, doc.root(), font, &mut images); + collect_and_rasterize_svgs(doc, doc.root(), font, scale, &mut images); images } @@ -526,6 +490,7 @@ fn collect_and_rasterize_svgs( doc: &Document, node: NodeId, font: &Font, + scale: f32, images: &mut HashMap, ) { if let NodeData::Element { @@ -535,7 +500,7 @@ fn collect_and_rasterize_svgs( } = *doc.node_data(node) { if tag_name == "svg" && namespace.as_deref() == Some("http://www.w3.org/2000/svg") { - if let Some((w, h, data)) = render_svg(doc, node, Some(font)) { + if let Some((w, h, data)) = render_svg_scaled(doc, node, Some(font), scale) { images.insert( node, Image { @@ -549,38 +514,10 @@ fn collect_and_rasterize_svgs( } } for child in doc.children(node) { - collect_and_rasterize_svgs(doc, child, font, images); + collect_and_rasterize_svgs(doc, child, font, scale, images); } } -/// Compute the shared baseline y for all chrome text. -/// -/// Both the address bar text and the button icons must sit on this baseline -/// so that their visual centers line up across the bar. The baseline is -/// chosen to vertically centre the cap-height region of the URL text within -/// the address bar; we approximate cap height as `font_size * 0.8`, which -/// holds for typical sans-serif system fonts. -fn chrome_text_baseline_y() -> f32 { - let button_y = (CHROME_HEIGHT - CHROME_BUTTON_HEIGHT) / 2.0; - let cap_height = CHROME_FONT_SIZE * 0.8; - button_y + (CHROME_BUTTON_HEIGHT + cap_height) / 2.0 -} - -/// Convert a shared baseline y into the `TextLine.y` ("line top") expected by -/// the renderer, which places the baseline at `line.y + font_size`. -fn line_y_for_baseline(baseline_y: f32, font_size: f32) -> f32 { - baseline_y - font_size -} - -/// Measure the rendered width of `text` in pixels at the given size, using -/// the shaped glyph advances from `font`. -fn measure_chrome_text_width(font: &Font, text: &str, font_size: f32) -> f32 { - font.shape_text(text, font_size) - .iter() - .map(|g| g.x_advance) - .sum() -} - /// Caret x-offsets (relative to the text origin) for every character boundary /// in `text`, computed from the font's real shaped glyph advances (and the /// kerning baked into each glyph's `x_offset`) so the caret and selection @@ -607,7 +544,9 @@ fn chrome_caret_offsets(font: &Font, text: &str, font_size: f32) -> Vec { } /// The caret x-offset (relative to the text origin) for the caret sitting at -/// byte position `byte_pos` within `text`, using real glyph metrics. +/// byte position `byte_pos` within `text`, using real glyph metrics. Used by +/// the round-trip test for [`chrome_byte_pos_at_offset`]. +#[cfg(test)] fn chrome_caret_x_at_byte(font: &Font, text: &str, byte_pos: usize, font_size: f32) -> f32 { let offsets = chrome_caret_offsets(font, text, font_size); let char_idx = text[..byte_pos.min(text.len())].chars().count(); @@ -635,423 +574,138 @@ fn chrome_byte_pos_at_offset(font: &Font, text: &str, offset_x: f32, font_size: text.len() } -/// Build a display list for the browser chrome UI (address bar, nav buttons). -fn build_chrome_display_list( +/// Hit-test the chrome area. Returns a `ChromeHit` describing what was clicked. +#[derive(Debug)] +enum ChromeHit { + Back, + Forward, + Reload, + AddressBar { local_x: f32 }, + None, +} + +fn hit_test_chrome( chrome: &ChromeState, history: &NavigationHistory, font: &Font, - viewport_width: f32, -) -> Vec { - let mut list = Vec::new(); - - // Toolbar background bar. - list.push(PaintCommand::FillRect { - x: 0.0, - y: 0.0, - width: viewport_width, - height: CHROME_HEIGHT, - color: CHROME_BAR_BG, - }); - - // Hairline separating the toolbar from page content. - list.push(PaintCommand::FillRect { - x: 0.0, - y: CHROME_HEIGHT - 1.0, - width: viewport_width, - height: 1.0, - color: CHROME_BAR_SEPARATOR, - }); - - let button_y = (CHROME_HEIGHT - CHROME_BUTTON_HEIGHT) / 2.0; - // The URL text uses cap-region centering so its baseline is stable - // regardless of whether the URL contains descenders. Button icons are - // symbols (arrows, reload, stop) that are *not* cap-region glyphs, so they - // are centered by their actual ink box inside `push_chrome_button`. - let text_line_y = line_y_for_baseline(chrome_text_baseline_y(), CHROME_FONT_SIZE); - let mut x = CHROME_LEFT_MARGIN; - - // -- Back button -- - let can_back = history.can_go_back(); - push_chrome_button(&mut list, x, button_y, font, "\u{2190}", can_back); - - x += CHROME_BUTTON_WIDTH + CHROME_BUTTON_GAP; - - // -- Forward button -- - let can_forward = history.can_go_forward(); - push_chrome_button(&mut list, x, button_y, font, "\u{2192}", can_forward); - - x += CHROME_BUTTON_WIDTH + CHROME_BUTTON_GAP; - - // -- Reload / stop button (always actionable) -- - let reload_label = if chrome.loading { - "\u{2715}" // ✕ (stop) - } else { - "\u{21BB}" // ↻ (reload) - }; - push_chrome_button(&mut list, x, button_y, font, reload_label, true); - - x += CHROME_BUTTON_WIDTH + CHROME_BUTTON_GAP + 4.0; - - // -- Address bar -- a macOS Tahoe style capsule. - let addr_bar_x = x; - let addr_bar_width = viewport_width - addr_bar_x - CHROME_RIGHT_MARGIN; - let addr_bar_y = button_y; - let addr_bar_height = CHROME_BUTTON_HEIGHT; - let radius = addr_bar_height / 2.0; - - if chrome.focused { - // Accent focus ring drawn as a concentric capsule under the field. - push_rounded_rect( - &mut list, - addr_bar_x - 2.0, - addr_bar_y - 2.0, - addr_bar_width + 4.0, - addr_bar_height + 4.0, - radius + 2.0, - CHROME_ACCENT, - ); - } else { - // Subtle hairline border drawn as a concentric capsule under the field. - push_rounded_rect( - &mut list, - addr_bar_x - 1.0, - addr_bar_y - 1.0, - addr_bar_width + 2.0, - addr_bar_height + 2.0, - radius + 1.0, - CHROME_ADDR_BORDER, - ); - } - push_rounded_rect( - &mut list, - addr_bar_x, - addr_bar_y, - addr_bar_width, - addr_bar_height, - radius, - CHROME_ADDR_BG, - ); - - // Address bar text, clipped to the bar interior. - let text_x = addr_bar_x + CHROME_PADDING; - let clip_x = addr_bar_x + 1.0; - let clip_width = addr_bar_width - 2.0; - - list.push(PaintCommand::PushClip { - x: clip_x, - y: addr_bar_y + 1.0, - width: clip_width, - height: addr_bar_height - 2.0, - }); - - // Draw selection highlight if there is one. The highlight edges are - // placed using the font's real glyph advances so the box hugs exactly the - // selected characters of the rendered URL. - if chrome.focused && chrome.has_selection() { - let (sel_start, sel_end) = chrome.selection_range(); - let sel_x = - text_x + chrome_caret_x_at_byte(font, &chrome.text, sel_start, CHROME_FONT_SIZE); - let sel_end_x = - text_x + chrome_caret_x_at_byte(font, &chrome.text, sel_end, CHROME_FONT_SIZE); - list.push(PaintCommand::FillRect { - x: sel_x, - y: addr_bar_y + 2.0, - width: sel_end_x - sel_x, - height: addr_bar_height - 4.0, - color: Color::new(CHROME_ACCENT.r, CHROME_ACCENT.g, CHROME_ACCENT.b, 60), - }); - } - - // Address bar text. Its baseline is cap-region centered in the bar. - list.push(PaintCommand::DrawGlyphs { - line: TextLine { - text: chrome.text.clone(), - x: text_x, - y: text_line_y, - width: clip_width, - font_size: CHROME_FONT_SIZE, - color: CHROME_TEXT, - text_decoration: TextDecoration::None, - background_color: Color::new(0, 0, 0, 0), - bold: false, - italic: false, - }, - font_size: CHROME_FONT_SIZE, - color: CHROME_TEXT, - }); - - // Draw cursor when focused. The caret x is derived from real glyph - // advances so it lands exactly between the rendered characters. - if chrome.focused { - let cursor_x = - text_x + chrome_caret_x_at_byte(font, &chrome.text, chrome.cursor, CHROME_FONT_SIZE); - list.push(PaintCommand::FillRect { - x: cursor_x, - y: addr_bar_y + 3.0, - width: 1.0, - height: addr_bar_height - 6.0, - color: CHROME_TEXT, - }); - } - - list.push(PaintCommand::PopClip); - - list -} - -/// Append paint commands approximating a filled rounded rectangle. -/// -/// Neither the software rasterizer nor the Metal compositor has a native -/// rounded-rect primitive, so the shape is built entirely from axis-aligned -/// `FillRect`s: a full-width middle band plus one scanline per corner row. -/// Each corner scanline is horizontally anti-aliased by emitting a -/// fractional-coverage edge pixel, so the capsule edges read smoothly through -/// both render backends. -fn push_rounded_rect( - list: &mut Vec, x: f32, y: f32, - width: f32, - height: f32, - radius: f32, - color: Color, -) { - let r = radius.min(width / 2.0).min(height / 2.0).max(0.0); - if r < 1.0 || color.a == 0 { - list.push(PaintCommand::FillRect { - x, - y, - width, - height, - color, - }); - return; - } - - let rows = r.ceil() as i32; - // Full-width band between the top and bottom corner regions. - let band_h = height - 2.0 * rows as f32; - if band_h > 0.0 { - list.push(PaintCommand::FillRect { - x, - y: y + rows as f32, - width, - height: band_h, - color, - }); + viewport_width: f32, +) -> ChromeHit { + if y >= CHROME_HEIGHT { + return ChromeHit::None; } - - // Corner rows: sample the circular boundary at each row's vertical centre - // and fill the horizontal chord, anti-aliasing the two edge pixels. - for i in 0..rows { - let dy = r - (i as f32 + 0.5); - if dy.abs() > r { - continue; + // Hit-test against the same laid-out chrome document the renderer paints, + // so button and address-bar regions follow the CSS layout exactly. + let Some(cr) = build_chrome_layout(chrome, history, font, viewport_width) else { + return ChromeHit::None; + }; + let Some(node) = hit_test_any_element(&cr.tree.root, x, y, 0.0, 0.0) else { + return ChromeHit::None; + }; + // Walk up from the hit node (e.g. the SVG inside a button) to the nearest + // control element carrying a known id. + let mut current = Some(node); + while let Some(n) = current { + match cr.doc.get_attribute(n, "id") { + Some(we_browser::chrome_ui::ID_BACK) => return ChromeHit::Back, + Some(we_browser::chrome_ui::ID_FORWARD) => return ChromeHit::Forward, + Some(we_browser::chrome_ui::ID_RELOAD) => return ChromeHit::Reload, + Some(we_browser::chrome_ui::ID_URLBAR) => { + let content_x = chrome_box_content_x(&cr.tree.root, n, 0.0).unwrap_or(x); + return ChromeHit::AddressBar { + local_x: x - content_x, + }; + } + _ => {} } - let half = (r * r - dy * dy).sqrt(); - let left = x + r - half; - let right = x + width - r + half; - push_aa_scanline(list, left, right, y + i as f32, color); - push_aa_scanline(list, left, right, y + height - 1.0 - i as f32, color); + current = cr.doc.parent(n); } + ChromeHit::None } -/// Emit a 1px-tall horizontal span from `left` to `right` (both fractional) -/// with anti-aliased end pixels: the interior is filled at full coverage and -/// each boundary pixel is filled with fractional-coverage alpha. -fn push_aa_scanline(list: &mut Vec, left: f32, right: f32, row_y: f32, color: Color) { - if right <= left { - return; - } - let inner_left = left.ceil(); - let inner_right = right.floor(); - if inner_right > inner_left { - list.push(PaintCommand::FillRect { - x: inner_left, - y: row_y, - width: inner_right - inner_left, - height: 1.0, - color, - }); - } - let left_cov = inner_left - left; - if left_cov > 0.02 { - list.push(PaintCommand::FillRect { - x: inner_left - 1.0, - y: row_y, - width: 1.0, - height: 1.0, - color: scale_alpha(color, left_cov), - }); +/// Absolute x of the content-box left edge of the layout box for `target`, +/// walking the chrome layout tree while accumulating parent offsets. +fn chrome_box_content_x(b: &we_layout::LayoutBox, target: NodeId, parent_x: f32) -> Option { + let node = match b.box_type { + we_layout::BoxType::Block(n) | we_layout::BoxType::Inline(n) => Some(n), + we_layout::BoxType::TextRun { node, .. } => Some(node), + we_layout::BoxType::Anonymous => None, + }; + let abs_x = parent_x + b.rect.x; + if node == Some(target) { + return Some(abs_x); } - let right_cov = right - inner_right; - if right_cov > 0.02 && inner_right >= inner_left { - list.push(PaintCommand::FillRect { - x: inner_right, - y: row_y, - width: 1.0, - height: 1.0, - color: scale_alpha(color, right_cov), - }); + for child in &b.children { + if let Some(v) = chrome_box_content_x(child, target, abs_x) { + return Some(v); + } } + None } -/// Return `color` with its alpha scaled by `factor` (clamped to `[0, 1]`), -/// used for anti-aliased coverage at rounded-rect edges. -fn scale_alpha(color: Color, factor: f32) -> Color { - let f = factor.clamp(0.0, 1.0); - Color::new(color.r, color.g, color.b, (color.a as f32 * f) as u8) +/// A laid-out chrome document, ready to paint or hit-test. +struct ChromeRender { + doc: Document, + tree: we_layout::LayoutTree, } -/// The `TextLine.y` ("line top") that vertically centers the icon `label`'s -/// rasterized ink box within a button of height `CHROME_BUTTON_HEIGHT` whose -/// top edge is at `button_y`. -/// -/// Symbols such as the back/forward arrows and the reload glyph are *not* -/// cap-region glyphs the way uppercase letters are, so placing them on the URL -/// text's cap baseline leaves them looking off-center. Centering their actual -/// ink box (`bearing_y`/`height` from the rasterized bitmap) is what makes them -/// look vertically centered in the button. Falls back to cap-region centering -/// when the glyph has no rasterizable outline (e.g. a missing glyph). -fn chrome_icon_line_y(font: &Font, label: &str, button_y: f32) -> f32 { - let button_center = button_y + CHROME_BUTTON_HEIGHT / 2.0; - let ink = label - .chars() - .next() - .and_then(|c| font.glyph_index(c as u32).ok().flatten()) - .and_then(|gid| font.get_glyph_bitmap(gid, CHROME_ICON_FONT_SIZE)); - let baseline = match ink { - Some(bmp) if bmp.height > 0 => { - button_center + bmp.bearing_y as f32 - bmp.height as f32 / 2.0 - } - _ => button_y + (CHROME_BUTTON_HEIGHT + CHROME_ICON_FONT_SIZE * 0.8) / 2.0, - }; - line_y_for_baseline(baseline, CHROME_ICON_FONT_SIZE) -} +/// Offset applied to chrome-document `NodeId`s when their `DrawImage` commands +/// and image references are merged with the page's, so the two documents' +/// independent id spaces cannot collide in the shared image map. +const CHROME_NODE_OFFSET: usize = 1 << 40; -/// Append paint commands for a single chrome navigation button: a soft rounded -/// pill (only when the button is enabled, matching the macOS Tahoe borderless -/// toolbar look), followed by the icon glyph centered both horizontally (by -/// advance) and vertically (by ink box) within the button. -fn push_chrome_button( - list: &mut Vec, - x: f32, - button_y: f32, - font: &Font, - label: &str, - enabled: bool, -) { - if enabled { - push_rounded_rect( - list, - x, - button_y, - CHROME_BUTTON_WIDTH, - CHROME_BUTTON_HEIGHT, - CHROME_BUTTON_RADIUS, - CHROME_BUTTON_BG, - ); - } - let color = if enabled { - CHROME_ICON_ENABLED - } else { - CHROME_ICON_DISABLED - }; - - let glyph_width = measure_chrome_text_width(font, label, CHROME_ICON_FONT_SIZE); - let glyph_x = x + (CHROME_BUTTON_WIDTH - glyph_width) / 2.0; - let icon_line_y = chrome_icon_line_y(font, label, button_y); - - list.push(PaintCommand::DrawGlyphs { - line: TextLine { - text: label.to_string(), - x: glyph_x, - y: icon_line_y, - width: CHROME_BUTTON_WIDTH, - font_size: CHROME_ICON_FONT_SIZE, - color, - text_decoration: TextDecoration::None, - background_color: Color::new(0, 0, 0, 0), - bold: false, - italic: false, - }, - font_size: CHROME_ICON_FONT_SIZE, - color, - }); +fn chrome_offset_node_id(id: NodeId) -> NodeId { + NodeId::from_index(id.index() + CHROME_NODE_OFFSET) } -/// Compute the x coordinate where the address bar starts. -fn chrome_address_bar_x() -> f32 { - CHROME_LEFT_MARGIN - + CHROME_BUTTON_WIDTH - + CHROME_BUTTON_GAP - + CHROME_BUTTON_WIDTH - + CHROME_BUTTON_GAP - + CHROME_BUTTON_WIDTH - + CHROME_BUTTON_GAP - + 4.0 -} - -/// Hit-test the chrome area. Returns a `ChromeHit` describing what was clicked. -enum ChromeHit { - Back, - Forward, - Reload, - AddressBar { local_x: f32 }, - None, -} - -fn hit_test_chrome(x: f32, y: f32, viewport_width: f32) -> ChromeHit { - if y >= CHROME_HEIGHT { - return ChromeHit::None; - } - let button_y = (CHROME_HEIGHT - CHROME_BUTTON_HEIGHT) / 2.0; - - let mut bx = CHROME_LEFT_MARGIN; - - // Back button. - if x >= bx - && x < bx + CHROME_BUTTON_WIDTH - && y >= button_y - && y < button_y + CHROME_BUTTON_HEIGHT - { - return ChromeHit::Back; - } - bx += CHROME_BUTTON_WIDTH + CHROME_BUTTON_GAP; - - // Forward button. - if x >= bx - && x < bx + CHROME_BUTTON_WIDTH - && y >= button_y - && y < button_y + CHROME_BUTTON_HEIGHT - { - return ChromeHit::Forward; - } - bx += CHROME_BUTTON_WIDTH + CHROME_BUTTON_GAP; - - // Reload button. - if x >= bx - && x < bx + CHROME_BUTTON_WIDTH - && y >= button_y - && y < button_y + CHROME_BUTTON_HEIGHT - { - return ChromeHit::Reload; - } +/// Build and lay out the browser chrome as a real HTML/CSS/SVG document, seeded +/// with the live address-bar edit state. Used by both the renderer and +/// hit-testing so the two always agree on geometry. +fn build_chrome_layout( + chrome: &ChromeState, + history: &NavigationHistory, + font: &Font, + viewport_width: f32, +) -> Option { + let html = we_browser::chrome_ui::chrome_html( + &chrome.text, + chrome.loading, + history.can_go_back(), + history.can_go_forward(), + ); + let mut doc = parse_html(&html); - // Address bar. - let addr_bar_x = chrome_address_bar_x(); - let addr_bar_width = viewport_width - addr_bar_x - CHROME_RIGHT_MARGIN; - if x >= addr_bar_x - && x < addr_bar_x + addr_bar_width - && y >= button_y - && y < button_y + CHROME_BUTTON_HEIGHT - { - return ChromeHit::AddressBar { - local_x: x - addr_bar_x - CHROME_PADDING, - }; + // Seed the address-bar with the live edit state so the engine + // renders the value, caret, and selection. + if let Some(urlbar) = doc.get_element_by_id("urlbar") { + let anchor = chrome.selection_anchor.min(chrome.text.len()); + let cursor = chrome.cursor.min(chrome.text.len()); + let is = doc.input_states.get_or_create(urlbar, &chrome.text); + is.set_text(&chrome.text); + is.set_cursor(anchor); + is.extend_selection(cursor); + if chrome.focused { + doc.set_active_element(Some(urlbar), true); + } } - ChromeHit::None + let stylesheet = we_css::parser::Parser::parse(we_browser::chrome_ui::CHROME_CSS); + let styled = resolve_styles( + &doc, + std::slice::from_ref(&stylesheet), + (viewport_width, CHROME_HEIGHT), + )?; + let svg_sizes = collect_svg_sizes(&doc); + let tree = layout( + &styled, + &doc, + viewport_width, + CHROME_HEIGHT, + font, + &svg_sizes, + ); + Some(ChromeRender { doc, tree }) } /// Unified render pipeline: resolve styles → layout → build display list → @@ -1100,7 +754,11 @@ fn render_page( // Build Image objects from canvas backing buffers for the renderer. let canvas_images = collect_canvas_images(&page.doc); - let svg_images = rasterize_svgs(&page.doc, font); + // Rasterize SVGs at the device backing scale so they stay crisp on Retina, + // and tessellate rounded rects at device resolution for smooth corners. + let scale = backend.scale(); + we_render::set_rounded_rect_scale(scale); + let svg_images = rasterize_svgs(&page.doc, font, scale); let mut refs = image_refs(&page.images, &svg_images); for (node_id, img) in &canvas_images { refs.insert(*node_id, img); @@ -1121,10 +779,38 @@ fn render_page( let web_display_list = build_display_list_with_page_scroll(&tree, page_scroll_y - CHROME_HEIGHT, scroll_offsets); - // Build chrome display list. - let chrome_display_list = build_chrome_display_list(chrome, history, font, viewport_width); + // Build the chrome as its own HTML/CSS/SVG document, rendered through the + // same pipeline as the page. Its SVG icons are rasterized like any page + // SVG; their node ids are offset so they cannot collide with the page's in + // the shared image map. + let chrome_render = build_chrome_layout(chrome, history, font, viewport_width); + let chrome_svg_images = chrome_render + .as_ref() + .map(|cr| rasterize_svgs(&cr.doc, font, scale)) + .unwrap_or_default(); + let empty_scroll: ScrollState = HashMap::new(); + let chrome_display_list = chrome_render + .as_ref() + .map(|cr| { + let mut list = build_display_list_with_page_scroll(&cr.tree, 0.0, &empty_scroll); + for cmd in list.iter_mut() { + match cmd { + PaintCommand::DrawImage { node_id, .. } + | PaintCommand::DrawBackgroundImage { node_id, .. } => { + *node_id = chrome_offset_node_id(*node_id); + } + _ => {} + } + } + list + }) + .unwrap_or_default(); + for (nid, img) in &chrome_svg_images { + refs.insert(chrome_offset_node_id(*nid), img); + } - // Combine: chrome first (background), then clipped web content, then chrome overlay. + // Combine: clipped web content, then the chrome overlay on top (its opaque + // toolbar bar covers the chrome strip). let mut display_list = Vec::with_capacity(chrome_display_list.len() + web_display_list.len() + 2); @@ -1171,8 +857,14 @@ fn handle_resize(width: f64, height: f64) { return; } - // Update Metal drawable size. - state.view.update_drawable_size(width, height); + // Size the Metal drawable at device resolution (points × backing + // scale) and tell the renderer the scale, so content is rendered crisp + // on Retina instead of upscaled by the compositor. + let scale = appkit::backing_scale_factor() as f32; + state.backend.set_scale(scale); + state + .view + .update_drawable_size(width * scale as f64, height * scale as f64); state.viewport_width = w; state.viewport_height = h; @@ -1729,7 +1421,14 @@ fn find_open_select(doc: &we_dom::Document) -> Option { /// Re-render the page and mark the view as needing display. /// Handle a click in the browser chrome area. fn handle_chrome_click(state: &mut BrowserState, x: f32, y: f32, viewport_width: f32) { - match hit_test_chrome(x, y, viewport_width) { + match hit_test_chrome( + &state.chrome, + &state.history, + &state.font, + x, + y, + viewport_width, + ) { ChromeHit::Back => { if state.chrome.focused { state.chrome.cancel(&state.page.base_url.serialize()); @@ -4096,6 +3795,11 @@ fn main() { }; window.set_content_view(&view.id()); + // Render at device resolution (points × backing scale) for Retina crispness. + let scale = appkit::backing_scale_factor() as f32; + backend.set_scale(scale); + view.update_drawable_size(800.0 * scale as f64, 600.0 * scale as f64); + // Initialise chrome state with the initial URL. let chrome = ChromeState::new(&page.base_url.serialize()); @@ -4867,52 +4571,67 @@ mod tests { // Chrome hit-test tests // ----------------------------------------------------------------------- + /// Build a chrome state + history + system font for hit-testing, or `None` + /// when no system font is available (e.g. CI). + fn chrome_hit_fixture() -> Option<(ChromeState, NavigationHistory, Font)> { + let font_path = std::path::Path::new("/System/Library/Fonts/Geneva.ttf"); + if !font_path.exists() { + return None; + } + let font = Font::from_file(font_path).ok()?; + let chrome = ChromeState::new("https://example.com/"); + let mut history = NavigationHistory::new(Url::parse("https://a.com/").unwrap()); + history.push(Url::parse("https://example.com/").unwrap()); // enable back + Some((chrome, history, font)) + } + #[test] fn hit_test_chrome_back_button() { - let x = CHROME_LEFT_MARGIN + CHROME_BUTTON_WIDTH / 2.0; - let y = CHROME_HEIGHT / 2.0; - assert!(matches!(hit_test_chrome(x, y, 800.0), ChromeHit::Back)); + let Some((chrome, history, font)) = chrome_hit_fixture() else { + return; + }; + // From CHROME_CSS: #bar padding-left 8, .btn width 32 → back center ≈ 24. + let hit = hit_test_chrome(&chrome, &history, &font, 24.0, CHROME_HEIGHT / 2.0, 800.0); + assert!(matches!(hit, ChromeHit::Back), "got {hit:?}"); } #[test] fn hit_test_chrome_forward_button() { - let x = CHROME_LEFT_MARGIN - + CHROME_BUTTON_WIDTH - + CHROME_BUTTON_GAP - + CHROME_BUTTON_WIDTH / 2.0; - let y = CHROME_HEIGHT / 2.0; - assert!(matches!(hit_test_chrome(x, y, 800.0), ChromeHit::Forward)); + let Some((chrome, history, font)) = chrome_hit_fixture() else { + return; + }; + // back(32)+gap(4) → forward center ≈ 8 + 36 + 16 = 60. + let hit = hit_test_chrome(&chrome, &history, &font, 60.0, CHROME_HEIGHT / 2.0, 800.0); + assert!(matches!(hit, ChromeHit::Forward), "got {hit:?}"); } #[test] fn hit_test_chrome_reload_button() { - let x = CHROME_LEFT_MARGIN - + CHROME_BUTTON_WIDTH - + CHROME_BUTTON_GAP - + CHROME_BUTTON_WIDTH - + CHROME_BUTTON_GAP - + CHROME_BUTTON_WIDTH / 2.0; - let y = CHROME_HEIGHT / 2.0; - assert!(matches!(hit_test_chrome(x, y, 800.0), ChromeHit::Reload)); + let Some((chrome, history, font)) = chrome_hit_fixture() else { + return; + }; + // two buttons (32+4)*2 → reload center ≈ 8 + 72 + 16 = 96. + let hit = hit_test_chrome(&chrome, &history, &font, 96.0, CHROME_HEIGHT / 2.0, 800.0); + assert!(matches!(hit, ChromeHit::Reload), "got {hit:?}"); } #[test] fn hit_test_chrome_address_bar() { - let addr_x = chrome_address_bar_x(); - let x = addr_x + 50.0; - let y = CHROME_HEIGHT / 2.0; - assert!(matches!( - hit_test_chrome(x, y, 800.0), - ChromeHit::AddressBar { .. } - )); + let Some((chrome, history, font)) = chrome_hit_fixture() else { + return; + }; + // The urlbar fills the rest of the bar; x=300 is well inside it. + let hit = hit_test_chrome(&chrome, &history, &font, 300.0, CHROME_HEIGHT / 2.0, 800.0); + assert!(matches!(hit, ChromeHit::AddressBar { .. }), "got {hit:?}"); } #[test] fn hit_test_chrome_below_bar_returns_none() { - assert!(matches!( - hit_test_chrome(100.0, CHROME_HEIGHT + 1.0, 800.0), - ChromeHit::None - )); + let Some((chrome, history, font)) = chrome_hit_fixture() else { + return; + }; + let hit = hit_test_chrome(&chrome, &history, &font, 100.0, CHROME_HEIGHT + 1.0, 800.0); + assert!(matches!(hit, ChromeHit::None), "got {hit:?}"); } // ----------------------------------------------------------------------- @@ -4934,348 +4653,4 @@ mod tests { assert_eq!(chrome_char_to_byte(s, 3), 3); // 'é' starts at byte 3. assert_eq!(chrome_char_to_byte(s, 4), 5); // Past 'é' (2 bytes). } - - // ----------------------------------------------------------------------- - // Chrome display list tests - // ----------------------------------------------------------------------- - - #[test] - fn chrome_display_list_contains_fill_rects() { - let chrome = ChromeState::new("https://example.com/"); - let history = NavigationHistory::new(Url::parse("https://example.com/").unwrap()); - let font_path = std::path::Path::new("/System/Library/Fonts/Geneva.ttf"); - if !font_path.exists() { - return; // Skip if font not available (e.g., CI). - } - let font = Font::from_file(font_path).unwrap(); - let list = build_chrome_display_list(&chrome, &history, &font, 800.0); - - // Should contain at least the chrome background rect. - let has_bg = list.iter().any(|cmd| { - matches!(cmd, - PaintCommand::FillRect { x, y, width, height, .. } - if *x == 0.0 && *y == 0.0 && *width == 800.0 && *height == CHROME_HEIGHT - ) - }); - assert!(has_bg, "Chrome display list should have background rect"); - } - - #[test] - fn chrome_display_list_has_text() { - let chrome = ChromeState::new("https://test.com/"); - let history = NavigationHistory::new(Url::parse("https://test.com/").unwrap()); - let font_path = std::path::Path::new("/System/Library/Fonts/Geneva.ttf"); - if !font_path.exists() { - return; - } - let font = Font::from_file(font_path).unwrap(); - let list = build_chrome_display_list(&chrome, &history, &font, 800.0); - - let has_url_text = list.iter().any(|cmd| { - matches!(cmd, - PaintCommand::DrawGlyphs { line, .. } if line.text == "https://test.com/" - ) - }); - assert!(has_url_text, "Chrome should render the URL text"); - } - - /// Each navigation-button icon must be vertically centered by its actual - /// ink box within the 28px-tall button. The renderer places the baseline - /// at `line.y + font_size`; the glyph's ink spans `[baseline - bearing_y, - /// baseline - bearing_y + height]`, whose midpoint must coincide with the - /// button's vertical center to within rounding noise. - #[test] - fn chrome_button_icons_are_vertically_centered() { - let chrome = ChromeState::new("https://test.com/"); - let history = NavigationHistory::new(Url::parse("https://test.com/").unwrap()); - let font_path = std::path::Path::new("/System/Library/Fonts/Geneva.ttf"); - if !font_path.exists() { - return; - } - let font = Font::from_file(font_path).unwrap(); - let list = build_chrome_display_list(&chrome, &history, &font, 800.0); - - let button_y = (CHROME_HEIGHT - CHROME_BUTTON_HEIGHT) / 2.0; - let button_center = button_y + CHROME_BUTTON_HEIGHT / 2.0; - - // The first three glyph runs are the back/forward/reload icons. - let icon_lines: Vec<(&TextLine, f32)> = list - .iter() - .filter_map(|cmd| match cmd { - PaintCommand::DrawGlyphs { - line, font_size, .. - } => Some((line, *font_size)), - _ => None, - }) - .take(3) - .collect(); - assert_eq!(icon_lines.len(), 3, "expected three button-icon glyph runs"); - - for (line, font_size) in icon_lines { - let ch = line.text.chars().next().unwrap(); - // Only assert centering for glyphs that actually rasterize. - let Some(gid) = font.glyph_index(ch as u32).ok().flatten() else { - continue; - }; - let Some(bmp) = font.get_glyph_bitmap(gid, font_size) else { - continue; - }; - if bmp.height == 0 { - continue; - } - let baseline = line.y + font_size; - let ink_top = baseline - bmp.bearing_y as f32; - let ink_center = ink_top + bmp.height as f32 / 2.0; - assert!( - (ink_center - button_center).abs() <= 1.0, - "icon {:?} ink center {} not centered in button (center {})", - line.text, - ink_center, - button_center, - ); - } - } - - /// `chrome_caret_offsets` must be monotonically non-decreasing, start at 0, - /// and have one more entry than there are characters (the trailing caret). - #[test] - fn chrome_caret_offsets_are_monotonic() { - let font_path = std::path::Path::new("/System/Library/Fonts/Geneva.ttf"); - if !font_path.exists() { - return; - } - let font = Font::from_file(font_path).unwrap(); - let text = "https://example.com/path"; - let offsets = chrome_caret_offsets(&font, text, CHROME_FONT_SIZE); - assert_eq!(offsets.len(), text.chars().count() + 1); - assert_eq!(offsets[0], 0.0); - for w in offsets.windows(2) { - assert!(w[1] >= w[0], "caret offsets must not move backwards"); - } - // The trailing caret sits at the full advance width of the string. - let total = measure_chrome_text_width(&font, text, CHROME_FONT_SIZE); - assert!((offsets[offsets.len() - 1] - total).abs() <= 1.0); - } - - /// `chrome_byte_pos_at_offset` must invert `chrome_caret_x_at_byte`: - /// clicking exactly at a character boundary's x snaps back to that - /// boundary's byte position. - #[test] - fn chrome_click_offset_round_trips_to_byte() { - let font_path = std::path::Path::new("/System/Library/Fonts/Geneva.ttf"); - if !font_path.exists() { - return; - } - let font = Font::from_file(font_path).unwrap(); - let text = "abc.def"; - let mut byte = 0usize; - for ch in text.chars().chain(std::iter::once('\0')) { - let x = chrome_caret_x_at_byte(&font, text, byte, CHROME_FONT_SIZE); - assert_eq!( - chrome_byte_pos_at_offset(&font, text, x, CHROME_FONT_SIZE), - byte, - "click at boundary byte {byte} did not round-trip", - ); - if byte >= text.len() { - break; - } - byte += ch.len_utf8(); - } - } - - /// A click far to the right of all text lands the caret at the end; a click - /// at or before the origin lands it at the start. - #[test] - fn chrome_click_clamps_to_text_bounds() { - let font_path = std::path::Path::new("/System/Library/Fonts/Geneva.ttf"); - if !font_path.exists() { - return; - } - let font = Font::from_file(font_path).unwrap(); - let text = "hello"; - assert_eq!( - chrome_byte_pos_at_offset(&font, text, -50.0, CHROME_FONT_SIZE), - 0, - ); - assert_eq!( - chrome_byte_pos_at_offset(&font, text, 10_000.0, CHROME_FONT_SIZE), - text.len(), - ); - } - - /// Each navigation-button icon must be centred horizontally within its - /// 32px-wide button. We allow at most a half-pixel asymmetry between the - /// left and right gaps to tolerate font-rounding noise. - #[test] - fn chrome_button_icons_are_horizontally_centered() { - let chrome = ChromeState::new("https://test.com/"); - let history = NavigationHistory::new(Url::parse("https://test.com/").unwrap()); - let font_path = std::path::Path::new("/System/Library/Fonts/Geneva.ttf"); - if !font_path.exists() { - return; - } - let font = Font::from_file(font_path).unwrap(); - let list = build_chrome_display_list(&chrome, &history, &font, 800.0); - - // The three nav buttons are laid out left-to-right from the chrome's - // left margin; their soft pills no longer emit a single full-size - // FillRect, so derive the button x positions from the layout instead. - let stride = CHROME_BUTTON_WIDTH + CHROME_BUTTON_GAP; - let button_xs: Vec = (0..3) - .map(|i| CHROME_LEFT_MARGIN + i as f32 * stride) - .collect(); - - // The first three glyph DrawCommands target those buttons in order. - let icon_lines: Vec<&TextLine> = list - .iter() - .filter_map(|cmd| match cmd { - PaintCommand::DrawGlyphs { line, .. } => Some(line), - _ => None, - }) - .take(3) - .collect(); - assert_eq!(icon_lines.len(), 3, "expected three button-icon glyph runs"); - - for (button_x, line) in button_xs.iter().zip(icon_lines.iter()) { - let icon_width = measure_chrome_text_width(&font, &line.text, line.font_size); - let left_gap = line.x - button_x; - let right_gap = (button_x + CHROME_BUTTON_WIDTH) - (line.x + icon_width); - assert!( - (left_gap - right_gap).abs() <= 0.5, - "icon {:?} not centred: left_gap={} right_gap={}", - line.text, - left_gap, - right_gap, - ); - } - } - - /// The shared chrome baseline must position the URL text so its visual - /// centre lands inside the address bar — specifically inside the band - /// `[button_y, button_y + CHROME_BUTTON_HEIGHT]`. The cap region of a - /// glyph drawn at `font_size = N` extends from `baseline - 0.8 * N` to - /// `baseline`, so its midpoint is `baseline - 0.4 * N`. - #[test] - fn chrome_baseline_centers_url_text_in_address_bar() { - let baseline = chrome_text_baseline_y(); - let button_y = (CHROME_HEIGHT - CHROME_BUTTON_HEIGHT) / 2.0; - let cap_mid = baseline - 0.4 * CHROME_FONT_SIZE; - let bar_mid = button_y + CHROME_BUTTON_HEIGHT / 2.0; - assert!( - (cap_mid - bar_mid).abs() <= 0.5, - "cap mid {} should be close to address-bar mid {}", - cap_mid, - bar_mid, - ); - } - - /// `push_rounded_rect` with a sub-pixel radius degrades to a single plain - /// `FillRect` covering the whole box. - #[test] - fn rounded_rect_tiny_radius_is_plain_fill() { - let mut list = Vec::new(); - push_rounded_rect(&mut list, 1.0, 2.0, 30.0, 10.0, 0.4, Color::rgb(1, 2, 3)); - assert_eq!(list.len(), 1); - assert!(matches!( - list[0], - PaintCommand::FillRect { - x, - y, - width, - height, - .. - } if x == 1.0 && y == 2.0 && width == 30.0 && height == 10.0 - )); - } - - /// `scale_alpha` multiplies only the alpha channel and clamps the factor. - #[test] - fn scale_alpha_scales_only_alpha() { - let c = Color::new(10, 20, 30, 200); - let half = scale_alpha(c, 0.5); - assert_eq!((half.r, half.g, half.b), (10, 20, 30)); - assert_eq!(half.a, 100); - assert_eq!(scale_alpha(c, 2.0).a, 200); // clamped to 1.0 - assert_eq!(scale_alpha(c, -1.0).a, 0); // clamped to 0.0 - } - - /// A filled rounded rectangle must actually round its corners: a pixel well - /// inside the body is painted with the fill colour, while the extreme corner - /// pixel is left untouched (still the white canvas background). A square - /// fill would paint the corner too, so this distinguishes the two. - #[test] - fn rounded_rect_leaves_corners_unpainted() { - use std::collections::HashMap; - use we_render::Renderer; - - let font_path = std::path::Path::new("/System/Library/Fonts/Geneva.ttf"); - if !font_path.exists() { - return; // Renderer::paint_display_list needs a font even with no glyphs. - } - let font = Font::from_file(font_path).unwrap(); - - let mut list = Vec::new(); - // A 60x40 capsule (radius 20) at (10,10) on an 80x60 white canvas. - let red = Color::rgb(255, 0, 0); - push_rounded_rect(&mut list, 10.0, 10.0, 60.0, 40.0, 20.0, red); - - let mut r = Renderer::new(80, 60); - let images: HashMap = HashMap::new(); - r.paint_display_list(&list, &font, &images); - - let px = |x: usize, y: usize| { - let o = (y * 80 + x) * 4; - let p = r.pixels(); - (p[o + 2], p[o + 1], p[o]) // (R, G, B) from BGRA - }; - - // Centre of the capsule is solid red. - assert_eq!(px(40, 30), (255, 0, 0), "capsule centre should be filled"); - // The extreme top-left corner of the bounding box lies outside the - // rounded corner and stays white. - assert_eq!( - px(10, 10), - (255, 255, 255), - "rounded corner should be unpainted" - ); - // The mid-height left edge is the capsule's leftmost extent: filled. - assert_eq!(px(11, 30), (255, 0, 0), "left edge mid-height should fill"); - } - - /// Optional visual smoke check: set `WE_CHROME_PNG=/path/out.png` to dump a - /// focused-state chrome render for manual inspection. No-op (and no - /// assertions) unless the env var is set and a system font is present. - #[test] - fn chrome_png_dump_when_requested() { - use std::collections::HashMap; - use we_render::Renderer; - - let Ok(out) = std::env::var("WE_CHROME_PNG") else { - return; - }; - let font_path = std::path::Path::new("/System/Library/Fonts/Geneva.ttf"); - if !font_path.exists() { - return; - } - let font = Font::from_file(font_path).unwrap(); - - let mut chrome = ChromeState::new("https://example.com/search?q=tahoe"); - chrome.focused = true; - let mut history = NavigationHistory::new(Url::parse("https://a.com/").unwrap()); - history.push(Url::parse("https://example.com/").unwrap()); - let width = 800u32; - let list = build_chrome_display_list(&chrome, &history, &font, width as f32); - - let mut r = Renderer::new(width, CHROME_HEIGHT as u32); - let images: HashMap = HashMap::new(); - r.paint_display_list(&list, &font, &images); - - let bgra = r.pixels(); - let mut rgba = Vec::with_capacity(bgra.len()); - for px in bgra.chunks_exact(4) { - rgba.extend_from_slice(&[px[2], px[1], px[0], px[3]]); - } - let png = we_image::png::encode_png_rgba(&rgba, width, CHROME_HEIGHT as u32); - std::fs::write(&out, png).unwrap(); - } } diff --git a/crates/css/src/values.rs b/crates/css/src/values.rs index 9f325b2..89fcbe2 100644 --- a/crates/css/src/values.rs +++ b/crates/css/src/values.rs @@ -855,6 +855,7 @@ pub fn expand_shorthand( }) .collect(), ), + "border-radius" => Some(expand_border_radius(values, important)), "background" => Some(expand_background(values, important)), "flex" => Some(expand_flex(values, important)), "flex-flow" => Some(expand_flex_flow(values, important)), @@ -866,6 +867,83 @@ pub fn expand_shorthand( } } +/// Expand the `border-radius` shorthand into the four per-corner longhands. +/// +/// Accepts 1–4 length/percentage values with the CSS corner order +/// (top-left, top-right, bottom-right, bottom-left). Any elliptical +/// `horizontal / vertical` form is reduced to its horizontal radii — we render +/// circular corners only — by dropping everything from the `/` onward. +fn expand_border_radius(values: &[ComponentValue], important: bool) -> Vec { + let horizontal: Vec<&ComponentValue> = values + .iter() + .take_while(|v| !matches!(v, ComponentValue::Delim('/'))) + .collect(); + let parsed: Vec = horizontal + .into_iter() + .filter(|v| !matches!(v, ComponentValue::Whitespace | ComponentValue::Comma)) + .map(parse_single_value) + .collect(); + + let (tl, tr, br, bl) = match parsed.len() { + 1 => ( + parsed[0].clone(), + parsed[0].clone(), + parsed[0].clone(), + parsed[0].clone(), + ), + 2 => ( + parsed[0].clone(), + parsed[1].clone(), + parsed[0].clone(), + parsed[1].clone(), + ), + 3 => ( + parsed[0].clone(), + parsed[1].clone(), + parsed[2].clone(), + parsed[1].clone(), + ), + 4 => ( + parsed[0].clone(), + parsed[1].clone(), + parsed[2].clone(), + parsed[3].clone(), + ), + _ => { + let fallback = parsed.first().cloned().unwrap_or(CssValue::Zero); + ( + fallback.clone(), + fallback.clone(), + fallback.clone(), + fallback, + ) + } + }; + + vec![ + LonghandDeclaration { + property: "border-top-left-radius".to_string(), + value: tl, + important, + }, + LonghandDeclaration { + property: "border-top-right-radius".to_string(), + value: tr, + important, + }, + LonghandDeclaration { + property: "border-bottom-right-radius".to_string(), + value: br, + important, + }, + LonghandDeclaration { + property: "border-bottom-left-radius".to_string(), + value: bl, + important, + }, + ] +} + /// Expand the `flex` shorthand into `flex-grow`, `flex-shrink`, `flex-basis`. fn expand_flex(values: &[ComponentValue], important: bool) -> Vec { let parsed: Vec = values diff --git a/crates/e2e/pages/45_border_radius.html b/crates/e2e/pages/45_border_radius.html new file mode 100644 index 0000000..d3f697a --- /dev/null +++ b/crates/e2e/pages/45_border_radius.html @@ -0,0 +1,38 @@ + + + + + + +
+
+
+
+
+ + + diff --git a/crates/e2e/scenarios/45_border_radius.expected.png b/crates/e2e/scenarios/45_border_radius.expected.png new file mode 100644 index 0000000000000000000000000000000000000000..ba6327cd5ddba32f16a9b0b021f0c346a8ea9e7e GIT binary patch literal 7491 zcmeAS@N?(olHy`uVBq!ia0y~yV02($V9elPV_;xV%UAlyz#z-z>EaktF(>(lJ(t@- z4GC6dksh&z#R(!kO@iER+y?>;xZDnQur6kOpdrD!IN?asMWzCgo~A&9q=OlZ7GNzK z6SgqOu!6Ki7(_AjHG#C~Na!-SgN+lTr*TZH#2fDK`>P;1f9Jo?`wMUV547oW*I|3g zSoGIncvb62w&BZJL=5Y{E5ewYudu5mAo&)7sL@``DCn@_Gq<)sfl;v!T9!ToUxYdxx`e2{&em0wpJ&yuyWDGU(c|#aYE`TK# z@f{sz2K0X#XL)-;WdBnwtsFPHI}KeJ>A%-)4|3SEPY`KLr<>k z+#eE07tfd`G37YhCIu-CpInEpOdq~2mS>)4;r3BOj#FaFb+%m&i_DjqEvRDLk#RiZ zd$~w|QdL4gob(H(&U-U%HN0X?xcTMcQvJmhBA1z)KizT=_xN(`3R{RigY#?mzi+Nd|-Mej=96>$u=p7-n3OCDW3Lk`98j7fG;41JFRefW7!Wi!VrT$B!$4%o{e z^TNfj%l)IrA*c{2#Z-v!C)!+K720dK6_n7r+&>B&85`u;CD!0SrFZImxG%%BA z#(aBhDTrt7f9nUF$s+xaCN?-$<}Tn?sY=dbUM1d;dig(3#UY8Kiz`%6(({8wvisR= z5<%HZ$UtK~Z%Ly|K2pXoz_gj8C?)6h3yTM!oOfh_joAd&iw=pfoLAQDj*;_N67Qe7 z_|yQDXPBBlU2_n1VD4SXn-RwF&f54Em(0<{GsGmOY-d}=AeG^h>#&(gpe6fxk6tGz z*B}=K%UBgS*st{Xb@V(6^x@?><^4skfK3`)rf?iG+qw90%b z@@;-tKIqI6>3_7a!LhRS8tbWm!&lf!^cfO=99ny^sX(MZDMw<8c}8dhPpMC?K?19S zkzH}^76X~1%F@lB7)-gl9(JwdWpib;=yLz4A$RmhLRK@VqUe7VXp>?9%fT9QMl)`K zDvP6_bS&wU3#u+21=@7A^DHw1RTv&ArHw#UV*_)*JjSQ80dkAN9gQOlw=%|Z9XLIG zeSD*=LdKp)r>E<;n1bqWE)TW^dst5;GlvTBiYzl*;K^9=`I)Z5KF^4c z8WOCF8*&&%^m=s-9^Rqeg$@r&|tUu&Q9bU9Fwf zYilky#<>g{>l;7aaS-?UatK^YtdHHzCo`wkC*mR4=p=?y*$ZsUFR%*lHQ35{mHWWu z<^KQw|M;k!*a|kfixEvfzr5X_|JT>Yw}^sU7aD8}_OP8QW{x$u$PKAL?tU`ka$~GL z=n=)|3{F)|fd)qoW-xl#fs)lh4T(nyTNqU2LFp<%gs0FTieVDJo4O+?7!M_s2(=g) z?BYoi>1k5rahrZHfXz+ZslO@Epyl8Tr;JkvUWDxkG~jA8khsg`E-7KllL%7C<;L&4 zK&D5o-IUAipaARQyLt3OdO3Dvkg~ryXm`uRCY96?7rBZ!N_sw{JgylZVyrqwSgn*0mGDZhf4M- z?Jq(v@Pzr~I&5bWXv@E|gR{>^pzilK-9%7)zC|IE z+{wiiBK=7$Y)^TkL=>E5gQa;gVEJ?jZ88##^QrPO2A`dmzQ@1JknxmQlD^=sEvVFS zOUP*ntU&5g$vY*?7eBz1Igg+_7~^Dgm*)AH}- zKRBgrNDh#=Xrt6?2$!jYrb81*CC zxY$}nZH2CYo8rOJ7uGVw{HwU%Ag<-i+kB`ejx!rvd4b#JSu);IADB{CBnHg5sGSJ5 z!T?+qTB!_(*G%yAt&r952DRq9&;ZMxW%!8Pm+ zgO+z6CDurYw0-f*0I7HL$#uBQ^x^S!edf3d9&sM7(~TYlAPF_FM8T`Sz07e1m=YKM zm3(XvmpRPZe5go4=#GZm(PmC?nQims)uje;sl&X@he{R*-2+K5f+ckJ8>S!N?s?ee zm}nKinJ?1+=wJi5`5-m#gv59A=LT_+!=lZH$`ph_EgR;} zmAn~246EdPB|bEvn{<`?KyiK^e}$sq9Yc*Cb}L0tE)oED4<5Xj_fZ~7& zQn=ePG=8i2lF$-p6J!XgE*zSLC7I(Kc*=RW9yj`RfI^=I+;>@FSM^iyEQZ@0#QBt2 z`AeL@Ap&WPxFA{4Q`Xq7AorFD;&eB4$2JAIxh#@Ll|_T4d-R$mn<`2q&uGZ4oN+6m zMDdHjk;U8#-9LI9y~4IQ`eB=b++8m7E_bbEW}u3Jt*jZ8QbFeJ%iN!bD|(s{a49}Xq2NDh#=WW5lS-6RaR zGM?o=Fn#`g-U>#eD~1|8FXvtW<>(%8Lq~yQ`@KlB2dyUpjxN}+IGqWchC!u+NQ1%} z!>UN3lM*6nU%VnfPGSYucLhHm*D}W~;4$UlGOkix18OodEi+pX&B$>`P&}G_Pk^MB zgh<#Ij{uMsRdA#B!_DR9%yAodM0vQht5jBiv$ zmcFo;f#u<&*T(F7CP=Q45HZ{0;{Y-y)hE|LfmMNH_x!ao9}cB$NDP>9LF*x?#~@>{ z^M=YwPh z4;OQm`V^3>l$V)Fd|(o2d0$gu^`P~FL*j({D`z@@LhIa&TL}Ro4GMpreLl{p-}9o^ zv2Ixyv@?^{>=?{&Xal%Pn1Nm;h&Cw9sm=Y=ARe^kkiz~zbb49a6s}we~->L^$8{(5| zpu_EuWAW5W{$#7H$9ad{)gqvHJaC0gc^LyJi;M8r4!WKN5e*{Hn9ud2LI3xkdn|W! z@eG?kKVCGXZVI%i0GD|O*00a!uX!eU^f0%*?B3`~5od#~j9VEiOo=i163%@hLoD{76Gi z0vb*WJH$$GK^Y=VvUQ zVF&HgIm@rVB=@1mvg;+R*Jkr!t1I(7OLxP!&_SGb_InBD4`vpNT$Y1&@_sy+ex2Q> zxi9Hf5~Rn-FJF^*sUiJBpieZ^>W9tWkMLBm$sXMTDsx#MXvo=BtUZaQMBJMXMTy&t z^aI8(G?wW?JC+*`G0$~$H_U}OL{#ntk5yM@H?)7b=7R&Wm5HtFR>*GBwz(i#B63*_ z>gb4vjpr6Dp0N$;@xxsFCCJVTt5`5<^`H#!i~xpDJQ-{fN0p`FQFcY20hD;uB#tVJ zBMV-i#Z!SH2ug_NKIKn&=l3f^mxf}%k?eeNVV0>6ZuCii*(KC%@ z{L9Q1#4=W-pKeHR3$*$4!6EfbppQIs@M!-#f7uT_hF$JraveUFT`$|gqsvVnu3!Dd zJWtc@nt-{P|e73IDY;-sUJMXUG3s^wX@SC^F{dipkqh>e*JiHAbnn-P07b3%~ffTQtsda z4RG7^=;9f$f(cYT4vwKA90fSzQPu~G-TVK|w=4#AmI7_geArw4oo&wn=i^t{QbZf- z|NUuX7g_{@=dxQ_F@!pwXg( z8(0?@F?wSF?0@R zEo=(r4R1gLCmWaoZWg?~_4Q#iLlc`-{r`Wx{+w(KrHYQN94jV7?5X(p-?^Qy@#yzA zH$U%RxbR_pdb)Z6+e^L}+ZD5UgZLLEGN{!5_>d^_Q{KMrPyFt(xBsV2oAzJYJnzr{ z_xJ5Zs>|Qs`@4Vt{{R2`WUc?LkKWF=RA6_>%S->?y?gh6vYPLqUB#^P<5C*lggL$Y z_2D6N#J}I)-~WGpZZ7vU``TY7ZI%WNZ=Tow{`OZ~RP^us*|WLJerrguHVZbKYU38y zKbCA{x*#FBV)l!N4?o5S1%3Me<>lprl9E#v8XFt`cXx65^Zdz^Kl{_p zN^Pjhx$6~>n3A90uk`-Kiy!w-o_tyF;c=oqEbQ9Kcm8j0Z~t#@ zWb|wO;>FDRN0#}_WSZ99)%ENB^XK2^cXxBg1PCq`msQw3b;9m*^X>VkZQr!%m%Nk| z*D)O(o&V3z%yhnRo|TpLfq{{U3Cn8G7}N%)6YDl4Mlm$mxN9|tHW(TjJ~-Kwe7x^( zdQ#G_`lqKvcg&hMZ{B|;C8Y<~ls2*^l$M&t?Ax>F&;EJyyxc*u)}s1Sn-7cYK{kBX97!#6)JhasA=<8rh-E)oCTEu+*8`y zpa1{*`ufLTk}Ph!4=hg5Xt>BEzW7t>ql}qpcv$N70ZnW`AgT|K)bPfdQGcdlGxt#4s=F}ht1_lOCS3j3^P6 bool { /// SVG namespace URI. const SVG_NAMESPACE: &str = "http://www.w3.org/2000/svg"; +/// Restore the correct (camelCase) name of an SVG attribute that the tokenizer +/// lowercased, per the HTML5 "adjust SVG attributes" table. Names not in the +/// table are case-insensitively lowercase already and pass through unchanged. +fn adjust_svg_attribute_name(lowercased: &str) -> &str { + match lowercased { + "attributename" => "attributeName", + "attributetype" => "attributeType", + "basefrequency" => "baseFrequency", + "baseprofile" => "baseProfile", + "calcmode" => "calcMode", + "clippathunits" => "clipPathUnits", + "diffuseconstant" => "diffuseConstant", + "edgemode" => "edgeMode", + "filterunits" => "filterUnits", + "glyphref" => "glyphRef", + "gradienttransform" => "gradientTransform", + "gradientunits" => "gradientUnits", + "kernelmatrix" => "kernelMatrix", + "kernelunitlength" => "kernelUnitLength", + "keypoints" => "keyPoints", + "keysplines" => "keySplines", + "keytimes" => "keyTimes", + "lengthadjust" => "lengthAdjust", + "limitingconeangle" => "limitingConeAngle", + "markerheight" => "markerHeight", + "markerunits" => "markerUnits", + "markerwidth" => "markerWidth", + "maskcontentunits" => "maskContentUnits", + "maskunits" => "maskUnits", + "numoctaves" => "numOctaves", + "pathlength" => "pathLength", + "patterncontentunits" => "patternContentUnits", + "patterntransform" => "patternTransform", + "patternunits" => "patternUnits", + "pointsatx" => "pointsAtX", + "pointsaty" => "pointsAtY", + "pointsatz" => "pointsAtZ", + "preservealpha" => "preserveAlpha", + "preserveaspectratio" => "preserveAspectRatio", + "primitiveunits" => "primitiveUnits", + "refx" => "refX", + "refy" => "refY", + "repeatcount" => "repeatCount", + "repeatdur" => "repeatDur", + "requiredextensions" => "requiredExtensions", + "requiredfeatures" => "requiredFeatures", + "specularconstant" => "specularConstant", + "specularexponent" => "specularExponent", + "spreadmethod" => "spreadMethod", + "startoffset" => "startOffset", + "stddeviation" => "stdDeviation", + "stitchtiles" => "stitchTiles", + "surfacescale" => "surfaceScale", + "systemlanguage" => "systemLanguage", + "tablevalues" => "tableValues", + "targetx" => "targetX", + "targety" => "targetY", + "textlength" => "textLength", + "viewbox" => "viewBox", + "viewtarget" => "viewTarget", + "xchannelselector" => "xChannelSelector", + "ychannelselector" => "yChannelSelector", + "zoomandpan" => "zoomAndPan", + other => other, + } +} + /// HTML tree builder that processes tokens and constructs a DOM tree. pub struct TreeBuilder { document: Document, @@ -756,7 +823,12 @@ impl TreeBuilder { { let id = self.document.create_element_ns(name, Some(SVG_NAMESPACE)); for (attr_name, attr_value) in attributes { - self.document.set_attribute(id, attr_name, attr_value); + // The tokenizer lowercases attribute names, but many SVG + // attributes are camelCase and case-sensitive (`viewBox`, + // `preserveAspectRatio`, …). Restore the correct casing per the + // HTML5 "adjust SVG attributes" table. + self.document + .set_attribute(id, adjust_svg_attribute_name(attr_name), attr_value); } id } else { @@ -1886,4 +1958,31 @@ mod tests { } assert_eq!(text, "for(var t=0;t", + ); + fn find_svg(doc: &Document, node: NodeId) -> Option { + if let NodeData::Element { tag_name, .. } = doc.node_data(node) { + if tag_name == "svg" { + return Some(node); + } + } + doc.children(node).find_map(|c| find_svg(doc, c)) + } + let svg = find_svg(&doc, doc.root()).expect("svg in tree"); + assert_eq!(doc.get_attribute(svg, "viewBox"), Some("0 0 24 24")); + assert_eq!( + doc.get_attribute(svg, "preserveAspectRatio"), + Some("xMidYMid") + ); + // The lowercased form must not be what's stored. + assert_eq!(doc.get_attribute(svg, "viewbox"), None); + } } diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 486d1c8..b5b5065 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -143,6 +143,17 @@ pub struct FormControlInfo { pub visible_size: usize, } +/// Glyph-accurate horizontal positions (offsets from the text content origin, +/// in px) for a focused text input's caret and selection, computed with the +/// real font during a post-layout pass. `None` falls back to a monospace +/// approximation at paint time. +#[derive(Debug, Clone, Copy)] +pub struct InputTextMetrics { + pub caret_x: f32, + pub sel_start_x: f32, + pub sel_end_x: f32, +} + /// A box in the layout tree with dimensions and child boxes. #[derive(Debug)] pub struct LayoutBox { @@ -178,6 +189,8 @@ pub struct LayoutBox { pub border_styles: [BorderStyle; 4], /// Border colors (top, right, bottom, left). pub border_colors: [Color; 4], + /// Border corner radii in px (top-left, top-right, bottom-right, bottom-left). + pub border_radii: [f32; 4], /// Text alignment for this box's inline content. pub text_align: TextAlign, /// Computed line height in px. @@ -186,6 +199,8 @@ pub struct LayoutBox { pub replaced_size: Option<(f32, f32)>, /// Form control rendering information (type, value, state). pub form_control: Option, + /// Glyph-accurate caret/selection x for a focused text input (post-layout). + pub input_text_metrics: Option, /// CSS `position` property. pub position: Position, /// CSS `z-index` property (None = auto). @@ -309,10 +324,17 @@ impl LayoutBox { style.border_bottom_color, style.border_left_color, ], + border_radii: [ + style.border_top_left_radius, + style.border_top_right_radius, + style.border_bottom_right_radius, + style.border_bottom_left_radius, + ], text_align: style.text_align, line_height: style.line_height, replaced_size: None, form_control: None, + input_text_metrics: None, position: style.position, z_index: style.z_index, relative_offset: (0.0, 0.0), @@ -5468,6 +5490,10 @@ pub fn layout( None, ); + // Compute glyph-accurate caret/selection positions for focused text inputs + // now that the font is in hand (the display-list builder has no font). + position_input_carets(&mut root, font); + let height = root.margin_box_height(); let box_count = root.iter().count(); LayoutTree { @@ -5480,6 +5506,55 @@ pub fn layout( } } +/// Walk the tree and fill `input_text_metrics` for focused text inputs/ +/// textareas, using the real font so the caret and selection align with the +/// rendered glyphs instead of a monospace estimate. +fn position_input_carets(b: &mut LayoutBox, font: &Font) { + if let Some(fc) = &b.form_control { + if matches!( + fc.control_type, + FormControlType::TextInput | FormControlType::Password + ) && fc.focused + { + // Password fields render bullets; measure those instead of the value. + let display: String = if fc.control_type == FormControlType::Password { + "\u{2022}".repeat(fc.value.chars().count()) + } else { + fc.value.clone() + }; + let x_at = |byte: usize| caret_x_for_byte(font, &display, &fc.value, byte, b.font_size); + let (s, e) = if fc.cursor <= fc.selection_anchor { + (fc.cursor, fc.selection_anchor) + } else { + (fc.selection_anchor, fc.cursor) + }; + b.input_text_metrics = Some(InputTextMetrics { + caret_x: x_at(fc.cursor), + sel_start_x: x_at(s), + sel_end_x: x_at(e), + }); + } + } + for child in &mut b.children { + position_input_carets(child, font); + } +} + +/// Pixel x (from the text origin) of the caret at byte offset `byte` in +/// `value`, measuring the corresponding prefix of `display` (which may be +/// bullet characters for password fields) with the font's shaped advances. +fn caret_x_for_byte(font: &Font, display: &str, value: &str, byte: usize, font_size: f32) -> f32 { + let char_idx = value[..byte.min(value.len())].chars().count(); + let prefix: String = display.chars().take(char_idx).collect(); + if prefix.is_empty() { + return 0.0; + } + font.shape_text(&prefix, font_size) + .iter() + .map(|g| g.x_advance) + .sum() +} + // --------------------------------------------------------------------------- // Incremental layout // --------------------------------------------------------------------------- diff --git a/crates/platform/src/appkit.rs b/crates/platform/src/appkit.rs index 871576d..92f7a1e 100644 --- a/crates/platform/src/appkit.rs +++ b/crates/platform/src/appkit.rs @@ -1094,6 +1094,25 @@ pub fn create_standard_window(title: &str) -> Window { /// Queries `NSApp.effectiveAppearance.name` and checks if it contains "Dark". /// Returns `false` if the application hasn't been initialized yet or if the /// appearance cannot be determined. +/// The backing scale factor (device pixels per point) of the main screen, +/// e.g. `2.0` on a Retina display. Returns `1.0` if it cannot be determined. +pub fn backing_scale_factor() -> f64 { + let cls = match class!("NSScreen") { + Some(c) => c, + None => return 1.0, + }; + let screen: *mut c_void = msg_send![cls.as_ptr(), mainScreen]; + if screen.is_null() { + return 1.0; + } + let scale: f64 = msg_send![screen, backingScaleFactor]; + if scale >= 1.0 { + scale + } else { + 1.0 + } +} + pub fn is_dark_mode() -> bool { let cls = match class!("NSApplication") { Some(c) => c, diff --git a/crates/render/src/atlas.rs b/crates/render/src/atlas.rs index 23e8742..3c7ff8c 100644 --- a/crates/render/src/atlas.rs +++ b/crates/render/src/atlas.rs @@ -355,15 +355,25 @@ impl GlyphAtlas { /// Each glyph in the text becomes a `TexturedQuad` with UV coordinates /// pointing into the atlas. Glyphs with no outline (e.g. space) are /// skipped. - pub fn build_text_quads(&mut self, line: &TextLine, font: &Font) -> Vec { + pub fn build_text_quads( + &mut self, + line: &TextLine, + font: &Font, + scale: f32, + ) -> Vec { let size_px = line.font_size; + // Rasterize glyphs at device resolution (size × backing scale) so they + // stay crisp, but keep quad geometry in logical coordinates by dividing + // the device-pixel bitmap metrics back down by `scale`. Shaping uses + // the logical size for advances/offsets. + let render_px = size_px * scale; let shaped = font.shape_text(&line.text, size_px); let color = color_to_f32(&line.color); let mut quads = Vec::with_capacity(shaped.len()); for sg in &shaped { - let region = match self.get_or_insert(sg.glyph_id, size_px, font) { + let region = match self.get_or_insert(sg.glyph_id, render_px, font) { Some(r) => r, None => continue, // no outline (space, etc.) }; @@ -374,8 +384,17 @@ impl GlyphAtlas { }; // Screen position: TextLine origin + glyph offset + bearing. - let gx = line.x + sg.x_offset + region.bearing_x as f32; - let gy = line.y - region.bearing_y as f32; + // + // `TextLine.y` is the top of the line box, with the baseline one + // `font_size` below it — the convention used by the display-list + // builder (see `paint_text`) and the software renderer + // (`draw_text_line`). `bearing_y` is the distance from the baseline + // up to the glyph's top edge, so the bitmap top sits at + // `baseline - bearing_y`. Bitmap metrics are device px → divide by + // `scale` for logical quad geometry. + let baseline = line.y + size_px; + let gx = line.x + sg.x_offset + region.bearing_x as f32 / scale; + let gy = baseline - region.bearing_y as f32 / scale; // UV coordinates in the atlas page. let u0 = region.x as f32 / page_w as f32; @@ -386,8 +405,8 @@ impl GlyphAtlas { quads.push(TexturedQuad { x: gx, y: gy, - width: region.width as f32, - height: region.height as f32, + width: region.width as f32 / scale, + height: region.height as f32 / scale, u0, v0, u1, diff --git a/crates/render/src/gpu.rs b/crates/render/src/gpu.rs index 7b1eacf..8eff9e7 100644 --- a/crates/render/src/gpu.rs +++ b/crates/render/src/gpu.rs @@ -133,6 +133,11 @@ pub struct GpuRenderer { atlas_textures: Vec>, /// Nearest-neighbor sampler for images. image_sampler: SamplerState, + /// Device pixels per logical point (Retina backing scale). The display + /// list stays in logical coordinates; this scales the drawable, scissor + /// rects, and glyph rasterization so content is rendered at device + /// resolution instead of being upscaled by the compositor. + scale: f32, } impl GpuRenderer { @@ -148,9 +153,21 @@ impl GpuRenderer { image_cache: TextureCache::new(), atlas_textures: Vec::new(), image_sampler, + scale: 1.0, }) } + /// Set the device backing scale (e.g. 2.0 on Retina). The caller must size + /// the Metal drawable to `logical_size × scale` to match. + pub fn set_scale(&mut self, scale: f32) { + self.scale = scale.max(1.0); + } + + /// Current device backing scale. + pub fn scale(&self) -> f32 { + self.scale + } + /// Render a display list to the Metal layer. /// /// Gets the next drawable from the layer, encodes all paint commands as @@ -236,12 +253,20 @@ impl GpuRenderer { let mut current_vw = initial_vw; let mut current_vh = initial_vh; + // Device backing scale. Uniforms and `current_vw/vh` stay in logical + // points (so the vertex shader maps the logical display list across the + // device-sized drawable, rasterizing geometry at full device + // resolution). Scissor rects, however, are in device pixels, so all + // clip coordinates are multiplied by `scale` via `dev`. + let scale = self.scale; + let dev = |v: u32| (v as f32 * scale).round() as u32; + // Coordinate offset: subtracted from display list positions when rendering // to a layer texture (maps screen coords → layer-local coords). let mut coord_offset: (f32, f32) = (0.0, 0.0); - // Clip stack and scissor state for the current render target. - let full_scissor = ScissorRect::full(current_vw, current_vh); + // Clip stack and scissor state for the current render target (device px). + let full_scissor = ScissorRect::full(dev(current_vw), dev(current_vh)); let mut clip_stack: Vec = vec![full_scissor]; let mut current_scissor = full_scissor; @@ -294,11 +319,11 @@ impl GpuRenderer { saved_scissor: current_scissor, }); - // Create offscreen render target. + // Create offscreen render target at device resolution. if let Some(offscreen) = self .renderer .device() - .new_render_target_texture(layer_w, layer_h) + .new_render_target_texture(dev(layer_w), dev(layer_h)) { let transparent = ClearColor::new(0.0, 0.0, 0.0, 0.0); let off_desc = @@ -319,8 +344,8 @@ impl GpuRenderer { coord_offset.0 -= *x; coord_offset.1 -= *y; - // Reset clip stack for the new target. - let layer_full = ScissorRect::full(layer_w, layer_h); + // Reset clip stack for the new target (device px). + let layer_full = ScissorRect::full(dev(layer_w), dev(layer_h)); clip_stack = vec![layer_full]; current_scissor = layer_full; @@ -343,7 +368,12 @@ impl GpuRenderer { if let Some(enc) = cmd_buf.render_command_encoder(desc) { encoder = enc; self.setup_encoder(&encoder, current_vw as f32, current_vh as f32); - apply_scissor(&encoder, current_scissor, current_vw, current_vh); + apply_scissor( + &encoder, + current_scissor, + dev(current_vw), + dev(current_vh), + ); } } } else { @@ -396,7 +426,12 @@ impl GpuRenderer { None => continue, }; self.setup_encoder(&encoder, current_vw as f32, current_vh as f32); - apply_scissor(&encoder, current_scissor, current_vw, current_vh); + apply_scissor( + &encoder, + current_scissor, + dev(current_vw), + dev(current_vh), + ); // Composite the layer texture as a quad with opacity. if let Some(ref tex) = state.texture { @@ -446,7 +481,7 @@ impl GpuRenderer { color, } => { // Build glyph quads from the atlas. - let quads = self.atlas.build_text_quads(line, font); + let quads = self.atlas.build_text_quads(line, font, scale); self.ensure_atlas_textures(); for quad in &quads { @@ -570,18 +605,19 @@ impl GpuRenderer { batch_vertices.clear(); } - let cx = (*x + coord_offset.0).max(0.0) as u32; - let cy = (*y + coord_offset.1).max(0.0) as u32; + // Clip rects are in device pixels (scaled from logical). + let cx = ((*x + coord_offset.0) * scale).max(0.0) as u32; + let cy = ((*y + coord_offset.1) * scale).max(0.0) as u32; let new_clip = ScissorRect { x: cx, y: cy, - width: (*width).max(0.0) as u32, - height: (*height).max(0.0) as u32, + width: (*width * scale).max(0.0) as u32, + height: (*height * scale).max(0.0) as u32, }; current_scissor = current_scissor.intersect(new_clip); clip_stack.push(current_scissor); - apply_scissor(&encoder, current_scissor, current_vw, current_vh); + apply_scissor(&encoder, current_scissor, dev(current_vw), dev(current_vh)); } PaintCommand::PopClip => { @@ -595,8 +631,8 @@ impl GpuRenderer { current_scissor = clip_stack .last() .copied() - .unwrap_or(ScissorRect::full(current_vw, current_vh)); - apply_scissor(&encoder, current_scissor, current_vw, current_vh); + .unwrap_or(ScissorRect::full(dev(current_vw), dev(current_vh))); + apply_scissor(&encoder, current_scissor, dev(current_vw), dev(current_vh)); } } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4dc66d7..1b0a711 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -11,6 +11,7 @@ pub mod layer; pub use gpu::GpuRenderer; +use std::cell::Cell; use std::collections::HashMap; use we_css::values::Color; @@ -29,6 +30,27 @@ use we_text::font::Font; /// Scroll state: maps NodeId of scrollable boxes to their (scroll_x, scroll_y) offsets. pub type ScrollState = HashMap; +thread_local! { + /// Device backing scale used when tessellating rounded rectangles. The + /// display list is built in logical coordinates, but rounded-rect curves + /// are stepped per *device* pixel (and antialiased at device-pixel width) + /// so `border-radius` stays smooth on Retina. The GPU renderer sets this to + /// the backing scale before building the display list; the software path + /// leaves it at `1.0`. + static ROUNDED_RECT_SCALE: Cell = const { Cell::new(1.0) }; +} + +/// Set the device scale used to tessellate rounded rectangles (see +/// [`ROUNDED_RECT_SCALE`]). Call before building a display list destined for a +/// device-resolution (Retina) target. +pub fn set_rounded_rect_scale(scale: f32) { + ROUNDED_RECT_SCALE.with(|c| c.set(scale.max(1.0))); +} + +fn rounded_rect_scale() -> f32 { + ROUNDED_RECT_SCALE.with(|c| c.get()).max(1.0) +} + /// Scroll bar track color (light gray). const SCROLLBAR_TRACK_COLOR: Color = Color { r: 230, @@ -575,6 +597,224 @@ pub(crate) fn node_id_from_box_type(box_type: &BoxType) -> Option { } } +/// Whether `layout_box` has any non-zero corner radius. +fn has_border_radius(layout_box: &LayoutBox) -> bool { + layout_box.border_radii.iter().any(|&r| r > 0.0) +} + +/// If the box has a uniform border (all four sides the same non-zero width, +/// the same color, and a solid style), return that `(width, color)`. Rounded +/// borders are only painted as a clean ring in this uniform case; non-uniform +/// rounded borders fall back to the square per-side painter. +fn uniform_solid_border(layout_box: &LayoutBox) -> Option<(f32, Color)> { + let b = &layout_box.border; + let w = b.top; + if w <= 0.0 || b.right != w || b.bottom != w || b.left != w { + return None; + } + let styles = &layout_box.border_styles; + if styles.iter().any(|s| *s != BorderStyle::Solid) { + return None; + } + let colors = &layout_box.border_colors; + if colors.iter().any(|c| *c != colors[0]) { + return None; + } + Some((w, colors[0])) +} + +/// Clamp the four corner radii so opposing corners never overlap the box. +fn clamp_radii(w: f32, h: f32, radii: [f32; 4]) -> [f32; 4] { + let max_r = (w / 2.0).min(h / 2.0).max(0.0); + [ + radii[0].clamp(0.0, max_r), + radii[1].clamp(0.0, max_r), + radii[2].clamp(0.0, max_r), + radii[3].clamp(0.0, max_r), + ] +} + +/// Horizontal extent (left, right) — as offsets from the rect's left edge — of +/// a rounded rectangle's interior at vertical center `yc`. `radii` is +/// (top-left, top-right, bottom-right, bottom-left), already clamped. +fn rounded_row_extent(w: f32, h: f32, radii: [f32; 4], yc: f32) -> (f32, f32) { + let [tl, tr, br, bl] = radii; + let left = if yc < tl { + tl - (tl * tl - (tl - yc) * (tl - yc)).max(0.0).sqrt() + } else if yc > h - bl { + let d = yc - (h - bl); + bl - (bl * bl - d * d).max(0.0).sqrt() + } else { + 0.0 + }; + let right = if yc < tr { + w - tr + (tr * tr - (tr - yc) * (tr - yc)).max(0.0).sqrt() + } else if yc > h - br { + let d = yc - (h - br); + w - br + (br * br - d * d).max(0.0).sqrt() + } else { + w + }; + (left, right) +} + +/// Fill a rounded rectangle with per-corner radii (top-left, top-right, +/// bottom-right, bottom-left), composed from horizontal `FillRect` scanlines +/// with anti-aliased ends. Neither render backend has a rounded-rect or +/// polygon primitive, so this is the shared building block for CSS +/// `border-radius`. +#[allow(clippy::too_many_arguments)] +fn fill_rounded_rect( + list: &mut DisplayList, + x: f32, + y: f32, + w: f32, + h: f32, + radii: [f32; 4], + color: Color, +) { + if w <= 0.0 || h <= 0.0 || color.a == 0 { + return; + } + let radii = clamp_radii(w, h, radii); + if radii.iter().all(|&r| r <= 0.0) { + list.push(PaintCommand::FillRect { + x, + y, + width: w, + height: h, + color, + }); + return; + } + // Tessellate in device pixels so the curve has device-resolution steps and + // antialiasing; emitted FillRects are converted back to logical coords. + let s = rounded_rect_scale(); + let (dw, dh, dx, dy) = (w * s, h * s, x * s, y * s); + let dradii = clamp_radii(dw, dh, radii.map(|r| r * s)); + let rows = dh.ceil() as i32; + for iy in 0..rows { + let yc = iy as f32 + 0.5; + if yc >= dh { + break; + } + let (l, r) = rounded_row_extent(dw, dh, dradii, yc); + push_device_scanline(list, dx + l, dx + r, dy + iy as f32, s, color); + } +} + +/// Stroke a rounded-rectangle outline of the given `thickness` (drawn inward +/// from the outer edge), composed from `FillRect` scanlines. Used for rounded +/// borders and focus rings without needing to know the backdrop color. +#[allow(clippy::too_many_arguments)] +fn stroke_rounded_rect( + list: &mut DisplayList, + x: f32, + y: f32, + w: f32, + h: f32, + radii: [f32; 4], + thickness: f32, + color: Color, +) { + if w <= 0.0 || h <= 0.0 || thickness <= 0.0 || color.a == 0 { + return; + } + let outer = clamp_radii(w, h, radii); + let t = thickness.min(w / 2.0).min(h / 2.0); + let iw = w - 2.0 * t; + let ih = h - 2.0 * t; + let inner = clamp_radii( + iw.max(0.0), + ih.max(0.0), + [ + (outer[0] - t).max(0.0), + (outer[1] - t).max(0.0), + (outer[2] - t).max(0.0), + (outer[3] - t).max(0.0), + ], + ); + // Tessellate in device pixels (see `fill_rounded_rect`). + let s = rounded_rect_scale(); + let (dw, dh, dx, dy, dt) = (w * s, h * s, x * s, y * s, t * s); + let douter = clamp_radii(dw, dh, outer.map(|r| r * s)); + let (diw, dih) = (iw * s, ih * s); + let dinner = clamp_radii(diw.max(0.0), dih.max(0.0), inner.map(|r| r * s)); + let rows = dh.ceil() as i32; + for iy in 0..rows { + let yc = iy as f32 + 0.5; + if yc >= dh { + break; + } + let (lo, ro) = rounded_row_extent(dw, dh, douter, yc); + let row_y = dy + iy as f32; + if yc < dt || yc > dh - dt || diw <= 0.0 || dih <= 0.0 { + // Top/bottom cap (or degenerate inner): fill the whole span. + push_device_scanline(list, dx + lo, dx + ro, row_y, s, color); + } else { + let (li, ri) = rounded_row_extent(diw, dih, dinner, yc - dt); + // Left band and right band straddling the hollow interior. + push_device_scanline(list, dx + lo, dx + dt + li, row_y, s, color); + push_device_scanline(list, dx + dt + ri, dx + ro, row_y, s, color); + } + } +} + +/// Emit a 1-device-pixel-tall horizontal span (`dleft`..`dright` at device row +/// `drow`, all in device pixels) as logical-coordinate `FillRect`s, dividing by +/// `scale`. End pixels are antialiased at device-pixel width. At `scale == 1` +/// this reduces to a plain logical 1px-tall antialiased scanline. +fn push_device_scanline( + list: &mut DisplayList, + dleft: f32, + dright: f32, + drow: f32, + scale: f32, + color: Color, +) { + if dright <= dleft { + return; + } + let inv = 1.0 / scale; + let inner_left = dleft.ceil(); + let inner_right = dright.floor(); + if inner_right > inner_left { + list.push(PaintCommand::FillRect { + x: inner_left * inv, + y: drow * inv, + width: (inner_right - inner_left) * inv, + height: inv, + color, + }); + } + let left_cov = inner_left - dleft; + if left_cov > 0.02 { + list.push(PaintCommand::FillRect { + x: (inner_left - 1.0) * inv, + y: drow * inv, + width: inv, + height: inv, + color: scale_alpha(color, left_cov), + }); + } + let right_cov = dright - inner_right; + if right_cov > 0.02 && inner_right >= inner_left { + list.push(PaintCommand::FillRect { + x: inner_right * inv, + y: drow * inv, + width: inv, + height: inv, + color: scale_alpha(color, right_cov), + }); + } +} + +/// Return `color` with its alpha scaled by `factor` (clamped to `[0, 1]`). +fn scale_alpha(color: Color, factor: f32) -> Color { + let f = factor.clamp(0.0, 1.0); + Color::new(color.r, color.g, color.b, (color.a as f32 * f) as u8) +} + fn paint_background(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { let bg = layout_box.background_color; // CSS default `background-clip: border-box`: the background fills the @@ -585,7 +825,26 @@ fn paint_background(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: if bb.width <= 0.0 || bb.height <= 0.0 { return; } - if bg.a > 0 { + if has_border_radius(layout_box) { + let radii = layout_box.border_radii; + // Rounded background fills the border box; a uniform solid border is + // then stroked on top as a rounded ring (`paint_borders` skips it). + if bg.a > 0 { + fill_rounded_rect(list, bb.x + tx, bb.y + ty, bb.width, bb.height, radii, bg); + } + if let Some((bw, bc)) = uniform_solid_border(layout_box) { + stroke_rounded_rect( + list, + bb.x + tx, + bb.y + ty, + bb.width, + bb.height, + radii, + bw, + bc, + ); + } + } else if bg.a > 0 { list.push(PaintCommand::FillRect { x: bb.x + tx, y: bb.y + ty, @@ -612,6 +871,12 @@ fn paint_background(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: } fn paint_borders(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { + // A uniform solid border on a rounded box is painted as a ring by + // `paint_background`, so skip the square per-side painter here. Non-uniform + // rounded borders fall through to the square approximation below. + if has_border_radius(layout_box) && uniform_solid_border(layout_box).is_some() { + return; + } let b = &layout_box.border; let r = &layout_box.rect; let styles = &layout_box.border_styles; @@ -1141,6 +1406,19 @@ fn paint_focus_ring(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: let h = bb.height + offset * 2.0; let color = FC_FOCUS_RING_COLOR; + // A rounded control gets a rounded (concentric) focus ring; the outer + // radius grows by the offset so the ring stays parallel to the border. + if has_border_radius(layout_box) { + let radii = [ + layout_box.border_radii[0] + offset, + layout_box.border_radii[1] + offset, + layout_box.border_radii[2] + offset, + layout_box.border_radii[3] + offset, + ]; + stroke_rounded_rect(list, x, y, w, h, radii, thickness, color); + return; + } + // Top list.push(PaintCommand::FillRect { x, @@ -1203,55 +1481,75 @@ fn paint_text_input( FC_TEXT_COLOR }; - // Background - list.push(PaintCommand::FillRect { - x, - y, - width: w, - height: h, - color: bg, - }); + if has_border_radius(layout_box) { + // CSS-styled input (e.g. the browser chrome address bar): honor the + // author's background and rounded border instead of the default inset + // look. A transparent CSS background falls back to the field default. + let fill = if layout_box.background_color.a > 0 { + layout_box.background_color + } else { + bg + }; + let radii = layout_box.border_radii; + fill_rounded_rect(list, x, y, w, h, radii, fill); + if let Some((bw, bc)) = uniform_solid_border(layout_box) { + stroke_rounded_rect(list, x, y, w, h, radii, bw, bc); + } + } else { + // Background + list.push(PaintCommand::FillRect { + x, + y, + width: w, + height: h, + color: bg, + }); - // Inset border: darker on top/left, lighter on bottom/right. - let border = 1.0f32; - // Top - list.push(PaintCommand::FillRect { - x, - y, - width: w, - height: border, - color: FC_BUTTON_BORDER_DARK, - }); - // Left - list.push(PaintCommand::FillRect { - x, - y, - width: border, - height: h, - color: FC_BUTTON_BORDER_DARK, - }); - // Bottom - list.push(PaintCommand::FillRect { - x, - y: y + h - border, - width: w, - height: border, - color: FC_BUTTON_BORDER_LIGHT, - }); - // Right - list.push(PaintCommand::FillRect { - x: x + w - border, - y, - width: border, - height: h, - color: FC_BUTTON_BORDER_LIGHT, - }); + // Inset border: darker on top/left, lighter on bottom/right. + let border = 1.0f32; + // Top + list.push(PaintCommand::FillRect { + x, + y, + width: w, + height: border, + color: FC_BUTTON_BORDER_DARK, + }); + // Left + list.push(PaintCommand::FillRect { + x, + y, + width: border, + height: h, + color: FC_BUTTON_BORDER_DARK, + }); + // Bottom + list.push(PaintCommand::FillRect { + x, + y: y + h - border, + width: w, + height: border, + color: FC_BUTTON_BORDER_LIGHT, + }); + // Right + list.push(PaintCommand::FillRect { + x: x + w - border, + y, + width: border, + height: h, + color: FC_BUTTON_BORDER_LIGHT, + }); + } - // Content area for text. + // Content area for text. A single line of text is vertically centered in + // the content box; `text_y` is the line-box top, and the caret/selection + // span the line height (not the whole content box). let font_size = layout_box.font_size; - let text_x = layout_box.rect.x + tx; - let text_y = layout_box.rect.y + ty; let content_h = layout_box.rect.height; + let line_h = font_size * 1.2; + let v_offset = ((content_h - line_h) / 2.0).max(0.0); + let text_x = layout_box.rect.x + tx; + let text_y = layout_box.rect.y + ty + v_offset; // For password fields, map byte offsets through the bullet-character mapping. let display_text = if is_placeholder { @@ -1262,25 +1560,33 @@ fn paint_text_input( fc.value.clone() }; - // Approximate character width (monospace assumption for cursor positioning). + // Approximate character width (monospace fallback when glyph-accurate + // metrics are unavailable, e.g. controls laid out without a font). let char_width = font_size * 0.6; + let metrics = layout_box.input_text_metrics; // Paint selection highlight (behind text) when focused and selection exists. if fc.focused && fc.cursor != fc.selection_anchor { - let (sel_start, sel_end) = if fc.cursor <= fc.selection_anchor { - (fc.cursor, fc.selection_anchor) + let (sel_x, sel_w) = if let Some(m) = metrics { + (text_x + m.sel_start_x, m.sel_end_x - m.sel_start_x) } else { - (fc.selection_anchor, fc.cursor) + let (sel_start, sel_end) = if fc.cursor <= fc.selection_anchor { + (fc.cursor, fc.selection_anchor) + } else { + (fc.selection_anchor, fc.cursor) + }; + let start_chars = char_count_for_bytes(&fc.value, sel_start); + let end_chars = char_count_for_bytes(&fc.value, sel_end); + ( + text_x + start_chars as f32 * char_width, + (end_chars - start_chars) as f32 * char_width, + ) }; - let start_chars = char_count_for_bytes(&fc.value, sel_start); - let end_chars = char_count_for_bytes(&fc.value, sel_end); - let sel_x = text_x + start_chars as f32 * char_width; - let sel_w = (end_chars - start_chars) as f32 * char_width; list.push(PaintCommand::FillRect { x: sel_x, y: text_y, width: sel_w, - height: content_h, + height: line_h, color: FC_SELECTION_COLOR, }); } @@ -1307,14 +1613,18 @@ fn paint_text_input( // Paint cursor (caret) when focused and no selection. if fc.focused && fc.cursor == fc.selection_anchor { - let cursor_chars = char_count_for_bytes(&fc.value, fc.cursor); - let cursor_x = text_x + cursor_chars as f32 * char_width; + let cursor_x = if let Some(m) = metrics { + text_x + m.caret_x + } else { + let cursor_chars = char_count_for_bytes(&fc.value, fc.cursor); + text_x + cursor_chars as f32 * char_width + }; let cursor_w = 1.0f32; list.push(PaintCommand::FillRect { x: cursor_x, y: text_y, width: cursor_w, - height: content_h, + height: line_h, color: FC_CURSOR_COLOR, }); } diff --git a/crates/style/src/computed.rs b/crates/style/src/computed.rs index ba0d621..5fcbd37 100644 --- a/crates/style/src/computed.rs +++ b/crates/style/src/computed.rs @@ -547,6 +547,14 @@ pub struct ComputedStyle { pub border_bottom_color: Color, pub border_left_color: Color, + // Box model: border corner radii (px), one per corner. Circular only + // (no separate horizontal/vertical radii) — sufficient for the cases we + // render and resolved to absolute px at cascade time. + pub border_top_left_radius: f32, + pub border_top_right_radius: f32, + pub border_bottom_right_radius: f32, + pub border_bottom_left_radius: f32, + // Box model: dimensions pub width: LengthOrAuto, pub height: LengthOrAuto, @@ -678,6 +686,11 @@ impl Default for ComputedStyle { border_bottom_color: Color::rgb(0, 0, 0), border_left_color: Color::rgb(0, 0, 0), + border_top_left_radius: 0.0, + border_top_right_radius: 0.0, + border_bottom_right_radius: 0.0, + border_bottom_left_radius: 0.0, + width: LengthOrAuto::Auto, height: LengthOrAuto::Auto, min_width: LengthOrAuto::Auto, @@ -1595,6 +1608,23 @@ fn apply_property( } } + // Border corner radii. The `border-radius` shorthand is expanded into + // these four longhands by `expand_shorthand`. Resolved to absolute px; + // percentages (which need the border-box size) are treated as 0 for now. + "border-top-left-radius" + | "border-top-right-radius" + | "border-bottom-right-radius" + | "border-bottom-left-radius" => { + let r = resolve_border_radius(value, current_fs, viewport); + match property { + "border-top-left-radius" => style.border_top_left_radius = r, + "border-top-right-radius" => style.border_top_right_radius = r, + "border-bottom-right-radius" => style.border_bottom_right_radius = r, + "border-bottom-left-radius" => style.border_bottom_left_radius = r, + _ => {} + } + } + // Dimensions (percentages preserved for layout resolution) "width" => { style.width = resolve_layout_length_or_auto(value, current_fs, viewport); @@ -2625,6 +2655,20 @@ fn resolve_border_width(value: &CssValue, em_base: f32, viewport: (f32, f32)) -> } } +/// Resolve a single `border-radius` corner value to absolute px. Negative +/// values are clamped to 0 per spec; percentages resolve to 0 for now (they +/// need the border-box size, which is not known at cascade time). +fn resolve_border_radius(value: &CssValue, em_base: f32, viewport: (f32, f32)) -> f32 { + let px = match value { + CssValue::Length(n, unit) => resolve_length_unit(*n, *unit, em_base, viewport), + CssValue::Zero => 0.0, + CssValue::Number(n) if *n == 0.0 => 0.0, + CssValue::Math(expr) => eval_math_expr_full(expr, em_base, 0.0, viewport).unwrap_or(0.0), + _ => 0.0, + }; + px.max(0.0) +} + fn parse_border_style(value: &CssValue) -> BorderStyle { match value { CssValue::Keyword(k) => match k.as_str() { @@ -2739,6 +2783,16 @@ fn reset_property_to_initial(style: &mut ComputedStyle, property: &str) { "border-right-width" => style.border_right_width = initial.border_right_width, "border-bottom-width" => style.border_bottom_width = initial.border_bottom_width, "border-left-width" => style.border_left_width = initial.border_left_width, + "border-top-left-radius" => style.border_top_left_radius = initial.border_top_left_radius, + "border-top-right-radius" => { + style.border_top_right_radius = initial.border_top_right_radius + } + "border-bottom-right-radius" => { + style.border_bottom_right_radius = initial.border_bottom_right_radius + } + "border-bottom-left-radius" => { + style.border_bottom_left_radius = initial.border_bottom_left_radius + } "width" => style.width = initial.width, "height" => style.height = initial.height, "min-width" => style.min_width = initial.min_width, @@ -4079,6 +4133,65 @@ mod tests { assert_eq!(div_node.style.border_top_color, Color::rgb(255, 0, 0)); } + fn radii_of(css: &str) -> [f32; 4] { + let (mut doc, _, _, body) = make_doc_with_body(); + let div = doc.create_element("div"); + doc.append_child(body, div); + let ss = Parser::parse(css); + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); + let div_node = &styled.children[0].children[0]; + let s = &div_node.style; + [ + s.border_top_left_radius, + s.border_top_right_radius, + s.border_bottom_right_radius, + s.border_bottom_left_radius, + ] + } + + #[test] + fn border_radius_one_value_all_corners() { + assert_eq!( + radii_of("div { border-radius: 8px; }"), + [8.0, 8.0, 8.0, 8.0] + ); + } + + #[test] + fn border_radius_four_values_corner_order() { + // top-left, top-right, bottom-right, bottom-left. + assert_eq!( + radii_of("div { border-radius: 1px 2px 3px 4px; }"), + [1.0, 2.0, 3.0, 4.0] + ); + } + + #[test] + fn border_radius_two_values_diagonal_pairs() { + // 1st → TL & BR, 2nd → TR & BL. + assert_eq!( + radii_of("div { border-radius: 10px 20px; }"), + [10.0, 20.0, 10.0, 20.0] + ); + } + + #[test] + fn border_radius_longhand_single_corner() { + assert_eq!( + radii_of("div { border-top-left-radius: 12px; }"), + [12.0, 0.0, 0.0, 0.0] + ); + } + + #[test] + fn border_radius_elliptical_uses_horizontal_radii() { + // `h / v` elliptical syntax: we keep the horizontal radii only. + assert_eq!( + radii_of("div { border-radius: 5px 6px / 9px 9px; }"), + [5.0, 6.0, 5.0, 6.0] + ); + } + // ----------------------------------------------------------------------- // Position // ----------------------------------------------------------------------- diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 2811f73..cbe9fec 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -7,4 +7,4 @@ pub mod path; pub mod render; pub mod transform; -pub use render::{render_svg, svg_intrinsic_size}; +pub use render::{render_svg, render_svg_scaled, svg_intrinsic_size}; diff --git a/crates/svg/src/render.rs b/crates/svg/src/render.rs index fd78a87..ef17cce 100644 --- a/crates/svg/src/render.rs +++ b/crates/svg/src/render.rs @@ -116,6 +116,20 @@ pub fn render_svg( doc: &Document, svg_node: NodeId, font: Option<&Font>, +) -> Option<(u32, u32, Vec)> { + render_svg_scaled(doc, svg_node, font, 1.0) +} + +/// Rasterize an `` element at `device_scale` device pixels per logical +/// unit of its `width`/`height`. The returned image is `width × device_scale` +/// by `height × device_scale` pixels, so callers that draw it into a +/// logical-sized box on a Retina display get a pixel-crisp result. Layout +/// still uses the logical intrinsic size from [`svg_intrinsic_size`]. +pub fn render_svg_scaled( + doc: &Document, + svg_node: NodeId, + font: Option<&Font>, + device_scale: f32, ) -> Option<(u32, u32, Vec)> { let tag = doc.tag_name(svg_node)?; if tag != "svg" { @@ -139,8 +153,10 @@ pub fn render_svg( return None; } - let pw = width.ceil() as u32; - let ph = height.ceil() as u32; + // Device-pixel output size. + let ds = device_scale.max(1.0); + let pw = (width * ds).ceil() as u32; + let ph = (height * ds).ceil() as u32; if pw == 0 || ph == 0 || pw > 4096 || ph > 4096 { return None; } @@ -151,12 +167,62 @@ pub fn render_svg( view_box, }; - let mut canvas = Canvas::new(pw, ph); - let base_transform = viewport.transform(); + // The scanline fill is not antialiased, so render into a supersampled + // canvas and box-downsample. This smooths edges and curves (especially + // important for small icons). The factor is reduced for large SVGs to keep + // the working canvas within bounds. + let mut ss = 4u32; + while ss > 1 && (pw * ss > 4096 || ph * ss > 4096) { + ss -= 1; + } + let (cw, ch) = (pw * ss, ph * ss); + + let mut canvas = Canvas::new(cw, ch); + // Apply the device scale and the supersample factor on top of the + // viewBox→viewport transform, so both viewBox and raw-coordinate SVGs fill + // the supersampled, device-scaled canvas. + let total = ss as f32 * ds; + let base_transform = Transform::scale(total, total).multiply(&viewport.transform()); render_children(doc, svg_node, &base_transform, &mut canvas, font); - Some((pw, ph, canvas.data)) + let data = if ss > 1 { + downsample_rgba(&canvas.data, cw, ch, ss) + } else { + canvas.data + }; + Some((pw, ph, data)) +} + +/// Box-downsample an RGBA buffer by integer factor `ss`, averaging in +/// premultiplied-alpha space so edge pixels don't pick up dark fringes from +/// transparent neighbours. +fn downsample_rgba(src: &[u8], cw: u32, ch: u32, ss: u32) -> Vec { + let pw = cw / ss; + let ph = ch / ss; + let mut out = vec![0u8; (pw * ph * 4) as usize]; + let n = ss * ss; + for oy in 0..ph { + for ox in 0..pw { + let (mut sr, mut sg, mut sb, mut sa) = (0u32, 0u32, 0u32, 0u32); + for sy in 0..ss { + for sx in 0..ss { + let ix = (((oy * ss + sy) * cw + (ox * ss + sx)) * 4) as usize; + let a = src[ix + 3] as u32; + sr += src[ix] as u32 * a; + sg += src[ix + 1] as u32 * a; + sb += src[ix + 2] as u32 * a; + sa += a; + } + } + let oi = ((oy * pw + ox) * 4) as usize; + out[oi + 3] = (sa / n) as u8; + out[oi] = sr.checked_div(sa).unwrap_or(0) as u8; + out[oi + 1] = sg.checked_div(sa).unwrap_or(0) as u8; + out[oi + 2] = sb.checked_div(sa).unwrap_or(0) as u8; + } + } + out } /// Get the intrinsic size of an SVG element without rendering it. @@ -361,33 +427,45 @@ fn render_text( let default_fill = Paint::Solid(SvgColor::black()); let fill = attrs.fill.as_ref().unwrap_or(&default_fill); + // Rasterize glyphs at the transform's (isotropic) scale so that scaling — + // e.g. a viewBox or supersample factor — produces correspondingly larger + // glyph bitmaps instead of spreading fixed-size bitmaps into gaps. The + // glyph pen origin is transformed to device space; the device-sized bitmap + // is then blitted there. (Rotation/shear are not modelled for text.) + let scale = (transform.a * transform.d - transform.b * transform.c) + .abs() + .sqrt(); + if scale <= 0.0 { + return; + } + let render_fs = font_size * scale; let glyphs = font.shape_text(&text, font_size); for glyph in &glyphs { - let bitmap = font.rasterize_glyph(glyph.glyph_id, font_size); - if let Some(bmp) = bitmap { - let bx = x + glyph.x_offset + bmp.bearing_x as f32; - let by = y + glyph.y_offset - bmp.bearing_y as f32; - - for row in 0..bmp.height { - for col in 0..bmp.width { - let coverage = bmp.data[(row * bmp.width + col) as usize]; - if coverage == 0 { - continue; - } - let px = bx + col as f32; - let py = by + row as f32; - let (tx, ty) = transform.apply(px, py); - - let color = - sample_paint(fill, tx, ty, attrs.opacity * attrs.fill_opacity, transform); - let a = (coverage as u32 * color.a as u32) / 255; - if a > 0 { - canvas.blend_pixel( - tx as i32, - ty as i32, - SvgColor::new(color.r, color.g, color.b, a as u8), - ); - } + let Some(bmp) = font.rasterize_glyph(glyph.glyph_id, render_fs) else { + continue; + }; + // Pen (baseline) origin in user space → device space. + let (pen_dx, pen_dy) = transform.apply(x + glyph.x_offset, y + glyph.y_offset); + let bx = pen_dx + bmp.bearing_x as f32; + let by = pen_dy - bmp.bearing_y as f32; + + for row in 0..bmp.height { + for col in 0..bmp.width { + let coverage = bmp.data[(row * bmp.width + col) as usize]; + if coverage == 0 { + continue; + } + let tx = bx + col as f32; + let ty = by + row as f32; + let color = + sample_paint(fill, tx, ty, attrs.opacity * attrs.fill_opacity, transform); + let a = (coverage as u32 * color.a as u32) / 255; + if a > 0 { + canvas.blend_pixel( + tx as i32, + ty as i32, + SvgColor::new(color.r, color.g, color.b, a as u8), + ); } } } @@ -1315,6 +1393,38 @@ mod tests { assert_eq!(data[center + 3], 255); // A } + #[test] + fn viewbox_scales_content_to_viewport() { + // A circle of radius 11 in a 0..24 viewBox, drawn into a 96px svg, must + // fill most of the canvas (scaled ×4) — the center pixel and a pixel + // near the edge are both inside the circle. + let mut doc = Document::new(); + let root = doc.root(); + let svg = doc.create_element_ns("svg", Some("http://www.w3.org/2000/svg")); + doc.set_attribute(svg, "width", "96"); + doc.set_attribute(svg, "height", "96"); + doc.set_attribute(svg, "viewBox", "0 0 24 24"); + doc.append_child(root, svg); + let circle = doc.create_element_ns("circle", Some("http://www.w3.org/2000/svg")); + doc.set_attribute(circle, "cx", "12"); + doc.set_attribute(circle, "cy", "12"); + doc.set_attribute(circle, "r", "11"); + doc.set_attribute(circle, "fill", "#ff0000"); + doc.append_child(svg, circle); + + let (w, h, data) = render_svg(&doc, svg, None).expect("render"); + assert_eq!((w, h), (96, 96)); + let alpha_at = |x: usize, y: usize| data[(y * 96 + x) * 4 + 3]; + // Center is filled. + assert!(alpha_at(48, 48) > 0, "center should be filled"); + // A point at (48, 8) is well inside a radius-44 circle centered at 48, + // but outside a radius-11 circle — so this only passes when scaled. + assert!( + alpha_at(48, 8) > 0, + "edge pixel should be filled when viewBox is scaled to the viewport", + ); + } + #[test] fn render_rect_with_linear_gradient() { let mut doc = Document::new(); diff --git a/tools/generate_icon/src/main.rs b/tools/generate_icon/src/main.rs index 6e498c0..2d3d9f3 100644 --- a/tools/generate_icon/src/main.rs +++ b/tools/generate_icon/src/main.rs @@ -224,11 +224,16 @@ mod tests { let pixels = render_icon().expect("render app icon from SVG"); assert_eq!(pixels.len(), (SIZE * SIZE * 4) as usize); + // Count bright (near-white) pixels; allow partial alpha so the + // antialiased edges of the wordmark glyphs still count. let bright_pixels = pixels .chunks_exact(4) - .filter(|px| px[3] == 255 && px[0] > 180 && px[1] > 180 && px[2] > 180) + .filter(|px| px[3] > 128 && px[0] > 180 && px[1] > 180 && px[2] > 180) .count(); - assert!(bright_pixels > 20_000); + assert!( + bright_pixels > 20_000, + "wordmark not visible: {bright_pixels}" + ); } #[test]