From 66a82830493701672e830b383cde28bf77c2c8b4 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 16:02:04 +0100 Subject: [PATCH 1/4] fix(native): HTML renders on iOS, tweet photos that fill their box - iOS shows HTML renders: frames.rs gains a UIKit side (a WKWebView per frame in a clipping host over Rune's view), sharing the macOS bookkeeping. Touches on an inline page also reach Rune's view, so a drag scrolls the list and a long press opens the message menu; a tap still presses the page. The iOS app now places frames after every frame drawn. - overflow-x: auto on a non-wrapping flex row becomes a Rune horizontal viewport that takes its parent's width, so a tweet's w-5/6 photo strip is no longer a sliver and several photos scroll. - Sticky boxes (a pinned date) only cut holes in frames of their own layer: a fullscreen render in a dialog no longer shows the page's date pills. - The wrapper page sets a device-width viewport. - Offline: link-preview requests go to the network, and HAZEL_FIXTURE_HTML takes a page URL (scripts/serve-fixture-html.ts serves one to the iOS Simulator). Co-Authored-By: Claude Opus 5.5 --- apps/native/README.md | 12 +- apps/native/host/Cargo.lock | 1 + apps/native/host/Cargo.toml | 8 +- apps/native/host/ios/src/main.rs | 7 +- apps/native/host/src/frames.rs | 976 +++++++++++++----- apps/native/host/src/layout.rs | 29 + apps/native/host/src/margins.rs | 1 + apps/native/host/src/tests.rs | 103 +- apps/native/host/src/wire.rs | 2 + apps/native/scripts/ios.ts | 17 +- apps/native/scripts/serve-fixture-html.ts | 16 + apps/native/src/data-source.ts | 10 +- .../native/src/fixture/html-render-message.ts | 30 + apps/native/src/fixture/html-render.ts | 44 +- apps/native/src/offline-backend.test.ts | 13 + apps/native/src/offline-backend.ts | 9 + apps/web-foldkit/src/chat/unfurl.ts | 2 +- packages/foldkit-rune/src/style.test.ts | 9 + packages/foldkit-rune/src/style.ts | 5 +- 19 files changed, 970 insertions(+), 324 deletions(-) create mode 100644 apps/native/scripts/serve-fixture-html.ts create mode 100644 apps/native/src/fixture/html-render-message.ts diff --git a/apps/native/README.md b/apps/native/README.md index c78f5e6c1..a726388c9 100644 --- a/apps/native/README.md +++ b/apps/native/README.md @@ -52,7 +52,10 @@ compiles after the commit that shows it, and the screen restyles. `NATIVE_THEME=dark`, `NATIVE_WIDTH`/`NATIVE_HEIGHT` set the window; `NATIVE_DEBUG=1` logs every Message and editor event; `NATIVE_STATS=` appends per-commit timings. `HAZEL_FIXTURE_HTML=` ends the offline #general with a message showing that page as an -HTML render, served from a local port as the api serves one (the file is read on every load). +HTML render, served from a local port as the api serves one (the file is read on every load); a +page URL instead is shown as it is (iOS: `bun scripts/serve-fixture-html.ts page.html` serves the +file and prints the URL to build with). Offline, links still unfurl: the link-preview worker's +requests (previews, tweets) go to the network. `host/target/release/hazel-native --capture script.json` runs headless on a real-time frame loop and follows a script (`settle`, `waitText`, `clickText`, `focusEditable`, `type`, `key`, `snap`, `dump`, @@ -147,7 +150,12 @@ Motion runs on Rune's display link (`CADisplayLink`), at the screen's rate: 120 ProMotion iPhone (`CADisableMinimumFrameDurationOnPhone`). When WebKit ends the page's process (memory pressure, a crash), the bridge loads a fresh page and runtime, as a browser reloads a tab. -Not yet on iOS: updates and HTML renders. +HTML renders show as on the Mac (`host/src/frames.rs`, its UIKit side): each page in a `WKWebView` +over its box. A finger on an inline page also reaches Rune's view, so a drag over it scrolls the +list and a long press opens the message's menu, while a tap presses what is under it in the page; a +fullscreen page scrolls itself. + +Not yet on iOS: updates. ### TestFlight diff --git a/apps/native/host/Cargo.lock b/apps/native/host/Cargo.lock index 332755932..2fee3155b 100644 --- a/apps/native/host/Cargo.lock +++ b/apps/native/host/Cargo.lock @@ -1196,6 +1196,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", + "objc2-ui-kit 0.3.2", "objc2-user-notifications 0.3.2", "objc2-web-kit", "rune", diff --git a/apps/native/host/Cargo.toml b/apps/native/host/Cargo.toml index e894d2e2a..4e48666f0 100644 --- a/apps/native/host/Cargo.toml +++ b/apps/native/host/Cargo.toml @@ -40,13 +40,17 @@ block2 = "0.6" objc2-foundation = "0.3" # The web views HTML render frames show their pages in (`src/frames.rs`). -[target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" -objc2-app-kit = "0.3" objc2-web-kit = "0.3" objc2-quartz-core = "0.3" objc2-core-graphics = "0.3" objc2-core-foundation = "0.3" + +[target.'cfg(target_os = "ios")'.dependencies] +objc2-ui-kit = "0.3" + +[target.'cfg(target_os = "macos")'.dependencies] +objc2-app-kit = "0.3" objc2-user-notifications = { version = "0.3", default-features = false, features = [ "std", "UNUserNotificationCenter", diff --git a/apps/native/host/ios/src/main.rs b/apps/native/host/ios/src/main.rs index d83931595..fbc75111c 100644 --- a/apps/native/host/ios/src/main.rs +++ b/apps/native/host/ios/src/main.rs @@ -13,7 +13,7 @@ mod sign_in; #[cfg(target_os = "ios")] fn main() { - use hazel_native::{State, TEXT_SMOOTHING, dom_motion, images, view, window_key}; + use hazel_native::{State, TEXT_SMOOTHING, dom_motion, frames, images, view, window_key}; use rune::{App, AppSpec}; dom_motion(); @@ -31,7 +31,10 @@ fn main() { // iOS gives the window the whole screen whatever size is asked for. ..AppSpec::new("Hazel", (402.0, 874.0), State::default, view) }; - let app = App::new(spec).font_smoothing(TEXT_SMOOTHING); + // HTML renders' web views go where their boxes were drawn, after every frame. + let app = App::new(spec) + .font_smoothing(TEXT_SMOOTHING) + .on_frame_drawn(frames::sync); bridge::set_handle(app.handle()); app.run(); } diff --git a/apps/native/host/src/frames.rs b/apps/native/host/src/frames.rs index afb18174e..c662792dc 100644 --- a/apps/native/host/src/frames.rs +++ b/apps/native/host/src/frames.rs @@ -21,17 +21,26 @@ pub struct FrameSpec { pub src: String, /// The ancestors that clip it (`overflow: hidden`, scroll viewports), by path. pub clips: Vec, + /// The portal it is drawn in (a dialog), by path; `None` in the page itself. + pub layer: Option, } /// The frames of a tree, in document order. pub fn collect(root: &Arc) -> Vec { let mut out = Vec::new(); - walk(root, "r", &mut Vec::new(), &mut out); + walk(root, "r", None, &mut Vec::new(), &mut out); out } -/// Collects the frames under `node`, with the clipping ancestors met on the way down (`clips`). -fn walk(node: &Arc, path: &str, clips: &mut Vec, out: &mut Vec) { +/// Collects the frames under `node`, with the portal they are in (`layer`) and the clipping +/// ancestors met on the way down (`clips`). +fn walk( + node: &Arc, + path: &str, + layer: Option<&str>, + clips: &mut Vec, + out: &mut Vec, +) { let Node::Elem { tag, st, @@ -51,23 +60,25 @@ fn walk(node: &Arc, path: &str, clips: &mut Vec, out: &mut Vec>, +} + +impl CoverSpec { + /// Whether it is drawn over the frame `frame`. + pub fn is_over(&self, frame: &FrameSpec) -> bool { + // The dialog a frame is in is not over it. + let inside = frame + .path + .strip_prefix(self.path.as_str()) + .is_some_and(|rest| rest.starts_with('/')); + !inside + && self + .sticky_in + .as_ref() + .is_none_or(|layer| *layer == frame.layer) + } } /// What is drawn over a frame's box: the overlays among `portals`, and the sticky elements of @@ -89,7 +120,7 @@ pub struct CoverSpec { /// element that places a menu is often an empty layer as large as the window. pub fn collect_covers(root: &Arc, portals: &[(String, Arc)]) -> Vec { let mut out = Vec::new(); - sticky(root, "r", &mut out); + sticky(root, "r", None, &mut out); for (path, node) in portals { let Node::Elem { ov: Some(ov), .. } = node.as_ref() else { // A fixed element with no overlay of its own is part of the page. @@ -102,34 +133,46 @@ pub fn collect_covers(root: &Arc, portals: &[(String, Arc)]) -> Vec< out.push(CoverSpec { path: path.clone(), modal: true, + sticky_in: None, }); } else { - painted(node, path, &mut out); + painted(node, path, None, &mut out); } } out } -/// The sticky elements under `node`, as what they paint: a frame scrolls under them. -fn sticky(node: &Arc, path: &str, out: &mut Vec) { +/// The sticky elements under `node`, as what they paint: a frame of the same layer (`layer`, +/// the portal they are in) scrolls under them. +fn sticky(node: &Arc, path: &str, layer: Option<&str>, out: &mut Vec) { let Node::Elem { st, children, .. } = node.as_ref() else { return; }; if st.display.as_deref() == Some("none") { return; } + let layer = if crate::is_portal(node) { + Some(path) + } else { + layer + }; if st.sticky_top.is_some() { - painted(node, path, out); + painted(node, path, Some(layer.map(str::to_string)), out); return; } for (i, child) in children.iter().enumerate() { - sticky(child, &crate::child_path(path, i, child), out); + sticky(child, &crate::child_path(path, i, child), layer, out); } } /// The outermost boxes under `node` that have a background: where an overlay is seen, and so /// where it covers. What such a box holds is inside it and adds nothing. -fn painted(node: &Arc, path: &str, out: &mut Vec) { +fn painted( + node: &Arc, + path: &str, + sticky_in: Option>, + out: &mut Vec, +) { let Node::Elem { st, children, .. } = node.as_ref() else { return; }; @@ -140,11 +183,17 @@ fn painted(node: &Arc, path: &str, out: &mut Vec) { out.push(CoverSpec { path: path.to_string(), modal: false, + sticky_in, }); return; } for (i, child) in children.iter().enumerate() { - painted(child, &crate::child_path(path, i, child), out); + painted( + child, + &crate::child_path(path, i, child), + sticky_in.clone(), + out, + ); } } @@ -237,8 +286,8 @@ pub fn place(rect: Rect, opacity: f32, clips: &[Rect], covers: &[Cover]) -> Opti /// What the wrapper page tells the host (`wrapper_html`'s script). #[derive(serde::Deserialize)] #[serde(tag = "k", rename_all = "lowercase")] -// Read by the macOS web views only. -#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +// Read by the web views on Apple platforms only. +#[cfg_attr(not(target_vendor = "apple"), allow(dead_code))] enum PageEvent { Load, Message { @@ -249,8 +298,8 @@ enum PageEvent { } /// The line for the runtime, for what the wrapper page of the frame at `path` posted. -// Read by the macOS web views only. -#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +// Read by the web views on Apple platforms only. +#[cfg_attr(not(target_vendor = "apple"), allow(dead_code))] fn runtime_message(path: &str, body: &str) -> Option { match serde_json::from_str::(body).ok()? { PageEvent::Load => Some(serde_json::json!({ "t": "frame", "path": path, "event": "load" })), @@ -292,8 +341,8 @@ fn is_light(src: &str) -> bool { } /// A fullscreen frame scrolls its own page; an inline one leaves the wheel to the app's list. -// Read by the macOS web views only. -#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +// Read by the web views on Apple platforms only. +#[cfg_attr(not(target_vendor = "apple"), allow(dead_code))] fn scrolls_itself(src: &str) -> bool { src.contains("hazel-display=fullscreen") } @@ -303,12 +352,13 @@ fn scrolls_itself(src: &str) -> bool { /// the host. `None` for a `src` that is not an http(s) URL. /// /// The frame's canvas stays transparent only while its colour scheme matches the page's, so the -/// wrapper follows the theme the app posts. +/// wrapper follows the theme the app posts. Its viewport is the view's width, or iOS lays the +/// page out for a desktop's and scales it down. pub fn wrapper_html(src: &str) -> Option { let origin = origin_of(src)?; let scheme = if is_light(src) { "light" } else { "dark" }; Some(format!( - r#""#, + r#""#, origin = origin, scheme = scheme, src = escape_attribute(src), @@ -318,7 +368,7 @@ pub fn wrapper_html(src: &str) -> Option { /// Puts every frame's web view where its box was drawn in the frame just presented. pub fn sync(runtime: &rune::Runtime) { let state = &runtime.state; - if state.frames.is_empty() && !native::any() { + if state.frames.is_empty() && !views::any() { return; } let rect_of = |path: &str| runtime.presented(path).map(|(rect, _)| rect); @@ -337,13 +387,7 @@ pub fn sync(runtime: &rune::Runtime) { let covers: Vec = state .covers .iter() - // The dialog a frame is in is not over it. - .filter(|cover| { - !frame - .path - .strip_prefix(cover.path.as_str()) - .is_some_and(|rest| rest.starts_with('/')) - }) + .filter(|cover| cover.is_over(frame)) .filter_map(|cover| { let rect = rect_of(&cover.path); Some(Cover { @@ -360,17 +404,17 @@ pub fn sync(runtime: &rune::Runtime) { (frame, place(rect, opacity, &clips, &covers)) }) .collect(); - native::apply(&placed); + views::apply(&placed); } /// `contentWindow.postMessage(data)` on the frame at `path`. pub fn post(path: &str, data: &serde_json::Value) { - native::post(path, data); + views::post(path, data); } /// No web view on this platform: frames keep their boxes and show nothing. -#[cfg(not(target_os = "macos"))] -mod native { +#[cfg(not(target_vendor = "apple"))] +mod views { use super::{FrameSpec, Placement}; pub fn any() -> bool { @@ -380,35 +424,213 @@ mod native { pub fn post(_: &str, _: &serde_json::Value) {} } -/// The frames' web views in the app's window: a `WKWebView` each, inside a host view that clips it. -#[cfg(target_os = "macos")] -mod native { - use std::cell::{Cell, RefCell}; +/// The frames' web views on macOS and iOS: a `WKWebView` each, inside a host view that clips it +/// and is cut out where something else is drawn over it. What differs between AppKit and UIKit +/// lives in `platform`. +#[cfg(target_vendor = "apple")] +mod views { + use std::cell::RefCell; use std::collections::HashMap; use objc2::rc::Retained; - use objc2::runtime::ProtocolObject; use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send}; - use objc2_app_kit::{NSApplication, NSColor, NSEvent, NSView}; use objc2_core_foundation::{CGPoint, CGRect, CGSize}; use objc2_core_graphics::CGMutablePath; - use objc2_foundation::{NSNumber, NSObject, NSObjectProtocol, NSString, ns_string}; - use objc2_quartz_core::{CAShapeLayer, kCAFillRuleEvenOdd}; - use objc2_web_kit::{ - WKScriptMessage, WKScriptMessageHandler, WKUserContentController, WKWebView, - WKWebViewConfiguration, WKWebsiteDataStore, - }; + use objc2_foundation::{NSObject, NSObjectProtocol, NSString}; + use objc2_quartz_core::{CALayer, CAShapeLayer, kCAFillRuleEvenOdd}; + use objc2_web_kit::{WKScriptMessage, WKScriptMessageHandler, WKUserContentController}; + use super::platform::FrameView; use super::{FrameSpec, MaskShape, Placement, Rect}; - /// A Rune rect (`[x, y, w, h]`, from the top left) as AppKit's. - fn cg(rect: Rect) -> CGRect { + /// A Rune rect (`[x, y, w, h]`, from the top left) as Core Graphics'. The host views are + /// flipped on macOS, so both platforms place from the top left. + pub(super) fn cg(rect: Rect) -> CGRect { CGRect::new( CGPoint::new(rect[0] as f64, rect[1] as f64), CGSize::new(rect[2] as f64, rect[3] as f64), ) } + /// Whether `[x, y]`, from the host's top left, is in one of the mask's holes. + pub(super) fn in_hole(mask: Option<&MaskShape>, [x, y]: [f32; 2]) -> bool { + mask.is_some_and(|mask| { + mask.holes + .iter() + .any(|[hx, hy, hw, hh]| x >= *hx && x < hx + hw && y >= *hy && y < hy + hh) + }) + } + + /// Cuts the holes out of the host's layer: everything but the holes shows. With the + /// even-odd rule a rect inside the outer one is cut out. + pub(super) fn set_mask(layer: &CALayer, mask: Option<&MaskShape>) { + let Some(shape) = mask else { + unsafe { layer.setMask(None) }; + return; + }; + let path = CGMutablePath::new(); + let outer = cg([0.0, 0.0, shape.size[0], shape.size[1]]); + unsafe { + CGMutablePath::add_rect(Some(&path), std::ptr::null(), outer); + for hole in &shape.holes { + CGMutablePath::add_rect(Some(&path), std::ptr::null(), cg(*hole)); + } + } + let layer_mask = CAShapeLayer::new(); + layer_mask.setFrame(outer); + layer_mask.setFillRule(unsafe { kCAFillRuleEvenOdd }); + layer_mask.setPath(Some(&path)); + unsafe { layer.setMask(Some(&layer_mask)) }; + } + + pub(super) struct MessagesIvars { + path: String, + } + + define_class!( + // SAFETY: NSObject has no subclassing requirements; the class does not implement `Drop`. + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "HazelFrameMessages"] + #[ivars = MessagesIvars] + /// What a frame's wrapper page posts to the host. + pub(super) struct FrameMessages; + + unsafe impl NSObjectProtocol for FrameMessages {} + + unsafe impl WKScriptMessageHandler for FrameMessages { + /// The wrapper page's `load` and the framed page's messages, on to the runtime. + #[unsafe(method(userContentController:didReceiveScriptMessage:))] + fn did_receive( + &self, + _controller: &WKUserContentController, + message: &WKScriptMessage, + ) { + // The handler exists in every frame of the view: only the wrapper is ours. The + // framed page reaches the app through `postMessage` to the wrapper, which says + // whose window a message came from. + if !unsafe { message.frameInfo().isMainFrame() } { + return; + } + let body = unsafe { message.body() }; + let Some(body) = body.downcast_ref::() else { + return; + }; + if let Some(line) = super::runtime_message(&self.ivars().path, &body.to_string()) { + crate::outbox::write_line(line); + } + } + } + ); + + impl FrameMessages { + pub(super) fn new(mtm: MainThreadMarker, path: &str) -> Retained { + let this = Self::alloc(mtm).set_ivars(MessagesIvars { + path: path.to_string(), + }); + unsafe { msg_send![super(this), init] } + } + } + + /// The wrapper page for `src`; `None` (and nothing loads) for a `src` it refuses. + pub(super) fn wrapper(src: &str) -> Option> { + let Some(html) = super::wrapper_html(src) else { + eprintln!("[host] a frame's src is not an http(s) URL"); + return None; + }; + Some(NSString::from_str(&html)) + } + + struct Entry { + view: FrameView, + src: String, + } + + thread_local! { + static VIEWS: RefCell> = RefCell::new(HashMap::new()); + } + + /// Whether any web view exists: with none and no frame in the tree, a drawn frame has nothing to place. + pub fn any() -> bool { + VIEWS.with_borrow(|views| !views.is_empty()) + } + + /// Brings the web views in line with the frames of the tree just drawn: removes the ones whose + /// frame is gone, creates the ones that are new, reloads a changed `src`, and places each. + pub fn apply(placed: &[(&FrameSpec, Option)]) { + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + VIEWS.with_borrow_mut(|views| { + views.retain(|path, entry| { + let keep = placed.iter().any(|(frame, _)| frame.path == *path); + if !keep { + entry.view.remove(); + } + keep + }); + for (frame, placement) in placed { + if !views.contains_key(&frame.path) { + // A frame nothing shows of yet still loads, as a browser's does; one under + // a dialog waits until it is uncovered. + if placement.is_none() { + continue; + } + let Some(view) = FrameView::create(mtm, frame) else { + continue; + }; + view.load(&frame.src); + views.insert( + frame.path.clone(), + Entry { + view, + src: frame.src.clone(), + }, + ); + } + let Some(entry) = views.get_mut(&frame.path) else { + continue; + }; + if entry.src != frame.src { + entry.src = frame.src.clone(); + entry.view.load(&frame.src); + } + match placement { + Some(placement) => entry.view.put(placement), + None => entry.view.hide(), + } + } + }); + } + + /// Hands `data` to the wrapper page of the frame at `path`, which posts it to the framed page. + pub fn post(path: &str, data: &serde_json::Value) { + VIEWS.with_borrow(|views| { + if let Some(entry) = views.get(path) { + entry + .view + .evaluate(&format!("window.__hazelPost&&window.__hazelPost({data})")); + } + }); + } +} + +/// AppKit: the web views go in the app window's own view, which Rune draws in. +#[cfg(target_os = "macos")] +mod platform { + use std::cell::{Cell, RefCell}; + + use objc2::rc::Retained; + use objc2::runtime::ProtocolObject; + use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send}; + use objc2_app_kit::{NSApplication, NSColor, NSEvent, NSView}; + use objc2_core_foundation::CGPoint; + use objc2_foundation::{NSNumber, NSObject, NSString, ns_string}; + use objc2_web_kit::{WKWebView, WKWebViewConfiguration, WKWebsiteDataStore}; + + use super::views::{FrameMessages, cg, in_hole, set_mask, wrapper}; + use super::{FrameSpec, MaskShape, Placement}; + struct HostIvars { /// The mask on the view now: where something else is drawn over it. mask: RefCell>, @@ -443,13 +665,7 @@ mod native { } // `point` is in the superview's coordinates. let own = self.convertPoint_fromView(point, unsafe { self.superview() }.as_deref()); - let (x, y) = (own.x as f32, own.y as f32); - let in_hole = ivars.mask.borrow().as_ref().is_some_and(|mask| { - mask.holes - .iter() - .any(|[hx, hy, hw, hh]| x >= *hx && x < hx + hw && y >= *hy && y < hy + hh) - }); - if in_hole { + if in_hole(ivars.mask.borrow().as_ref(), [own.x as f32, own.y as f32]) { return std::ptr::null_mut(); } unsafe { msg_send![super(self), hitTest: point] } @@ -485,59 +701,9 @@ mod native { } ); - struct MessagesIvars { - path: String, - } - - define_class!( - // SAFETY: NSObject has no subclassing requirements; the class does not implement `Drop`. - #[unsafe(super(NSObject))] - #[thread_kind = MainThreadOnly] - #[name = "HazelFrameMessages"] - #[ivars = MessagesIvars] - /// What a frame's wrapper page posts to the host. - struct FrameMessages; - - unsafe impl NSObjectProtocol for FrameMessages {} - - unsafe impl WKScriptMessageHandler for FrameMessages { - /// The wrapper page's `load` and the framed page's messages, on to the runtime. - #[unsafe(method(userContentController:didReceiveScriptMessage:))] - fn did_receive( - &self, - _controller: &WKUserContentController, - message: &WKScriptMessage, - ) { - // The handler exists in every frame of the view: only the wrapper is ours. The - // framed page reaches the app through `postMessage` to the wrapper, which says - // whose window a message came from. - if !unsafe { message.frameInfo().isMainFrame() } { - return; - } - let body = unsafe { message.body() }; - let Some(body) = body.downcast_ref::() else { - return; - }; - if let Some(line) = super::runtime_message(&self.ivars().path, &body.to_string()) { - crate::outbox::write_line(line); - } - } - } - ); - - struct FrameView { + pub(super) struct FrameView { host: Retained, web: Retained, - src: String, - } - - thread_local! { - static VIEWS: RefCell> = RefCell::new(HashMap::new()); - } - - /// Whether any web view exists: with none and no frame in the tree, a drawn frame has nothing to place. - pub fn any() -> bool { - VIEWS.with_borrow(|views| !views.is_empty()) } /// The app window's own view, which Rune draws in. The app has one window that can be a @@ -551,190 +717,451 @@ mod native { .find_map(|window| window.contentView()) } - /// Loads the wrapper page for `src` into the view; a `src` the wrapper refuses loads nothing. - fn load(web: &FrameWebView, src: &str) { - let Some(html) = super::wrapper_html(src) else { - eprintln!("[host] a frame's src is not an http(s) URL"); - return; - }; - web.ivars().scrolls_itself.set(super::scrolls_itself(src)); - unsafe { web.loadHTMLString_baseURL(&NSString::from_str(&html), None) }; - } + impl FrameView { + /// A frame's views, hidden in the window until placed: the clipping host, and in it the + /// web view with its bridge to the runtime, a store of its own and no background. + pub(super) fn create(mtm: MainThreadMarker, frame: &FrameSpec) -> Option { + let parent = window_view(mtm)?; + let host: Retained = { + let this = FrameHost::alloc(mtm).set_ivars(HostIvars { + mask: RefCell::new(None), + passes_presses: Cell::new(true), + }); + unsafe { msg_send![super(this), initWithFrame: cg([0.0; 4])] } + }; + host.setWantsLayer(true); + if let Some(layer) = host.layer() { + layer.setMasksToBounds(true); + } - /// A frame's views, hidden in `parent` until placed: the clipping host, and in it the web view - /// with its bridge to the runtime, a store of its own and no background. - fn create(mtm: MainThreadMarker, parent: &NSView, frame: &FrameSpec) -> FrameView { - let host: Retained = { - let this = FrameHost::alloc(mtm).set_ivars(HostIvars { - mask: RefCell::new(None), - passes_presses: Cell::new(true), - }); - unsafe { msg_send![super(this), initWithFrame: cg([0.0; 4])] } - }; - host.setWantsLayer(true); - if let Some(layer) = host.layer() { - layer.setMasksToBounds(true); + let configuration = unsafe { WKWebViewConfiguration::new(mtm) }; + // Nothing a page's assets store (cookies, caches) outlives the app or is shared with + // anything else that keeps web data. + unsafe { + configuration.setWebsiteDataStore(&WKWebsiteDataStore::nonPersistentDataStore(mtm)); + } + let messages = FrameMessages::new(mtm, &frame.path); + unsafe { + configuration + .userContentController() + .addScriptMessageHandler_name( + ProtocolObject::from_ref(&*messages), + ns_string!("hazel"), + ); + } + let web: Retained = { + let this = FrameWebView::alloc(mtm).set_ivars(WebIvars { + scrolls_itself: Cell::new(false), + }); + unsafe { + msg_send![super(this), initWithFrame: cg([0.0; 4]), configuration: &*configuration] + } + }; + // The page sits on the channel's background, drawn by Rune under the view. + // `drawsBackground` is the web view's own switch for it, set by key as there is no property. + unsafe { + let no = NSNumber::new_bool(false); + let _: () = msg_send![&*web, setValue: &*no, forKey: ns_string!("drawsBackground")]; + web.setUnderPageBackgroundColor(Some(&NSColor::clearColor())); + } + host.addSubview(&web); + host.setHidden(true); + parent.addSubview(&host); + Some(Self { host, web }) } - let configuration = unsafe { WKWebViewConfiguration::new(mtm) }; - // Nothing a page's assets store (cookies, caches) outlives the app or is shared with - // anything else that keeps web data. - unsafe { - configuration.setWebsiteDataStore(&WKWebsiteDataStore::nonPersistentDataStore(mtm)); + pub(super) fn load(&self, src: &str) { + let Some(html) = wrapper(src) else { + return; + }; + self.web + .ivars() + .scrolls_itself + .set(super::scrolls_itself(src)); + unsafe { self.web.loadHTMLString_baseURL(&html, None) }; } - let messages: Retained = { - let this = FrameMessages::alloc(mtm).set_ivars(MessagesIvars { - path: frame.path.clone(), - }); - unsafe { msg_send![super(this), init] } - }; - unsafe { - configuration - .userContentController() - .addScriptMessageHandler_name( - ProtocolObject::from_ref(&*messages), - ns_string!("hazel"), - ); + + /// The host shows `visible`; the web view inside keeps the whole box, so the page does + /// not reflow as it scrolls under a clip. + pub(super) fn put(&self, placement: &Placement) { + let [vx, vy, ..] = placement.visible; + let host_frame = cg(placement.visible); + if self.host.frame() != host_frame { + self.host.setFrame(host_frame); + } + let [x, y, w, h] = placement.rect; + let web_frame = cg([x - vx, y - vy, w, h]); + if self.web.frame() != web_frame { + self.web.setFrame(web_frame); + } + let shown = placement.opacity > 0.0; + self.host.ivars().passes_presses.set(!shown); + self.host.setAlphaValue(placement.opacity as f64); + self.host.setHidden(false); + + // Rebuilt when the holes move and when the part that shows changes size. + let mask = placement.mask(); + if *self.host.ivars().mask.borrow() == mask { + return; + } + if let Some(layer) = self.host.layer() { + set_mask(&layer, mask.as_ref()); + } + *self.host.ivars().mask.borrow_mut() = mask; } - let web: Retained = { - let this = FrameWebView::alloc(mtm).set_ivars(WebIvars { - scrolls_itself: Cell::new(false), - }); + + pub(super) fn hide(&self) { + self.host.setHidden(true); + } + + /// Takes the views out of the window; the bridge goes first, as the web view's content + /// controller would otherwise keep it, and the frame's path, alive. + pub(super) fn remove(&self) { unsafe { - msg_send![super(this), initWithFrame: cg([0.0; 4]), configuration: &*configuration] + self.web + .configuration() + .userContentController() + .removeAllScriptMessageHandlers(); } - }; - // The page sits on the channel's background, drawn by Rune under the view. - // `drawsBackground` is the web view's own switch for it, set by key as there is no property. - unsafe { - let no = NSNumber::new_bool(false); - let _: () = msg_send![&*web, setValue: &*no, forKey: ns_string!("drawsBackground")]; - web.setUnderPageBackgroundColor(Some(&NSColor::clearColor())); + self.host.removeFromSuperview(); } - host.addSubview(&web); - host.setHidden(true); - parent.addSubview(&host); - load(&web, &frame.src); - FrameView { - host, - web, - src: frame.src.clone(), + + pub(super) fn evaluate(&self, script: &str) { + unsafe { + self.web + .evaluateJavaScript_completionHandler(&NSString::from_str(script), None) + }; } } +} - /// The host shows `visible`; the web view inside keeps the whole box, so the page does not - /// reflow as it scrolls under a clip. - fn put(view: &FrameView, placement: &Placement) { - let [vx, vy, ..] = placement.visible; - let host_frame = cg(placement.visible); - if view.host.frame() != host_frame { - view.host.setFrame(host_frame); - } - let [x, y, w, h] = placement.rect; - let web_frame = cg([x - vx, y - vy, w, h]); - if view.web.frame() != web_frame { - view.web.setFrame(web_frame); - } - let shown = placement.opacity > 0.0; - view.host.ivars().passes_presses.set(!shown); - view.host.setAlphaValue(placement.opacity as f64); - view.host.setHidden(false); - - // Rebuilt when the holes move and when the part that shows changes size. - let mask = placement.mask(); - if *view.host.ivars().mask.borrow() == mask { - return; - } - let Some(layer) = view.host.layer() else { - return; - }; - match &mask { - None => unsafe { layer.setMask(None) }, - Some(shape) => { - // Everything but the holes: with the even-odd rule a rect inside the outer one is cut out. - let path = CGMutablePath::new(); - let outer = cg([0.0, 0.0, shape.size[0], shape.size[1]]); - unsafe { - CGMutablePath::add_rect(Some(&path), std::ptr::null(), outer); - for hole in &shape.holes { - CGMutablePath::add_rect(Some(&path), std::ptr::null(), cg(*hole)); - } +/// UIKit: the web views go in the window, over the view Rune draws in (its root view +/// controller's). A finger on an inline page also reaches Rune's view (`TouchForwarder`), so +/// the list it is in scrolls under the finger as it does anywhere else. +#[cfg(target_os = "ios")] +mod platform { + use std::cell::{Cell, RefCell}; + + use objc2::rc::{Retained, Weak}; + use objc2::runtime::{AnyObject, ProtocolObject}; + use objc2::{ + DefinedClass, MainThreadMarker, MainThreadOnly, define_class, extern_class, msg_send, + }; + use objc2_core_foundation::CGPoint; + use objc2_foundation::{NSObject, NSSet, NSString, ns_string}; + use objc2_ui_kit::{ + UIApplication, UIColor, UIEvent, UIGestureRecognizer, UIGestureRecognizerState, + UIResponder, UIScrollView, UIScrollViewContentInsetAdjustmentBehavior, UITouch, UIView, + UIWindow, UIWindowScene, + }; + use objc2_web_kit::{WKUserContentController, WKWebViewConfiguration, WKWebsiteDataStore}; + + use super::views::{FrameMessages, cg, in_hole, set_mask, wrapper}; + use super::{FrameSpec, MaskShape, Placement}; + + extern_class!( + /// WebKit's web view, a `UIView` on iOS (objc2-web-kit declares it for macOS only). + #[unsafe(super(UIView, UIResponder, NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "WKWebView"] + struct WebView; + ); + + struct HostIvars { + /// The mask on the view now: where something else is drawn over it. + mask: RefCell>, + /// Not shown yet (its box has no opacity): touches go to what is behind. + passes_presses: Cell, + } + + define_class!( + // SAFETY: UIView has no subclassing requirements beyond the main thread, and the class + // does not implement `Drop`. + #[unsafe(super(UIView, UIResponder, NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "HazelFrameHost"] + #[ivars = HostIvars] + /// The part of a frame's box that shows: it clips the web view inside it. + struct FrameHost; + + impl FrameHost { + /// No view where the frame is not shown yet or something else is drawn over it: the + /// touch goes to the view behind, which is Rune's. + #[unsafe(method(hitTest:withEvent:))] + fn hit_test(&self, point: CGPoint, event: Option<&UIEvent>) -> *mut UIView { + let ivars = self.ivars(); + // `point` is in the host's own coordinates. + if ivars.passes_presses.get() + || in_hole(ivars.mask.borrow().as_ref(), [point.x as f32, point.y as f32]) + { + return std::ptr::null_mut(); } - let layer_mask = CAShapeLayer::new(); - layer_mask.setFrame(outer); - layer_mask.setFillRule(unsafe { kCAFillRuleEvenOdd }); - layer_mask.setPath(Some(&path)); - unsafe { layer.setMask(Some(&layer_mask)) }; + unsafe { msg_send![super(self), hitTest: point, withEvent: event] } } } - *view.host.ivars().mask.borrow_mut() = mask; - } + ); - /// Takes a frame's views out of the window; the bridge goes first, as the web view's content - /// controller would otherwise keep it, and the frame's path, alive. - fn remove(view: FrameView) { - unsafe { - view.web - .configuration() - .userContentController() - .removeAllScriptMessageHandlers(); - } - view.host.removeFromSuperview(); + struct ForwarderIvars { + /// Rune's view, which turns touches into its own (winit's `touchesBegan:` and the rest). + target: Weak, + /// Fingers down on the page now. + down: Cell, } - /// Brings the web views in line with the frames of the tree just drawn: removes the ones whose - /// frame is gone, creates the ones that are new, reloads a changed `src`, and places each. - pub fn apply(placed: &[(&FrameSpec, Option)]) { - let Some(mtm) = MainThreadMarker::new() else { - return; - }; - VIEWS.with_borrow_mut(|views| { - let gone: Vec = views - .keys() - .filter(|path| !placed.iter().any(|(frame, _)| frame.path == **path)) - .cloned() - .collect(); - for path in gone { - if let Some(view) = views.remove(&path) { - remove(view); + define_class!( + // SAFETY: UIGestureRecognizer may be subclassed for its touch methods; the class does + // not implement `Drop`. + #[unsafe(super(UIGestureRecognizer, NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "HazelFrameTouchForwarder"] + #[ivars = ForwarderIvars] + /// Hands every touch on an inline page to Rune's view too, without taking it from the + /// page: it never recognizes, so a tap still presses what is under it in the page, and + /// a drag scrolls the list in Rune, as it would over any other part of a message. + struct TouchForwarder; + + impl TouchForwarder { + #[unsafe(method(touchesBegan:withEvent:))] + fn touches_began(&self, touches: &NSSet, event: &UIEvent) { + let _: () = unsafe { msg_send![super(self), touchesBegan: touches, withEvent: event] }; + self.ivars().down.set(self.ivars().down.get() + touches.count()); + if let Some(target) = self.ivars().target.load() { + target.touchesBegan_withEvent(touches, Some(event)); } } - for (frame, placement) in placed { - if !views.contains_key(&frame.path) { - // A frame nothing shows of yet still loads, as a browser's does; one under - // a dialog waits until it is uncovered. - let Some(parent) = placement.as_ref().and_then(|_| window_view(mtm)) else { - continue; - }; - views.insert(frame.path.clone(), create(mtm, &parent, frame)); + + #[unsafe(method(touchesMoved:withEvent:))] + fn touches_moved(&self, touches: &NSSet, event: &UIEvent) { + let _: () = unsafe { msg_send![super(self), touchesMoved: touches, withEvent: event] }; + if let Some(target) = self.ivars().target.load() { + target.touchesMoved_withEvent(touches, Some(event)); } - let Some(view) = views.get_mut(&frame.path) else { - continue; - }; - if view.src != frame.src { - view.src = frame.src.clone(); - load(&view.web, &frame.src); + } + + #[unsafe(method(touchesEnded:withEvent:))] + fn touches_ended(&self, touches: &NSSet, event: &UIEvent) { + let _: () = unsafe { msg_send![super(self), touchesEnded: touches, withEvent: event] }; + if let Some(target) = self.ivars().target.load() { + target.touchesEnded_withEvent(touches, Some(event)); } - match placement { - Some(placement) => put(view, placement), - None => view.host.setHidden(true), + self.lifted(touches); + } + + #[unsafe(method(touchesCancelled:withEvent:))] + fn touches_cancelled(&self, touches: &NSSet, event: &UIEvent) { + let _: () = unsafe { msg_send![super(self), touchesCancelled: touches, withEvent: event] }; + if let Some(target) = self.ivars().target.load() { + target.touchesCancelled_withEvent(touches, Some(event)); } + self.lifted(touches); } - }); + + /// Nothing the page recognizes (a long press selecting text) ends it early: Rune would + /// keep a finger that never lifts. + #[unsafe(method(canBePreventedByGestureRecognizer:))] + fn can_be_prevented(&self, _other: &UIGestureRecognizer) -> bool { + false + } + + /// It never takes a touch from the page's own gestures. + #[unsafe(method(canPreventGestureRecognizer:))] + fn can_prevent(&self, _other: &UIGestureRecognizer) -> bool { + false + } + + #[unsafe(method(reset))] + fn reset(&self) { + self.ivars().down.set(0); + let _: () = unsafe { msg_send![super(self), reset] }; + } + } + ); + + impl TouchForwarder { + fn new(mtm: MainThreadMarker, target: &UIView) -> Retained { + let this = Self::alloc(mtm).set_ivars(ForwarderIvars { + target: Weak::new(target), + down: Cell::new(0), + }); + let this: Retained = unsafe { + msg_send![super(this), initWithTarget: None::<&AnyObject>, action: None::] + }; + this.setCancelsTouchesInView(false); + this.setDelaysTouchesBegan(false); + this.setDelaysTouchesEnded(false); + this + } + + /// Once the last finger is up the gesture is over: failing ends it, so the next touch + /// starts afresh, and the page's own gestures never wait on it. + fn lifted(&self, touches: &NSSet) { + let down = self.ivars().down.get().saturating_sub(touches.count()); + self.ivars().down.set(down); + if down == 0 { + self.setState(UIGestureRecognizerState::Failed); + } + } } - /// Hands `data` to the wrapper page of the frame at `path`, which posts it to the framed page. - pub fn post(path: &str, data: &serde_json::Value) { - VIEWS.with_borrow(|views| { - let Some(view) = views.get(path) else { - return; + pub(super) struct FrameView { + host: Retained, + web: Retained, + forwarder: Retained, + } + + /// The app's window, and in it the view Rune draws in. + fn window(mtm: MainThreadMarker) -> Option<(Retained, Retained)> { + let app = UIApplication::sharedApplication(mtm); + #[allow(deprecated)] + let window = app.connectedScenes().iter().find_map(|scene| { + let scene = scene.downcast::().ok()?; + scene.windows().firstObject() + })?; + let rune = window.rootViewController()?.view()?; + Some((window, rune)) + } + + fn scroll_view(web: &WebView) -> Retained { + unsafe { msg_send![web, scrollView] } + } + + impl FrameView { + /// A frame's views, hidden over Rune's until placed: the clipping host, and in it the web + /// view with its bridge to the runtime, a store of its own and no background. + pub(super) fn create(mtm: MainThreadMarker, frame: &FrameSpec) -> Option { + let (window, rune) = window(mtm)?; + let host: Retained = { + let this = FrameHost::alloc(mtm).set_ivars(HostIvars { + mask: RefCell::new(None), + passes_presses: Cell::new(true), + }); + unsafe { msg_send![super(this), initWithFrame: cg([0.0; 4])] } }; - let script = format!("window.__hazelPost&&window.__hazelPost({data})"); + host.setClipsToBounds(true); + + let configuration = unsafe { WKWebViewConfiguration::new(mtm) }; + // Nothing a page's assets store (cookies, caches) outlives the app or is shared with + // anything else that keeps web data. + unsafe { + configuration.setWebsiteDataStore(&WKWebsiteDataStore::nonPersistentDataStore(mtm)); + // A page's video plays in its box, as in a browser tab, not fullscreen. + let _: () = msg_send![&*configuration, setAllowsInlineMediaPlayback: true]; + // A long press on an inline page is the message's menu: no text selection or + // its callout from the page over it. A fullscreen page selects text as a tab does. + let preferences: Retained = msg_send![&*configuration, preferences]; + let selects = super::scrolls_itself(&frame.src); + let _: () = msg_send![&*preferences, setTextInteractionEnabled: selects]; + } + let messages = FrameMessages::new(mtm, &frame.path); unsafe { - view.web - .evaluateJavaScript_completionHandler(&NSString::from_str(&script), None) + configuration + .userContentController() + .addScriptMessageHandler_name( + ProtocolObject::from_ref(&*messages), + ns_string!("hazel"), + ); + } + let web: Retained = unsafe { + msg_send![WebView::alloc(mtm), initWithFrame: cg([0.0; 4]), configuration: &*configuration] }; - }); + // The page sits on the channel's background, drawn by Rune under the view. + let clear = UIColor::clearColor(); + web.setOpaque(false); + web.setBackgroundColor(Some(&clear)); + let scroll = scroll_view(&web); + scroll.setBackgroundColor(Some(&clear)); + // The page is laid out in its box alone: the window's safe area is not its business. + scroll.setContentInsetAdjustmentBehavior( + UIScrollViewContentInsetAdjustmentBehavior::Never, + ); + unsafe { + let _: () = msg_send![&*web, setUnderPageBackgroundColor: &*clear]; + // A long press is the message's menu, as on the rest of the message. + let _: () = msg_send![&*web, setAllowsLinkPreview: false]; + } + let forwarder = TouchForwarder::new(mtm, &rune); + host.addGestureRecognizer(&forwarder); + host.addSubview(&web); + host.setHidden(true); + window.addSubview(&host); + Some(Self { + host, + web, + forwarder, + }) + } + + /// An inline page is as tall as its box and never scrolls: a drag over it is Rune's. + /// A fullscreen one scrolls its own page and keeps its touches. + pub(super) fn load(&self, src: &str) { + let Some(html) = wrapper(src) else { + return; + }; + let scrolls_itself = super::scrolls_itself(src); + let scroll = scroll_view(&self.web); + scroll.setScrollEnabled(scrolls_itself); + scroll.setBounces(scrolls_itself); + self.forwarder.setEnabled(!scrolls_itself); + unsafe { + let _: Option> = + msg_send![&*self.web, loadHTMLString: &*html, baseURL: None::<&AnyObject>]; + } + } + + /// The host shows `visible`; the web view inside keeps the whole box, so the page does + /// not reflow as it scrolls under a clip. + pub(super) fn put(&self, placement: &Placement) { + let [vx, vy, ..] = placement.visible; + let host_frame = cg(placement.visible); + if self.host.frame() != host_frame { + self.host.setFrame(host_frame); + } + let [x, y, w, h] = placement.rect; + let web_frame = cg([x - vx, y - vy, w, h]); + if self.web.frame() != web_frame { + self.web.setFrame(web_frame); + } + let shown = placement.opacity > 0.0; + self.host.ivars().passes_presses.set(!shown); + self.host.setAlpha(placement.opacity as f64); + self.host.setHidden(false); + + // Rebuilt when the holes move and when the part that shows changes size. + let mask = placement.mask(); + if *self.host.ivars().mask.borrow() == mask { + return; + } + set_mask(&self.host.layer(), mask.as_ref()); + *self.host.ivars().mask.borrow_mut() = mask; + } + + pub(super) fn hide(&self) { + self.host.setHidden(true); + } + + /// Takes the views out of the window; the bridge goes first, as the web view's content + /// controller would otherwise keep it, and the frame's path, alive. + pub(super) fn remove(&self) { + unsafe { + let configuration: Retained = + msg_send![&*self.web, configuration]; + let controller: Retained = + configuration.userContentController(); + controller.removeAllScriptMessageHandlers(); + } + self.host.removeFromSuperview(); + } + + pub(super) fn evaluate(&self, script: &str) { + let script = NSString::from_str(script); + unsafe { + let _: () = msg_send![ + &*self.web, + evaluateJavaScript: &*script, + completionHandler: std::ptr::null::() + ]; + } + } } } @@ -809,6 +1236,7 @@ mod tests { assert!(html.contains(r#"sandbox="allow-scripts" referrerpolicy="no-referrer""#)); assert!(html.contains("exp=1&sig=x"y#hazel-theme")); assert!(html.contains("color-scheme:light")); + assert!(html.contains(r#"name="viewport" content="width=device-width"#)); assert!(!scrolls_itself(src)); assert!(scrolls_itself( "https://api.hazel.sh/x#hazel-theme=%7B%7D&hazel-display=fullscreen" @@ -821,6 +1249,26 @@ mod tests { assert_eq!(wrapper_html("https://a.test'; script-src *//x"), None); } + #[test] + fn a_sticky_box_covers_the_frames_of_its_layer_but_not_one_it_holds() { + let frame = |path: &str, layer: Option<&str>| FrameSpec { + path: path.into(), + src: "https://api.hazel.sh/x".into(), + clips: vec![], + layer: layer.map(str::to_string), + }; + let pinned = CoverSpec { + path: "r/0".into(), + modal: false, + sticky_in: Some(None), + }; + assert!(pinned.is_over(&frame("r/1", None))); + // A frame inside the sticky box is part of what it paints. + assert!(!pinned.is_over(&frame("r/0/1", None))); + // A dialog's frame is above the page's sticky boxes. + assert!(!pinned.is_over(&frame("r/2/0", Some("r/2")))); + } + #[test] fn what_the_wrapper_posts_goes_to_the_runtime_by_path() { assert_eq!( diff --git a/apps/native/host/src/layout.rs b/apps/native/host/src/layout.rs index 2bd2a37c1..cdb9ddbc7 100644 --- a/apps/native/host/src/layout.rs +++ b/apps/native/host/src/layout.rs @@ -676,9 +676,38 @@ pub fn apply_flex(mut e: El, st: &Style) -> El { stick_bottom: false, }) } + if scrolls_x(st) { + e = if st.scroll_y == Some(true) { + e.scroll_xy() + } else { + e.scroll_x() + }; + // In CSS a scroller with no width of its own takes the room it has: its content (a + // picture at its natural size) is wider, and as an overflow box it may shrink to fit. + // Rune would size it to the content alone, which a `w-5/6` item sized off the + // viewport leaves empty. + // A horizontal margin would take it past its parent: it keeps Rune's own width then. + let side_margins = st + .margin + .is_some_and(|[_, r, _, l]| r.0 != 0.0 || l.0 != 0.0); + if matches!(st.w, None | Some(Len::Auto)) && !side_margins { + e = e.w_full().shrink() + } + } e } +/// `overflow-x: auto` on a flex row that does not wrap (a filmstrip, a tab strip): Rune's +/// horizontal viewport lays its items out side by side at their own widths, as CSS does there. +/// Anywhere else (a code block, a table, a wrapped row) Rune would unwrap the content to its +/// natural width, which CSS does not, so that box stays as wide as its parent. +pub(crate) fn scrolls_x(st: &Style) -> bool { + st.scroll_x == Some(true) + && st.display.as_deref() == Some("flex") + && st.dir.as_deref() != Some("col") + && st.wrap != Some(true) +} + /// Auto margins, which Rune lacks. On the parent's main axis the child sits in a growing slot /// that takes the free space and places it at the slot's end, start or centre (so no extra gap /// appears); on the cross axis they align the child itself. diff --git a/apps/native/host/src/margins.rs b/apps/native/host/src/margins.rs index 13f12eb87..ca99745fa 100644 --- a/apps/native/host/src/margins.rs +++ b/apps/native/host/src/margins.rs @@ -62,6 +62,7 @@ fn lets_margins_through(tag: &str, st: &Style) -> bool { && st.clip != Some(true) && st.isolated != Some(true) && st.scroll_y != Some(true) + && st.scroll_x != Some(true) && st.absolute.is_none() && st.fixed != Some(true) } diff --git a/apps/native/host/src/tests.rs b/apps/native/host/src/tests.rs index cb54887c7..25c961ff6 100644 --- a/apps/native/host/src/tests.rs +++ b/apps/native/host/src/tests.rs @@ -2784,6 +2784,7 @@ fn a_frame_takes_its_box_its_clips_and_what_overlays_paint_over_it() { path: "r/0/2".into(), src: "https://api.hazel.sh/html-renders/a?sig=1#hazel-display=inline".into(), clips: vec!["r/0".into()], + layer: None, }] ); // The pinned date's pill; and the menu's panel, not its layer and not what the panel holds. @@ -2792,11 +2793,13 @@ fn a_frame_takes_its_box_its_clips_and_what_overlays_paint_over_it() { vec![ CoverSpec { path: "r/0/0/0".into(), - modal: false + modal: false, + sticky_in: Some(None), }, CoverSpec { path: "r/1/0".into(), - modal: false + modal: false, + sticky_in: None, } ] ); @@ -2828,6 +2831,12 @@ fn a_dialog_covers_the_frames_behind_it_and_not_its_own() { e("iframe", json!({ "w": 100, "h": 100 }), vec![]), src.clone(), ); + // The page's pinned date, behind the dialog. + let pinned = e( + "div", + json!({ "w": 80, "h": 24, "stickyTop": 0, "bg": "#333333ff" }), + vec![], + ); let fullscreen = with_attrs(e("iframe", json!({ "w": 100, "h": 100 }), vec![]), src); let mut dialog = e( "div", @@ -2838,29 +2847,41 @@ fn a_dialog_covers_the_frames_behind_it_and_not_its_own() { vec![fullscreen], )], ); - dialog["ov"] = json!({ "portal": 1, "modal": "r/1" }); + dialog["ov"] = json!({ "portal": 1, "modal": "r/2" }); let s = state(e( "div", json!({ "w": 400, "h": 300 }), - vec![inline, dialog], + vec![pinned, inline, dialog], )); assert_eq!( s.covers, - vec![CoverSpec { - path: "r/1".into(), - modal: true - }] + vec![ + CoverSpec { + path: "r/0".into(), + modal: false, + sticky_in: Some(None), + }, + CoverSpec { + path: "r/2".into(), + modal: true, + sticky_in: None, + } + ] ); - let inside = |frame: &str| { - s.covers.iter().any(|cover| { - frame - .strip_prefix(cover.path.as_str()) - .is_some_and(|rest| rest.starts_with('/')) - }) + let over = |frame: &str| -> Vec<&str> { + let frame = s.frames.iter().find(|f| f.path == frame).unwrap(); + s.covers + .iter() + .filter(|cover| cover.is_over(frame)) + .map(|cover| cover.path.as_str()) + .collect() }; let paths: Vec<&str> = s.frames.iter().map(|frame| frame.path.as_str()).collect(); - assert_eq!(paths, ["r/0", "r/1/0/0"]); - assert!(!inside("r/0") && inside("r/1/0/0")); + assert_eq!(paths, ["r/1", "r/2/0/0"]); + // The page's frame is under the pinned date and the dialog; the dialog's own frame is under + // neither: the date is in the page behind the dialog. + assert_eq!(over("r/1"), ["r/0", "r/2"]); + assert!(over("r/2/0/0").is_empty()); } fn composer_page() -> Runtime { @@ -3449,3 +3470,53 @@ fn a_command_key_reaches_document_with_meta() { "Cmd+K sent to document: {keys:?}" ); } + +/// A tweet's photo strip (`url-embeds.ts`): a centred row holding an `overflow-x-auto` row whose +/// photos are `w-5/6` of it. The strip takes the row's width, as in CSS, so each photo is 5/6 +/// of that and not of the strip's own (empty) content. +#[test] +fn a_horizontal_strip_takes_its_parents_width_and_its_items_share_it() { + let photo = || e("button", json!({ "w": { "frac": 0.8 }, "h": 100 }), vec![]); + let rt = runtime( + e( + "div", + json!({ "display": "flex", "dir": "col", "w": 400, "h": 300 }), + vec![e( + "div", + json!({ "display": "flex", "justify": "center", "items": "center" }), + vec![e( + "div", + json!({ "display": "flex", "scrollX": true, "gapCol": 10 }), + vec![photo(), photo()], + )], + )], + ), + (400.0, 300.0), + ); + assert_eq!(rect(&rt, "r/0/0")[2], 400.0); + assert_eq!(rect(&rt, "r/0/0/0"), [0.0, 0.0, 320.0, 100.0]); + // The second photo goes on past the strip's edge, where it scrolls into view. + assert_eq!(rect(&rt, "r/0/0/1")[0], 330.0); +} + +/// `overflow-x-auto` on a block (a code block, a table's wrapper) keeps CSS's wrapping: Rune's +/// horizontal viewport would lay its text out on one line. +#[test] +fn overflow_x_on_a_block_leaves_its_text_wrapped() { + let words = "lorem ipsum dolor sit amet ".repeat(8); + let rt = runtime( + e( + "div", + json!({ "w": 200, "h": 400 }), + vec![e( + "pre", + json!({ "scrollX": true, "fontSize": 14, "lineHeight": 20 }), + vec![t(&words)], + )], + ), + (200.0, 400.0), + ); + let pre = rect(&rt, "r/0"); + assert_eq!(pre[2], 200.0); + assert!(pre[3] > 40.0, "the text wraps onto several lines: {pre:?}"); +} diff --git a/apps/native/host/src/wire.rs b/apps/native/host/src/wire.rs index 80bfc2ff5..ddde421aa 100644 --- a/apps/native/host/src/wire.rs +++ b/apps/native/host/src/wire.rs @@ -38,6 +38,8 @@ pub struct Style { pub font_weight: Option, pub opacity: Option, pub scroll_y: Option, + /// `overflow-x: auto`/`scroll`. + pub scroll_x: Option, pub clip: Option, /// Its own formatting context (`contain: layout`, `display: flow-root`). pub isolated: Option, diff --git a/apps/native/scripts/ios.ts b/apps/native/scripts/ios.ts index abd45c19e..7e725e9ba 100644 --- a/apps/native/scripts/ios.ts +++ b/apps/native/scripts/ios.ts @@ -55,12 +55,17 @@ const environment: Record = release VITE_R2_PUBLIC_URL: process.env.HAZEL_R2_PUBLIC_URL || "https://cdn.hazel.sh", } : Object.fromEntries( - ["HAZEL_DATA", "VITE_BACKEND_URL", "VITE_APP_URL", "NATIVE_DEBUG", "NATIVE_PATH"].flatMap( - (name) => { - const value = process.env[name] - return value === undefined ? [] : [[name, value]] - }, - ), + [ + "HAZEL_DATA", + "VITE_BACKEND_URL", + "VITE_APP_URL", + "NATIVE_DEBUG", + "NATIVE_PATH", + "HAZEL_FIXTURE_HTML", + ].flatMap((name) => { + const value = process.env[name] + return value === undefined ? [] : [[name, value]] + }), ) writeFileSync(`${app}/environment.json`, JSON.stringify(environment)) cpSync(`${root}../web-foldkit/public`, `${app}/public`, { recursive: true }) diff --git a/apps/native/scripts/serve-fixture-html.ts b/apps/native/scripts/serve-fixture-html.ts new file mode 100644 index 000000000..152f42e38 --- /dev/null +++ b/apps/native/scripts/serve-fixture-html.ts @@ -0,0 +1,16 @@ +/** + * Serves a page as the api serves an HTML render, for the iOS Simulator's offline fixture, whose + * runtime cannot serve one itself: + * + * bun scripts/serve-fixture-html.ts page.html [port] + * HAZEL_FIXTURE_HTML= bun run ios --install booted + * + * The file is read on every load, so an edit shows when the app reloads the render. + */ +import { resolve } from "node:path" +import { serveFixtureHtml } from "../src/fixture/html-render" + +const [file, port] = process.argv.slice(2) +if (file === undefined) throw new Error("usage: bun scripts/serve-fixture-html.ts [port]") +const { url } = await serveFixtureHtml(resolve(file), port === undefined ? 4310 : Number(port)) +console.log(url) diff --git a/apps/native/src/data-source.ts b/apps/native/src/data-source.ts index ac0491cdd..79eaf4b90 100644 --- a/apps/native/src/data-source.ts +++ b/apps/native/src/data-source.ts @@ -47,13 +47,19 @@ const installOffline = async ( const { makeOfflineFetch } = await import("./offline-backend") const { makeFixtureServer, makeFixtureWebSocket } = await import("./fixture/fixture-socket") const server = makeFixtureServer() - // `HAZEL_FIXTURE_HTML=`: #general ends with that page as an HTML render. + // `HAZEL_FIXTURE_HTML=`: #general ends with that page as an HTML render. + const { fixtureHtmlRenderAt, isPageUrl } = await import("./fixture/html-render-message") const htmlRender = - config.fixtureHtml === undefined ? undefined : await platform.fixtureHtmlRender?.(config.fixtureHtml) + config.fixtureHtml === undefined + ? undefined + : isPageUrl(config.fixtureHtml) + ? fixtureHtmlRenderAt(config.fixtureHtml) + : await platform.fixtureHtmlRender?.(config.fixtureHtml) if (htmlRender !== undefined) server.write("channel", htmlRender.message.channelId, "messages", [htmlRender.message]) globalThis.fetch = makeOfflineFetch({ backendUrl: config.backendUrl, + unfurls: globalThis.fetch.bind(globalThis), server, htmlRenderUrl: htmlRender?.url, user: offline.currentUser, diff --git a/apps/native/src/fixture/html-render-message.ts b/apps/native/src/fixture/html-render-message.ts new file mode 100644 index 000000000..71772c7c2 --- /dev/null +++ b/apps/native/src/fixture/html-render-message.ts @@ -0,0 +1,30 @@ +/** + * The offline #general's HTML render message (`HAZEL_FIXTURE_HTML`), for a page at a URL: the + * Bun runtime serves a file itself (`./html-render`); iOS's worker cannot, so there the variable + * is the URL of a page the Mac serves (`scripts/serve-fixture-html.ts`), which the Simulator reaches. + */ +import { generalChannelId, generalMessageRows } from "./snapshot" + +const RENDER_ID = "00000000-0000-4000-8000-0000000f0001" +const MESSAGE_ID = "00000000-0000-4000-8000-0000000f0002" +const HOUR = 3_600_000 + +/** The message to add, and `message.htmlRenderUrl`'s answer for the page at `url`. */ +export const fixtureHtmlRenderAt = (url: string) => ({ + /** A row for the channel's `messages`: the last message's author posts the render. */ + message: { + ...generalMessageRows.at(-1), + id: MESSAGE_ID, + channelId: generalChannelId, + content: "", + embeds: [{ html: { renderId: RENDER_ID, title: "Fixture render", height: 320 } }], + replyToMessageId: null, + createdAt: new Date().toISOString(), + updatedAt: null, + }, + /** `message.htmlRenderUrl`'s answer. */ + url: () => ({ url, expiresAt: Date.now() + HOUR }), +}) + +/** `HAZEL_FIXTURE_HTML` names a page by URL rather than a file. */ +export const isPageUrl = (value: string) => /^https?:\/\//.test(value) diff --git a/apps/native/src/fixture/html-render.ts b/apps/native/src/fixture/html-render.ts index 4de0af6c0..135d7c6bd 100644 --- a/apps/native/src/fixture/html-render.ts +++ b/apps/native/src/fixture/html-render.ts @@ -4,17 +4,16 @@ * page can be tried in the native app without a backend. */ import { readFileSync } from "node:fs" -import { createServer } from "node:http" +import { createServer, type Server } from "node:http" import type { AddressInfo } from "node:net" import { HTML_RENDER_CONTENT_SECURITY_POLICY, injectHtmlRenderBootstrap } from "@hazel/domain/html-render" -import { generalChannelId, generalMessageRows } from "./snapshot" +import { fixtureHtmlRenderAt } from "./html-render-message" -const RENDER_ID = "00000000-0000-4000-8000-0000000f0001" -const MESSAGE_ID = "00000000-0000-4000-8000-0000000f0002" -const HOUR = 3_600_000 - -/** Starts the page server for `file` and answers with the message to add and its `message.htmlRenderUrl`. */ -export const makeFixtureHtmlRender = async (file: string) => { +/** Serves `file` as the api serves an HTML render, on `port` (any free one by default). */ +export const serveFixtureHtml = async ( + file: string, + port = 0, +): Promise<{ readonly url: string; readonly server: Server }> => { // Read per request: an edit to the file shows on the next load. const server = createServer((_request, response) => { let page: string @@ -35,27 +34,16 @@ export const makeFixtureHtmlRender = async (file: string) => { }) response.end(page) }) - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve)) // SAFETY: a TCP server that is listening has an `AddressInfo`. - const { port } = server.address() as AddressInfo + const address = server.address() as AddressInfo + return { url: `http://127.0.0.1:${address.port}/html-renders/fixture`, server } +} + +/** Starts the page server for `file` and answers with the message to add and its `message.htmlRenderUrl`. */ +export const makeFixtureHtmlRender = async (file: string) => { + const { url, server } = await serveFixtureHtml(file) // The runtime exits with the app; the server must not keep it alive on its own. server.unref() - return { - /** A row for the channel's `messages`: the last message's author posts the render. */ - message: { - ...generalMessageRows.at(-1), - id: MESSAGE_ID, - channelId: generalChannelId, - content: "", - embeds: [{ html: { renderId: RENDER_ID, title: "Fixture render", height: 320 } }], - replyToMessageId: null, - createdAt: new Date().toISOString(), - updatedAt: null, - }, - /** `message.htmlRenderUrl`'s answer. */ - url: () => ({ - url: `http://127.0.0.1:${port}/html-renders/fixture`, - expiresAt: Date.now() + HOUR, - }), - } + return fixtureHtmlRenderAt(url) } diff --git a/apps/native/src/offline-backend.test.ts b/apps/native/src/offline-backend.test.ts index 78d156344..8ef28aa52 100644 --- a/apps/native/src/offline-backend.test.ts +++ b/apps/native/src/offline-backend.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest" +import { LINK_PREVIEW_URL } from "../../web-foldkit/src/chat/unfurl" import { makeFixtureServer } from "./fixture/fixture-socket" import { designChannelId, orgId, snapshotFor } from "./fixture/snapshot" import { makeOfflineFetch } from "./offline-backend" @@ -48,4 +49,16 @@ describe("makeOfflineFetch", () => { const response = await fetch("http://offline.hazel.test.evil.example/auth/token") expect(response.status).toBe(404) }) + + it("sends the link-preview worker's requests to the network, and nothing else", async () => { + const sent: Array = [] + const unfurls: typeof globalThis.fetch = async (input) => { + sent.push(new Request(input).url) + return new Response("{}") + } + const fetch = makeOfflineFetch({ backendUrl, user: currentUser, unfurls }) + expect((await fetch(`${LINK_PREVIEW_URL}/tweet?id=1`)).status).toBe(200) + expect((await fetch("https://link-preview.hazel.sh.evil.example/tweet?id=1")).status).toBe(404) + expect(sent).toEqual([`${LINK_PREVIEW_URL}/tweet?id=1`]) + }) }) diff --git a/apps/native/src/offline-backend.ts b/apps/native/src/offline-backend.ts index f302469dc..c6cbead73 100644 --- a/apps/native/src/offline-backend.ts +++ b/apps/native/src/offline-backend.ts @@ -4,6 +4,7 @@ import * as DomainRpc from "@hazel/domain/rpc" import { ChannelId, UserId } from "@hazel/schema" import { Schema } from "effect" import * as CurrentUser from "../../../packages/domain/src/current-user" +import { LINK_PREVIEW_URL } from "../../web-foldkit/src/chat/unfurl" import { isBackendUrl } from "./backend-origin" import type { FixtureServer } from "./fixture/fixture-socket" import { @@ -31,6 +32,12 @@ export const makeOfflineFetch = (options: { readonly server?: FixtureServer /** `message.htmlRenderUrl`'s answer, when the fixture shows an HTML render. */ readonly htmlRenderUrl?: () => { readonly url: string; readonly expiresAt: number } + /** + * Where requests to the link-preview worker go (the network's own `fetch`): its link previews + * and tweets are public and carry no credentials, so a fixture message's links unfurl as + * they do live. Without it they fail like any other URL outside the backend. + */ + readonly unfurls?: typeof fetch }): typeof fetch => { const now = Date.now() const session: typeof HazelAuthApi.sessions.Session.Type = Schema.decodeUnknownSync( @@ -184,6 +191,8 @@ export const makeOfflineFetch = (options: { const request = new Request(input, init) const url = new URL(request.url) options.onRequest?.(request.method, url.pathname) + if (options.unfurls !== undefined && url.origin === new URL(LINK_PREVIEW_URL).origin) + return options.unfurls(request) if (!isBackendUrl(request.url, options.backendUrl)) return new Response(null, { status: 404 }) switch (url.pathname.replace(/\/$/, "")) { case "/auth/getSession": diff --git a/apps/web-foldkit/src/chat/unfurl.ts b/apps/web-foldkit/src/chat/unfurl.ts index 4cc92fa09..f73f7c2b1 100644 --- a/apps/web-foldkit/src/chat/unfurl.ts +++ b/apps/web-foldkit/src/chat/unfurl.ts @@ -9,7 +9,7 @@ import { defineTaggedUnion } from "foldkit/schema" * `https://link-preview.hazel.sh`): `LinkPreview`'s Open Graph data and `TweetEmbed`'s tweet. */ -const LINK_PREVIEW_URL = "https://link-preview.hazel.sh" +export const LINK_PREVIEW_URL = "https://link-preview.hazel.sh" // MODEL diff --git a/packages/foldkit-rune/src/style.test.ts b/packages/foldkit-rune/src/style.test.ts index 652e86ee5..3a0763600 100644 --- a/packages/foldkit-rune/src/style.test.ts +++ b/packages/foldkit-rune/src/style.test.ts @@ -305,6 +305,15 @@ describe("StyleResolver", () => { expect(resolve("absolute top-0").style.insetFrac).toBeUndefined() }) + it("sends horizontal and vertical overflow as the scrolling the host draws", () => { + expect(resolve("flex overflow-x-auto").style).toMatchObject({ scrollX: true }) + expect(resolve("flex overflow-x-auto").style.scrollY).toBeUndefined() + expect(resolve("overflow-y-auto").style).toMatchObject({ scrollY: true }) + expect(resolve("overflow-y-auto").style.scrollX).toBeUndefined() + expect(resolve("overflow-auto").style).toMatchObject({ scrollX: true, scrollY: true }) + expect(resolve("overflow-x-hidden").style).toMatchObject({ clip: true }) + }) + it("hides screen-reader-only text and reads inline styles last", () => { expect(resolve("sr-only").style.display).toBe("none") // A screen-reader-only control (a keyboard drag handle) stays a Tab stop. diff --git a/packages/foldkit-rune/src/style.ts b/packages/foldkit-rune/src/style.ts index 1dc3960f4..4184bc044 100644 --- a/packages/foldkit-rune/src/style.ts +++ b/packages/foldkit-rune/src/style.ts @@ -177,6 +177,8 @@ export type Style = { fontWeight?: number opacity?: number scrollY?: boolean + /** `overflow-x: auto`/`scroll`: the host scrolls a flex row sideways (`scroll_x`). */ + scrollX?: boolean clip?: boolean /** `contain: layout`/`paint`, `display: flow-root`: its own formatting context, so its * children's margins stay inside it. */ @@ -1613,6 +1615,7 @@ export class StyleResolver { case "overflow": { const [x = "visible", y = x] = v.split(/\s+/) if (y === "auto" || y === "scroll") s.scrollY = true + if (x === "auto" || x === "scroll") s.scrollX = true if ([x, y].some((o) => o === "hidden" || o === "clip")) s.clip = true break } @@ -1622,7 +1625,7 @@ export class StyleResolver { break case "overflow-x": if (v === "hidden" || v === "clip") s.clip = true - else if (v === "auto" || v === "scroll") unsupported() + else if (v === "auto" || v === "scroll") s.scrollX = true break case "text-overflow": s.truncate = v === "ellipsis" From aea707c8a2dc0cb54ef3c6bad0df894bc12769dc Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 16:09:59 +0100 Subject: [PATCH 2/4] chore(native): run the fixture page server as a package script knip read scripts/serve-fixture-html.ts as unused. Co-Authored-By: Claude Opus 5.5 --- apps/native/README.md | 4 ++-- apps/native/package.json | 1 + apps/native/scripts/serve-fixture-html.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/native/README.md b/apps/native/README.md index a726388c9..ada9b08e5 100644 --- a/apps/native/README.md +++ b/apps/native/README.md @@ -53,8 +53,8 @@ compiles after the commit that shows it, and the screen restyles. Message and editor event; `NATIVE_STATS=` appends per-commit timings. `HAZEL_FIXTURE_HTML=` ends the offline #general with a message showing that page as an HTML render, served from a local port as the api serves one (the file is read on every load); a -page URL instead is shown as it is (iOS: `bun scripts/serve-fixture-html.ts page.html` serves the -file and prints the URL to build with). Offline, links still unfurl: the link-preview worker's +page URL instead is shown as it is (iOS: `bun run serve-fixture-html page.html` serves the file +and prints the URL to build with). Offline, links still unfurl: the link-preview worker's requests (previews, tweets) go to the network. `host/target/release/hazel-native --capture script.json` runs headless on a real-time frame loop and diff --git a/apps/native/package.json b/apps/native/package.json index 8cf63a977..660f21eb7 100644 --- a/apps/native/package.json +++ b/apps/native/package.json @@ -7,6 +7,7 @@ "build": "bun scripts/build.ts && bun scripts/precompile-styles.ts", "bundle": "bun scripts/bundle.ts", "ios": "bun scripts/ios.ts", + "serve-fixture-html": "bun scripts/serve-fixture-html.ts", "host:build": "cd host && cargo build --release", "start": "bun run host:build && host/target/release/hazel-native", "typecheck": "tsc --noEmit", diff --git a/apps/native/scripts/serve-fixture-html.ts b/apps/native/scripts/serve-fixture-html.ts index 152f42e38..ca48e6a1f 100644 --- a/apps/native/scripts/serve-fixture-html.ts +++ b/apps/native/scripts/serve-fixture-html.ts @@ -2,7 +2,7 @@ * Serves a page as the api serves an HTML render, for the iOS Simulator's offline fixture, whose * runtime cannot serve one itself: * - * bun scripts/serve-fixture-html.ts page.html [port] + * bun run serve-fixture-html page.html [port] * HAZEL_FIXTURE_HTML= bun run ios --install booted * * The file is read on every load, so an edit shows when the app reloads the render. From 7b7e929d0771b2ffa292ef38f368ef370d1be873 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 16:24:20 +0100 Subject: [PATCH 3/4] fix(native): review fixes, and the Linux/Windows host job - An iframe that is itself a portal is its own layer for sticky covers. - A frame that turns inline <-> fullscreen gets a new web view, so iOS's text selection follows the mode. - A horizontal strip with shrink-0 keeps its width. - A later overflow declaration clears scrollX/scrollY. - The SVG test waits for lavapipe as the PNG one does (render_until). - Windows: the stylesheet's base directory comes from a backslash path too; the bundle-swap updater test runs on macOS only. Co-Authored-By: Claude Opus 5.5 --- apps/native/host/src/frames.rs | 18 ++++++--- apps/native/host/src/layout.rs | 6 ++- apps/native/host/src/tests.rs | 50 ++++++++++++++++++++----- apps/native/host/src/updater.rs | 2 + packages/foldkit-rune/src/style.test.ts | 8 ++++ packages/foldkit-rune/src/style.ts | 19 +++++++--- 6 files changed, 81 insertions(+), 22 deletions(-) diff --git a/apps/native/host/src/frames.rs b/apps/native/host/src/frames.rs index c662792dc..291481fe6 100644 --- a/apps/native/host/src/frames.rs +++ b/apps/native/host/src/frames.rs @@ -54,6 +54,9 @@ fn walk( if st.display.as_deref() == Some("none") { return; } + // A portal draws at the window's root: what clips the place it left does not clip it. + let portal = crate::is_portal(node); + let layer = if portal { Some(path) } else { layer }; if tag == "iframe" { if let Some(src) = attrs.get("src").filter(|src| !src.is_empty()) { out.push(FrameSpec { @@ -65,13 +68,8 @@ fn walk( } return; } - // A portal draws at the window's root: what clips the place it left does not clip it. let mut own_clips = Vec::new(); - let (layer, clips) = if crate::is_portal(node) { - (Some(path), &mut own_clips) - } else { - (layer, clips) - }; + let clips = if portal { &mut own_clips } else { clips }; let clipping = st.scroll_y == Some(true) || st.clip == Some(true) || crate::layout::scrolls_x(st); if clipping { @@ -570,6 +568,14 @@ mod views { keep }); for (frame, placement) in placed { + // A page that turns from inline to fullscreen or back gets a new view: some of + // what differs (iOS's text selection) is set only when a web view is made. + let mode_changed = views.get(&frame.path).is_some_and(|entry| { + super::scrolls_itself(&entry.src) != super::scrolls_itself(&frame.src) + }); + if mode_changed && let Some(entry) = views.remove(&frame.path) { + entry.view.remove(); + } if !views.contains_key(&frame.path) { // A frame nothing shows of yet still loads, as a browser's does; one under // a dialog waits until it is uncovered. diff --git a/apps/native/host/src/layout.rs b/apps/native/host/src/layout.rs index cdb9ddbc7..5d6627f7d 100644 --- a/apps/native/host/src/layout.rs +++ b/apps/native/host/src/layout.rs @@ -691,7 +691,11 @@ pub fn apply_flex(mut e: El, st: &Style) -> El { .margin .is_some_and(|[_, r, _, l]| r.0 != 0.0 || l.0 != 0.0); if matches!(st.w, None | Some(Len::Auto)) && !side_margins { - e = e.w_full().shrink() + e = e.w_full(); + // `shrink-0` keeps it at that width. + if f(st.shrink) != Some(0.0) { + e = e.shrink() + } } } e diff --git a/apps/native/host/src/tests.rs b/apps/native/host/src/tests.rs index 25c961ff6..06b1acd10 100644 --- a/apps/native/host/src/tests.rs +++ b/apps/native/host/src/tests.rs @@ -81,6 +81,23 @@ fn render(rt: &mut Runtime, size: (u32, u32)) -> Frame { draw(rt, size) } +/// Renders until `drawn` holds of the frame, for at most two seconds: Rune takes a decoded +/// picture in on a later frame on some platforms (lavapipe on Linux), as a window keeps drawing +/// until it lands. +fn render_until( + rt: &mut Runtime, + size: (u32, u32), + drawn: impl Fn(&Frame) -> bool, +) -> Frame { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let mut frame = render(rt, size); + while !drawn(&frame) && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(20)); + frame = render(rt, size); + } + frame +} + /// The last frame as drawn, running animations included. fn draw(rt: &mut Runtime, (w, h): (u32, u32)) -> Frame { const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; @@ -345,14 +362,7 @@ fn an_img_with_a_data_png_draws_its_pixels() { .wait() .expect("a png"); let mut rt = Runtime::new(s, view, (40.0, 40.0), Env::new(false, 1.0)); - // Rune takes the decoded picture in on a later frame on some platforms (lavapipe on Linux), as - // a window keeps drawing until it lands. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - let mut frame = render(&mut rt, (40, 40)); - while !frame.is(20, 20, RED) && std::time::Instant::now() < deadline { - std::thread::sleep(std::time::Duration::from_millis(20)); - frame = render(&mut rt, (40, 40)); - } + let frame = render_until(&mut rt, (40, 40), |frame| frame.is(20, 20, RED)); assert!(frame.is(20, 20, RED), "centre {:?}", frame.at(20, 20)); } @@ -948,7 +958,7 @@ fn an_svg_keeps_its_aspect_ratio_unless_it_asks_to_stretch() { s.images.svg(16.0, body, 128.0).wait().expect("an svg"); let mut rt = Runtime::new(s, view, (64.0, 32.0), Env::new(false, 1.0)); rt.frame(0.0); - let frame = render(&mut rt, (64, 32)); + let frame = render_until(&mut rt, (64, 32), |frame| frame.is(32, 16, RED)); assert!(frame.is(32, 16, RED), "{:?}", frame.at(32, 16)); frame.at(3, 16) }; @@ -3520,3 +3530,25 @@ fn overflow_x_on_a_block_leaves_its_text_wrapped() { assert_eq!(pre[2], 200.0); assert!(pre[3] > 40.0, "the text wraps onto several lines: {pre:?}"); } + +/// An `