diff --git a/apps/native/bench/interaction/run.ts b/apps/native/bench/interaction/run.ts index f7970b53e..47a01ca2a 100644 --- a/apps/native/bench/interaction/run.ts +++ b/apps/native/bench/interaction/run.ts @@ -36,7 +36,9 @@ const OVERLAYS: ReadonlyArray = [ { name: "org menu", open: [{ clickText: "Hazel" }], shows: "Switch Server", toggles: true }, { name: "sidebar channel menu", - open: [{ move: [200, 220] }, { wait: 250 }, { clickLabel: "Channel options" }], + // Over Favorites' first channel, the first row with a dots trigger: `clickLabel` presses + // the first trigger in the tree, which shows only while its own row is hovered. + open: [{ move: [200, 270] }, { wait: 250 }, { clickLabel: "Channel options" }], // The item a panel drawn inside its row (clipped there) would still show. shows: "Settings", toggles: true, diff --git a/apps/native/host/Cargo.lock b/apps/native/host/Cargo.lock index 7722eefbe..53bfa1eef 100644 --- a/apps/native/host/Cargo.lock +++ b/apps/native/host/Cargo.lock @@ -2905,7 +2905,7 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "rune" version = "0.0.1" -source = "git+https://github.com/Makisuo/rune?rev=3b676200f9ab1923059651c4e81c0b77cb8699a2#3b676200f9ab1923059651c4e81c0b77cb8699a2" +source = "git+https://github.com/Makisuo/rune?rev=c3536c225a3a7e0bb8af798e011ef56fd4a8bb92#c3536c225a3a7e0bb8af798e011ef56fd4a8bb92" dependencies = [ "accesskit", "accesskit_macos", @@ -2982,7 +2982,7 @@ dependencies = [ [[package]] name = "rune-fx" version = "0.0.1" -source = "git+https://github.com/Makisuo/rune?rev=3b676200f9ab1923059651c4e81c0b77cb8699a2#3b676200f9ab1923059651c4e81c0b77cb8699a2" +source = "git+https://github.com/Makisuo/rune?rev=c3536c225a3a7e0bb8af798e011ef56fd4a8bb92#c3536c225a3a7e0bb8af798e011ef56fd4a8bb92" dependencies = [ "bytemuck", "rune-motion", @@ -2993,12 +2993,12 @@ dependencies = [ [[package]] name = "rune-motion" version = "0.1.0" -source = "git+https://github.com/Makisuo/rune?rev=3b676200f9ab1923059651c4e81c0b77cb8699a2#3b676200f9ab1923059651c4e81c0b77cb8699a2" +source = "git+https://github.com/Makisuo/rune?rev=c3536c225a3a7e0bb8af798e011ef56fd4a8bb92#c3536c225a3a7e0bb8af798e011ef56fd4a8bb92" [[package]] name = "rune-staging" version = "0.0.1" -source = "git+https://github.com/Makisuo/rune?rev=3b676200f9ab1923059651c4e81c0b77cb8699a2#3b676200f9ab1923059651c4e81c0b77cb8699a2" +source = "git+https://github.com/Makisuo/rune?rev=c3536c225a3a7e0bb8af798e011ef56fd4a8bb92#c3536c225a3a7e0bb8af798e011ef56fd4a8bb92" dependencies = [ "wgpu", ] @@ -3006,7 +3006,7 @@ dependencies = [ [[package]] name = "rune-taffy" version = "0.0.1" -source = "git+https://github.com/Makisuo/rune?rev=3b676200f9ab1923059651c4e81c0b77cb8699a2#3b676200f9ab1923059651c4e81c0b77cb8699a2" +source = "git+https://github.com/Makisuo/rune?rev=c3536c225a3a7e0bb8af798e011ef56fd4a8bb92#c3536c225a3a7e0bb8af798e011ef56fd4a8bb92" dependencies = [ "taffy", ] diff --git a/apps/native/host/Cargo.toml b/apps/native/host/Cargo.toml index 4e48666f0..31bfde7f4 100644 --- a/apps/native/host/Cargo.toml +++ b/apps/native/host/Cargo.toml @@ -13,11 +13,12 @@ publish = false # El::focusable_by_script and El::clip_fade_linear_frac (Makisuo/rune's `hazel-grid` branch), plus # iOS's full-screen window, safe area and motion frames (`hazel-ios`), plus a soft keyboard's # return as Enter, iOS's display link and El::pin_insets (`hazel-ios-followups`), plus a -# long-press opening an element's context menu (`hazel-long-press`), until they land upstream. -rune = { git = "https://github.com/Makisuo/rune", rev = "3b676200f9ab1923059651c4e81c0b77cb8699a2", features = [ +# long-press opening an element's context menu (`hazel-long-press`), plus Tx::scroll_by (`hazel-scroll-by`), +# until they land upstream. +rune = { git = "https://github.com/Makisuo/rune", rev = "c3536c225a3a7e0bb8af798e011ef56fd4a8bb92", features = [ "native-file-dialog", ] } -rune-fx = { git = "https://github.com/Makisuo/rune", rev = "3b676200f9ab1923059651c4e81c0b77cb8699a2" } +rune-fx = { git = "https://github.com/Makisuo/rune", rev = "c3536c225a3a7e0bb8af798e011ef56fd4a8bb92" } base64 = "0.22" # Decodes a picture and its mask picture to composite them (Rune's own decoder, same features). image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] } diff --git a/apps/native/host/ios/Cargo.toml b/apps/native/host/ios/Cargo.toml index ec44ba619..d89040590 100644 --- a/apps/native/host/ios/Cargo.toml +++ b/apps/native/host/ios/Cargo.toml @@ -7,7 +7,7 @@ publish = false [dependencies] hazel-native = { path = ".." } # The same revision as `hazel-native`'s (`../Cargo.toml`). -rune = { git = "https://github.com/Makisuo/rune", rev = "3b676200f9ab1923059651c4e81c0b77cb8699a2" } +rune = { git = "https://github.com/Makisuo/rune", rev = "c3536c225a3a7e0bb8af798e011ef56fd4a8bb92" } serde_json = "1" # The hidden web view the runtime's worker runs in (`src/bridge.rs`), and the UIKit views of diff --git a/apps/native/host/ios/src/islands.rs b/apps/native/host/ios/src/islands.rs index a5c7e9e6d..396b3788d 100644 --- a/apps/native/host/ios/src/islands.rs +++ b/apps/native/host/ios/src/islands.rs @@ -119,6 +119,9 @@ struct Island { spec: IslandSpec, /// The size of the part that shows and the holes in it, when anything covers the view. mask: Option<([f32; 2], Vec)>, + /// A tab bar's height for a width, as `sizeThatFits` last said: asked once per width, not + /// in every frame (it lays the bar out). + fitted: Option<(f32, f32)>, } thread_local! { @@ -478,6 +481,7 @@ fn create(mtm: MainThreadMarker, parent: &UIView, spec: &IslandSpec) -> Option { let bottom = y + h; - let fitted = bar.sizeThatFits(CGSize::new(w as f64, 0.0)).height as f32; + let fitted = match island.fitted { + Some((width, height)) if width == w => height, + _ => { + let height = bar.sizeThatFits(CGSize::new(w as f64, 0.0)).height as f32; + island.fitted = Some((w, height)); + height + } + }; // The bar adds the home indicator's inset itself once it is in the window. let height = if fitted > bottom_inset + 1.0 { fitted diff --git a/apps/native/host/ios/src/page.rs b/apps/native/host/ios/src/page.rs index 256696f11..43719ce1c 100644 --- a/apps/native/host/ios/src/page.rs +++ b/apps/native/host/ios/src/page.rs @@ -95,14 +95,15 @@ window.__hazel = {{ worker.onmessage = (event) => host.postMessage(event.data) worker.onerror = (event) => host.postMessage({{ log: `[worker] ${{event.message}} ${{event.filename}}:${{event.lineno}}` }}) - for (const line of early.splice(0)) worker.postMessage(line) + for (const batch of early.splice(0)) worker.postMessage(batch) }}, reply(message) {{ worker?.postMessage(message) }}, + // A frame's lines cross in one message, as they do down the desktop's pipe: the worker + // splits them, and handles a frame's reports in one task. toWorker(text) {{ - for (const line of text.split("\n")) - if (line !== "") worker === undefined ? early.push(line) : worker.postMessage(line) + if (text !== "") worker === undefined ? early.push(text) : worker.postMessage(text) }}, }} host.postMessage({{ ready: {id} }}) @@ -215,8 +216,8 @@ mod tests { assert!(ready < start); // Lines that arrive before the worker exists wait for it, in order. assert!( - page.contains("worker === undefined ? early.push(line) : worker.postMessage(line)") + page.contains("worker === undefined ? early.push(text) : worker.postMessage(text)") ); - assert!(page.contains("for (const line of early.splice(0)) worker.postMessage(line)")); + assert!(page.contains("for (const batch of early.splice(0)) worker.postMessage(batch)")); } } diff --git a/apps/native/host/src/capture.rs b/apps/native/host/src/capture.rs index 503a5f9c5..8a4d8c8b3 100644 --- a/apps/native/host/src/capture.rs +++ b/apps/native/host/src/capture.rs @@ -12,7 +12,8 @@ use serde_json::json; use hazel_native::wire::{Node, RuntimeMsg}; use hazel_native::{ - PRESS_EVENTS, State, TEXT_SMOOTHING, images, note_scroll, outbox, view, window_key, + PRESS_EVENTS, State, TEXT_SMOOTHING, apply_scroll, images, note_scroll, outbox, view, + window_key, }; use crate::runtime::{FromRuntime, RuntimeChild, spawn_runtime}; @@ -268,13 +269,9 @@ impl Session { self.timings.trees += 1; self.rt.update_if(move |s, _| s.apply(tree)); } - RuntimeMsg::Scroll { path, top, seq } => { + RuntimeMsg::Scroll { path, top, seq, by } => { note_scroll(&path, seq); - if std::env::var("NATIVE_DEBUG").is_ok() { - eprintln!("[host] scroll_to {top}") - } - self.rt - .update(move |_, tx| tx.scroll_to_instant(path, 0.0, top)) + self.rt.update(move |_, tx| apply_scroll(tx, path, top, by)) } request => { if let Some(run) = request.request() { diff --git a/apps/native/host/src/outbox.rs b/apps/native/host/src/outbox.rs index bf130c1d1..88804c63c 100644 --- a/apps/native/host/src/outbox.rs +++ b/apps/native/host/src/outbox.rs @@ -32,7 +32,12 @@ pub fn set_writer(out: Box) { } pub fn write_line(message: serde_json::Value) { - let line = format!("{message}\n"); + write_text(format!("{message}\n")); +} + +/// A line already serialized, newline included: for reports sent every frame (a scrolling +/// row's rect), which skip building a `serde_json::Value` each time. +pub fn write_text(line: String) { // Tests read what was sent right after the frame that sent it. if cfg!(test) { write_now(&line); diff --git a/apps/native/host/src/render/handlers.rs b/apps/native/host/src/render/handlers.rs index 3d97296bf..f71494e2f 100644 --- a/apps/native/host/src/render/handlers.rs +++ b/apps/native/host/src/render/handlers.rs @@ -8,7 +8,7 @@ use serde_json::json; use crate::html::{focusable, is_checkable, script_focusable}; use crate::layout::focus_styles; -use crate::outbox::write_line; +use crate::outbox::{write_line, write_text}; use crate::wire::Style; use super::Ctx; @@ -211,12 +211,13 @@ pub(super) fn behaviour( /// An element a runtime Mount observes: report its layout, and its offset when it scrolls. pub(super) fn observe(e: El, path: &str, st: &Style) -> El { - let layout_path = path.to_string(); + // Serialized once per build: inside a scrolling list every observed row reports each frame. + let prefix = format!(r#"{{"t":"layout","path":{},"#, json!(path)); // A report for the runtime, no change to the host's own state: no need to build its view // again, which inside a scrolling list would be every frame. - let e = e.on_layout_tx(move |_: &mut State, [x, y, w, h]: [f32; 4], tx| { + let e = e.on_layout_tx(move |_: &mut State, rect: [f32; 4], tx| { tx.live_only(); - write_line(json!({ "t": "layout", "path": layout_path, "x": x, "y": y, "w": w, "h": h })); + write_text(layout_line(&prefix, rect)); }); if st.scroll_y != Some(true) { return e; @@ -241,6 +242,13 @@ pub(super) fn observe(e: El, path: &str, st: &Style) -> El { }) } +/// A layout report after its serialized `{"t":"layout","path":…,` prefix. A coordinate that is +/// not finite has no JSON number and reports as zero. +fn layout_line(prefix: &str, rect: [f32; 4]) -> String { + let [x, y, w, h] = rect.map(|v| if v.is_finite() { v } else { 0.0 }); + format!("{prefix}\"x\":{x},\"y\":{y},\"w\":{w},\"h\":{h}}}\n") +} + /// The last runtime scroll the host applied to each viewport: its offset reports carry that /// scroll's `seq`, so the runtime can tell the ones sent before it apart. pub(crate) fn scroll_seqs() -> &'static std::sync::Mutex> { @@ -249,6 +257,21 @@ pub(crate) fn scroll_seqs() -> &'static std::sync::Mutex> { SEQS.get_or_init(Default::default) } +/// Applies a runtime scroll: `by` shifts the viewport from where it is now (`element.scrollBy`), +/// which leaves a finger's drag and a fling's momentum running; otherwise it jumps to `top`. +pub fn apply_scroll(tx: &mut rune::Tx, path: String, top: f32, by: Option) { + if std::env::var("NATIVE_DEBUG").is_ok() { + match by { + Some(by) => eprintln!("[host] scroll_by {by}"), + None => eprintln!("[host] scroll_to {top}"), + } + } + match by { + Some(by) => tx.scroll_by(path, by), + None => tx.scroll_to_instant(path, 0.0, top), + } +} + /// Notes a runtime scroll's `seq` for the offset reports after it. pub fn note_scroll(path: &str, seq: Option) { if let Some(seq) = seq { @@ -265,3 +288,24 @@ pub(super) fn focus_field_on_press(e: El, path: &str) -> El { e.cursor(Cursor::Text) .on_press_tx(move |_: &mut State, tx| tx.focus(field.clone())) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_layout_line_is_the_json_the_runtime_reads() { + let path = r#"r/0/k"quoted" key"#; + let prefix = format!(r#"{{"t":"layout","path":{},"#, json!(path)); + let line = layout_line(&prefix, [1.5, -20.25, 300.0, f32::NAN]); + assert!(line.ends_with('\n')); + let parsed: serde_json::Value = serde_json::from_str(line.trim_end()).unwrap(); + assert_eq!(parsed["t"], "layout"); + assert_eq!(parsed["path"], path); + let at = |k: &str| parsed[k].as_f64().unwrap(); + assert_eq!( + [at("x"), at("y"), at("w"), at("h")], + [1.5, -20.25, 300.0, 0.0] + ); + } +} diff --git a/apps/native/host/src/render/mod.rs b/apps/native/host/src/render/mod.rs index 9eddc9b1f..65f056bbb 100644 --- a/apps/native/host/src/render/mod.rs +++ b/apps/native/host/src/render/mod.rs @@ -20,9 +20,9 @@ use crate::layout::{Inherit, apply_box, apply_flex, center_vertically, color, te use crate::wire::{self, Node, Style}; pub use events::{PRESS_EVENTS, window_key}; -pub use handlers::note_scroll; #[cfg(test)] pub(crate) use handlers::scroll_seqs; +pub use handlers::{apply_scroll, note_scroll}; pub(crate) use state::is_portal; pub use state::{Env, State, child_path, last_env}; @@ -218,6 +218,109 @@ fn build(node: &Arc, path: &str, inh: Inherit, ctx: &Ctx) -> Option { } } +/// `white-space: pre` inline children split at the newlines in their text, one list per line, +/// or None when no text has one. A newline inside an inline element (each of an editor's leaves +/// wraps its text in spans) fragments that element across the lines, as CSS does an inline box. +/// An empty line holds a space, so it keeps a line's height; the newline that ends the content +/// starts no line of its own, as in a browser. +fn split_pre_lines(kids: &[(usize, &Arc)]) -> Option)>>> { + let pieces: Vec>>>> = + kids.iter().map(|(_, kid)| pre_fragments(kid)).collect(); + if pieces.iter().all(Option::is_none) { + return None; + } + let mut lines: Vec)>> = vec![Vec::new()]; + for ((i, kid), split) in kids.iter().zip(pieces) { + let Some(fragments) = split else { + lines.last_mut().expect("a line").push((*i, (*kid).clone())); + continue; + }; + for (n, fragment) in fragments.into_iter().enumerate() { + if n > 0 { + lines.push(Vec::new()); + } + if let Some(fragment) = fragment { + lines.last_mut().expect("a line").push((*i, fragment)); + } + } + } + if lines.len() > 1 && lines.last().is_some_and(Vec::is_empty) { + lines.pop(); + } + for line in &mut lines { + if line.is_empty() { + line.push((0, Arc::new(Node::Text(" ".to_string())))); + } + } + Some(lines) +} + +/// A node's parts on each line its newlines start, or None when it holds no newline: text +/// splits at them, and an inline element takes its children's parts line by line. +fn pre_fragments(node: &Arc) -> Option>>> { + match node.as_ref() { + Node::Text(text) if text.contains('\n') => Some( + text.split('\n') + .map(|piece| (!piece.is_empty()).then(|| Arc::new(Node::Text(piece.to_string())))) + .collect(), + ), + Node::Elem { children, .. } if is_inline(node) => { + let split: Vec<_> = children.iter().map(pre_fragments).collect(); + if split.iter().all(Option::is_none) { + return None; + } + let mut lines: Vec>> = vec![Vec::new()]; + for (child, split) in children.iter().zip(split) { + let Some(fragments) = split else { + lines.last_mut().expect("a line").push(child.clone()); + continue; + }; + for (n, fragment) in fragments.into_iter().enumerate() { + if n > 0 { + lines.push(Vec::new()); + } + lines.last_mut().expect("a line").extend(fragment); + } + } + Some( + lines + .into_iter() + .map(|line| (!line.is_empty()).then(|| Arc::new(with_children(node, line)))) + .collect(), + ) + } + _ => None, + } +} + +/// An element as it is, holding `children` instead of its own. +fn with_children(node: &Node, children: Vec>) -> Node { + match node { + Node::Elem { + hash, + tag, + key, + st, + attrs, + on, + observed, + ov, + .. + } => Node::Elem { + hash: hash.clone(), + tag: tag.clone(), + key: key.clone(), + st: st.clone(), + attrs: attrs.clone(), + on: on.clone(), + observed: *observed, + ov: ov.clone(), + children, + }, + Node::Text(_) | Node::Svg { .. } => Node::Text(String::new()), + } +} + #[allow(clippy::too_many_arguments)] fn build_elem( path: &str, @@ -278,7 +381,36 @@ fn build_elem( .grid_cols .as_deref() .or(st.grid_rows.as_ref().map(|_| &one_column[..])); + // `white-space: pre` inline content (a highlighted code block: token spans with the line + // breaks in the text between them) breaks its lines at its newlines, which a wrapping row + // would run together. + let pre_lines = if inh.pre + && matches!(st.display.as_deref(), None | Some("block") | Some("inline")) + && grid_cols.is_none() + && kids.iter().all(|(_, c)| is_inline(c)) + { + split_pre_lines(&kids) + } else { + None + }; let mut e = match (st.display.as_deref(), grid_cols) { + _ if let Some(lines) = &pre_lines => { + let parent = Parent { + is_row: true, + items: Some("start"), + block: false, + }; + let mut column = crate::fx::under(apply_flex(col(), st), attrs, st, path); + for (n, line) in lines.iter().enumerate() { + let line: Vec<(usize, &Arc)> = line.iter().map(|(i, c)| (*i, c)).collect(); + // Each line is its own parent: an element fragmented across lines has a part on + // each, under a path of its own. + let line_path = format!("{path}/line{n}"); + let r = add_children(inline_row(), &line, &line_path, inh, &inline_ctx, parent); + column = column.child(r.key(line_path)); + } + column + } // `overflow-y: auto` on a grid (the command palette's list). A Rune viewport scrolls only // in a bounded height, so a grid taller than its `max-h` becomes a viewport exactly that // tall; a shorter one stays a plain grid at its own height. diff --git a/apps/native/host/src/shell.rs b/apps/native/host/src/shell.rs index 2da8553d0..1aa57a3e3 100644 --- a/apps/native/host/src/shell.rs +++ b/apps/native/host/src/shell.rs @@ -76,9 +76,9 @@ pub fn route(message: RuntimeMsg) -> Routed { pub fn apply(message: RuntimeMsg, handle: &AppHandle) { match message { RuntimeMsg::Tree(tree) => handle.update_if(move |s: &mut State, _| s.apply(tree)), - RuntimeMsg::Scroll { path, top, seq } => { + RuntimeMsg::Scroll { path, top, seq, by } => { crate::note_scroll(&path, seq); - handle.update(move |_: &mut State, tx| tx.scroll_to_instant(path, 0.0, top)); + handle.update(move |_: &mut State, tx| crate::apply_scroll(tx, path, top, by)); } request => { if let Some(run) = request.request() { diff --git a/apps/native/host/src/tests.rs b/apps/native/host/src/tests.rs index ad8440d8f..fc07148ad 100644 --- a/apps/native/host/src/tests.rs +++ b/apps/native/host/src/tests.rs @@ -3582,3 +3582,49 @@ fn a_fixed_frame_is_its_own_layer() { assert!(s.frames[0].clips.is_empty(), "{:?}", s.frames[0].clips); assert!(!s.covers.iter().any(|cover| cover.is_over(&s.frames[0]))); } + +/// A highlighted code block as the editor's viewer draws one: each leaf wraps its text in two +/// spans, so the line breaks are inside inline elements. Each newline starts a line, as +/// `white-space: pre` does; the last one starts none. +#[test] +fn pre_code_with_token_spans_breaks_at_its_newlines() { + let leaf = |s: &str| { + e( + "span", + json!({ "color": "#f00" }), + vec![e("span", json!({}), vec![t(s)])], + ) + }; + let rt = runtime( + e( + "div", + json!({ "w": 400, "h": 400 }), + vec![e( + "pre", + json!({ "whiteSpacePre": true, "fontSize": 14, "lineHeight": 20 }), + vec![e( + "code", + json!({}), + vec![ + leaf("const"), + leaf(" results = {\n "), + leaf("boot"), + leaf(": 412,\n\n "), + leaf("ready"), + leaf(": 731,\n}\n"), + ], + )], + )], + ), + (400.0, 400.0), + ); + let pre = rect(&rt, "r/0"); + // Five lines: `const results = {`, `boot: 412,`, an empty one, `ready: 731,` and `}`. + assert!((pre[3] - 100.0).abs() < 1.0, "five lines of 20: {pre:?}"); + let first = rect(&rt, "r/0/line0"); + let boot = rect(&rt, "r/0/line1"); + assert!( + boot[1] >= first[1] + first[3] - 0.5, + "boot starts its own line: {first:?} {boot:?}" + ); +} diff --git a/apps/native/host/src/wire.rs b/apps/native/host/src/wire.rs index ddde421aa..42835b1d9 100644 --- a/apps/native/host/src/wire.rs +++ b/apps/native/host/src/wire.rs @@ -390,6 +390,11 @@ pub enum RuntimeMsg { /// Echoed in the next offset report, so the runtime can tell reports from before it. #[serde(default)] seq: Option, + /// An `element.scrollBy()`: shift the offset by this much from wherever the viewport is + /// now (`top` is where the runtime expects it to land), keeping a finger's drag and a + /// fling's momentum. The runtime's idea of the offset is a report behind the host's. + #[serde(default)] + by: Option, }, /// Focus the element keyed `key` (an `element.focus()` in the runtime). #[serde(rename = "focus")] diff --git a/apps/native/src/ios.ts b/apps/native/src/ios.ts index f1541f8eb..390c109ba 100644 --- a/apps/native/src/ios.ts +++ b/apps/native/src/ios.ts @@ -72,8 +72,14 @@ globalThis.onmessage = (event: MessageEvent) => else if (typeof data !== "string") { replies.get(data.id)?.(data) replies.delete(data.id) - } else if (deliver === undefined) early.push(data) - else deliver(data) + } else { + // A batch of host lines (one frame's reports), split here so they run in one task. + for (const line of data.split("\n")) { + if (line === "") continue + if (deliver === undefined) early.push(line) + else deliver(line) + } + } } /** diff --git a/apps/web-foldkit/src/core/lib/keybindings/keymap.test.ts b/apps/web-foldkit/src/core/lib/keybindings/keymap.test.ts index 4ff01ac02..8abffbbb7 100644 --- a/apps/web-foldkit/src/core/lib/keybindings/keymap.test.ts +++ b/apps/web-foldkit/src/core/lib/keybindings/keymap.test.ts @@ -20,6 +20,13 @@ describe("defineKeymap", () => { }) }) + test("the same overrides resolve to the same table, per platform", () => { + const overrides = { open: combo("Mod+P") } + expect(keymap.resolve(overrides, "mac")).toBe(keymap.resolve(overrides, "mac")) + expect(keymap.resolve(overrides, "windows")).not.toBe(keymap.resolve(overrides, "mac")) + expect(keymap.resolve({ ...overrides }, "mac")).toEqual(keymap.resolve(overrides, "mac")) + }) + test("an override replaces the default; null unbinds", () => { expect(keymap.resolve({ open: combo("Mod+P"), inbox: null }, "mac")).toMatchObject({ open: "Mod+P", diff --git a/apps/web-foldkit/src/core/lib/keybindings/keymap.ts b/apps/web-foldkit/src/core/lib/keybindings/keymap.ts index feb07463f..b9a199ed5 100644 --- a/apps/web-foldkit/src/core/lib/keybindings/keymap.ts +++ b/apps/web-foldkit/src/core/lib/keybindings/keymap.ts @@ -128,7 +128,19 @@ export const defineKeymap = >>() const resolve = (overrides: Overrides, platform: Platform): Keys => { + const byPlatform = resolved.get(overrides) ?? new Map>() + const cached = byPlatform.get(platform) + if (cached !== undefined) return cached + const keys = resolveFresh(overrides, platform) + byPlatform.set(platform, keys) + resolved.set(overrides, byPlatform) + return keys + } + const resolveFresh = (overrides: Overrides, platform: Platform): Keys => { // Overrides first, in keymap order: a later one that repeats a claimed key is unbound. const claimed = new Set() const overridden = new Map() diff --git a/apps/web-foldkit/src/mount/message-list.test.ts b/apps/web-foldkit/src/mount/message-list.test.ts index 346771fbf..85a4b2503 100644 --- a/apps/web-foldkit/src/mount/message-list.test.ts +++ b/apps/web-foldkit/src/mount/message-list.test.ts @@ -61,3 +61,84 @@ describe("scrollByPage", () => { expect(MessageList.scrollByPage(list(1500), "Down").model.scrollTop).toBe(2000 - 400) }) }) + +describe("the overscan window while scrolling", () => { + test("a steady scroll moves it every few rows, not at every row boundary", () => { + const rows = 400 + let model: MessageList.Model = { + ...MessageList.init({ id: "list", estimatedRowHeightPx: 50, viewportHeight: 800 }), + keys: Array.from({ length: rows }, (_, index) => `row-${index}`), + scrollTop: 0, + } + const windowOf = (current: MessageList.Model) => { + const phase = current.scrollPhase + return phase._tag === "Scrolling" ? `${phase.renderedFromKey}..${phase.renderedToKey}` : "settled" + } + let changes = 0 + let last = windowOf(model) + // 10px a frame through 6000px: 120 rows cross each edge of the window. + for (let top = 10; top <= 6000; top += 10) { + model = MessageList.update(model, MessageList.Message.ScrolledList({ scrollTop: top })).model + const now = windowOf(model) + if (now !== last) changes++ + last = now + } + expect(changes).toBeLessThan(60) + // The viewport is always inside what renders. + const phase = model.scrollPhase + if (phase._tag !== "Scrolling") throw new Error("not scrolling") + const from = model.keys.indexOf(phase.renderedFromKey ?? "") + const to = model.keys.indexOf(phase.renderedToKey ?? "") + expect(from * 50).toBeLessThanOrEqual(6000 - 300) + expect((to + 1) * 50).toBeGreaterThanOrEqual(6000 + 800 + 300) + }) +}) + +describe("the overscan window after a scroll settles", () => { + const scrolled = () => { + let model: MessageList.Model = { + ...MessageList.init({ id: "list", estimatedRowHeightPx: 50, viewportHeight: 800 }), + keys: Array.from({ length: 400 }, (_, index) => `row-${index}`), + scrollTop: 0, + } + for (let top = 3000; top <= 3600; top += 20) + model = MessageList.update(model, MessageList.Message.ScrolledList({ scrollTop: top })).model + return model + } + + test("stays rendered, and the next scroll goes on from it", () => { + const scrolling = scrolled() + const phase = scrolling.scrollPhase + if (phase._tag !== "Scrolling") throw new Error("not scrolling") + const settled = MessageList.update( + scrolling, + MessageList.Message.CompletedWaitForScrollSettle({ version: phase.eventVersion }), + ).model + expect(settled.scrollPhase).toEqual( + MessageList.ScrollPhase.Settled({ + keptFromKey: phase.renderedFromKey, + keptToKey: phase.renderedToKey, + }), + ) + expect(MessageList.keptIndexes(settled, MessageList.layoutOf(settled))).toBeDefined() + const again = MessageList.update(settled, MessageList.Message.ScrolledList({ scrollTop: 3610 })).model + const next = again.scrollPhase + if (next._tag !== "Scrolling") throw new Error("not scrolling") + expect([next.renderedFromKey, next.renderedToKey]).toEqual([ + phase.renderedFromKey, + phase.renderedToKey, + ]) + }) + + test("is let go once the viewport is elsewhere", () => { + const scrolling = scrolled() + const phase = scrolling.scrollPhase + if (phase._tag !== "Scrolling") throw new Error("not scrolling") + const settled = MessageList.update( + scrolling, + MessageList.Message.CompletedWaitForScrollSettle({ version: phase.eventVersion }), + ).model + const away = { ...settled, scrollTop: 18000 } + expect(MessageList.keptIndexes(away, MessageList.layoutOf(away))).toBeUndefined() + }) +}) diff --git a/apps/web-foldkit/src/mount/message-list.ts b/apps/web-foldkit/src/mount/message-list.ts index df0bc0dce..54c973953 100644 --- a/apps/web-foldkit/src/mount/message-list.ts +++ b/apps/web-foldkit/src/mount/message-list.ts @@ -34,7 +34,11 @@ export type ViewportAnchor = typeof ViewportAnchor.Type * change no DOM. */ export const ScrollPhase = defineTaggedUnion({ - Settled: {}, + /** + * The last scroll's overscan window stays rendered (beside the pool) while it overlaps the + * viewport, so the next scroll starts from it instead of building those rows again. + */ + Settled: { keptFromKey: Schema.NullOr(Schema.String), keptToKey: Schema.NullOr(Schema.String) }, Scrolling: { eventVersion: Schema.Number, renderedFromKey: Schema.NullOr(Schema.String), @@ -113,7 +117,7 @@ export const init = (config: { scrollVersion: 0, appliedScrollVersion: 0, pool: emptyPool, - scrollPhase: ScrollPhase.Settled(), + scrollPhase: ScrollPhase.Settled({ keptFromKey: null, keptToKey: null }), activeStickyKey: null, stuckKey: null, }) @@ -243,8 +247,10 @@ export const ApplyScroll = Command.define("ApplyScroll", { const element = document.getElementById(id) if (element === null) return Message.SkippedApplyScroll({ version }) yield* afterLayout(element) - element.scrollTop = - adjustment._tag === "To" ? adjustment.scrollTop : element.scrollTop + adjustment.deltaPx + // `By` is relative: the native shell applies it to the offset the reader is at by then, + // so a drag or fling under way carries on instead of snapping back to a stale offset. + if (adjustment._tag === "To") element.scrollTop = adjustment.scrollTop + else element.scrollBy({ top: adjustment.deltaPx }) return Message.CompletedApplyScroll({ version, scrollTop: element.scrollTop }) }), }) @@ -289,19 +295,36 @@ const changedMeasurements = ( /** * The overscan window always covers the viewport plus `RENDER_MARGIN_PX`, then grows toward * `RENDER_LEAD_PX` by at most `RENDER_STEP_PX` per side and update, so a frame builds a few rows, - * never a 2400px chunk. Rows past the lead plus `RENDER_SLACK_PX` are dropped. + * never a 2400px chunk. Rows past the lead plus `RENDER_SLACK_PX` are dropped. Both wait a step + * before acting, so a steady scroll re-renders the window every few rows. */ const RENDER_MARGIN_PX = 300 const RENDER_LEAD_PX = 2400 const RENDER_STEP_PX = 320 const RENDER_SLACK_PX = 600 +const indexesOf = (layout: Layout, fromKey: string | null, toKey: string | null) => { + const from = fromKey === null ? undefined : layout.indexByKey.get(fromKey) + const to = toKey === null ? undefined : layout.indexByKey.get(toKey) + return from === undefined || to === undefined || from > to ? undefined : { from, to } +} + export const renderedIndexes = (model: Model, layout: Layout) => { const phase = model.scrollPhase if (phase._tag === "Settled") return undefined - const from = phase.renderedFromKey === null ? undefined : layout.indexByKey.get(phase.renderedFromKey) - const to = phase.renderedToKey === null ? undefined : layout.indexByKey.get(phase.renderedToKey) - return from === undefined || to === undefined || from > to ? undefined : { from, to } + return indexesOf(layout, phase.renderedFromKey, phase.renderedToKey) +} + +/** The window the last scroll left rendered, while it still covers part of the viewport. */ +export const keptIndexes = (model: Model, layout: Layout) => { + const phase = model.scrollPhase + if (phase._tag === "Scrolling" || model.viewportHeight === 0) return undefined + const kept = indexesOf(layout, phase.keptFromKey, phase.keptToKey) + if (kept === undefined) return undefined + const top = Math.max(0, model.scrollTop) + const viewFrom = rowIndexAt(layout, top) + const viewTo = rowIndexAt(layout, top + model.viewportHeight) + return kept.to < viewFrom || kept.from > viewTo ? undefined : kept } const keepOverscan = (model: Model): Model => { @@ -318,16 +341,26 @@ const keepOverscan = (model: Model): Model => { const isDisjoint = current === undefined || current.to < hardFrom || current.from > hardTo const coveredFrom = isDisjoint ? hardFrom : Math.min(current.from, hardFrom) const coveredTo = isDisjoint ? hardTo : Math.max(current.to, hardTo) - const grownFrom = Math.max( - at(top - RENDER_LEAD_PX), - Math.min(coveredFrom, at(layout.offsets[coveredFrom]! - RENDER_STEP_PX)), - ) - const grownTo = Math.min( - at(bottom + RENDER_LEAD_PX), - Math.max(coveredTo, at(layout.offsets[coveredTo + 1]! + RENDER_STEP_PX)), - ) - const from = Math.max(Math.min(coveredFrom, grownFrom), at(top - RENDER_LEAD_PX - RENDER_SLACK_PX)) - const to = Math.min(Math.max(coveredTo, grownTo), at(bottom + RENDER_LEAD_PX + RENDER_SLACK_PX)) + // A side grows once its lead has run down by a step, and drops rows once they are a step past + // the slack: a steady scroll changes the window every few rows, not at every row boundary. + const coveredTop = layout.offsets[coveredFrom]! + const coveredBottom = layout.offsets[coveredTo + 1]! + const grownFrom = + coveredTop <= top - RENDER_LEAD_PX + RENDER_STEP_PX + ? coveredFrom + : Math.max(at(top - RENDER_LEAD_PX), Math.min(coveredFrom, at(coveredTop - RENDER_STEP_PX))) + const grownTo = + coveredBottom >= bottom + RENDER_LEAD_PX - RENDER_STEP_PX + ? coveredTo + : Math.min(at(bottom + RENDER_LEAD_PX), Math.max(coveredTo, at(coveredBottom + RENDER_STEP_PX))) + const dropAbove = top - RENDER_LEAD_PX - RENDER_SLACK_PX + const dropBelow = bottom + RENDER_LEAD_PX + RENDER_SLACK_PX + const from = + layout.offsets[grownFrom + 1]! < dropAbove - RENDER_STEP_PX + ? at(dropAbove) + : Math.min(coveredFrom, grownFrom) + const to = + layout.offsets[grownTo]! > dropBelow + RENDER_STEP_PX ? at(dropBelow) : Math.max(coveredTo, grownTo) if (current !== undefined && from === current.from && to === current.to) return model return modifyFields(model, { scrollPhase: () => @@ -349,10 +382,16 @@ const startedScrolling = (model: Model): ListReturn => ScrollPhase.Scrolling({ ...phase, eventVersion: Number.increment(phase.eventVersion) }), }), }), - Settled: () => ({ + // The window the last scroll left goes on from where it is (or starts over, when it is + // far from the viewport now). + Settled: ({ keptFromKey, keptToKey }) => ({ model: modifyFields(model, { scrollPhase: () => - ScrollPhase.Scrolling({ eventVersion: 1, renderedFromKey: null, renderedToKey: null }), + ScrollPhase.Scrolling({ + eventVersion: 1, + renderedFromKey: keptFromKey, + renderedToKey: keptToKey, + }), }), commands: [WaitForScrollSettle({ version: 1 })], }), @@ -440,9 +479,17 @@ const updateScroll = (model: Model, message: Message): ListReturn => CompletedWaitForScrollSettle: ({ version }) => ScrollPhase.match(model.scrollPhase, { Settled: () => ({ model }), - Scrolling: ({ eventVersion }) => + Scrolling: ({ eventVersion, renderedFromKey, renderedToKey }) => version === eventVersion - ? { model: modifyFields(model, { scrollPhase: () => ScrollPhase.Settled() }) } + ? { + model: modifyFields(model, { + scrollPhase: () => + ScrollPhase.Settled({ + keptFromKey: renderedFromKey, + keptToKey: renderedToKey, + }), + }), + } : { model, commands: [WaitForScrollSettle({ version: eventVersion })] }, }), ResizedViewport: ({ viewportHeight }) => @@ -699,7 +746,7 @@ export const isViewEqual = (a: Model, b: Model): boolean => const isSamePhaseView = (a: ScrollPhase, b: ScrollPhase): boolean => a === b || (a._tag === "Settled" - ? b._tag === "Settled" + ? b._tag === "Settled" && a.keptFromKey === b.keptFromKey && a.keptToKey === b.keptToKey : b._tag === "Scrolling" && a.renderedFromKey === b.renderedFromKey && a.renderedToKey === b.renderedToKey) @@ -779,15 +826,17 @@ export const view = ( // absolute offsets, so DOM order is not visible, and keeping it the same means a phase switch // never moves a row's node. Moving a node reloads any iframe in it (HTML renders flash). const containerRows = () => { - const indexes: Array = [] + const indexes = new Set() const empty: Array = [] for (let slot = 0; slot < model.pool.pooledCount; slot++) { const key = model.pool.slots[slot] ?? null const index = key === null ? undefined : layout.indexByKey.get(key) if (index === undefined) empty.push(slot) - else indexes.push(index) + else indexes.add(index) } - return [...indexes.sort((a, b) => a - b).map(row), ...empty.map(emptyContainer)] + const kept = keptIndexes(model, layout) + if (kept !== undefined) for (let index = kept.from; index <= kept.to; index++) indexes.add(index) + return [...[...indexes].sort((a, b) => a - b).map(row), ...empty.map(emptyContainer)] } const rows = model.viewportHeight === 0 diff --git a/packages/foldkit-rune/src/bridge.test.ts b/packages/foldkit-rune/src/bridge.test.ts index 384f1c6be..877bf76b1 100644 --- a/packages/foldkit-rune/src/bridge.test.ts +++ b/packages/foldkit-rune/src/bridge.test.ts @@ -84,7 +84,7 @@ describe("makeElementBridge", () => { expect(calls).toEqual(["insert", "postpatch", "destroy"]) }) - it("answers queries, sizes and ResizeObserver from host layout", () => { + it("answers queries, sizes and ResizeObserver from host layout", async () => { const bridge = makeElementBridge({ mounts: new Set(), send: () => {} }) bridge.commit(record(bridge), new Map()) const list = bridge.findById("list") @@ -97,10 +97,28 @@ describe("makeElementBridge", () => { }) observer.observe(rows[1]) bridge.reportLayout("r/1", { x: 0, y: 40, width: 300, height: 72 }) + await Promise.resolve() expect(heights).toEqual([72]) expect(rows[1]?.getBoundingClientRect().top).toBe(40) }) + it("delivers a batch of host layouts to a ResizeObserver in one callback", async () => { + const bridge = makeElementBridge({ mounts: new Set(), send: () => {} }) + bridge.commit(record(bridge), new Map()) + const rows = bridge.findById("list")?.querySelectorAll("[data-list-key]") ?? [] + const calls: Array> = [] + const observer = new bridge.ResizeObserver((entries) => { + calls.push(entries.map((entry) => entry.contentRect.height)) + }) + for (const row of rows) observer.observe(row) + bridge.reportLayout("r/0", { x: 0, y: 0, width: 300, height: 40 }) + bridge.reportLayout("r/1", { x: 0, y: 40, width: 300, height: 72 }) + // Resized again in the same batch: the entry carries the latest size, once. + bridge.reportLayout("r/1", { x: 0, y: 40, width: 300, height: 80 }) + await Promise.resolve() + expect(calls).toEqual([[40, 80]]) + }) + it("asks the host to measure an element once, when it is first observed", () => { const measured: Array = [] const bridge = makeElementBridge({ @@ -141,6 +159,27 @@ describe("makeElementBridge", () => { expect(sent).toEqual([480]) }) + it("sends scrollBy as a shift from the host's offset, and takes the host's answer", () => { + const sent: Array<{ top: number; by?: number; seq?: number }> = [] + const bridge = makeElementBridge({ + mounts: new Set(), + send: (command) => { + if (command.t === "scroll") sent.push({ top: command.top, by: command.by, seq: command.seq }) + }, + }) + bridge.commit(record(bridge), new Map()) + const list = bridge.findById("list") + if (list === null) throw new Error("no #list element") + bridge.reportScroll("r", 300) + list.scrollBy({ top: 40 }) + list.scrollBy(0, 0) + expect(list.scrollTop).toBe(340) + expect(sent).toEqual([{ top: 340, by: 40, seq: 1 }]) + // The reader kept scrolling while the shift crossed: the host's offset after it wins. + bridge.reportScroll("r", 390, 1) + expect(list.scrollTop).toBe(390) + }) + it("fires scroll once the host's rects for that frame are in, as one batch", async () => { const bridge = makeElementBridge({ mounts: new Set(), send: () => {} }) bridge.commit(record(bridge), new Map()) diff --git a/packages/foldkit-rune/src/bridge.ts b/packages/foldkit-rune/src/bridge.ts index 89b2f8899..501bd86b7 100644 --- a/packages/foldkit-rune/src/bridge.ts +++ b/packages/foldkit-rune/src/bridge.ts @@ -53,6 +53,7 @@ type ProxyElement = EventTarget & { readonly parentElement: ProxyElement | null contains(other: EventTarget | null): boolean scrollTo(arg: { top?: number } | number, y?: number): void + scrollBy(arg: { top?: number } | number, y?: number): void scrollIntoView(arg?: boolean | { block?: HostReveal["block"] }): void focus(): void blur(): void @@ -259,7 +260,7 @@ export const makeElementBridge = (options: { } const rect = () => rects.get(path) ?? { x: 0, y: 0, width: 0, height: 0 } const record = () => records.get(path) - const setScrollTop = (top: number) => { + const setScrollTop = (top: number, by?: number) => { // A write of the offset it read (an anchor kept with no change) is no move: sending it // would put back an offset the user may have scrolled on from. if (top === (scrollTops.get(path) ?? 0)) return @@ -267,7 +268,9 @@ export const makeElementBridge = (options: { clearTimeout(pendingScrolls.get(path)?.timer) const seq = ++scrollSeq pendingScrolls.set(path, { top, at: performance.now(), seq }) - options.send({ t: "scroll", path, top, seq }) + options.send( + by === undefined ? { t: "scroll", path, top, seq } : { t: "scroll", path, top, seq, by }, + ) } const proxy = Object.defineProperties(new ElementClass(), { __path: { value: path }, @@ -320,6 +323,13 @@ export const makeElementBridge = (options: { value: (arg: { top?: number } | number, y?: number) => setScrollTop(typeof arg === "number" ? (y ?? 0) : (arg.top ?? 0)), }, + // Relative, as the host applies it: what the reader scrolled since the last report stays. + scrollBy: { + value: (arg: { top?: number } | number, y?: number) => { + const by = typeof arg === "number" ? (y ?? 0) : (arg.top ?? 0) + if (by !== 0) setScrollTop((scrollTops.get(path) ?? 0) + by, by) + }, + }, scrollIntoView: { value: (arg?: boolean | { block?: HostReveal["block"] }) => options.send({ t: "reveal", path, block: revealBlock(arg) }), @@ -656,21 +666,31 @@ export const makeElementBridge = (options: { class BridgeResizeObserver { private targets = new Map void>() + /** Targets resized in the current batch of host lines, delivered in one callback. */ + private due = new Map() constructor( private callback: (entries: Array, observer: BridgeResizeObserver) => void, ) {} + /** As a browser's: one callback per frame with every target that resized in it. */ + private deliver = () => { + const entries: Array = [] + for (const [path, proxy] of this.due) { + if (!this.targets.has(path)) continue + const r = rects.get(path) ?? { x: 0, y: 0, width: 0, height: 0 } + const size = [{ inlineSize: r.width, blockSize: r.height }] + entries.push({ target: proxy, contentRect: r, borderBoxSize: size, contentBoxSize: size }) + } + this.due.clear() + if (entries.length > 0) this.callback(entries, this) + } observe(target: unknown) { if (!isProxy(target)) return const path = target.__path // An element reached by walking (`parentElement`) is not observed yet: its size is asked for. proxyFor(path) const notify = (proxy: ProxyElement) => { - const r = rects.get(path) ?? { x: 0, y: 0, width: 0, height: 0 } - const size = [{ inlineSize: r.width, blockSize: r.height }] - this.callback( - [{ target: proxy, contentRect: r, borderBoxSize: size, contentBoxSize: size }], - this, - ) + if (this.due.size === 0) queueMicrotask(this.deliver) + this.due.set(path, proxy) } this.targets.set(path, notify) const set = resizeObservers.get(path) ?? new Set() diff --git a/packages/foldkit-rune/src/stdio.ts b/packages/foldkit-rune/src/stdio.ts index 32f0572a8..eb1480fbb 100644 --- a/packages/foldkit-rune/src/stdio.ts +++ b/packages/foldkit-rune/src/stdio.ts @@ -1,9 +1,5 @@ -import { Result, Schema } from "effect" - import type { HostToRuntime, RuntimeToHost } from "./wire" -const decodeJson = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Unknown)) - /** Every host message's tag, keyed by the wire type: a new kind of message cannot be left out. */ const HOST_MESSAGE_TAGS = { event: true, @@ -37,9 +33,15 @@ const isHostMessage = (value: unknown): value is HostToRuntime => /** One line from the host as a message, or the reason it is not one. */ export const parseHostLine = (line: string): { message: HostToRuntime } | { error: string } => { - const decoded = decodeJson(line) - if (Result.isFailure(decoded)) return { error: decoded.failure.message } - return isHostMessage(decoded.success) ? { message: decoded.success } : { error: "not a host message" } + // `JSON.parse`, not a Schema decode: the host sends a line per observed row each frame of a + // scroll, and a decoder of `Unknown` checks nothing JSON.parse does not. + let value: unknown + try { + value = JSON.parse(line) + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) } + } + return isHostMessage(value) ? { message: value } : { error: "not a host message" } } /** Splits a stream into lines; a malformed one is reported and skipped, never fatal. */ diff --git a/packages/foldkit-rune/src/wire.ts b/packages/foldkit-rune/src/wire.ts index acb2b5a13..3919c1917 100644 --- a/packages/foldkit-rune/src/wire.ts +++ b/packages/foldkit-rune/src/wire.ts @@ -178,6 +178,12 @@ export type RuntimeToHost = readonly top: number /** Echoed by the host's next offset report, so reports from before it can be told apart. */ readonly seq?: number + /** + * `element.scrollBy()`: the host shifts the viewport by this much from where it is when + * it applies it (`top` is where the runtime expects it), so a finger's drag and a fling + * carry on; the runtime's offset is always a report behind the host's. + */ + readonly by?: number } /** Focus the element keyed `key` once the host has the tree it is in (`element.focus()`). */ | { readonly t: "focus"; readonly key: string }