diff --git a/apps/native/README.md b/apps/native/README.md index ada9b08e5..96decba9d 100644 --- a/apps/native/README.md +++ b/apps/native/README.md @@ -137,6 +137,13 @@ on-screen keyboard shows, the page's viewport ends above it and the end of the list stay visible (a floating iPad keyboard leaves the page as it is). The keyboard's return key is Enter, so it sends, as it does in the web app on a phone. +**Navigation moves as a navigation stack's** (`host/ios/src/system.rs`). Rune draws the page in +one view, so the runtime tells the host before every in-app navigation (`navigate`, from the +history shim), and the host keeps a picture of the page it leaves. A page that leads back to the +one left (its back chevron mounts `SwipeBack`, `web-foldkit/src/platform/system-ui.ts`) slides in +over it from the right; going back slides the page off over the picture of where it goes; a drag +from the left edge does that under the finger. Tabs change at once. + **Live data** (`HAZEL_DATA=live bun run ios`, with `VITE_APP_URL`/`VITE_BACKEND_URL` for a dev stack on this Mac) signs in as the Mac app does: the sign-in link opens in the system's sign-in sheet (`ASWebAuthenticationSession`, `host/ios/src/sign_in.rs`), whose callback goes to the @@ -170,6 +177,36 @@ the commit count (`HAZEL_IOS_BUILD` overrides it), the version `HAZEL_IOS_VERSIO is `host/ios/assets/AppIcon.png` (opaque, 1024px), compiled into `Assets.car`; the privacy manifest `host/ios/PrivacyInfo.xcprivacy` declares the required-reason APIs the executable links. +### Native islands + +An element the web app marks `data-native=""` (`web-foldkit/src/platform/native-island.ts`) +gets a real UIKit view on iOS, laid over the box Rune draws for it. The element's children stay its +fallback: what the browser, the Mac app and a build with `HAZEL_ISLANDS=off` draw. + +``` +Foldkit view hazel_native (host/src/islands.rs) iOS shell (host/ios/src/islands.rs) + data-native="tab-bar" -> collect: islands of the tree -> create the UIKit view + data-native-item (tabs) sync, after every frame: where the put it at the box: clipped, cut + data-native-props (JSON) box was drawn (frames' placement) out under overlays, hidden + h.OnChange / the items' <- press / change: the fallback's own <- under dialogs + own links and clicks DOM events, on the existing wire delegate callbacks +``` + +| Component | Where | Native view | Who draws | Comes back as | +| -------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | --------- | --------------------------------------------------------- | +| `tab-bar` | the mobile nav (`shell/mobile`) | `UITabBar`, with the items' counts as badges | UIKit | the `click` of the item's own link or button | +| `menu` | every Select's trigger, and every menu whose items are all words (`Menu.nativeMenuItems`) | a clear `UIButton` with a `UIMenu` | Rune | `change` on the trigger, with the item's key | +| `date-picker` | every DatePicker's field | a clear `UIButton` that presents a calendar sheet | Rune | `change` on the field, `yyyy-MM-dd` | +| `context-menu` | every message row | none: one `UIContextMenuInteraction` on Rune's view finds the row under a held press | Rune | `change` on the row: the action's key, or `react:` | + +Nothing new crosses the wire: the host replays DOM events on handlers the tree already carries, +so a component needs no Message of its own beyond what its `change` maps to. +A view is only covered by what Rune paints above its own layer (`frames::layer_of`): a dialog hides +the tab bar, and a Select inside that dialog still gets its menu. A shell with `context-menu` sends no hover reports, so the message toolbar (a pointer's) stays away under a finger. Not solved yet: a view inside a +scrolling list follows the scroll one frame late (it is placed after the frame is drawn), a +translucent overlay cuts a view out instead of dimming it, and VoiceOver reads the native view +only (the fallback has no accessibility node of its own to hide). + ## Releases The native app is what Hazel ships for macOS: `.github/workflows/tauri-release.yml` (on a `v*` tag) diff --git a/apps/native/host/Cargo.lock b/apps/native/host/Cargo.lock index 2fee3155b..7722eefbe 100644 --- a/apps/native/host/Cargo.lock +++ b/apps/native/host/Cargo.lock @@ -662,6 +662,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -947,6 +956,29 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + [[package]] name = "fontique" version = "0.12.0" @@ -1087,6 +1119,16 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gif" version = "0.14.2" @@ -1174,9 +1216,12 @@ dependencies = [ "objc2 0.6.5", "objc2-authentication-services", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", "objc2-ui-kit 0.3.2", "objc2-web-kit", + "resvg", "rune", "serde_json", ] @@ -1373,14 +1418,14 @@ dependencies = [ "bytemuck", "byteorder-lite", "color_quant", - "gif", + "gif 0.14.2", "image-webp", "moxcms", "num-traits", "png 0.18.1", "tiff", - "zune-core", - "zune-jpeg", + "zune-core 0.5.3", + "zune-jpeg 0.5.15", ] [[package]] @@ -2794,12 +2839,15 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" dependencies = [ + "gif 0.13.3", + "image-webp", "log", "pico-args", "rgb", "svgtypes", "tiny-skia", "usvg", + "zune-jpeg 0.4.21", ] [[package]] @@ -3057,6 +3105,24 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3572,7 +3638,7 @@ dependencies = [ "half", "quick-error", "weezl", - "zune-jpeg", + "zune-jpeg 0.5.15", ] [[package]] @@ -3612,6 +3678,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokio" version = "1.54.1" @@ -3711,6 +3783,15 @@ dependencies = [ "petgraph", ] +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + [[package]] name = "uds_windows" version = "1.2.1" @@ -3722,18 +3803,54 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + [[package]] name = "unicode-ident" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "unicode-properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993ad287eb20e8ba755312801cfe1413e0f25d604075863acaddca10b04c1833" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + [[package]] name = "unicode-width" version = "0.2.3" @@ -3784,16 +3901,21 @@ dependencies = [ "base64 0.22.1", "data-url", "flate2", + "fontdb", "imagesize", "kurbo", "log", "pico-args", "roxmltree", + "rustybuzz", "simplecss", "siphasher", "strict-num", "svgtypes", "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", "xmlwriter", ] @@ -4974,19 +5096,34 @@ dependencies = [ "futures-io", ] +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + [[package]] name = "zune-core" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + [[package]] name = "zune-jpeg" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core", + "zune-core 0.5.3", ] [[package]] diff --git a/apps/native/host/ios/Cargo.toml b/apps/native/host/ios/Cargo.toml index 558cf50c3..ec44ba619 100644 --- a/apps/native/host/ios/Cargo.toml +++ b/apps/native/host/ios/Cargo.toml @@ -10,7 +10,8 @@ hazel-native = { path = ".." } rune = { git = "https://github.com/Makisuo/rune", rev = "3b676200f9ab1923059651c4e81c0b77cb8699a2" } serde_json = "1" -# The hidden web view the runtime's worker runs in (`src/bridge.rs`). +# The hidden web view the runtime's worker runs in (`src/bridge.rs`), and the UIKit views of +# native islands (`src/islands.rs`). [target.'cfg(target_os = "ios")'.dependencies] # Sign-in through the system's web authentication sheet (`src/sign_in.rs`). objc2-authentication-services = "0.3" @@ -18,6 +19,10 @@ block2 = "0.6" dispatch2 = "0.3" objc2 = "0.6" objc2-core-foundation = "0.3" +objc2-core-graphics = "0.3" objc2-foundation = "0.3" +# The app's own icons as native images (the tab bar's), at the version Rune draws SVG with. +resvg = "0.45" +objc2-quartz-core = "0.3" objc2-ui-kit = "0.3" objc2-web-kit = "0.3" diff --git a/apps/native/host/ios/src/bridge.rs b/apps/native/host/ios/src/bridge.rs index b9cd163af..2734c0e19 100644 --- a/apps/native/host/ios/src/bridge.rs +++ b/apps/native/host/ios/src/bridge.rs @@ -61,6 +61,17 @@ pub fn set_handle(handle: AppHandle) { outbox::set_writer(Box::new(ToWorker)); } +/// A variable of the bundle's `environment.json`, as set when building (`scripts/ios.ts`). +pub fn environment(name: &str) -> Option { + let path = std::env::current_exe() + .ok()? + .parent()? + .join("environment.json"); + let bundled: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?; + Some(bundled.get(name)?.as_str()?.to_string()) +} + /// Each frame's poll; the first one, with the window up, starts the runtime. pub fn poll(s: &mut State, at: f64, tx: &mut rune::Tx) -> bool { if !STARTED.get() { @@ -164,7 +175,14 @@ fn receive(line: &str) { }; match shell::route(message) { Routed::Done => {} - Routed::Window(message) => shell::apply(message, &handle), + Routed::Window(message) => { + let is_tree = matches!(message, RuntimeMsg::Tree(_)); + shell::apply(message, &handle); + // A navigation waiting on the next page moves once it is drawn. + if is_tree { + crate::system::tree_applied(); + } + } // A full page load (after sign-in or sign-out): a fresh runtime at that path. Routed::Shell(ShellRequest::Reload { path }) => { if let Some(mtm) = MainThreadMarker::new() { @@ -217,6 +235,12 @@ fn request(mtm: MainThreadMarker, body: &NSDictionary) { crate::http::send(&text); } else if let Some(text) = field("copy") { unsafe { UIPasteboard::generalPasteboard().setString(Some(&NSString::from_str(&text))) }; + } else if body.objectForKey(ns_string!("haptic")).is_some() { + crate::system::haptic(mtm); + } else if let Some(path) = field("swipeBack") { + crate::system::set_swipe_back(mtm, &path); + } else if let Some(navigation) = field("navigate") { + crate::system::navigate(mtm, &navigation); } } @@ -273,7 +297,7 @@ define_class!( ); /// The app's window, once the scene has one. -pub(crate) fn window(mtm: MainThreadMarker) -> Option> { +pub fn window(mtm: MainThreadMarker) -> Option> { let scenes = UIApplication::sharedApplication(mtm).connectedScenes(); scenes.iter().find_map(|scene| { let scene = scene.downcast::().ok()?; diff --git a/apps/native/host/ios/src/context_menu.rs b/apps/native/host/ios/src/context_menu.rs new file mode 100644 index 000000000..70ba46e44 --- /dev/null +++ b/apps/native/host/ios/src/context_menu.rs @@ -0,0 +1,341 @@ +//! The `context-menu` island: the system's long-press menu for an element Rune draws (a message). +//! +//! Unlike the other islands it has no view of its own. A list holds many of these and scrolls, +//! so each one is only a region: where its element was drawn in the last frame. One +//! `UIContextMenuInteraction` on Rune's view asks, when a press has been held, which region is +//! under the finger, and opens that element's menu (`props.groups`, with a row of quick +//! reactions from `props.reactions`). A choice is a `change` on the element, with the action's key. +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::ptr::NonNull; + +use block2::RcBlock; +use hazel_native::frames::Placement; +use hazel_native::islands::{self, IslandSpec}; +use objc2::rc::Retained; +use objc2::runtime::ProtocolObject; +use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send}; +use objc2_core_foundation::{CGPoint, CGRect, CGSize}; +use objc2_foundation::{NSArray, NSCopying, NSObject, NSObjectProtocol, NSString, ns_string}; +use objc2_ui_kit::{ + UIAction, UIBezierPath, UIColor, UIContextMenuConfiguration, UIContextMenuInteraction, + UIContextMenuInteractionAnimating, UIContextMenuInteractionDelegate, UIEdgeInsets, UIImage, + UIInteraction, UIMenu, UIMenuElement, UIMenuElementAttributes, UIMenuElementSize, + UIMenuOptions, UIPreviewParameters, UIPreviewTarget, UITargetedPreview, UIUserInterfaceStyle, + UIView, +}; + +type Rect = [f32; 4]; + +/// How many small elements a menu shows side by side; more wrap onto rows of their own. +const REACTIONS_IN_A_ROW: usize = 4; + +struct Region { + spec: IslandSpec, + /// Where the element shows, and where Rune draws over it; `None` while none of it shows. + shown: Option<(Rect, Vec)>, + /// In the frame being applied. + live: bool, +} + +thread_local! { + static REGIONS: RefCell> = RefCell::new(HashMap::new()); + static INTERACTION: RefCell, Retained)>> = + const { RefCell::new(None) }; + /// The page's appearance, which the menu takes for as long as it is open. + static DARK: Cell = const { Cell::new(false) }; +} + +fn contains([x, y, w, h]: Rect, at: CGPoint) -> bool { + let (px, py) = (at.x as f32, at.y as f32); + px >= x && px < x + w && py >= y && py < y + h +} + +/// A frame's islands are about to be placed. +pub fn begin(dark: bool) { + DARK.set(dark); + REGIONS.with_borrow_mut(|regions| { + for region in regions.values_mut() { + region.live = false; + } + }); +} + +/// The island at `spec.path`, where the frame just drawn has it. +pub fn place(spec: &IslandSpec, placement: Option<&Placement>) { + let shown = placement.map(|placement| (placement.visible, placement.holes.clone())); + REGIONS.with_borrow_mut(|regions| match regions.get_mut(&spec.path) { + Some(region) => { + if region.spec != *spec { + region.spec = spec.clone(); + } + region.shown = shown; + region.live = true; + } + None => { + regions.insert( + spec.path.clone(), + Region { + spec: spec.clone(), + shown, + live: true, + }, + ); + } + }); +} + +/// The frame's islands are placed: the ones that left the tree go, and Rune's view listens for +/// a held press once there is anything to open. +pub fn end(mtm: MainThreadMarker, parent: &UIView) { + let any = REGIONS.with_borrow_mut(|regions| { + regions.retain(|_, region| region.live); + !regions.is_empty() + }); + if !any || INTERACTION.with_borrow(|interaction| interaction.is_some()) { + return; + } + let delegate: Retained = { + let this = ContextMenus::alloc(mtm).set_ivars(MenuIvars { + target: Cell::new(None), + }); + unsafe { msg_send![super(this), init] } + }; + let interaction = UIContextMenuInteraction::initWithDelegate( + UIContextMenuInteraction::alloc(mtm), + ProtocolObject::from_ref(&*delegate), + ); + parent.addInteraction(ProtocolObject::from_ref(&*interaction)); + INTERACTION.set(Some((interaction, delegate))); +} + +/// The island whose element is drawn at `at`, and where. +fn region_at(at: CGPoint) -> Option<(IslandSpec, Rect)> { + REGIONS.with_borrow(|regions| { + regions.values().find_map(|region| { + let (rect, holes) = region.shown.as_ref()?; + let pressed = contains(*rect, at) && !holes.iter().any(|hole| contains(*hole, at)); + pressed.then(|| (region.spec.clone(), *rect)) + }) + }) +} + +fn action( + mtm: MainThreadMarker, + island: &IslandSpec, + title: &str, + symbol: Option<&str>, + value: String, +) -> Retained { + let island = island.clone(); + let handler = RcBlock::new(move |_: NonNull| islands::change(&island, &value)); + let image = symbol.and_then(|symbol| UIImage::systemImageNamed(&NSString::from_str(symbol))); + unsafe { + UIAction::actionWithTitle_image_identifier_handler( + &NSString::from_str(title), + image.as_deref(), + None, + RcBlock::as_ptr(&handler), + mtm, + ) + } +} + +fn inline(mtm: MainThreadMarker, children: Vec>) -> Retained { + UIMenu::menuWithTitle_image_identifier_options_children( + ns_string!(""), + None, + None, + UIMenuOptions::DisplayInline, + &NSArray::from_retained_slice(&children), + mtm, + ) +} + +/// The element's menu: its quick reactions in a row, then each group of actions. +fn menu_of(mtm: MainThreadMarker, island: &IslandSpec) -> Retained { + let props = &island.props; + let text = |value: &serde_json::Value, name: &str| { + value + .get(name) + .and_then(|field| field.as_str()) + .map(str::to_string) + }; + let mut sections: Vec> = Vec::new(); + let prefix = text(props, "reactionPrefix").unwrap_or_default(); + let reactions: Vec> = props + .get("reactions") + .and_then(|reactions| reactions.as_array()) + .into_iter() + .flatten() + .filter_map(|emoji| emoji.as_str()) + // As many as the system lays out in one row. + .take(REACTIONS_IN_A_ROW) + .map(|emoji| { + Retained::into_super(action(mtm, island, emoji, None, format!("{prefix}{emoji}"))) + }) + .collect(); + if !reactions.is_empty() { + let row = inline(mtm, reactions); + row.setPreferredElementSize(UIMenuElementSize::Small); + sections.push(Retained::into_super(row)); + } + let groups = props.get("groups").and_then(|groups| groups.as_array()); + for group in groups.into_iter().flatten() { + let items: Vec> = group + .as_array() + .into_iter() + .flatten() + .filter_map(|item| { + let key = text(item, "key")?; + let title = text(item, "label").unwrap_or_else(|| key.clone()); + let item_action = action(mtm, island, &title, text(item, "symbol").as_deref(), key); + if item.get("destructive").and_then(|d| d.as_bool()) == Some(true) { + item_action.setAttributes(UIMenuElementAttributes::Destructive); + } + Some(Retained::into_super(item_action)) + }) + .collect(); + if !items.is_empty() { + sections.push(Retained::into_super(inline(mtm, items))); + } + } + UIMenu::menuWithChildren(&NSArray::from_retained_slice(§ions), mtm) +} + +struct MenuIvars { + /// Where the element the open menu belongs to was drawn: what lifts while it is open. + target: Cell>, +} + +define_class!( + // SAFETY: NSObject has no subclassing requirements; the class does not implement `Drop`. + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "HazelContextMenus"] + #[ivars = MenuIvars] + struct ContextMenus; + + unsafe impl NSObjectProtocol for ContextMenus {} + + unsafe impl UIContextMenuInteractionDelegate for ContextMenus { + #[unsafe(method_id(contextMenuInteraction:configurationForMenuAtLocation:))] + fn configuration( + &self, + interaction: &UIContextMenuInteraction, + location: CGPoint, + ) -> Option> { + self.configure(interaction, location) + } + + #[unsafe(method_id(contextMenuInteraction:configuration:highlightPreviewForItemWithIdentifier:))] + fn highlight_preview( + &self, + interaction: &UIContextMenuInteraction, + _configuration: &UIContextMenuConfiguration, + _identifier: &ProtocolObject, + ) -> Option> { + self.preview(interaction) + } + + #[unsafe(method_id(contextMenuInteraction:configuration:dismissalPreviewForItemWithIdentifier:))] + fn dismissal_preview( + &self, + interaction: &UIContextMenuInteraction, + _configuration: &UIContextMenuConfiguration, + _identifier: &ProtocolObject, + ) -> Option> { + self.preview(interaction) + } + + #[unsafe(method(contextMenuInteraction:willEndForConfiguration:animator:))] + fn will_end( + &self, + interaction: &UIContextMenuInteraction, + _configuration: &UIContextMenuConfiguration, + animator: Option<&ProtocolObject>, + ) { + let Some(window) = interaction.view().and_then(|view| view.window()) else { + return; + }; + // Back to the system's appearance once the menu has animated away. + let restore = RcBlock::new(move || { + window.setOverrideUserInterfaceStyle(UIUserInterfaceStyle::Unspecified); + }); + match animator { + Some(animator) => animator.addCompletion(&restore), + None => restore.call(()), + } + } + } +); + +impl ContextMenus { + /// The menu of the element under a held press; none when nothing with one is drawn there. + fn configure( + &self, + interaction: &UIContextMenuInteraction, + location: CGPoint, + ) -> Option> { + let mtm = self.mtm(); + let (island, rect) = region_at(location)?; + self.ivars().target.set(Some(rect)); + // The menu opens in the page's appearance, not the system's: the window takes it + // until the menu has gone (`will_end`). + if let Some(window) = interaction.view().and_then(|view| view.window()) { + window.setOverrideUserInterfaceStyle(if DARK.get() { + UIUserInterfaceStyle::Dark + } else { + UIUserInterfaceStyle::Light + }); + } + let provider = RcBlock::new(move |_: NonNull>| { + Retained::autorelease_ptr(menu_of(mtm, &island)) + }); + Some(unsafe { + UIContextMenuConfiguration::configurationWithIdentifier_previewProvider_actionProvider( + None, + std::ptr::null_mut(), + RcBlock::as_ptr(&provider), + mtm, + ) + }) + } + + /// What lifts with the menu: a picture of the element as Rune drew it, where it is. + fn preview( + &self, + interaction: &UIContextMenuInteraction, + ) -> Option> { + let mtm = self.mtm(); + let view = interaction.view()?; + let [x, y, w, h] = self.ivars().target.get()?; + let frame = CGRect::new( + CGPoint::new(x as f64, y as f64), + CGSize::new(w as f64, h as f64), + ); + let zero = UIEdgeInsets { + top: 0.0, + left: 0.0, + bottom: 0.0, + right: 0.0, + }; + let picture = view + .resizableSnapshotViewFromRect_afterScreenUpdates_withCapInsets(frame, false, zero)?; + let bounds = CGRect::new(CGPoint::new(0.0, 0.0), frame.size); + let parameters = UIPreviewParameters::new(mtm); + parameters.setBackgroundColor(Some(&UIColor::clearColor())); + parameters.setVisiblePath(Some(&UIBezierPath::bezierPathWithRoundedRect_cornerRadius( + bounds, 12.0, + ))); + let center = CGPoint::new((x + w / 2.0) as f64, (y + h / 2.0) as f64); + let target = + UIPreviewTarget::initWithContainer_center(UIPreviewTarget::alloc(mtm), &view, center); + Some(UITargetedPreview::initWithView_parameters_target( + UITargetedPreview::alloc(mtm), + &picture, + ¶meters, + &target, + )) + } +} diff --git a/apps/native/host/ios/src/islands.rs b/apps/native/host/ios/src/islands.rs new file mode 100644 index 000000000..a5c7e9e6d --- /dev/null +++ b/apps/native/host/ios/src/islands.rs @@ -0,0 +1,663 @@ +//! The UIKit half of native islands (`hazel_native::islands`): the views the shell has for the +//! components the app marks `data-native`, each laid over the box Rune draws for its element. +//! +//! - `tab-bar`: a `UITabBar` in place of the mobile nav. Its items are the nav's own links and +//! buttons; a press on one is that element's `click`. +//! - `menu`: a clear button over a Select's trigger, which Rune keeps drawing; a tap opens the +//! system menu of the Select's options, and a choice is a `change` on the trigger. +//! - `date-picker`: a clear button over a date field; a tap presents a sheet with the system's +//! calendar, and the day picked is a `change` on the field (`yyyy-MM-dd`). +//! - `context-menu`: no view; a held press on the element opens its menu (`context_menu.rs`). +//! +//! A view is as wide as its box and is clipped, cut out and hidden as the box is (`Placement`): +//! by the list it scrolls in, under a menu Rune draws over it, behind a dialog. +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::ptr::NonNull; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +use block2::RcBlock; +use hazel_native::frames::{Placement, uncovered}; +use hazel_native::islands::{self, Draws, IslandSpec, Item, Placed, Realiser}; +use objc2::rc::{Retained, Weak}; +use objc2::runtime::ProtocolObject; +use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send}; +use objc2_core_foundation::{CGPoint, CGRect, CGSize}; +use objc2_core_graphics::CGMutablePath; +use objc2_foundation::{ + NSArray, NSData, NSDateFormatter, NSObject, NSObjectProtocol, NSString, ns_string, +}; +use objc2_quartz_core::CAShapeLayer; +use objc2_ui_kit::{ + UIAction, UIButton, UIButtonType, UIColor, UIContextMenuConfigurationElementOrder, + UIControlEvents, UIDatePicker, UIDatePickerMode, UIDatePickerStyle, UIImage, + UIImageRenderingMode, UIMenu, UIMenuElement, UIMenuElementAttributes, UIMenuElementState, + UIMenuOptions, UISheetPresentationControllerDetent, UITabBar, UITabBarDelegate, UITabBarItem, + UIUserInterfaceStyle, UIView, UIViewController, +}; + +/// How long a tab the reader pressed stays selected before the app's own selection wins: the +/// app answers a press with a tree that selects it, or (the Menu item) one that does not. +const OPTIMISTIC: Duration = Duration::from_millis(350); + +type Rect = [f32; 4]; + +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), + ) +} + +/// The shell's components, and who draws each. +fn draws(name: &str) -> Option { + match name { + "tab-bar" => Some(Draws::Native), + "menu" | "date-picker" | "context-menu" => Some(Draws::Fallback), + _ => None, + } +} + +/// Makes this shell the one that realises islands. +pub fn install() { + // `HAZEL_ISLANDS=off` when building leaves every island its fallback, to compare the two. + if crate::bridge::environment("HAZEL_ISLANDS").as_deref() == Some("off") { + return; + } + islands::set_realiser(Realiser { draws, apply }); +} + +struct TabIvars { + /// The bar's items, as the tree last gave them: a press replays on the one at the item's tag. + items: RefCell>, + pressed: Cell>, +} + +define_class!( + // SAFETY: NSObject has no subclassing requirements; the class does not implement `Drop`. + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "HazelTabBarDelegate"] + #[ivars = TabIvars] + struct TabDelegate; + + unsafe impl NSObjectProtocol for TabDelegate {} + + unsafe impl UITabBarDelegate for TabDelegate { + #[unsafe(method(tabBar:didSelectItem:))] + fn did_select(&self, _bar: &UITabBar, item: &UITabBarItem) { + self.ivars().pressed.set(Some(Instant::now())); + if let Some(item) = self.ivars().items.borrow().get(item.tag() as usize) { + islands::press(item); + } + } + } +); + +enum Native { + TabBar { + bar: Retained, + delegate: Retained, + }, + Menu { + button: Retained, + }, + DatePicker { + button: Retained, + /// The island as the tree last gave it: what a tap opens the sheet with. + current: Rc>, + }, +} + +struct Island { + /// Shows the part of the box its ancestors let show; the native view inside keeps the whole + /// box, so it does not relayout as it scrolls under a clip. + host: Retained, + native: Native, + /// The island as last applied: the native view changes only when it does. + spec: IslandSpec, + /// The size of the part that shows and the holes in it, when anything covers the view. + mask: Option<([f32; 2], Vec)>, +} + +thread_local! { + /// The page's appearance, as the last frame said: what a sheet opens in. + static DARK: Cell = const { Cell::new(false) }; + static ISLANDS: RefCell> = RefCell::new(HashMap::new()); +} + +fn ui_color(css: &Option) -> Option> { + let color = hazel_native::layout::color(css)?; + let channel = |linear: f32| rune::color::srgb_encode(linear) as f64; + Some(UIColor::colorWithRed_green_blue_alpha( + channel(color.r), + channel(color.g), + channel(color.b), + color.a as f64, + )) +} + +fn tab_items(mtm: MainThreadMarker, spec: &IslandSpec) -> Retained> { + let items: Vec> = spec + .items + .iter() + .enumerate() + .map(|(index, item)| { + // The app's own icon, as it draws it; the SF Symbol where it has none. + let image = item + .icon + .as_ref() + .and_then(|(view, body)| icon_image(*view, body, TAB_ICON)) + .or_else(|| { + item.symbol + .as_deref() + .and_then(|symbol| UIImage::systemImageNamed(&NSString::from_str(symbol))) + }); + let tab = UITabBarItem::initWithTitle_image_tag( + UITabBarItem::alloc(mtm), + Some(&NSString::from_str(&item.title)), + image.as_deref(), + index as isize, + ); + tab.setBadgeValue(item.badge.as_deref().map(NSString::from_str).as_deref()); + tab + }) + .collect(); + NSArray::from_retained_slice(&items) +} + +/// A tab's icon, in points: the app's own size (`size-6`). +const TAB_ICON: f32 = 24.0; +/// Pixels per point the icons are drawn at: every iPhone with this app's OS is 3x. +const ICON_SCALE: f32 = 3.0; + +/// One of the app's inline SVG icons as a template image, `points` square, which the native view +/// tints (selected, unselected) as it does an SF Symbol. `currentColor` draws black, and only the +/// shape's alpha counts. +fn icon_image(view: f32, body: &str, points: f32) -> Option> { + let markup = format!( + "{body}" + ); + let tree = resvg::usvg::Tree::from_str(&markup, &resvg::usvg::Options::default()).ok()?; + let pixels = (points * ICON_SCALE).round() as u32; + let mut pixmap = resvg::tiny_skia::Pixmap::new(pixels, pixels)?; + resvg::render( + &tree, + resvg::tiny_skia::Transform::from_scale(ICON_SCALE, ICON_SCALE), + &mut pixmap.as_mut(), + ); + let png = pixmap.encode_png().ok()?; + let image = UIImage::imageWithData_scale(&NSData::from_vec(png), ICON_SCALE as f64)?; + Some(image.imageWithRenderingMode(UIImageRenderingMode::AlwaysTemplate)) +} + +/// The app's selection: the bar shows it unless the reader pressed a tab a moment ago. +fn select_tab(bar: &UITabBar, spec: &IslandSpec) { + let wanted = spec.items.iter().position(|item| item.selected); + let shown = bar.selectedItem().map(|item| item.tag() as usize); + if wanted == shown { + return; + } + let items = bar.items(); + let item = wanted.and_then(|index| items.as_ref().map(|items| items.objectAtIndex(index))); + bar.setSelectedItem(item.as_deref()); +} + +fn tint_tab_bar(bar: &UITabBar, spec: &IslandSpec) { + let tint = spec + .items + .iter() + .find(|item| item.selected) + .and_then(|item| ui_color(&item.color)); + if let Some(tint) = tint { + unsafe { bar.setTintColor(Some(&tint)) }; + } +} + +/// The system menu of a Select's options (`props.items`), the selected one checked. A choice is +/// a `change` on the island's element, with the option's key. +/// The island's menu (`props.items`, from a Select or the app's menus): items, `{ separator }` +/// between groups (each group an inline section), and `{ label, items }` submenus. A choice is a +/// `change` on the island with the item's key. +fn menu_of(mtm: MainThreadMarker, spec: &IslandSpec) -> Retained { + let selected = spec.props.get("selected").and_then(|key| key.as_str()); + let options = spec.props.get("items").and_then(|items| items.as_array()); + let children = menu_children( + mtm, + spec, + selected, + options.map_or(&[][..], |o| o.as_slice()), + ); + UIMenu::menuWithChildren(&NSArray::from_retained_slice(&children), mtm) +} + +fn menu_children( + mtm: MainThreadMarker, + spec: &IslandSpec, + selected: Option<&str>, + options: &[serde_json::Value], +) -> Vec> { + let groups: Vec<&[serde_json::Value]> = options + .split(|option| option.get("separator").is_some()) + .filter(|group| !group.is_empty()) + .collect(); + let element = |option: &serde_json::Value| menu_element(mtm, spec, selected, option); + if groups.len() <= 1 { + return options.iter().filter_map(element).collect(); + } + groups + .into_iter() + .map(|group| inline_section(mtm, group.iter().filter_map(element).collect())) + .collect() +} + +/// A group of items shown as one section of the menu, divided from the next. +fn inline_section( + mtm: MainThreadMarker, + children: Vec>, +) -> Retained { + let section = UIMenu::menuWithTitle_image_identifier_options_children( + ns_string!(""), + None, + None, + UIMenuOptions::DisplayInline, + &NSArray::from_retained_slice(&children), + mtm, + ); + Retained::into_super(section) +} + +fn menu_element( + mtm: MainThreadMarker, + spec: &IslandSpec, + selected: Option<&str>, + option: &serde_json::Value, +) -> Option> { + let flag = |name: &str| option.get(name).and_then(|v| v.as_bool()) == Some(true); + let label = option.get("label").and_then(|label| label.as_str()); + let image = option + .get("symbol") + .and_then(|symbol| symbol.as_str()) + .and_then(|symbol| UIImage::systemImageNamed(&NSString::from_str(symbol))); + if let Some(items) = option.get("items").and_then(|items| items.as_array()) { + let children = menu_children(mtm, spec, selected, items); + let submenu = UIMenu::menuWithTitle_image_identifier_options_children( + &NSString::from_str(label.unwrap_or_default()), + image.as_deref(), + None, + UIMenuOptions::empty(), + &NSArray::from_retained_slice(&children), + mtm, + ); + return Some(Retained::into_super(submenu)); + } + let key = option.get("key")?.as_str()?.to_string(); + let title = NSString::from_str(label.unwrap_or(&key)); + let island = spec.clone(); + let choice = key.clone(); + let handler = RcBlock::new(move |_: NonNull| { + islands::change(&island, &choice); + }); + let action = unsafe { + UIAction::actionWithTitle_image_identifier_handler( + &title, + image.as_deref(), + None, + RcBlock::as_ptr(&handler), + mtm, + ) + }; + if selected == Some(key.as_str()) || flag("checked") { + action.setState(UIMenuElementState::On); + } + let mut attributes = UIMenuElementAttributes::empty(); + if flag("disabled") { + attributes |= UIMenuElementAttributes::Disabled; + } + if flag("destructive") { + attributes |= UIMenuElementAttributes::Destructive; + } + action.setAttributes(attributes); + Some(Retained::into_super(action)) +} + +fn interface_style(dark: bool) -> UIUserInterfaceStyle { + if dark { + UIUserInterfaceStyle::Dark + } else { + UIUserInterfaceStyle::Light + } +} + +/// Days cross as `yyyy-MM-dd`, in the reader's own calendar day. +fn day_formatter() -> Retained { + let formatter = NSDateFormatter::new(); + formatter.setDateFormat(Some(ns_string!("yyyy-MM-dd"))); + formatter +} + +/// A sheet with the system's calendar on the field's day (`props.value`, not before `props.min`). +/// The day picked is a `change` on the island's element, and closes the sheet. +/// Whether a `menu` island shows as a sheet rather than a pull-down. +fn is_sheet(spec: &IslandSpec) -> bool { + spec.props.get("presentation").and_then(|p| p.as_str()) == Some("sheet") +} + +/// The controller to present from: the one on top of the window's root. +fn presenter(mtm: MainThreadMarker) -> Option> { + let mut top = crate::bridge::window(mtm)?.rootViewController()?; + while let Some(next) = top.presentedViewController() { + top = next; + } + Some(top) +} + +fn present_date_picker(mtm: MainThreadMarker, spec: &IslandSpec) { + let Some(presenter) = crate::bridge::window(mtm).and_then(|window| window.rootViewController()) + else { + return; + }; + let controller = + UIViewController::initWithNibName_bundle(UIViewController::alloc(mtm), None, None); + let Some(view) = controller.view() else { + return; + }; + view.setBackgroundColor(Some(&UIColor::systemBackgroundColor())); + let width = presenter + .view() + .map_or(390.0, |view| view.bounds().size.width); + let frame = CGRect::new(CGPoint::new(16.0, 24.0), CGSize::new(width - 32.0, 340.0)); + let picker: Retained = + unsafe { msg_send![UIDatePicker::alloc(mtm), initWithFrame: frame] }; + picker.setDatePickerMode(UIDatePickerMode::Date); + picker.setPreferredDatePickerStyle(UIDatePickerStyle::Inline); + let formatter = day_formatter(); + let day = |name: &str| { + let day = spec.props.get(name)?.as_str()?; + formatter.dateFromString(&NSString::from_str(day)) + }; + if let Some(min) = day("min") { + picker.setMinimumDate(Some(&min)); + } + if let Some(value) = day("value") { + picker.setDate(&value); + } + let island = spec.clone(); + let sheet = Weak::from_retained(&controller); + let handler = RcBlock::new(move |action: NonNull| { + // SAFETY: UIKit passes the action it is running. + let sender = unsafe { action.as_ref() }.sender(); + let Some(picker) = sender.and_then(|sender| sender.downcast::().ok()) else { + return; + }; + islands::change( + &island, + &day_formatter().stringFromDate(&picker.date()).to_string(), + ); + if let Some(sheet) = sheet.load() { + sheet.dismissViewControllerAnimated_completion(true, None); + } + }); + let picked = unsafe { UIAction::actionWithHandler(RcBlock::as_ptr(&handler), mtm) }; + picker.addAction_forControlEvents(&picked, UIControlEvents::ValueChanged); + view.addSubview(&picker); + if let Some(sheet) = controller.sheetPresentationController() { + let medium = UISheetPresentationControllerDetent::mediumDetent(mtm); + sheet.setDetents(&NSArray::from_retained_slice(&[medium])); + sheet.setPrefersGrabberVisible(true); + } + controller.setOverrideUserInterfaceStyle(interface_style(DARK.get())); + presenter.presentViewController_animated_completion(&controller, true, None); +} + +fn create(mtm: MainThreadMarker, parent: &UIView, spec: &IslandSpec) -> Option { + let zero = cg([0.0; 4]); + let host = UIView::initWithFrame(UIView::alloc(mtm), zero); + host.setClipsToBounds(true); + let native = match spec.name.as_str() { + "tab-bar" => { + let bar: Retained = + unsafe { msg_send![UITabBar::alloc(mtm), initWithFrame: zero] }; + let delegate: Retained = { + let this = TabDelegate::alloc(mtm).set_ivars(TabIvars { + items: RefCell::new(spec.items.clone()), + pressed: Cell::new(None), + }); + unsafe { msg_send![super(this), init] } + }; + bar.setDelegate(Some(ProtocolObject::from_ref(&*delegate))); + bar.setItems(Some(&tab_items(mtm, spec))); + select_tab(&bar, spec); + tint_tab_bar(&bar, spec); + host.addSubview(&bar); + Native::TabBar { bar, delegate } + } + // An app-level picker (the account, the servers) rises as a sheet. + "menu" if is_sheet(spec) => { + let current = Rc::new(RefCell::new(spec.clone())); + let shown = current.clone(); + let handler = RcBlock::new(move |_: NonNull| { + if let Some(presenter) = presenter(mtm) { + crate::menu_sheet::present(mtm, &presenter, &shown.borrow(), DARK.get()); + } + }); + let open = unsafe { UIAction::actionWithHandler(RcBlock::as_ptr(&handler), mtm) }; + let button = + UIButton::buttonWithType_primaryAction(UIButtonType::Custom, Some(&open), mtm); + host.addSubview(&button); + Native::DatePicker { button, current } + } + "menu" => { + let button = UIButton::buttonWithType(UIButtonType::Custom, mtm); + button.setMenu(Some(&menu_of(mtm, spec))); + button.setShowsMenuAsPrimaryAction(true); + // The items in the order the app gives them, however the menu opens (upward from + // a trigger low on the screen, UIKit would turn them around). + button.setPreferredMenuElementOrder(UIContextMenuConfigurationElementOrder::Fixed); + host.addSubview(&button); + Native::Menu { button } + } + "date-picker" => { + let current = Rc::new(RefCell::new(spec.clone())); + let shown = current.clone(); + let handler = RcBlock::new(move |_: NonNull| { + present_date_picker(mtm, &shown.borrow()); + }); + let open = unsafe { UIAction::actionWithHandler(RcBlock::as_ptr(&handler), mtm) }; + let button = + UIButton::buttonWithType_primaryAction(UIButtonType::Custom, Some(&open), mtm); + host.addSubview(&button); + Native::DatePicker { button, current } + } + _ => return None, + }; + parent.addSubview(&host); + Some(Island { + host, + native, + spec: spec.clone(), + mask: None, + }) +} + +/// The island's tree changed: the native view takes what is new in it. +fn update(mtm: MainThreadMarker, island: &mut Island, spec: &IslandSpec) { + match &island.native { + Native::TabBar { bar, delegate } => { + type Look = (String, Option, Option, Option); + let titles = |spec: &IslandSpec| -> Vec { + spec.items + .iter() + .map(|item| { + let icon = item.icon.as_ref().map(|(_, body)| body.clone()); + ( + item.title.clone(), + item.symbol.clone(), + item.badge.clone(), + icon, + ) + }) + .collect() + }; + if titles(spec) != titles(&island.spec) { + bar.setItems(Some(&tab_items(mtm, spec))); + } + *delegate.ivars().items.borrow_mut() = spec.items.clone(); + tint_tab_bar(bar, spec); + } + Native::Menu { button } => button.setMenu(Some(&menu_of(mtm, spec))), + Native::DatePicker { current, .. } => *current.borrow_mut() = spec.clone(), + } + island.spec = spec.clone(); +} + +fn put(island: &mut Island, placement: &Placement, bottom_inset: f32) { + let [x, y, w, h] = placement.rect; + let (rect, visible) = match &island.native { + // A tab bar is as tall as the system makes one, not as the nav it stands for, and + // reaches the screen's bottom edge, under the home indicator: stretched over the nav's + // box it spreads its icons and titles apart. The nav pads itself down to that edge. + Native::TabBar { bar, .. } => { + let bottom = y + h; + let fitted = bar.sizeThatFits(CGSize::new(w as f64, 0.0)).height as f32; + // The bar adds the home indicator's inset itself once it is in the window. + let height = if fitted > bottom_inset + 1.0 { + fitted + } else { + fitted + bottom_inset + }; + let rect = [x, bottom - height, w, height]; + let [vx, _, vw, _] = placement.visible; + (rect, [vx, rect[1], vw, height]) + } + Native::Menu { .. } | Native::DatePicker { .. } => (placement.rect, placement.visible), + }; + let [vx, vy, ..] = visible; + let host_frame = cg(visible); + if island.host.frame() != host_frame { + island.host.setFrame(host_frame); + } + let inner = cg([rect[0] - vx, rect[1] - vy, rect[2], rect[3]]); + let view: &UIView = match &island.native { + Native::TabBar { bar, .. } => bar, + Native::Menu { button } | Native::DatePicker { button, .. } => button, + }; + if view.frame() != inner { + view.setFrame(inner); + } + island.host.setHidden(false); + if let Native::TabBar { bar, delegate } = &island.native { + let settled = delegate + .ivars() + .pressed + .get() + .is_none_or(|at| at.elapsed() > OPTIMISTIC); + if settled { + select_tab(bar, &island.spec); + } + } + + // Cut out where Rune draws over the view (a menu, a sticky header, a sheet's backdrop): + // rebuilt when the covers move and when the part that shows changes size. + let holes: Vec = placement + .holes + .iter() + .map(|[hx, hy, hw, hh]| [hx - vx, hy - vy, *hw, *hh]) + .collect(); + let size = [visible[2], visible[3]]; + let mask = (!holes.is_empty()).then_some((size, holes)); + if island.mask == mask { + return; + } + let layer = island.host.layer(); + match &mask { + None => unsafe { layer.setMask(None) }, + Some((size, holes)) => { + // The parts nothing covers, as rects that do not overlap. + let path = CGMutablePath::new(); + for shown in uncovered(*size, holes) { + unsafe { CGMutablePath::add_rect(Some(&path), std::ptr::null(), cg(shown)) }; + } + let shape = CAShapeLayer::new(); + shape.setFrame(cg([0.0, 0.0, size[0], size[1]])); + shape.setPath(Some(&path)); + unsafe { layer.setMask(Some(&shape)) }; + } + } + island.mask = mask; +} + +/// Brings the native views in line with the islands of the frame just drawn. +fn apply(placed: &[Placed], dark: bool) { + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + let Some(window) = crate::bridge::window(mtm) else { + return; + }; + DARK.set(dark); + let bottom_inset = window.safeAreaInsets().bottom as f32; + // Inside the view controller's view (Rune's own), not the window: UIKit presents a menu or a + // sheet from a view only through the view controller above it. + let Some(parent) = window + .rootViewController() + .and_then(|controller| controller.view()) + else { + return; + }; + ISLANDS.with_borrow_mut(|islands| { + let gone: Vec = islands + .keys() + .filter(|path| !placed.iter().any(|(spec, _)| spec.path == **path)) + .cloned() + .collect(); + for path in gone { + if let Some(island) = islands.remove(&path) { + island.host.removeFromSuperview(); + } + } + crate::context_menu::begin(dark); + for (spec, placement) in placed { + // A region Rune's own view answers for, with no view of its own. + if spec.name == "context-menu" { + crate::context_menu::place(spec, placement.as_ref()); + continue; + } + // The same path with another component is another view. + if islands + .get(&spec.path) + .is_some_and(|island| island.spec.name != spec.name) + && let Some(island) = islands.remove(&spec.path) + { + island.host.removeFromSuperview(); + } + if !islands.contains_key(&spec.path) { + if placement.is_none() { + continue; + } + let Some(island) = create(mtm, &parent, spec) else { + continue; + }; + crate::bridge::log(&format!("[islands] {} at {}", spec.name, spec.path)); + islands.insert(spec.path.clone(), island); + } + let Some(island) = islands.get_mut(&spec.path) else { + continue; + }; + if island.spec != **spec { + update(mtm, island, spec); + } + // The app's theme, not the system's: a dark page gets dark glass and menus. + let style = interface_style(dark); + if island.host.overrideUserInterfaceStyle() != style { + island.host.setOverrideUserInterfaceStyle(style); + } + match placement { + Some(placement) => put(island, placement, bottom_inset), + None => island.host.setHidden(true), + } + } + }); + crate::context_menu::end(mtm, &parent); +} diff --git a/apps/native/host/ios/src/main.rs b/apps/native/host/ios/src/main.rs index fbc75111c..1a0eefdef 100644 --- a/apps/native/host/ios/src/main.rs +++ b/apps/native/host/ios/src/main.rs @@ -3,13 +3,21 @@ #[cfg(target_os = "ios")] mod bridge; #[cfg(target_os = "ios")] +mod context_menu; +#[cfg(target_os = "ios")] mod http; #[cfg(target_os = "ios")] +mod islands; +#[cfg(target_os = "ios")] mod keyboard; +#[cfg(target_os = "ios")] +mod menu_sheet; #[cfg(any(test, target_os = "ios"))] mod page; #[cfg(target_os = "ios")] mod sign_in; +#[cfg(target_os = "ios")] +mod system; #[cfg(target_os = "ios")] fn main() { @@ -31,10 +39,15 @@ fn main() { // iOS gives the window the whole screen whatever size is asked for. ..AppSpec::new("Hazel", (402.0, 874.0), State::default, view) }; - // HTML renders' web views go where their boxes were drawn, after every frame. + // The UIKit views the app's `data-native` elements get, and HTML renders' web views, go + // where their boxes were drawn, after every frame. + islands::install(); let app = App::new(spec) .font_smoothing(TEXT_SMOOTHING) - .on_frame_drawn(frames::sync); + .on_frame_drawn(|runtime| { + frames::sync(runtime); + hazel_native::islands::sync(runtime); + }); bridge::set_handle(app.handle()); app.run(); } diff --git a/apps/native/host/ios/src/menu_sheet.rs b/apps/native/host/ios/src/menu_sheet.rs new file mode 100644 index 000000000..8ee4e51b8 --- /dev/null +++ b/apps/native/host/ios/src/menu_sheet.rs @@ -0,0 +1,278 @@ +//! A `menu` island shown as a sheet (`props.presentation: "sheet"`): an app-level picker, such as +//! the account or the servers, rises from the bottom edge as a phone's own does. A title and +//! subtitle over grouped, rounded sections of rows (an SF Symbol, the item's words, a checkmark on +//! the current one, red for a destructive one), the sheet only as tall as what it holds. A row's +//! press closes the sheet and is a `change` on the island with the item's key, as a pull-down +//! menu's choice is. +use std::ptr::NonNull; + +use block2::RcBlock; +use hazel_native::islands::{self, IslandSpec}; +use objc2::rc::{Retained, Weak}; +use objc2::runtime::ProtocolObject; +use objc2::{MainThreadMarker, MainThreadOnly}; +use objc2_core_foundation::{CGFloat, CGPoint, CGRect, CGSize}; +use objc2_foundation::{NSArray, NSString}; +use objc2_quartz_core::kCACornerCurveContinuous; +use objc2_ui_kit::{ + NSDirectionalEdgeInsets, UIAction, UIButton, UIButtonConfiguration, UIColor, + UIControlContentHorizontalAlignment, UIFont, UIImage, UIImageSymbolConfiguration, UIImageView, + UILabel, UISheetPresentationControllerDetent, + UISheetPresentationControllerDetentResolutionContext, UIUserInterfaceStyle, UIView, + UIViewContentMode, UIViewController, +}; +use serde_json::Value; + +/// A row's height: a thumb's target and some air, as Settings' rows. +const ROW: CGFloat = 52.0; +/// The sheet's side margins, and the gap between sections. +const MARGIN: CGFloat = 16.0; +const GAP: CGFloat = 20.0; +/// Where the rows' words start, past the icon. +const TEXT_INSET: CGFloat = 56.0; +/// The icon's slot: its left edge and width. +const ICON_SLOT: (CGFloat, CGFloat) = (14.0, 30.0); +/// The brand purple (`#6938EF`): the checkmark's colour. +const BRAND: (CGFloat, CGFloat, CGFloat) = (0x69 as CGFloat, 0x38 as CGFloat, 0xEF as CGFloat); + +/// One row of the sheet. +struct Row { + key: String, + label: String, + symbol: Option, + disabled: bool, + destructive: bool, + checked: bool, +} + +/// The island's items as sections of rows: separators divide them, and a submenu's items are a +/// section of their own (a sheet has no nesting). +fn sections(items: &[Value], selected: Option<&str>) -> Vec> { + let flag = |item: &Value, name: &str| item.get(name).and_then(Value::as_bool) == Some(true); + let text = + |item: &Value, name: &str| item.get(name).and_then(Value::as_str).map(str::to_string); + let mut out: Vec> = vec![Vec::new()]; + for item in items { + if item.get("separator").is_some() { + out.push(Vec::new()); + } else if let Some(children) = item.get("items").and_then(Value::as_array) { + out.extend(sections(children, selected)); + out.push(Vec::new()); + } else if let Some(key) = text(item, "key") { + let current = out.last_mut().expect("a section"); + current.push(Row { + label: text(item, "label").unwrap_or_else(|| key.clone()), + checked: flag(item, "checked") || selected == Some(key.as_str()), + key, + symbol: text(item, "symbol"), + disabled: flag(item, "disabled"), + destructive: flag(item, "destructive"), + }); + } + } + out.retain(|rows| !rows.is_empty()); + out +} + +fn label( + mtm: MainThreadMarker, + text: &str, + font: &UIFont, + color: &UIColor, + frame: CGRect, +) -> Retained { + let label = UILabel::initWithFrame(UILabel::alloc(mtm), frame); + label.setText(Some(&NSString::from_str(text))); + unsafe { + label.setFont(Some(font)); + label.setTextColor(Some(color)); + } + label +} + +fn rounded(view: &UIView, radius: CGFloat) { + let layer = view.layer(); + layer.setCornerRadius(radius); + layer.setCornerCurve(unsafe { kCACornerCurveContinuous }); + view.setClipsToBounds(true); +} + +/// Presents the island's menu as a sheet over `presenter`, in the page's theme (`dark`). +pub fn present(mtm: MainThreadMarker, presenter: &UIViewController, spec: &IslandSpec, dark: bool) { + let props = &spec.props; + let selected = props.get("selected").and_then(Value::as_str); + let items = props + .get("items") + .and_then(Value::as_array) + .map_or(&[][..], Vec::as_slice); + let sections = sections(items, selected); + let controller = + UIViewController::initWithNibName_bundle(UIViewController::alloc(mtm), None, None); + let Some(view) = controller.view() else { + return; + }; + let width = presenter + .view() + .map_or(390.0, |view| view.bounds().size.width); + let inner = width - MARGIN * 2.0; + let mut y: CGFloat = 28.0; + + let text = |name: &str| { + props + .get(name) + .and_then(Value::as_str) + .filter(|t| !t.is_empty()) + }; + if let Some(title) = text("title") { + let font = UIFont::boldSystemFontOfSize(20.0); + let frame = CGRect::new( + CGPoint::new(MARGIN + 8.0, y), + CGSize::new(inner - 16.0, 26.0), + ); + view.addSubview(&label(mtm, title, &font, &UIColor::labelColor(), frame)); + y += 28.0; + if let Some(subtitle) = text("subtitle") { + let font = UIFont::systemFontOfSize(15.0); + let frame = CGRect::new( + CGPoint::new(MARGIN + 8.0, y), + CGSize::new(inner - 16.0, 20.0), + ); + view.addSubview(&label( + mtm, + subtitle, + &font, + &UIColor::secondaryLabelColor(), + frame, + )); + y += 22.0; + } + y += 14.0; + } + + let sheet = Weak::from_retained(&controller); + let brand = UIColor::colorWithRed_green_blue_alpha( + BRAND.0 / 255.0, + BRAND.1 / 255.0, + BRAND.2 / 255.0, + 1.0, + ); + let symbol_size = UIImageSymbolConfiguration::configurationWithPointSize(17.0); + for rows in §ions { + let height = ROW * rows.len() as CGFloat; + let group = UIView::initWithFrame( + UIView::alloc(mtm), + CGRect::new(CGPoint::new(MARGIN, y), CGSize::new(inner, height)), + ); + group.setBackgroundColor(Some(&UIColor::secondarySystemGroupedBackgroundColor())); + rounded(&group, 24.0); + for (index, row) in rows.iter().enumerate() { + let top = ROW * index as CGFloat; + if index > 0 { + let line = UIView::initWithFrame( + UIView::alloc(mtm), + CGRect::new( + CGPoint::new(TEXT_INSET, top), + CGSize::new(inner - TEXT_INSET, 1.0 / 3.0), + ), + ); + line.setBackgroundColor(Some(&UIColor::separatorColor())); + group.addSubview(&line); + } + let configuration = UIButtonConfiguration::plainButtonConfiguration(mtm); + configuration.setTitle(Some(&NSString::from_str(&row.label))); + let image = row + .symbol + .as_deref() + .and_then(|name| UIImage::systemImageNamed(&NSString::from_str(name))) + .map(|image| { + image + .imageByApplyingSymbolConfiguration(&symbol_size) + .unwrap_or(image) + }); + // The icon sits centred in a slot of its own, so every row's words start at one line + // whatever the symbol's width. + configuration.setContentInsets(NSDirectionalEdgeInsets { + top: 0.0, + leading: TEXT_INSET, + bottom: 0.0, + trailing: 44.0, + }); + let ink = if row.destructive { + UIColor::systemRedColor() + } else { + UIColor::labelColor() + }; + configuration.setBaseForegroundColor(Some(&ink)); + let island = spec.clone(); + let key = row.key.clone(); + let closing = sheet.clone(); + let handler = RcBlock::new(move |_: NonNull| { + if let Some(sheet) = closing.load() { + sheet.dismissViewControllerAnimated_completion(true, None); + } + islands::change(&island, &key); + }); + let action = unsafe { UIAction::actionWithHandler(RcBlock::as_ptr(&handler), mtm) }; + let button = + UIButton::buttonWithConfiguration_primaryAction(&configuration, Some(&action)); + button.setContentHorizontalAlignment(UIControlContentHorizontalAlignment::Leading); + button.setFrame(CGRect::new(CGPoint::new(0.0, top), CGSize::new(inner, ROW))); + button.setEnabled(!row.disabled); + group.addSubview(&button); + if let Some(image) = image { + let icon = UIImageView::initWithImage(UIImageView::alloc(mtm), Some(&image)); + unsafe { icon.setTintColor(Some(&ink)) }; + icon.setContentMode(UIViewContentMode::Center); + icon.setFrame(CGRect::new( + CGPoint::new(ICON_SLOT.0, top), + CGSize::new(ICON_SLOT.1, ROW), + )); + icon.setUserInteractionEnabled(false); + icon.setAlpha(if row.disabled { 0.4 } else { 1.0 }); + group.addSubview(&icon); + } + if row.checked { + let check = UIImage::systemImageNamed(ns_check()).map(|image| { + let view = UIImageView::initWithImage(UIImageView::alloc(mtm), Some(&image)); + unsafe { view.setTintColor(Some(&brand)) }; + view + }); + if let Some(check) = check { + let size = check.frame().size; + check.setFrame(CGRect::new( + CGPoint::new(inner - 18.0 - size.width, top + (ROW - size.height) / 2.0), + size, + )); + group.addSubview(&check); + } + } + } + view.addSubview(&group); + y += height + GAP; + } + + // As tall as what it holds, over the home indicator. + let content = y - GAP + 24.0; + if let Some(sheet) = controller.sheetPresentationController() { + let fits = RcBlock::new( + move |_: NonNull< + ProtocolObject, + >| { content }, + ); + let detent = UISheetPresentationControllerDetent::customDetentWithIdentifier_resolver( + None, &fits, mtm, + ); + sheet.setDetents(&NSArray::from_retained_slice(&[detent])); + sheet.setPrefersGrabberVisible(true); + } + controller.setOverrideUserInterfaceStyle(if dark { + UIUserInterfaceStyle::Dark + } else { + UIUserInterfaceStyle::Light + }); + presenter.presentViewController_animated_completion(&controller, true, None); +} + +fn ns_check() -> &'static NSString { + objc2_foundation::ns_string!("checkmark") +} diff --git a/apps/native/host/ios/src/system.rs b/apps/native/host/ios/src/system.rs new file mode 100644 index 000000000..d64da99d1 --- /dev/null +++ b/apps/native/host/ios/src/system.rs @@ -0,0 +1,529 @@ +//! The phone's own controls the app asks for (`web-foldkit/src/platform/system-ui.ts`): a tap of +//! the finger, and navigation that moves as a navigation stack's does. +//! +//! Rune draws the whole page in one view, so the page left behind is gone once the next is +//! drawn. The runtime says before every in-app navigation where it goes (`navigate`), and the +//! host takes a picture of the page it leaves (`snapshotViewAfterScreenUpdates:`, of what Rune +//! drew last). With that picture: +//! - a page that leads back to the one left (it has a back chevron to it) slides in from the right +//! over it, the old page drifting left and dimming (a push); +//! - going back slides the page off to the right, over the picture of the page it goes to (a pop); +//! - the swipe from the left edge does the same under the finger, and goes back when let go far +//! or fast enough; +//! - anything else (a tab) changes at once. +use std::cell::{Cell, RefCell}; +use std::time::{Duration, Instant}; + +use block2::RcBlock; +use dispatch2::{DispatchQueue, DispatchTime}; +use objc2::rc::Retained; +use objc2::runtime::{Bool, ProtocolObject}; +use objc2::{MainThreadMarker, MainThreadOnly, define_class, msg_send, sel}; +use objc2_core_foundation::{CGAffineTransform, CGFloat, CGSize}; +use objc2_foundation::{NSObject, NSObjectProtocol}; +use objc2_ui_kit::{ + UIBezierPath, UIColor, UIGestureRecognizer, UIGestureRecognizerDelegate, + UIImpactFeedbackGenerator, UIImpactFeedbackStyle, UIPanGestureRecognizer, UIView, + UIViewAnimationOptions, +}; +use serde_json::Value; + +use crate::bridge::{log, reply, window}; + +/// A tap of the finger, as a long-press's menu gives. +pub fn haptic(mtm: MainThreadMarker) { + // `feedbackGeneratorWithStyle:forView:` replaces it from iOS 17.5; the app runs on 17.0. + #[allow(deprecated)] + let generator = UIImpactFeedbackGenerator::initWithStyle( + UIImpactFeedbackGenerator::alloc(mtm), + UIImpactFeedbackStyle::Medium, + ); + generator.impactOccurred(); +} + +// PICTURES OF PAGES + +/// Pictures of the pages left, newest last, by path: what a pop slides over. +const KEPT: usize = 8; +/// How far left (a share of the width) the page underneath sits, as UIKit's stack moves it. +const PARALLAX: CGFloat = 0.3; +/// How dark the page underneath is at the start of a pop, or the end of a push. +const DIM: CGFloat = 0.12; +/// How long a push or pop takes. +const SLIDE: f64 = 0.35; +/// The longest a navigation waits for the runtime's next page before it gives up on moving. +const WAIT: Duration = Duration::from_millis(500); +/// After the next page's tree arrives, a frame for Rune to draw it. +const DRAWN: Duration = Duration::from_millis(32); + +thread_local! { + static PICTURES: RefCell)>> = const { RefCell::new(Vec::new()) }; + /// Where the back chevron on screen leads (a path), if there is one. + static BACK: RefCell> = const { RefCell::new(None) }; + /// A navigation waiting for its page to be drawn. + static PENDING: RefCell> = const { RefCell::new(None) }; + /// A swipe went back: the picture covers the page until the runtime has drawn it. + static SWIPED: RefCell>> = const { RefCell::new(None) }; + /// A slide is moving: a navigation in the middle of one changes at once. + static MOVING: Cell = const { Cell::new(false) }; + static SWIPE: RefCell, Retained)>> = + const { RefCell::new(None) }; + /// What the swipe moving now slides over: the picture, and the dim over it. + static UNDER: RefCell, Retained)>> = const { RefCell::new(None) }; +} + +struct Pending { + /// The page left, frozen over the page while the next one is drawn. + cover: Retained, + from: String, + back: bool, + at: Instant, +} + +/// The view Rune draws in (the window's root view controller's), which slides. +fn page(mtm: MainThreadMarker) -> Option> { + window(mtm)?.rootViewController()?.view() +} + +/// A picture of what Rune drew last, the islands' views over it included. +fn picture(page: &UIView) -> Option> { + page.snapshotViewAfterScreenUpdates(false) +} + +fn keep(path: &str, view: Retained) { + PICTURES.with_borrow_mut(|pictures| { + pictures.retain(|(kept, _)| kept != path); + pictures.push((path.to_string(), view)); + if pictures.len() > KEPT { + pictures.remove(0); + } + }); +} + +fn picture_of(path: &str) -> Option> { + PICTURES.with_borrow(|pictures| { + pictures + .iter() + .rev() + .find(|(kept, _)| kept == path) + .map(|(_, view)| view.clone()) + }) +} + +/// The runtime is about to draw another page (`{ from, to, back }`): the page it leaves is +/// pictured, and held over the screen until the next one is drawn and the move decided. +pub fn navigate(mtm: MainThreadMarker, json: &str) { + let Ok(request) = serde_json::from_str::(json) else { + return log("[host] a navigation that is not JSON"); + }; + let (Some(from), back) = ( + request["from"].as_str(), + request["back"].as_bool() == Some(true), + ) else { + return; + }; + // A swipe already moved the page; a slide still moving lets this one change at once. + if SWIPED.with_borrow(Option::is_some) || MOVING.get() { + return; + } + let (Some(window), Some(page)) = (window(mtm), page(mtm)) else { + return; + }; + let (Some(kept), Some(cover)) = (picture(&page), picture(&page)) else { + return; + }; + keep(from, kept); + finish_pending(); + cover.setFrame(page.frame()); + window.addSubview(&cover); + PENDING.set(Some(Pending { + cover, + from: from.to_string(), + back, + at: Instant::now(), + })); + // A runtime that draws nothing new still lets the screen go. + later(WAIT, move || { + if PENDING.with_borrow(|p| p.as_ref().is_some_and(|p| p.at.elapsed() >= WAIT)) { + finish_pending(); + } + }); +} + +/// A tree from the runtime was applied: once Rune has drawn it, a waiting navigation moves. +pub fn tree_applied() { + if SWIPED.with_borrow(Option::is_some) { + later(DRAWN, || { + if let Some(cover) = SWIPED.take() { + fade_out(cover); + } + }); + return; + } + if PENDING.with_borrow(Option::is_none) { + return; + } + later(DRAWN, || { + let Some(pending) = PENDING.take() else { + return; + }; + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + // The page drawn now leads back to the one left: it was pushed onto it. + let pushed = BACK.with_borrow(|back| back.as_deref() == Some(pending.from.as_str())); + match (page(mtm), pending.back, pushed) { + (Some(page), true, _) => pop(mtm, page, pending.cover), + (Some(page), false, true) => push(mtm, page, pending.cover), + _ => pending.cover.removeFromSuperview(), + } + }); +} + +/// The navigation waiting, if any, changes at once. +fn finish_pending() { + if let Some(pending) = PENDING.take() { + pending.cover.removeFromSuperview(); + } +} + +fn later(after: Duration, work: impl FnOnce() + Send + 'static) { + let when = DispatchTime::NOW.time(after.as_nanos() as i64); + let _ = DispatchQueue::main().after(when, work); +} + +fn shifted(x: CGFloat) -> CGAffineTransform { + CGAffineTransform { + a: 1.0, + b: 0.0, + c: 0.0, + d: 1.0, + tx: x, + ty: 0.0, + } +} + +/// A shadow down the page's left edge, as a pushed page casts on the one under it. +fn shadow(view: &UIView, on: bool) { + let layer = view.layer(); + layer.setShadowOpacity(if on { 0.25 } else { 0.0 }); + if on { + let path = UIBezierPath::bezierPathWithRect(view.bounds()); + layer.setShadowRadius(8.0); + layer.setShadowOffset(CGSize::new(-2.0, 0.0)); + layer.setShadowPath(Some(&path.CGPath())); + } +} + +/// A dim the size of `over`, laid on it. +fn dim_over(mtm: MainThreadMarker, over: &UIView, alpha: CGFloat) -> Retained { + let dim = UIView::initWithFrame(UIView::alloc(mtm), over.bounds()); + dim.setBackgroundColor(Some(&UIColor::blackColor())); + dim.setAlpha(alpha); + dim.setUserInteractionEnabled(false); + over.addSubview(&dim); + dim +} + +fn animate( + mtm: MainThreadMarker, + duration: f64, + moves: impl Fn() + 'static, + done: impl Fn() + 'static, +) { + MOVING.set(true); + let moves = RcBlock::new(moves); + let done = RcBlock::new(move |_: Bool| { + MOVING.set(false); + done(); + }); + // UIKit's own push: a spring with no bounce, as `UINavigationController`'s. + UIView::animateWithDuration_delay_usingSpringWithDamping_initialSpringVelocity_options_animations_completion( + duration, + 0.0, + 1.0, + 0.0, + UIViewAnimationOptions::AllowUserInteraction, + &moves, + Some(&done), + mtm, + ); +} + +/// The new page slides in from the right over the old one, which drifts left and dims. +fn push(mtm: MainThreadMarker, page: Retained, old: Retained) { + let Some(parent) = page.superview() else { + return old.removeFromSuperview(); + }; + let width = page.bounds().size.width; + parent.insertSubview_belowSubview(&old, &page); + let dim = dim_over(mtm, &old, 0.0); + page.setTransform(shifted(width)); + shadow(&page, true); + let (moving, under, dimming) = (page.clone(), old.clone(), dim.clone()); + animate( + mtm, + SLIDE, + move || { + moving.setTransform(shifted(0.0)); + under.setTransform(shifted(-width * PARALLAX)); + dimming.setAlpha(DIM); + }, + move || { + shadow(&page, false); + old.removeFromSuperview(); + }, + ); +} + +/// The old page slides off to the right, over the new one coming back from the left. +fn pop(mtm: MainThreadMarker, page: Retained, old: Retained) { + let width = page.bounds().size.width; + let dim = dim_over(mtm, &page, DIM); + page.setTransform(shifted(-width * PARALLAX)); + shadow(&old, true); + let (moving, under, dimming) = (old.clone(), page.clone(), dim.clone()); + animate( + mtm, + SLIDE, + move || { + moving.setTransform(shifted(width)); + under.setTransform(shifted(0.0)); + dimming.setAlpha(0.0); + }, + move || { + dim.removeFromSuperview(); + old.removeFromSuperview(); + }, + ); +} + +fn fade_out(cover: Retained) { + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + let fading = cover.clone(); + let fade = RcBlock::new(move || fading.setAlpha(0.0)); + let done = RcBlock::new(move |_: Bool| cover.removeFromSuperview()); + UIView::animateWithDuration_animations_completion(0.12, &fade, Some(&done), mtm); +} + +// SWIPE BACK + +/// A swipe starts this close (points) to the left edge, as a navigation stack's does. +const EDGE: CGFloat = 24.0; +/// A swipe let go past this share of the width goes back; short of it, the page returns. +const COMMIT_SHARE: CGFloat = 0.35; +/// A flick this fast (points per second) goes back however short. +const COMMIT_VELOCITY: CGFloat = 500.0; + +/// Where the back chevron on screen leads (a path); empty when there is none. The edge swipe is +/// on while there is one. +pub fn set_swipe_back(mtm: MainThreadMarker, path: &str) { + BACK.set((!path.is_empty()).then(|| path.to_string())); + let enabled = !path.is_empty(); + if SWIPE.with_borrow(Option::is_none) { + if !enabled { + return; + } + let Some(window) = window(mtm) else { + return; + }; + let target: Retained = { + let this = SwipeBack::alloc(mtm).set_ivars(()); + unsafe { msg_send![super(this), init] } + }; + // A pan that only begins at the edge (`gestureRecognizerShouldBegin:`), rather than + // UIKit's edge recognizer, which ignores the touches XCTest makes up. + // SAFETY: the target is kept alive beside the recognizer, and answers `pan:`. + let swipe = unsafe { + UIPanGestureRecognizer::initWithTarget_action( + UIPanGestureRecognizer::alloc(mtm), + Some(&target), + Some(sel!(pan:)), + ) + }; + swipe.setDelegate(Some(ProtocolObject::from_ref(&*target))); + window.addGestureRecognizer(&swipe); + // Behind a page with nothing pictured under it. + window.setBackgroundColor(Some(&UIColor::blackColor())); + SWIPE.set(Some((swipe, target))); + } + SWIPE.with_borrow(|swipe| { + if let Some((swipe, _)) = swipe { + swipe.setEnabled(enabled); + } + }); +} + +/// The swipe began: the page it goes back to, pictured, slides in underneath. +fn begin_swipe(mtm: MainThreadMarker, page: &UIView) { + finish_pending(); + let Some(parent) = page.superview() else { + return; + }; + let under = BACK + .with_borrow(Clone::clone) + .and_then(|path| picture_of(&path)); + if let Some(under) = under { + under.setFrame(page.frame()); + under.setAlpha(1.0); + parent.insertSubview_belowSubview(&under, page); + let dim = dim_over(mtm, &under, DIM); + UNDER.set(Some((under, dim))); + } + shadow(page, true); +} + +/// The swipe moved the page `progress` of the way across. +fn follow(page: &UIView, moved: CGFloat, width: CGFloat) { + page.setTransform(shifted(moved)); + let progress = moved / width; + UNDER.with_borrow(|under| { + if let Some((under, dim)) = under { + under.setTransform(shifted(-width * PARALLAX * (1.0 - progress))); + dim.setAlpha(DIM * (1.0 - progress)); + } + }); +} + +/// Let go short: the page returns, and what was under it goes. +fn settle(mtm: MainThreadMarker, page: Retained, width: CGFloat) { + let moving = page.clone(); + let under = UNDER.with_borrow(Clone::clone); + let moved_under = under.clone(); + animate( + mtm, + 0.3, + move || { + moving.setTransform(shifted(0.0)); + if let Some((under, dim)) = &moved_under { + under.setTransform(shifted(-width * PARALLAX)); + dim.setAlpha(DIM); + } + }, + move || { + shadow(&page, false); + if let Some((under, dim)) = &under { + dim.removeFromSuperview(); + under.removeFromSuperview(); + } + UNDER.set(None); + }, + ); +} + +/// Let go far or fast: the page slides off, and the app goes back. The picture of the page it +/// goes to covers the screen until the runtime has drawn it (`tree_applied`). +fn slide_off(mtm: MainThreadMarker, page: Retained, width: CGFloat) { + let moving = page.clone(); + let under = UNDER.take(); + let moved_under = under.clone(); + animate( + mtm, + 0.25, + move || { + moving.setTransform(shifted(width)); + if let Some((under, dim)) = &moved_under { + under.setTransform(shifted(0.0)); + dim.setAlpha(0.0); + } + }, + move || { + shadow(&page, false); + if let Some((under, dim)) = &under { + dim.removeFromSuperview(); + if let Some(parent) = under.superview() { + parent.bringSubviewToFront(under); + } + SWIPED.set(Some(under.clone())); + } + page.setTransform(shifted(0.0)); + reply(&serde_json::json!({ "back": true })); + // Nothing pictured: the page comes back as soon as it moved. + if under.is_none() { + page.setAlpha(0.0); + let shown = page.clone(); + let fade_in = RcBlock::new(move || shown.setAlpha(1.0)); + if let Some(mtm) = MainThreadMarker::new() { + UIView::animateWithDuration_delay_options_animations_completion( + 0.18, + 0.06, + UIViewAnimationOptions::CurveEaseOut, + &fade_in, + None, + mtm, + ); + } + } + }, + ); +} + +define_class!( + // SAFETY: NSObject has no subclassing requirements; the class does not implement `Drop`. + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "HazelSwipeBack"] + /// Moves the page with the edge swipe, and goes back when it is let go far or fast enough. + struct SwipeBack; + + unsafe impl NSObjectProtocol for SwipeBack {} + + unsafe impl UIGestureRecognizerDelegate for SwipeBack { + /// Only a drag from the left edge that heads right more than it climbs or falls, while + /// no slide is moving. + #[unsafe(method(gestureRecognizerShouldBegin:))] + fn should_begin(&self, recognizer: &UIGestureRecognizer) -> Bool { + let Some(swipe) = recognizer.downcast_ref::() else { + return Bool::NO; + }; + let Some(window) = window(self.mtm()) else { + return Bool::NO; + }; + let (at, moved) = ( + swipe.locationInView(Some(&window)), + swipe.translationInView(Some(&window)), + ); + let velocity = swipe.velocityInView(Some(&window)); + Bool::new( + !MOVING.get() + && at.x - moved.x <= EDGE + && velocity.x > 0.0 + && velocity.x > velocity.y.abs(), + ) + } + } + + impl SwipeBack { + #[unsafe(method(pan:))] + fn pan(&self, swipe: &UIPanGestureRecognizer) { + let mtm = self.mtm(); + let (Some(window), Some(page)) = (window(mtm), page(mtm)) else { + return; + }; + let width = window.bounds().size.width; + let moved = swipe.translationInView(Some(&window)).x.clamp(0.0, width); + // UIGestureRecognizerState: 1 began, 2 changed, 3 ended, 4 cancelled, 5 failed. + let state: isize = unsafe { msg_send![swipe, state] }; + match state { + 1 => { + begin_swipe(mtm, &page); + follow(&page, moved, width); + } + 2 => follow(&page, moved, width), + 3 => { + let velocity = swipe.velocityInView(Some(&window)).x; + let back = velocity > COMMIT_VELOCITY + || (moved > width * COMMIT_SHARE && velocity > -COMMIT_VELOCITY); + if back { + slide_off(mtm, page, width); + } else { + settle(mtm, page, width); + } + } + _ => settle(mtm, page, width), + } + } + } +); diff --git a/apps/native/host/src/frames.rs b/apps/native/host/src/frames.rs index 39748a6d4..b7e736272 100644 --- a/apps/native/host/src/frames.rs +++ b/apps/native/host/src/frames.rs @@ -281,6 +281,46 @@ pub fn place(rect: Rect, opacity: f32, clips: &[Rect], covers: &[Cover]) -> Opti }) } +/// The parts of a `size` box that none of `holes` covers, as rects that do not overlap: what a +/// mask of the box shows. Holes may overlap each other (a dialog's backdrop and the panel on it), +/// where a path of the holes themselves, filled even-odd, would show the box again. +pub fn uncovered(size: [f32; 2], holes: &[Rect]) -> Vec { + let edges = |at: usize, length: f32| { + let mut edges = vec![0.0, length]; + for hole in holes { + edges.push(hole[at].clamp(0.0, length)); + edges.push((hole[at] + hole[at + 2]).clamp(0.0, length)); + } + edges.sort_by(f32::total_cmp); + edges.dedup(); + edges + }; + let (xs, ys) = (edges(0, size[0]), edges(1, size[1])); + let covered = |x: f32, y: f32| { + holes + .iter() + .any(|[hx, hy, hw, hh]| x > *hx && x < hx + hw && y > *hy && y < hy + hh) + }; + let mut out: Vec = Vec::new(); + for rows in ys.windows(2) { + let (top, bottom) = (rows[0], rows[1]); + // Cells side by side in a row join into one rect. + let mut open: Option = None; + for columns in xs.windows(2) { + let (left, right) = (columns[0], columns[1]); + if covered((left + right) / 2.0, (top + bottom) / 2.0) { + out.extend(open.take()); + } else if let Some(run) = &mut open { + run[2] = right - run[0]; + } else { + open = Some([left, top, right - left, bottom - top]); + } + } + out.extend(open); + } + out +} + /// What the wrapper page tells the host (`wrapper_html`'s script). #[derive(serde::Deserialize)] #[serde(tag = "k", rename_all = "lowercase")] @@ -363,6 +403,73 @@ pub fn wrapper_html(src: &str) -> Option { )) } +/// How far up the window's paint order the element at `path` is drawn (`view`): `0` in the page, +/// `1` in a fixed element, and above those in a portal (a menu, a dialog), later ones higher. +/// The highest layer it is in counts: a menu opened from a dialog is its own portal, above the +/// dialog, and a fixed box inside a dialog is drawn with the dialog. +pub(crate) fn layer_of(path: &str, portals: &[(String, Arc)]) -> usize { + portals + .iter() + .enumerate() + .filter(|(_, (portal, _))| { + path.strip_prefix(portal.as_str()) + .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')) + }) + .map(|(index, (_, node))| match node.as_ref() { + Node::Elem { ov: Some(_), .. } => 2 + index, + _ => 1, + }) + .max() + .unwrap_or(0) +} + +/// Where a native view goes for the box drawn at `path` in the frame just presented: clipped by +/// the ancestors in `clips`, cut out where an overlay or a sticky element is drawn over it, and +/// `None` when none of it shows. Shared with `islands`, whose views sit over Rune's drawing too. +pub(crate) fn placement_of( + runtime: &rune::Runtime, + path: &str, + clips: &[String], +) -> Option { + let rect_of = |path: &str| runtime.presented(path).map(|(rect, _)| rect); + let (rect, opacity) = runtime.presented(path)?; + let clips: Vec = clips.iter().filter_map(|path| rect_of(path)).collect(); + let portals = &runtime.state.portals; + let layer = layer_of(path, portals); + let covers: Vec = runtime + .state + .covers + .iter() + // Only what is painted above the view covers it: a layer over its own (a dialog over + // the page, a menu over the dialog it opened from; never the sheet a dialog opened + // over), or, in the page, a sticky element it scrolls under. + .filter(|cover| { + let above = layer_of(&cover.path, portals); + above > layer || (above == 0 && layer == 0) + }) + // What the view is part of is not over it. + .filter(|cover| { + cover.path != path + && !path + .strip_prefix(cover.path.as_str()) + .is_some_and(|rest| rest.starts_with('/')) + }) + .filter_map(|cover| { + let rect = rect_of(&cover.path); + Some(Cover { + // A dialog covers wherever it is drawn. + rect: if cover.modal { + rect.unwrap_or_default() + } else { + rect? + }, + modal: cover.modal, + }) + }) + .collect(); + place(rect, opacity, &clips, &covers) +} + /// 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; @@ -1174,6 +1281,7 @@ mod platform { #[cfg(test)] mod tests { use super::*; + use crate::wire::Overlay; #[test] fn a_frame_shows_where_its_ancestors_let_it() { @@ -1209,6 +1317,78 @@ mod tests { assert_eq!(place(rect, 1.0, &[], &[dialog]), None); } + #[test] + fn a_layer_is_above_the_ones_opened_before_it() { + let elem = |ov: Option| { + Arc::new(Node::Elem { + hash: String::new(), + tag: "div".to_string(), + key: None, + st: Default::default(), + attrs: Default::default(), + on: Default::default(), + observed: false, + ov: ov.map(Box::new), + children: Vec::new(), + }) + }; + let overlay = || Some(Overlay::default()); + // A fixed nav, a sheet, a dialog opened over it, and a menu opened from the dialog. + let portals = vec![ + ("r/1".to_string(), elem(None)), + ("r/2".to_string(), elem(overlay())), + ("r/3".to_string(), elem(overlay())), + ("r/3/0/4".to_string(), elem(overlay())), + ]; + let layer = |path: &str| layer_of(path, &portals); + assert_eq!(layer("r/0/5"), 0, "the page"); + assert_eq!(layer("r/1"), 1, "a fixed element"); + assert!(layer("r/2/0") > layer("r/1"), "a sheet is over the nav"); + assert!( + layer("r/3/0/1") > layer("r/2/0"), + "a dialog is over the sheet before it" + ); + assert!( + layer("r/3/0/4/0") > layer("r/3/0/1"), + "a menu is over the dialog it is in" + ); + // A fixed box inside the dialog is drawn with the dialog, over the sheet. + let mut portals = portals; + portals.push(("r/3/1".to_string(), elem(None))); + assert!(layer_of("r/3/1/0", &portals) > layer_of("r/2/0", &portals)); + } + + #[test] + fn overlapping_covers_stay_cut_out() { + // A backdrop over the right half and a panel inside it: only the left half shows (in + // one rect per row of the grid the covers' edges make). + let backdrop = [50.0, 0.0, 50.0, 40.0]; + let panel = [60.0, 10.0, 20.0, 20.0]; + assert_eq!( + uncovered([100.0, 40.0], &[backdrop, panel]), + vec![ + [0.0, 0.0, 50.0, 10.0], + [0.0, 10.0, 50.0, 20.0], + [0.0, 30.0, 50.0, 10.0], + ] + ); + // Covered whole: nothing shows. + assert_eq!( + uncovered([100.0, 40.0], &[[0.0, 0.0, 100.0, 40.0], panel]), + Vec::::new() + ); + // A hole in the middle leaves the box around it. + assert_eq!( + uncovered([30.0, 30.0], &[[10.0, 10.0, 10.0, 10.0]]), + vec![ + [0.0, 0.0, 30.0, 10.0], + [0.0, 10.0, 10.0, 10.0], + [20.0, 10.0, 10.0, 10.0], + [0.0, 20.0, 30.0, 10.0], + ] + ); + } + #[test] fn a_frame_that_grows_under_a_cover_needs_a_new_mask() { let toolbar = Cover { diff --git a/apps/native/host/src/islands.rs b/apps/native/host/src/islands.rs new file mode 100644 index 000000000..929493f25 --- /dev/null +++ b/apps/native/host/src/islands.rs @@ -0,0 +1,493 @@ +//! Native islands: an element the app marks `data-native=""` is shown or driven by a +//! native view (UIKit on iOS) laid over the box Rune draws for it, the way `frames` lays a web +//! view over an `