diff --git a/crates/trawler/src/editor.rs b/crates/trawler/src/editor.rs index 5be0700..fef3870 100644 --- a/crates/trawler/src/editor.rs +++ b/crates/trawler/src/editor.rs @@ -32,6 +32,41 @@ use unicode_segmentation::UnicodeSegmentation; pub const CONTEXT: &str = "BlockEditor"; +thread_local! { + /// Neovide-style caret glide: when the caret's target rectangle + /// moves, its four painted corners chase the target with + /// direction-dependent lag — corners leading the direction of travel + /// move ~3× faster than trailing ones, so the caret visibly stretches + /// toward where it's going and snaps crisp on arrival. The value is + /// the trailing corners' exponential time constant in milliseconds; + /// zero disables the effect entirely (the reduced-motion switch, and + /// what the UI tests set via [`set_caret_animation_ms`] so caret + /// assertions stay deterministic). Thread-local rather than a const + /// so tests can flip it without racing parallel test threads (see + /// `SCROLL_ANIMATION_MS` in main.rs); production reads it from the UI + /// thread only. + static CARET_ANIMATION_MS: std::cell::Cell = const { std::cell::Cell::new(80) }; +} + +fn caret_animation_ms() -> f32 { + CARET_ANIMATION_MS.with(|v| v.get()) as f32 +} + +#[cfg(test)] +pub fn set_caret_animation_ms(ms: u32) { + CARET_ANIMATION_MS.with(|v| v.set(ms)); +} + +/// Content kept visible *below* the caret's line whenever this editor asks +/// the containing list to autoscroll (vim's `scrolloff`, in pixels): typing +/// near the window's bottom edge scrolls early enough that upcoming lines +/// stay in view, instead of the caret riding flush against the bottom. Must +/// stay ≤ the outline's trailing overscroll spacer (`OVERSCROLL_PX` in +/// main.rs) — that spacer is the scroll room that lets a caret on the +/// document's last line still honor the margin. `pub(crate)`: the animated +/// reveal path in main.rs targets the same margin. +pub(crate) const TYPEAHEAD_MARGIN: f32 = 120.0; + actions!( block_editor, [ @@ -120,6 +155,18 @@ pub enum BlockEditorEvent { /// Ctrl+Enter with the cursor at/inside a reference span (spec: /// graph-navigation "Follow reference from keyboard"). FollowReference(ReferenceKind), + /// The caret moved and may need the viewport scrolled to keep it (plus + /// the typeahead margin) in view, and scroll animation is enabled: the + /// owner decides whether/where to ease the containing list, from its + /// own index-based geometry, instead of this editor requesting the + /// list's native — instant — autoscroll. The payload is the caret's + /// visual row in *element-local* coordinates, deliberately not window + /// coordinates: a focused row laid out past the list's rendered range + /// gets painted at a synthetic tail origin, so its window position is + /// fiction, while local offsets stay true everywhere. Emitted from the + /// element's prepaint under exactly the same conditions the native + /// request would fire. + RevealCaret { line_top: Pixels, line_height: Pixels }, } /// One candidate in the reference-completion popup. @@ -164,9 +211,108 @@ pub struct BlockEditor { /// whether a block is a query is graph state `BlockEditor` itself /// doesn't know about. highlight_scheme: bool, + /// The `(cursor, content length)` pair last used to request the + /// containing list autoscroll to this editor (openspec change + /// ui-polish, design D5 — "the viewport follows focus"). The element's + /// prepaint requests autoscroll exactly when this key changes while + /// focused: a fresh editor (None) reveals itself once on attach, and + /// every subsequent cursor/content change keeps the caret in view — + /// but an unchanged editor never re-requests, so wheel-scrolling away + /// from the focused block isn't fought. + autoscroll_key: Option<(usize, usize)>, + /// Which `autoscroll_key` the last reveal was issued for, plus how many + /// consecutive reveals that key has needed (a landing re-check that + /// finds the caret still out of view re-issues; see `request_reveal`). + /// Bounds the animated chase: the emitted caret coordinates are only + /// trustworthy while the caret's row is laid out in the list's real + /// flow — a row past the rendered range gets appended at a synthetic + /// tail origin ("rendered after the visible items so keyboard + /// interaction continues to work", gpui list) that always reads as + /// just-below-the-fold. Two eased passes cover every honest case; a + /// third means we're crawling a large jump on fake coordinates, so it + /// falls back to the list's native autoscroll, which is index-based + /// and exact. + reveal_key: Option<(usize, usize)>, + reveal_attempts: u32, + /// Where the caret was last laid out, in window coordinates — read by + /// the owner when focus moves to another block and handed to the new + /// editor via [`Self::seed_caret_glide`], so the caret glide (see + /// `CARET_ANIMATION_MS`) crosses block boundaries even though every + /// focus change constructs a fresh `BlockEditor`. + last_caret_bounds: Option>, + /// A departing editor's caret rectangle (window coordinates) for this + /// editor's caret glide to start from; consumed on first prepaint. + incoming_caret: Option>, + caret_anim: Option, _blur_subscription: Subscription, } +/// In-flight caret glide state (see `CARET_ANIMATION_MS`): the painted +/// caret quad's four corners in element-local coordinates — local, so list +/// scrolling moves the caret rigidly with its block and only movement +/// *within the text* animates — plus the instant of the last advance (the +/// delta-time source; clamped per tick so a stale timestamp after idling +/// can't teleport-skip the glide). +struct CaretAnim { + corners: [Point; 4], + last_tick: std::time::Instant, +} + +/// One tick of the caret glide: move each corner toward its target with a +/// lag that depends on how much the corner *leads* the direction of travel +/// (Neovide's cursor model — leading corners use `tau_slow / 3`, trailing +/// ones the full `tau_slow`, both in seconds). Returns whether the glide +/// has converged, with every corner snapped onto its target. +fn advance_caret_corners( + corners: &mut [Point; 4], + targets: &[Point; 4], + dt: f32, + tau_slow: f32, +) -> bool { + let center = |pts: &[Point; 4]| { + ( + f32::from(pts[0].x + pts[1].x + pts[2].x + pts[3].x) / 4., + f32::from(pts[0].y + pts[1].y + pts[2].y + pts[3].y) / 4., + ) + }; + let target_center = center(targets); + let current_center = center(corners); + let travel = ( + target_center.0 - current_center.0, + target_center.1 - current_center.1, + ); + let travel_len = (travel.0 * travel.0 + travel.1 * travel.1).sqrt(); + let tau_fast = tau_slow / 3.; + let mut converged = true; + for (corner, target) in corners.iter_mut().zip(targets) { + // How aligned this corner's rest offset (from the target rect's + // center) is with the direction of travel: +1 = dead ahead + // (leading), -1 = dead behind (trailing). + let rel = ( + f32::from(target.x) - target_center.0, + f32::from(target.y) - target_center.1, + ); + let rel_len = (rel.0 * rel.0 + rel.1 * rel.1).sqrt(); + let align = if travel_len > f32::EPSILON && rel_len > f32::EPSILON { + (travel.0 * rel.0 + travel.1 * rel.1) / (travel_len * rel_len) + } else { + 0. + }; + let tau = tau_fast + (tau_slow - tau_fast) * (1. - align) / 2.; + let step = 1. - (-dt / tau.max(f32::EPSILON)).exp(); + corner.x += (target.x - corner.x) * step; + corner.y += (target.y - corner.y) * step; + if f32::from((target.x - corner.x).abs()) > 0.25 + || f32::from((target.y - corner.y).abs()) > 0.25 + { + converged = false; + } else { + *corner = *target; + } + } + converged +} + impl EventEmitter for BlockEditor {} impl Focusable for BlockEditor { @@ -193,10 +339,53 @@ impl BlockEditor { is_selecting: false, completion: None, highlight_scheme: false, + autoscroll_key: None, + reveal_key: None, + reveal_attempts: 0, + last_caret_bounds: None, + incoming_caret: None, + caret_anim: None, _blur_subscription: blur_subscription, } } + /// The caret's last laid-out rectangle in window coordinates (see the + /// field doc on `last_caret_bounds`) — the departing half of the + /// cross-block caret-glide handoff. + pub fn caret_window_bounds(&self) -> Option> { + self.last_caret_bounds + } + + /// Start this editor's caret glide from `from` (window coordinates — + /// typically the previously focused editor's `caret_window_bounds`) + /// instead of materializing in place. The arriving half of the + /// cross-block handoff; a no-op while the glide is disabled. + pub fn seed_caret_glide(&mut self, from: Bounds) { + if caret_animation_ms() > 0. { + self.incoming_caret = Some(from); + } + } + + /// Mark the caret's current position as already revealed, suppressing + /// the reveal this editor's next prepaint would otherwise issue. Used + /// by history navigation: the restored scroll offset must win over + /// the freshly attached editor's attach-time reveal (which would + /// otherwise cancel the restore animation — or, with animation off, + /// natively scroll the restore away). + pub fn mark_revealed(&mut self) { + self.autoscroll_key = Some((self.cursor_offset(), self.content.len())); + } + + /// Forget the last reveal so the next prepaint re-evaluates caret + /// visibility from scratch — the follow-up pass an animated reveal + /// runs on landing. Each re-issue for the same caret position counts + /// against `reveal_attempts`, whose third strike downgrades to the + /// native exact autoscroll (see the field doc on `reveal_key`). + pub fn request_reveal(&mut self, cx: &mut Context) { + self.autoscroll_key = None; + cx.notify(); + } + pub fn value(&self) -> &str { &self.content } @@ -868,11 +1057,19 @@ struct BlockTextElement { struct PrepaintState { lines: Vec, line_height: Pixels, - cursor: Option, + cursor: Option, selections: Vec, paren_matches: Vec, } +/// What the caret paints as this frame: the plain target quad when at rest +/// (or with the glide disabled), or the stretched four-corner polygon while +/// a glide is in flight (see `CARET_ANIMATION_MS`). +enum CaretPaint { + Quad(PaintQuad), + Path(gpui::Path), +} + // `pub(crate)`: `main.rs` reuses these to render a query block's source the // same way when it's *not* the focused editor, so a block doesn't visibly // change color scheme the moment you click into or away from it. @@ -1102,6 +1299,31 @@ impl Element for BlockTextElement { window: &mut Window, cx: &mut App, ) -> Self::PrepaintState { + // Keep the focused editor in view (see `autoscroll_key`'s doc): + // the containing gpui `list` honors autoscroll requests made + // during an item's prepaint by scrolling — synchronously, this + // same frame — to reveal the requesting bounds. This is what keeps + // a block split below the fold (or a caret walked past the + // viewport edge) visible AND interactive: no deferred scroll to + // race the next keystroke. The request itself is issued *after* + // the caret is laid out below, so it can target the caret's own + // line plus the typeahead margin rather than the whole element. + let (is_focused, request_autoscroll, reveal_attempt) = + self.editor.update(cx, |editor, _| { + let is_focused = editor.focus_handle.is_focused(window); + let key = (editor.cursor_offset(), editor.content.len()); + let request = is_focused && editor.autoscroll_key != Some(key); + if request { + editor.autoscroll_key = Some(key); + if editor.reveal_key == Some(key) { + editor.reveal_attempts += 1; + } else { + editor.reveal_key = Some(key); + editor.reveal_attempts = 0; + } + } + (is_focused, request, editor.reveal_attempts) + }); let editor = self.editor.read(cx); let content = editor.content.clone(); let selected_range = editor.selected_range.clone(); @@ -1149,6 +1371,11 @@ impl Element for BlockTextElement { } let mut cursor_quad = None; + // The caret's visual row as (element-local top, height) — the + // reveal target for the autoscroll request below, so a tall + // multi-line block reveals the line being edited rather than its + // own top edge. Local, not window coordinates: see `RevealCaret`. + let mut cursor_line_box = None; let mut selection_quads = Vec::new(); for (line_ix, line) in lines.iter().enumerate() { let line_start = line_starts[line_ix]; @@ -1179,6 +1406,7 @@ impl Element for BlockTextElement { // cursor's right. let x = (bounds.left() + local.x - px(1.)).max(bounds.left()); let y = line_top + local.y + caret_y_inset; + cursor_line_box = Some((line_tops[line_ix] + local.y, line_height)); cursor_quad = Some(fill( Bounds::new(point(x, y), size(px(2.), caret_height)), gpui::blue(), @@ -1227,6 +1455,93 @@ impl Element for BlockTextElement { } } + if request_autoscroll { + // The reveal target: the caret's line, falling back to the + // whole element when there's no collapsed caret to target. + let (line_top, line_height_local) = cursor_line_box.unwrap_or((px(0.), bounds.size.height)); + // With scroll animation enabled, hand the reveal to the owner + // to *ease* toward (`RevealCaret`) instead of the list's + // native autoscroll, which lands instantly mid-frame. The + // native path stays for animation-off (reduced motion, UI + // tests) and as the third-attempt fallback (see `reveal_key`): + // synchronous, exact, same-frame. An out-of-view focused block + // stays interactive either way — the list keeps rendering it + // via its registered focus handle. + if crate::scroll_animation_ms() > 0. && reveal_attempt < 2 { + self.editor.update(cx, |_, cx| { + cx.emit(BlockEditorEvent::RevealCaret { + line_top, + line_height: line_height_local, + }); + }); + } else { + // Native reveal: the caret's line plus `TYPEAHEAD_MARGIN` + // beneath it — the list bottom-aligns the requested + // bounds, so the margin becomes content kept visible + // below the active line (the trailing overscroll spacer + // supplies the room at document end). + window.request_autoscroll(Bounds::new( + point(bounds.left(), bounds.top() + line_top), + size( + bounds.size.width, + line_height_local + px(TYPEAHEAD_MARGIN), + ), + )); + } + } + + // Caret glide (see `CARET_ANIMATION_MS`): chase the target caret + // rect with per-corner lag and paint the in-flight shape as a + // filled polygon. At knob 0 (reduced motion, UI tests) the caret + // is always the plain target quad. + let cursor_paint = cursor_quad.map(|quad| { + let anim_ms = caret_animation_ms(); + if anim_ms <= 0. || !is_focused { + self.editor.update(cx, |editor, _| { + editor.last_caret_bounds = Some(quad.bounds); + editor.caret_anim = None; + }); + return CaretPaint::Quad(quad); + } + let local_corners = |b: Bounds| -> [Point; 4] { + let o = bounds.origin; + [ + point(b.left() - o.x, b.top() - o.y), + point(b.right() - o.x, b.top() - o.y), + point(b.right() - o.x, b.bottom() - o.y), + point(b.left() - o.x, b.bottom() - o.y), + ] + }; + let targets = local_corners(quad.bounds); + let (corners, converged) = self.editor.update(cx, |editor, _| { + editor.last_caret_bounds = Some(quad.bounds); + let now = std::time::Instant::now(); + let seed = editor.incoming_caret.take(); + let anim = editor.caret_anim.get_or_insert_with(|| CaretAnim { + corners: seed.map(&local_corners).unwrap_or(targets), + last_tick: now, + }); + // Clamp dt so the first tick after idling (or a dropped + // frame) advances smoothly instead of teleport-skipping. + let dt = now.duration_since(anim.last_tick).as_secs_f32().min(0.05); + anim.last_tick = now; + let converged = + advance_caret_corners(&mut anim.corners, &targets, dt, anim_ms / 1000.); + (anim.corners, converged) + }); + if converged { + CaretPaint::Quad(quad) + } else { + window.request_animation_frame(); + let o = bounds.origin; + let mut path = gpui::Path::new(o + corners[0]); + path.line_to(o + corners[1]); + path.line_to(o + corners[2]); + path.line_to(o + corners[3]); + CaretPaint::Path(path) + } + }); + let mut paren_matches = Vec::new(); if highlight_scheme { if let Some((open, close)) = @@ -1259,7 +1574,7 @@ impl Element for BlockTextElement { PrepaintState { lines, line_height, - cursor: cursor_quad, + cursor: cursor_paint, selections: selection_quads, paren_matches, } @@ -1302,8 +1617,10 @@ impl Element for BlockTextElement { y += line_visual_height(line, prepaint.line_height); } if focus_handle.is_focused(window) { - if let Some(cursor) = prepaint.cursor.take() { - window.paint_quad(cursor); + match prepaint.cursor.take() { + Some(CaretPaint::Quad(quad)) => window.paint_quad(quad), + Some(CaretPaint::Path(path)) => window.paint_path(path, gpui::blue()), + None => {} } } @@ -1413,3 +1730,61 @@ impl Render for BlockEditor { .children(self.render_completion_popup(cx)) } } + +#[cfg(test)] +mod caret_glide_tests { + use super::*; + + fn rect_corners(left: f32, top: f32, w: f32, h: f32) -> [Point; 4] { + [ + point(px(left), px(top)), + point(px(left + w), px(top)), + point(px(left + w), px(top + h)), + point(px(left), px(top + h)), + ] + } + + #[test] + fn glide_converges_onto_target() { + let mut corners = rect_corners(0., 0., 2., 18.); + let targets = rect_corners(300., 120., 2., 18.); + let mut converged = false; + // 80ms trailing time constant: a couple hundred 16ms ticks is far + // past settling; the loop must terminate by convergence well + // before that. + for _ in 0..200 { + if advance_caret_corners(&mut corners, &targets, 0.016, 0.08) { + converged = true; + break; + } + } + assert!(converged, "glide never converged: {corners:?}"); + assert_eq!(corners, targets, "convergence must snap exactly onto the target"); + } + + #[test] + fn leading_corners_outrun_trailing_ones() { + // Travel is straight right, so the right-edge corners (indices 1 + // and 2) lead and the left-edge corners (0 and 3) trail. + let mut corners = rect_corners(0., 0., 2., 18.); + let targets = rect_corners(200., 0., 2., 18.); + advance_caret_corners(&mut corners, &targets, 0.016, 0.08); + let progress = + |ix: usize| f32::from(corners[ix].x) - if ix == 1 || ix == 2 { 2. } else { 0. }; + assert!( + progress(1) > progress(0) + 1. && progress(2) > progress(3) + 1., + "leading corners should visibly outrun trailing ones: {corners:?}" + ); + } + + #[test] + fn zero_travel_still_settles() { + // Degenerate case: target equals current position (plus sub-pixel + // noise) — travel length ~0 must not divide by zero, and the tick + // must report convergence by snapping. + let mut corners = rect_corners(10., 10., 2., 18.); + let targets = rect_corners(10.1, 10., 2., 18.); + assert!(advance_caret_corners(&mut corners, &targets, 0.016, 0.08)); + assert_eq!(corners, targets); + } +} diff --git a/crates/trawler/src/main.rs b/crates/trawler/src/main.rs index eaa9d0d..a453bbb 100644 --- a/crates/trawler/src/main.rs +++ b/crates/trawler/src/main.rs @@ -204,6 +204,11 @@ struct BacklinkRow { struct FocusedEditor { block: TreeID, input: Entity, + /// The editor's focus handle, registered with the list via + /// `splice_focusable` (see `replace_list_items`) so the focused row + /// stays rendered and interactive even off-screen. Stored here so + /// list-item rebuilds don't need a `cx` to re-read it. + focus_handle: FocusHandle, _event_subscription: Subscription, /// Re-measures this row whenever the editor's content changes height /// (e.g. wrapping to another line while typing) — plain keystrokes @@ -337,6 +342,10 @@ struct TrawlerApp { /// release, so the drag keeps working even when the cursor leaves the /// 4px handle. sidebar_resizing: bool, + /// Bumped by every programmatic scroll and every wheel event; a running + /// scroll animation stops when its captured generation is stale + /// (design D5: user input always wins over an in-flight animation). + scroll_anim_generation: usize, /// Blocks lexically similar to the currently focused block (spec: /// "Similar blocks (lexical)"), refreshed on every focus change. /// Shown as a dedicated panel rather than an inline footer on the @@ -454,6 +463,7 @@ impl TrawlerApp { sidebar_open: true, sidebar_width: SIDEBAR_DEFAULT_WIDTH, sidebar_resizing: false, + scroll_anim_generation: 0, similar: Vec::new(), query_results: HashMap::new(), query_pending: HashSet::new(), @@ -498,13 +508,63 @@ impl TrawlerApp { fn refresh_rows(&mut self) { let scroll_top = self.list_state.logical_scroll_top(); self.rows = self.main_rows(); - // `+ 1`: a synthetic trailing spacer row (see `OVERSCROLL_PX`) past - // the real outline rows, rendered by the `ix == rows.len()` arm in - // the list's `cx.processor` closure. - self.list_state.reset(self.rows.len() + 1); + self.replace_list_items(); self.list_state.scroll_to(scroll_top); } + /// Rebuild the list's item set from `self.rows`, registering the + /// focused row's focus handle with the list. The handle is what makes + /// gpui's `list` render the focused item even when it's scrolled out + /// of view ("so keyboard interaction continues to work for it" — the + /// off-screen-focus path in list layout) and honor its autoscroll + /// requests — without it, focusing a row below the fold left a live + /// editor whose element was never laid out: keystrokes dispatched + /// nowhere, which is exactly the "typing stalls / focus wedge" bug. + /// + /// `+ 1`: a synthetic trailing spacer row (see `OVERSCROLL_PX`) past + /// the real outline rows, rendered by the `ix == rows.len()` arm in + /// the list's `cx.processor` closure. + fn replace_list_items(&mut self) { + // `splice_focusable` snaps the scroll anchor to the splice start + // when the anchor falls inside the replaced range (here: always) — + // preserve and restore around it. + let scroll_top = self.list_state.logical_scroll_top(); + let focused = self + .editor + .as_ref() + .map(|e| (e.block, e.focus_handle.clone())); + let old_len = self.list_state.item_count(); + let handles = self + .rows + .iter() + .map(|r| match &focused { + Some((block, handle)) if *block == r.id => Some(handle.clone()), + _ => None, + }) + .chain(std::iter::once(None)) // the spacer row + .collect::>(); + self.list_state.splice_focusable(0..old_len, handles); + self.list_state.scroll_to(scroll_top); + } + + /// Replace a single row's list item, (re-)registering its focus + /// handle, without disturbing the scroll anchor: `splice_focusable` + /// snaps the anchor to the splice start whenever the anchor sits + /// inside the replaced range — and the focused row is respliced on + /// *every* editor notify (each keystroke, every caret-glide frame), + /// so an unpreserved anchor got yanked to the row's top the moment an + /// eased scroll carried it into the focused row, killing the tail of + /// every top-align reveal ("jolts, then settles back"). + fn splice_row_preserving_scroll( + list_state: &ListState, + ix: usize, + handle: Option, + ) { + let scroll_top = list_state.logical_scroll_top(); + list_state.splice_focusable(ix..ix + 1, [handle]); + list_state.scroll_to(scroll_top); + } + /// Pick up a midnight rollover while the app is left running: if the /// local date has advanced since the last check, ensure a fresh /// journal page exists and, if the journal timeline is what's on @@ -536,8 +596,7 @@ impl TrawlerApp { fn refresh_view_data(&mut self) { let scroll_top = self.list_state.logical_scroll_top(); self.rows = self.main_rows(); - // `+ 1`: see the matching comment in `refresh_rows`. - self.list_state.reset(self.rows.len() + 1); + self.replace_list_items(); self.list_state.scroll_to(scroll_top); self.backlinks = self.backlinks_for_current_view(); self.breadcrumb = self.breadcrumb_for_current_view(); @@ -663,16 +722,272 @@ impl TrawlerApp { } self.view = view; self.refresh_view_data(); - self.list_state.scroll_to(gpui::ListOffset::default()); + // Scroll is owned by focus: focusing reveals the target block + // (top of the page for the default first-block focus, wherever the + // hint lives otherwise). Only the no-rows case scrolls explicitly. let target = focus_hint.or_else(|| self.rows.first().map(|r| r.id)); match target { Some(block) => self.focus_block(block, window, cx), - None => window.focus(&self.root_focus), + None => { + self.animated_scroll_to(ScrollTarget::Top, cx); + window.focus(&self.root_focus); + } } cx.notify(); } + /// Animate the outline's scroll to `target` (design D5): apply the + /// target instantly to measure the pixel delta (invisible — this all + /// happens before the next paint), jump back, then ease toward it, + /// re-applying the exact target on the final tick so estimation drift + /// from unmeasured rows can't accumulate. A zero animation duration + /// (reduced motion; the UI tests' default) keeps the instant jump. + /// Elapsed time accrues per tick rather than from a wall clock, so the + /// test executor's fake timers drive the easing deterministically. + fn animated_scroll_to(&mut self, target: ScrollTarget, cx: &mut Context) { + self.animated_scroll_with(target, false, cx); + } + + /// [`Self::animated_scroll_to`] plus an optional landing re-check for + /// caret reveals (`recheck`): on completing uncancelled, ask the + /// focused editor to re-evaluate caret visibility — rows that were + /// unmeasured at takeoff make a `RevealBottom` an estimate, and the + /// re-issued reveal either confirms (no further motion) or eases a + /// precision pass, with the editor's attempt counter downgrading a + /// third consecutive pass to the native exact autoscroll. Skipped when + /// the viewport didn't move at all, so a pinned scroll can't loop. + fn animated_scroll_with( + &mut self, + target: ScrollTarget, + recheck: bool, + cx: &mut Context, + ) { + self.scroll_anim_generation += 1; + let start_offset = self.list_state.logical_scroll_top(); + let start_px = self.list_state.scroll_px_offset_for_scrollbar().y; + apply_scroll_target(&self.list_state, target); + let total_ms = scroll_animation_ms(); + if total_ms <= 0.0 { + return; + } + let end_px = self.list_state.scroll_px_offset_for_scrollbar().y; + // `scroll_px_offset_for_scrollbar` reports the *negated* scroll + // offset (`-offset`), while `scroll_by` takes plain offset units + // (positive = down) — so the start-minus-end order here is what + // yields the applied movement in `scroll_by`'s sign convention. + // (Getting this backwards double-applies the jump and plays the + // ease in reverse, rescued only by the final exact re-apply.) + let delta = f32::from(start_px - end_px); + #[cfg(feature = "devtools")] + if std::env::var_os("TRAWLER_SCROLL_TRACE").is_some() { + eprintln!( + "TRACE scroll_with start=({}, {:?}) start_px={start_px:?} end_px={end_px:?} \ + delta={delta}", + start_offset.item_ix, start_offset.offset_in_item, + ); + } + if delta.abs() < 1.0 { + return; + } + const STEP_MS: u64 = 12; + // Apply the first tick's worth synchronously: jump back from the + // (already applied) target by only the *un-eased* fraction, so + // every animation makes immediate progress even when its async + // ticks get starved — under sustained key repeat each keystroke + // supersedes the previous animation before its 12ms timer ever + // fires, which otherwise froze the viewport during rapid + // navigation and dumped the whole accumulated distance into one + // catch-up lurch when the keys stopped. + let first_t = (STEP_MS as f32 / total_ms).min(1.0); + let first_eased = 1.0 - (1.0 - first_t).powi(3); + self.list_state.scroll_by(px(-delta * (1.0 - first_eased))); + #[cfg(feature = "devtools")] + if std::env::var_os("TRAWLER_SCROLL_TRACE").is_some() { + let a = self.list_state.logical_scroll_top(); + eprintln!( + "TRACE sync_step total_ms={total_ms} first_eased={first_eased} \ + walked_back={} now=({}, {:?})", + -delta * (1.0 - first_eased), + a.item_ix, + a.offset_in_item, + ); + } + let generation = self.scroll_anim_generation; + cx.spawn(async move |this, cx| { + let mut elapsed = STEP_MS as f32; + // The fraction of the distance that should remain after the + // previous tick (1 − eased). Each tick re-measures the actual + // remaining distance in the *current* summary space and keeps + // the easing curve's share of it, instead of walking a total + // measured at takeoff: row measurements churn mid-animation + // (the ruler under the anchor stretches as overdraw rows get + // measured), and a takeoff-fixed pixel budget walks past the + // logical target and gets visibly snapped back by the final + // exact apply — the "overshoot then settle" jolt. + let mut prev_remaining = 1.0 - first_eased; + loop { + cx.background_executor() + .timer(Duration::from_millis(STEP_MS)) + .await; + elapsed += STEP_MS as f32; + let t = (elapsed / total_ms).min(1.0); + // Ease-out cubic: fast start, gentle landing. + let eased = 1.0 - (1.0 - t).powi(3); + let done = t >= 1.0; + let Ok(stop) = this.update(cx, |this, cx| { + if this.scroll_anim_generation != generation { + return true; // superseded or wheel-cancelled + } + // Ticks fire between draws — exactly where an edit's + // full resplice can have zeroed every row measurement. + // Pixel walks over an unmeasured summary teleport (a + // positive seek past zero-height items runs to the + // list end), so when the rows above the anchor sum to + // nothing the summary is unmeasured: skip this tick's + // movement and let the upcoming draw re-measure. + // Elapsed keeps accruing, so the ease still lands on + // schedule. + let anchor = this.list_state.logical_scroll_top(); + let above = -f32::from(this.list_state.scroll_px_offset_for_scrollbar().y) + - f32::from(anchor.offset_in_item); + let summary_measured = anchor.item_ix == 0 || above > 0.5; + if done { + if summary_measured { + apply_scroll_target(&this.list_state, target); + } + if recheck { + // The *logical* anchor is the movement + // detector: the px ruler drifts as overdraw + // rows get measured mid-animation. An + // unmeasured landing couldn't apply the + // target, so it always re-checks. + let now = this.list_state.logical_scroll_top(); + let moved = (now.item_ix, now.offset_in_item) + != (start_offset.item_ix, start_offset.offset_in_item); + if moved || !summary_measured { + if let Some(input) = + this.editor.as_ref().map(|e| e.input.clone()) + { + input.update(cx, |editor, cx| editor.request_reveal(cx)); + } + } + } + } else if summary_measured { + // Measure the remaining run fresh: jump to the + // target, note the displacement, then walk back + // the fraction the easing curve says should still + // remain. Monotonic by construction — every tick + // ends strictly between the current position and + // the target. + let current_px = this.list_state.scroll_px_offset_for_scrollbar().y; + apply_scroll_target(&this.list_state, target); + let target_px = this.list_state.scroll_px_offset_for_scrollbar().y; + // Same negated-ruler convention as the takeoff + // measurement above. + let toward = f32::from(current_px - target_px); + let keep = if prev_remaining > f32::EPSILON { + ((1.0 - eased) / prev_remaining).clamp(0.0, 1.0) + } else { + 0.0 + }; + this.list_state.scroll_by(px(-toward * keep)); + prev_remaining = 1.0 - eased; + } + cx.notify(); + done + }) else { + return; + }; + if stop { + return; + } + } + }) + .detach(); + } + + /// Decide whether the focused caret (whose visual row spans `line_top + /// .. line_top + line_height` in the *editor's local coordinates*) + /// needs the viewport moved, and ease there via + /// [`Self::animated_scroll_with`]. Every decision is made in the + /// list's own index/summary space — the focused row's index, its + /// summary-derived bounds, the scroll anchor — never from the editor + /// element's painted window position, which is synthetic whenever the + /// focused row sits past the list's rendered range (gpui appends such + /// rows at a tail origin that always reads as just-below-the-fold; a + /// coordinate-based reveal would scroll *down* to reach a caret that + /// walked off the *top*). An already-visible caret does nothing — + /// crucially without bumping the animation generation, so no-op + /// reveals can't cancel an in-flight scroll such as a history restore. + fn animated_reveal( + &mut self, + line_top: gpui::Pixels, + line_height: gpui::Pixels, + cx: &mut Context, + ) { + let Some(item_ix) = self + .rows + .iter() + .position(|r| Some(r.id) == self.editor.as_ref().map(|e| e.block)) + else { + return; + }; + let viewport = self.list_state.viewport_bounds(); + if viewport.size.height <= px(0.) { + return; + } + let top_align = ScrollTarget::Offset(gpui::ListOffset { + item_ix, + offset_in_item: line_top, + }); + let bottom_align = ScrollTarget::RevealBottom { + item_ix, + line_top, + line_height, + }; + let target = match self.list_state.bounds_for_item(item_ix) { + Some(item) => { + let caret_top = item.top() + line_top; + let caret_bottom = caret_top + line_height; + if caret_top < viewport.top() - px(2.) { + Some(top_align) + } else if caret_bottom + px(editor::TYPEAHEAD_MARGIN) + > viewport.bottom() + px(REVEAL_PAD_SLOP) + { + Some(bottom_align) + } else { + None + } + } + // No measured bounds: the row is either above the scroll + // anchor (walked off the top) or in unmeasured territory far + // below — the index against the anchor says which way. + None if item_ix < self.list_state.logical_scroll_top().item_ix => Some(top_align), + None => Some(bottom_align), + }; + #[cfg(feature = "devtools")] + if std::env::var_os("TRAWLER_SCROLL_TRACE").is_some() { + eprintln!( + "TRACE reveal item_ix={item_ix} line_top={line_top:?} target={:?} \ + anchor=({}, {:?}) item_bounds={:?} viewport=({:?}..{:?})", + target.map(|t| match t { + ScrollTarget::Top => "top".to_string(), + ScrollTarget::Offset(o) => format!("top_align({}, {:?})", o.item_ix, o.offset_in_item), + ScrollTarget::RevealBottom { item_ix, .. } => format!("bottom_align({item_ix})"), + }), + self.list_state.logical_scroll_top().item_ix, + self.list_state.logical_scroll_top().offset_in_item, + self.list_state.bounds_for_item(item_ix).map(|b| (b.top(), b.bottom())), + viewport.top(), + viewport.bottom(), + ); + } + if let Some(target) = target { + self.animated_scroll_with(target, true, cx); + } + } + fn navigate_back(&mut self, _: &NavigateBack, window: &mut Window, cx: &mut Context) { let Some((view, restore_scroll)) = self.history_back.pop() else { return; @@ -682,12 +997,20 @@ impl TrawlerApp { self.history_forward.push((self.view.clone(), left_scroll)); self.view = view; self.refresh_view_data(); - self.list_state.scroll_to(restore_scroll); + // The saved offset must win on back/forward: the freshly attached + // editor would otherwise issue an attach-time caret reveal from + // its first prepaint (animated: cancelling this restore animation; + // animation off: natively scrolling the restore away), so mark it + // already revealed before restoring. if let Some(block) = self.rows.first().map(|r| r.id) { self.focus_block(block, window, cx); } else { window.focus(&self.root_focus); } + if let Some(editor) = self.editor.as_ref() { + editor.input.update(cx, |editor, _| editor.mark_revealed()); + } + self.animated_scroll_to(ScrollTarget::Offset(restore_scroll), cx); cx.notify(); } @@ -705,12 +1028,16 @@ impl TrawlerApp { self.history_back.push((self.view.clone(), left_scroll)); self.view = view; self.refresh_view_data(); - self.list_state.scroll_to(restore_scroll); + // Same reveal-suppression rationale as `navigate_back`. if let Some(block) = self.rows.first().map(|r| r.id) { self.focus_block(block, window, cx); } else { window.focus(&self.root_focus); } + if let Some(editor) = self.editor.as_ref() { + editor.input.update(cx, |editor, _| editor.mark_revealed()); + } + self.animated_scroll_to(ScrollTarget::Offset(restore_scroll), cx); cx.notify(); } @@ -1133,6 +1460,18 @@ impl TrawlerApp { }; let content = editor.input.read(cx).value().to_string(); let outline = Outline::new(self.storage.doc()); + // Unchanged content commits nothing: pure focus navigation lands + // here on every arrow press, and the full pipeline below is both + // wasteful (a disk persist per keystroke) and actively harmful — + // `refresh_view_data`'s full resplice clears every row's cached + // measurement, and any scroll-animation tick that fires before + // the next draw re-measures then does its pixel walk over an + // all-zero summary, teleporting the viewport (a positive seek + // past zero-height items runs to the list end — "bounced to the + // bottom of the journals"). + if outline.content(editor.block).as_deref() == Ok(content.as_str()) { + return; + } outline .set_content(editor.block, &content) .expect("focused block still exists"); @@ -1331,12 +1670,37 @@ impl TrawlerApp { } return; } + // Hand the departing caret's on-screen rectangle to the new editor + // so the caret glide crosses block boundaries — every focus change + // constructs a fresh `BlockEditor`, and without the handoff the + // glide could only ever animate within a single block (see + // `editor::CARET_ANIMATION_MS`). Exception: a destination row + // *above the viewport top* is rendered at the list's synthetic + // tail origin (near the viewport bottom) until the eased reveal + // lands, so a glide seeded through that origin smears the caret + // toward the bottom and back — skip the handoff there and let the + // viewport's own ease carry the eye. (Rows below the fold render + // at approximately their true position, so downward glides stay.) + let departing_caret = self + .editor + .as_ref() + .and_then(|e| e.input.read(cx).caret_window_bounds()) + .filter(|_| { + self.rows + .iter() + .position(|r| r.id == id) + .is_none_or(|ix| ix >= self.list_state.logical_scroll_top().item_ix) + }); self.commit_editor(cx); let content = Outline::new(self.storage.doc()) .content(id) .unwrap_or_default(); + let content_at_attach = content.clone(); let input = cx.new(|cx| BlockEditor::new(window, cx, content)); + if let Some(from) = departing_caret { + input.update(cx, |editor, _| editor.seed_caret_glide(from)); + } if let Some(offset) = cursor { input.update(cx, |editor, cx| editor.set_cursor(offset, cx)); } @@ -1357,6 +1721,12 @@ impl TrawlerApp { BlockEditorEvent::CompletionQueryChanged { trigger, query } => { this.update_completions(*trigger, query.clone(), cx); } + BlockEditorEvent::RevealCaret { + line_top, + line_height, + } => { + this.animated_reveal(*line_top, *line_height, cx); + } BlockEditorEvent::FollowReference(kind) => { let kind = kind.clone(); let entity = cx.entity(); @@ -1376,18 +1746,47 @@ impl TrawlerApp { editor.set_highlight_scheme(is_query, cx); }); - let content_observation = cx.observe(&input, move |this: &mut Self, _input, cx| { - if let Some(ix) = this.rows.iter().position(|r| r.id == id) { - this.list_state.splice(ix..ix + 1, 1); - } - // Live preview with debounce (spec: steel-queries/"Live - // preview with debounce") — plain keystrokes don't reach any - // of the `BlockEditorEvent` variants above, so this content - // observer is the only signal that a query's source changed. - if is_query_block(&Outline::new(this.storage.doc()), id) { - this.schedule_query_eval(id, Duration::from_millis(400), cx); + let content_observation = cx.observe(&input, { + // The editor notifies on every caret move and every + // caret-glide animation frame, not just edits — and the + // resplice below clears the row's cached measurement, leaving + // the focused row height-0 in the list's summary between + // draws. Scroll-animation ticks run exactly there (between + // draws), so an unconditional resplice made every reveal's + // pixel math mis-anchor by one row height during quick + // navigation ("the outline dips to the bottom and returns"). + // Only content changes can change the row's height (or a + // query's source), so gate both on actual content change. + let mut last_content = content_at_attach; + move |this: &mut Self, input, cx| { + let content_changed = input.read(cx).value() != last_content; + if content_changed { + last_content = input.read(cx).value().to_string(); + if let Some(ix) = this.rows.iter().position(|r| r.id == id) { + // `splice_focusable`, not `splice`: a plain splice + // would drop this row's registered focus handle on + // every keystroke, and with it the list's + // keep-the-focused-item-rendered behavior (the + // typing-stall fix). Scroll-anchor preserving: see + // `splice_row_preserving_scroll`. + let handle = this + .editor + .as_ref() + .filter(|e| e.block == id) + .map(|e| e.focus_handle.clone()); + Self::splice_row_preserving_scroll(&this.list_state, ix, handle); + } + // Live preview with debounce (spec: steel-queries/ + // "Live preview with debounce") — plain keystrokes + // don't reach any of the `BlockEditorEvent` variants + // above, so this content observer is the only signal + // that a query's source changed. + if is_query_block(&Outline::new(this.storage.doc()), id) { + this.schedule_query_eval(id, Duration::from_millis(400), cx); + } + } + cx.notify(); } - cx.notify(); }); let focus_handle = input.read(cx).focus_handle(cx); @@ -1396,9 +1795,26 @@ impl TrawlerApp { self.editor = Some(FocusedEditor { block: id, input, + focus_handle, _event_subscription: event_subscription, _content_observation: content_observation, }); + // Register the new editor's focus handle with the list (the + // preceding `commit_editor` rebuilt the items with no focused + // row) — a single-row splice, so every other row keeps its + // measurement. Scroll-to-focus itself needs no explicit call here: + // the editor element requests list autoscroll from its own + // prepaint whenever its cursor/content changes while focused — + // including its very first paint after attaching (see + // `BlockEditor::autoscroll_key`). + if let Some(ix) = self.rows.iter().position(|r| r.id == id) { + let handle = self + .editor + .as_ref() + .map(|e| e.focus_handle.clone()) + .expect("editor was just attached"); + Self::splice_row_preserving_scroll(&self.list_state, ix, Some(handle)); + } self.refresh_similar(); if is_query && !self.query_results.contains_key(&id) { self.schedule_query_eval(id, Duration::ZERO, cx); @@ -1953,6 +2369,82 @@ const THREAD_WIDTH: f32 = 2.0; const THREAD_COLOR: u32 = 0x7c9dd9; /// Corner radius of the thread's elbow bend into a bullet. const THREAD_BEND_RADIUS: f32 = 6.0; +thread_local! { + /// Duration of animated programmatic scrolls (openspec change + /// ui-polish, design D5), in milliseconds. Zero disables animation + /// entirely — the reduced-motion switch, and what the UI tests set + /// (via [`set_scroll_animation_ms`]) so scroll assertions stay + /// deterministic. Thread-local rather than a const so tests can flip + /// it — and *only* thread-local, never a process global: parallel + /// test threads each carry their own value, so one test enabling + /// animation can't make another test's scrolls (whose fake timers are + /// never advanced) hang mid-flight. Production reads it from the UI + /// thread only. + static SCROLL_ANIMATION_MS: std::cell::Cell = const { std::cell::Cell::new(180) }; +} + +fn scroll_animation_ms() -> f32 { + SCROLL_ANIMATION_MS.with(|v| v.get()) as f32 +} + +#[cfg(test)] +fn set_scroll_animation_ms(ms: u32) { + SCROLL_ANIMATION_MS.with(|v| v.set(ms)); +} + +/// A programmatic scroll destination (design D5): the shapes the app +/// navigates to. All variants are logical (item-index-based), so applying +/// one is exact regardless of which rows happen to be measured — +/// `RevealBottom` is the caret-reveal destination ("this item's line +/// `line_top..+line_height` sits `TYPEAHEAD_MARGIN` above the viewport +/// bottom"), used by [`TrawlerApp::animated_reveal`]; top-aligned reveals +/// are just `Offset`. +#[derive(Clone, Copy)] +enum ScrollTarget { + Top, + Offset(gpui::ListOffset), + RevealBottom { + item_ix: usize, + line_top: gpui::Pixels, + line_height: gpui::Pixels, + }, +} + +/// The visible row box wraps the editor in a little vertical padding that +/// the editor's element-local line offsets can't see; reveals over-scroll +/// by this much so the padding never eats into the typeahead margin. +const REVEAL_PAD_SLOP: f32 = 8.0; + +fn apply_scroll_target(list_state: &ListState, target: ScrollTarget) { + match target { + ScrollTarget::Top => list_state.scroll_to(gpui::ListOffset::default()), + ScrollTarget::Offset(offset) => list_state.scroll_to(offset), + ScrollTarget::RevealBottom { + item_ix, + line_top, + line_height, + } => { + // Anchor the caret's line at the viewport top (index-exact, + // no heights involved), then push down so the line sits + // `TYPEAHEAD_MARGIN` above the bottom. The `scroll_by` walk + // runs in the list's own summary space, so it lands exactly + // where the list believes that position is — and re-applying + // this same target later (the animation's final tick, the + // landing re-check) recomputes against fresher measurements. + list_state.scroll_to(gpui::ListOffset { + item_ix, + offset_in_item: line_top, + }); + let viewport_height = list_state.viewport_bounds().size.height; + if viewport_height > px(0.) { + list_state.scroll_by( + line_height + px(editor::TYPEAHEAD_MARGIN) + px(REVEAL_PAD_SLOP) + - viewport_height, + ); + } + } + } +} /// A query block's own row gets this background tint — without it a query /// block was indistinguishable from an ordinary one. Deliberately applied /// only to the row, not the result section below it too: tinting both @@ -1964,7 +2456,9 @@ const QUERY_BG: u32 = 0x22252e; const QUERY_ACCENT_COLOR: u32 = 0x7c9dd9; /// Blank space rendered as a synthetic trailing row after the real outline /// rows, so the last block can be scrolled up away from the window's bottom -/// edge instead of staying glued to it while editing. +/// edge instead of staying glued to it while editing. Must stay ≥ the +/// editor's `TYPEAHEAD_MARGIN` (editor.rs) — this spacer is what lets a +/// caret on the document's last line still keep that margin below itself. const OVERSCROLL_PX: f32 = 240.0; /// Render a query block's raw source with the same token colors @@ -2630,6 +3124,35 @@ fn caption_button( impl Render for TrawlerApp { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // Scroll-trajectory tracing for dev-loop debugging: one line per + // render with the logical anchor and px offset. Set + // TRAWLER_SCROLL_TRACE=1 on a devtools build to enable. + #[cfg(feature = "devtools")] + if std::env::var_os("TRAWLER_SCROLL_TRACE").is_some() { + let anchor = self.list_state.logical_scroll_top(); + let focused_ix = self + .editor + .as_ref() + .and_then(|e| self.rows.iter().position(|r| r.id == e.block)); + // Tops of the first visible rows, to catch layout churn that + // an anchor-only trace can't see (a row height flapping makes + // everything below it bounce while the anchor stays smooth). + let tops: Vec = (0..12) + .map(|k| { + self.list_state + .bounds_for_item(anchor.item_ix + k) + .map(|b| format!("{:.0}", f32::from(b.top()))) + .unwrap_or_else(|| "?".into()) + }) + .collect(); + eprintln!( + "TRACE render anchor=({}, {:?}) px={:?} focused={focused_ix:?} tops={}", + anchor.item_ix, + anchor.offset_in_item, + self.list_state.scroll_px_offset_for_scrollbar().y, + tops.join(","), + ); + } self.check_journal_rollover(cx); let row_count = self.rows.len(); let focused_block = self.editor.as_ref().map(|e| e.block); @@ -3182,7 +3705,20 @@ impl Render for TrawlerApp { .flex_1() .min_w_0() .child( - div().flex_1().min_h_0().child( + div() + .flex_1() + .min_h_0() + // Wheel input always wins over an + // in-flight scroll animation (design + // D5): observe (don't consume) the + // event and retire any running + // animation task. + .on_scroll_wheel(cx.listener( + |this, _e: &gpui::ScrollWheelEvent, _window, _cx| { + this.scroll_anim_generation += 1; + }, + )) + .child( list( self.list_state.clone(), cx.processor(move |this, ix: usize, window, cx| { diff --git a/crates/trawler/src/ui_tests.rs b/crates/trawler/src/ui_tests.rs index 721b911..3e864ac 100644 --- a/crates/trawler/src/ui_tests.rs +++ b/crates/trawler/src/ui_tests.rs @@ -27,11 +27,16 @@ fn fixture_dir(name: &str) -> PathBuf { dir } -/// Open the real app over a seeded fixture graph in a test window. +/// Open the real app over a seeded fixture graph in a test window. Scroll +/// and caret animation are disabled (knobs at 0) so positions are always +/// deterministic; the dedicated scroll-animation test re-enables its knob +/// and drives the fake clock explicitly. fn open_app<'a>( name: &str, cx: &'a mut TestAppContext, ) -> (Entity, &'a mut VisualTestContext) { + crate::set_scroll_animation_ms(0); + crate::editor::set_caret_animation_ms(0); let dir = fixture_dir(name); cx.update(|cx| { crate::editor::init(cx); @@ -649,3 +654,450 @@ async fn backspace_on_page_first_block_deletes_only_when_empty(cx: &mut gpui::Te "page title must be untouched" ); } + +// --- smooth scrolling (openspec change ui-polish, design D5) ---------------- + +/// The scroll animator converges exactly on its target, and a superseding +/// programmatic scroll cancels an in-flight animation (the same generation +/// mechanism the wheel listener uses). Runs with the animation enabled and +/// the fake clock driven by hand; every other test keeps the knob at 0. +#[gpui::test] +async fn scroll_animation_converges_and_is_superseded(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("scroll-animation", cx); + cx.run_until_parked(); + + // Grow today's page until the outline genuinely scrolls. + for i in 0..40 { + cx.simulate_input(&format!("filler line {i}")); + cx.simulate_keystrokes("enter"); + } + cx.run_until_parked(); + + crate::set_scroll_animation_ms(120); + + // Animate from the current position (bottom-ish, where typing left us) + // to the top: drive the clock past the duration and require exact + // convergence (the final tick re-applies the target precisely). + let start = app.update(cx, |app, cx| { + let start = app.list_state.logical_scroll_top(); + app.animated_scroll_to(crate::ScrollTarget::Top, cx); + start + }); + // Mid-flight, the viewport must be strictly *between* start and + // target — moving toward the top, not away from it. (Regression: a + // sign inversion in the animator's jump-back played the ease in + // reverse and let the final exact re-apply mask it.) + cx.executor() + .advance_clock(std::time::Duration::from_millis(24)); + cx.run_until_parked(); + app.update(cx, |app, _| { + let mid = app.list_state.logical_scroll_top(); + assert!( + mid.item_ix < start.item_ix, + "animation must move toward the target: started at item {}, \ + mid-flight at item {}", + start.item_ix, + mid.item_ix + ); + assert!( + mid.item_ix > 0 || mid.offset_in_item > gpui::px(0.), + "animation must not jump straight to the target" + ); + }); + for _ in 0..15 { + cx.executor() + .advance_clock(std::time::Duration::from_millis(12)); + cx.run_until_parked(); + } + app.update(cx, |app, _| { + let top = app.list_state.logical_scroll_top(); + assert_eq!( + (top.item_ix, top.offset_in_item), + (0, gpui::px(0.)), + "animation must land exactly on the target" + ); + }); + + // Supersede mid-flight: start a long scroll down, cancel it almost + // immediately with a scroll back to top — the first animation must + // stop dead and the second must win. + let deep_target = app.update(cx, |app, cx| { + app.list_state.scroll_to(gpui::ListOffset { + item_ix: 30, + offset_in_item: gpui::px(0.), + }); + let deep = app.list_state.logical_scroll_top(); + app.list_state.scroll_to(gpui::ListOffset::default()); + app.animated_scroll_to(crate::ScrollTarget::Offset(deep), cx); + deep + }); + cx.executor() + .advance_clock(std::time::Duration::from_millis(24)); + cx.run_until_parked(); + app.update(cx, |app, cx| { + app.animated_scroll_to(crate::ScrollTarget::Top, cx); + }); + for _ in 0..15 { + cx.executor() + .advance_clock(std::time::Duration::from_millis(12)); + cx.run_until_parked(); + } + app.update(cx, |app, _| { + let top = app.list_state.logical_scroll_top(); + assert_eq!( + (top.item_ix, top.offset_in_item), + (0, gpui::px(0.)), + "the superseding scroll must win; got item {} (deep target was {})", + top.item_ix, + deep_target.item_ix + ); + }); + + crate::set_scroll_animation_ms(0); +} + +/// With scroll animation enabled, a caret reveal *eases* the viewport +/// instead of the native autoscroll's instant jump: right after the split +/// the scroll offset is untouched (the caret is still on-screen thanks to +/// the typeahead margin), and driving the clock lands the active line back +/// at the margin. Every other test keeps the knob at 0, where reveals stay +/// native and instant. +#[gpui::test] +async fn caret_reveal_eases_instead_of_jumping(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("smooth-reveal", cx); + cx.run_until_parked(); + + // Overflow the viewport at knob 0 (instant reveals): typing leaves the + // active line exactly at the typeahead margin. + for i in 0..80 { + cx.simulate_input(&format!("overflow line {i}")); + cx.simulate_keystrokes("enter"); + } + cx.simulate_input("x"); + cx.run_until_parked(); + + crate::set_scroll_animation_ms(120); + + // The logical anchor (item index + offset) is the stable ruler here: + // the raw px offset shifts on every splice as item measurements reset, + // even when the viewport hasn't visually moved. + let before = app.update(cx, |app, _| app.list_state.logical_scroll_top()); + // One more split pushes the caret one line past the margin. + cx.simulate_keystrokes("enter"); + cx.run_until_parked(); + + // No instant jump: with the animation's timers not yet advanced, only + // the synchronously applied *first eased step* may have moved the + // viewport — a fraction of one row, not the full row-plus-margin jump + // the native autoscroll (knob 0) would have applied here. (Each + // subsequent keystroke chases another eased step by design, so this + // samples right after the split, before any typing.) + app.update(cx, |app, _| { + let now = app.list_state.logical_scroll_top(); + // Approximate px moved across the (uniform, ~29px) fixture rows — + // the first step may legitimately cross an item boundary when the + // steady-state anchor sits near a row's edge. + let moved = (now.item_ix as f32 - before.item_ix as f32) * 29.0 + + f32::from(now.offset_in_item - before.offset_in_item); + assert!( + moved.abs() < 15.0, + "only the first eased step may apply synchronously (moved \ + {moved}px): before {before:?}, now {now:?}" + ); + }); + cx.simulate_input("eased line"); + cx.run_until_parked(); + + // Drive the animation (plus its landing re-check pass) to completion. + for _ in 0..30 { + cx.executor() + .advance_clock(std::time::Duration::from_millis(12)); + cx.run_until_parked(); + } + app.update(cx, |app, _| { + let now = app.list_state.logical_scroll_top(); + assert_ne!( + (now.item_ix, now.offset_in_item), + (before.item_ix, before.offset_in_item), + "animated reveal should have scrolled the viewport" + ); + let focused_ix = app + .rows + .iter() + .position(|r| Some(r.id) == app.editor.as_ref().map(|e| e.block)) + .expect("focused row visible in rows"); + let viewport = app.list_state.viewport_bounds(); + let bounds = app + .list_state + .bounds_for_item(focused_ix) + .expect("focused row has measured bounds"); + assert!( + bounds.bottom() + <= viewport.bottom() - gpui::px(crate::editor::TYPEAHEAD_MARGIN - 20.0) + && bounds.bottom() + >= viewport.bottom() - gpui::px(crate::editor::TYPEAHEAD_MARGIN + 60.0), + "eased reveal must land the active line at the typeahead margin: \ + bounds {bounds:?} viewport {viewport:?}" + ); + }); + + crate::set_scroll_animation_ms(0); +} + +/// Walking the caret off the *top* of the viewport eases the viewport +/// upward — never downward. (Regression: gpui lays out an off-screen +/// focused row at a synthetic tail origin below the fold, so a reveal +/// computed from the editor's painted window position scrolled *down* to +/// chase a caret that walked off the top.) +#[gpui::test] +async fn caret_reveal_follows_upward_navigation(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("upward-reveal", cx); + cx.run_until_parked(); + + // Overflow the viewport at knob 0, leaving the viewport scrolled down + // with the caret at the typeahead margin. + for i in 0..80 { + cx.simulate_input(&format!("upward line {i}")); + cx.simulate_keystrokes("enter"); + } + cx.simulate_input("x"); + cx.run_until_parked(); + + crate::set_scroll_animation_ms(120); + let start = app.update(cx, |app, _| app.list_state.logical_scroll_top()); + + // Walk the caret up past the viewport's top edge. Focus moves are + // deferred, so each Up is its own dispatch; the animation's timers + // are only driven afterwards. + for _ in 0..45 { + cx.simulate_keystrokes("up"); + } + cx.run_until_parked(); + for _ in 0..60 { + cx.executor() + .advance_clock(std::time::Duration::from_millis(12)); + cx.run_until_parked(); + } + app.update(cx, |app, _| { + let now = app.list_state.logical_scroll_top(); + assert!( + now.item_ix < start.item_ix, + "viewport must have followed the caret upward: started at item \ + {}, ended at item {}", + start.item_ix, + now.item_ix + ); + let focused_ix = app + .rows + .iter() + .position(|r| Some(r.id) == app.editor.as_ref().map(|e| e.block)) + .expect("focused row visible in rows"); + let viewport = app.list_state.viewport_bounds(); + let bounds = app + .list_state + .bounds_for_item(focused_ix) + .expect("focused row has measured bounds"); + // Top-aligned reveal: the caret's row lands at (or just below) + // the viewport top. + assert!( + bounds.top() >= viewport.top() - gpui::px(5.0) + && bounds.top() <= viewport.top() + gpui::px(60.0), + "upward reveal must land the caret's row at the viewport top: \ + bounds {bounds:?} viewport {viewport:?}" + ); + }); + + crate::set_scroll_animation_ms(0); +} + +/// Edits full-resplice the item set, clearing every row measurement; an +/// animation tick firing before the next draw re-measures must not do +/// pixel math over the zeroed summary — a positive seek over zero-height +/// rows runs to the list end (live symptom: quick navigation "bounced the +/// viewport to the bottom of the journals", then recovered with the caret +/// top-aligned instead of at the typeahead margin). +#[gpui::test] +async fn caret_reveal_survives_hot_ticks_across_edits(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("hot-ticks", cx); + cx.run_until_parked(); + + for i in 0..80 { + cx.simulate_input(&format!("hot base {i}")); + cx.simulate_keystrokes("enter"); + } + cx.simulate_input("x"); + cx.run_until_parked(); + + crate::set_scroll_animation_ms(120); + + // Interleave edits with mid-flight ticks: every Enter splits (full + // resplice) while the previous reveal's ease is still hot. + for i in 0..12 { + cx.simulate_keystrokes("enter"); + cx.simulate_input(&format!("hot {i}")); + cx.executor() + .advance_clock(std::time::Duration::from_millis(24)); + cx.run_until_parked(); + app.update(cx, |app, _| { + let focused_ix = app + .rows + .iter() + .position(|r| Some(r.id) == app.editor.as_ref().map(|e| e.block)) + .expect("focused row visible in rows"); + let viewport = app.list_state.viewport_bounds(); + let bounds = app + .list_state + .bounds_for_item(focused_ix) + .expect("focused row has measured bounds"); + assert!( + bounds.top() >= viewport.top() - gpui::px(40.0) + && bounds.top() <= viewport.bottom(), + "viewport must stay pinned near the caret through hot \ + edits, not teleport: bounds {bounds:?} viewport {viewport:?}" + ); + }); + } + + // Let the chain settle and require the typeahead margin. + for _ in 0..60 { + cx.executor() + .advance_clock(std::time::Duration::from_millis(12)); + cx.run_until_parked(); + } + app.update(cx, |app, _| { + let focused_ix = app + .rows + .iter() + .position(|r| Some(r.id) == app.editor.as_ref().map(|e| e.block)) + .expect("focused row visible in rows"); + let viewport = app.list_state.viewport_bounds(); + let bounds = app + .list_state + .bounds_for_item(focused_ix) + .expect("focused row has measured bounds"); + assert!( + bounds.bottom() + <= viewport.bottom() - gpui::px(crate::editor::TYPEAHEAD_MARGIN - 20.0) + && bounds.bottom() + >= viewport.bottom() - gpui::px(crate::editor::TYPEAHEAD_MARGIN + 60.0), + "hot-tick chain must still land at the typeahead margin: \ + bounds {bounds:?} viewport {viewport:?}" + ); + }); + + crate::set_scroll_animation_ms(0); +} + +/// A burst of edits that leaves the caret far past the rendered range +/// still converges onto the typeahead margin: eased reveals computed from +/// the list's synthetic tail origin (the off-screen focused row's fake +/// position) are estimates, and the attempt-limited re-check chain must +/// end in the native exact autoscroll rather than crawling forever — or, +/// worse, stranding the caret off-screen. +#[gpui::test] +async fn caret_reveal_converges_after_large_jumps(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("burst-reveal", cx); + cx.run_until_parked(); + + crate::set_scroll_animation_ms(120); + + // Type well past the fold with the animation's timers never advanced: + // every reveal stays queued or superseded, so the caret ends far below + // the viewport (the dev-loop reproduction of the stranded-caret bug). + for i in 0..80 { + cx.simulate_input(&format!("burst line {i}")); + cx.simulate_keystrokes("enter"); + } + cx.simulate_input("x"); + cx.run_until_parked(); + + // Drive the full chain: eased pass, eased precision pass, native snap. + for _ in 0..60 { + cx.executor() + .advance_clock(std::time::Duration::from_millis(12)); + cx.run_until_parked(); + } + app.update(cx, |app, _| { + let focused_ix = app + .rows + .iter() + .position(|r| Some(r.id) == app.editor.as_ref().map(|e| e.block)) + .expect("focused row visible in rows"); + let viewport = app.list_state.viewport_bounds(); + let bounds = app + .list_state + .bounds_for_item(focused_ix) + .expect("focused row has measured bounds"); + assert!( + bounds.bottom() <= viewport.bottom() + gpui::px(5.0) + && bounds.top() >= viewport.top() - gpui::px(5.0), + "caret must not be stranded off-screen after the burst: \ + bounds {bounds:?} viewport {viewport:?}" + ); + assert!( + bounds.bottom() + <= viewport.bottom() - gpui::px(crate::editor::TYPEAHEAD_MARGIN - 20.0), + "the reveal chain must land the active line at the typeahead \ + margin: bounds {bounds:?} viewport {viewport:?}" + ); + }); + + crate::set_scroll_animation_ms(0); +} + +/// Scroll-to-focus: creating blocks past the bottom of the viewport keeps +/// the focused (new) block in view — the viewport follows focus (reported +/// as "create a node that has overflowed the window height and the +/// viewport does not follow"). +#[gpui::test] +async fn viewport_follows_focus_past_the_fold(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("scroll-to-focus", cx); + cx.run_until_parked(); + + // Type enough blocks to decisively overflow the test window (the + // fake-platform window is ~1046px of viewport, ~36 rows). + for i in 0..80 { + cx.simulate_input(&format!("overflow line {i}")); + cx.simulate_keystrokes("enter"); + } + // The reveal runs on the frame after the last split (measurements only + // exist post-paint); one more input drives that frame. + cx.simulate_input("x"); + cx.run_until_parked(); + + // With animation at 0 the reveal applies instantly: the list must have + // scrolled away from the top so the focused (latest) row is visible. + app.update(cx, |app, _| { + let top = app.list_state.logical_scroll_top(); + assert!( + top.item_ix > 0, + "viewport should have followed focus past the fold (still at item {})", + top.item_ix + ); + let focused_ix = app + .rows + .iter() + .position(|r| Some(r.id) == app.editor.as_ref().map(|e| e.block)) + .expect("focused row visible in rows"); + let viewport = app.list_state.viewport_bounds(); + let bounds = app + .list_state + .bounds_for_item(focused_ix) + .expect("focused row has measured bounds"); + assert!( + bounds.bottom() <= viewport.bottom() + gpui::px(5.0) + && bounds.top() >= viewport.top() - gpui::px(5.0), + "focused row must be within the viewport: bounds {bounds:?} viewport {viewport:?}" + ); + // The autoscroll reveals the caret's line plus the typeahead + // margin below it (editor.rs `TYPEAHEAD_MARGIN`), so the active + // line must sit well clear of the window's bottom edge — asserted + // with slack for the row's own padding. + assert!( + bounds.bottom() + <= viewport.bottom() - gpui::px(crate::editor::TYPEAHEAD_MARGIN - 20.0), + "typeahead margin should keep the active line clear above the \ + viewport bottom: bounds {bounds:?} viewport {viewport:?}" + ); + }); +} diff --git a/openspec/changes/ui-polish/design.md b/openspec/changes/ui-polish/design.md index 41b672d..94a01d6 100644 --- a/openspec/changes/ui-polish/design.md +++ b/openspec/changes/ui-polish/design.md @@ -109,6 +109,28 @@ hover-highlighting a thread is a possible follow-up, not in scope. ### D5 — Smooth scrolling: ease programmatic offsets only +> **Revised during review.** Three additions grew out of using D5: (1) +> **scroll-to-focus** — the focused editor detects reveal needs from its +> own prepaint, which required registering focus handles via +> `splice_focusable` and en route fixed the long-standing typing stall +> (focused rows below the fold were never laid out, so keys dispatched +> nowhere). With animation off, the reveal is the native gpui `list` +> autoscroll (synchronous, exact); with animation on, the editor emits +> the caret line's element-local offsets (`RevealCaret`) and the app +> eases toward an *index-based logical target* (top-align: exact +> `ListOffset`; bottom-align: `RevealBottom`, applied in the list's own +> summary space) on the shared generation machinery. Deliberately never +> coordinate-based: the list lays out a far-off-screen focused row at a +> synthetic tail origin, which sent coordinate-chasing reveals the wrong +> way. Attempt-limited — after two eased passes a still-unsatisfied +> reveal falls back to the exact native path. (2) A **typeahead margin**: reveals target the caret's +> line plus 240px below it (`TYPEAHEAD_MARGIN`, backed by the equal +> trailing overscroll spacer), vim-`scrolloff`-style. (3) A +> **Neovide-style caret glide**: the caret's four corners chase its target +> rect with direction-dependent lag and paint as a stretched polygon in +> flight, handed across block boundaries when focus constructs a fresh +> editor; own knob (`CARET_ANIMATION_MS`), zero = reduced motion. + All programmatic scrolls (back/forward restore, follow-reference, zoom in/out, scroll-to-focused-block) animate from the current `ListState` offset to the target over a short ease-out (~180ms — named knob), driven by gpui's diff --git a/openspec/changes/ui-polish/specs/outline-editor/spec.md b/openspec/changes/ui-polish/specs/outline-editor/spec.md index 0882694..6bd3cf6 100644 --- a/openspec/changes/ui-polish/specs/outline-editor/spec.md +++ b/openspec/changes/ui-polish/specs/outline-editor/spec.md @@ -71,6 +71,59 @@ MUST remain instantaneous, never animated. duration, and a wheel event during the animation stops it at the user's control +### Requirement: The viewport follows focus with a typeahead margin +When the focused block's caret moves or its content changes, the outline +SHALL scroll to keep the caret's line visible, maintaining a typeahead +margin (a named knob, 120px — reduced from the initial 240px on review +feedback) of content visible below the active line so typing never rides +the window's bottom edge. With scroll animation +enabled, the adjustment SHALL ease rather than jump, converging exactly +onto the margin position — including after bursts of movement that leave +the caret far outside the rendered range (bounded eased passes, then an +exact native fallback). With animation disabled (reduced motion; UI +tests), the reveal is the list's native synchronous autoscroll. The +margin SHALL be honored even on the document's last line (backed by a +trailing overscroll spacer at least as tall). A focused block scrolled out of view +by the user MUST remain interactive (keystrokes apply), and the viewport +MUST NOT scroll back until the caret or content next changes. + +#### Scenario: Typing past the fold +- **WHEN** the user splits blocks repeatedly until new blocks would fall + below the window's bottom edge +- **THEN** the outline scrolls so the active line stays visible with the + typeahead margin below it, and every keystroke lands in the focused + block with no stalls + +#### Scenario: The follow is eased, not instant +- **WHEN** scroll animation is enabled and the caret moves one line past + the margin (Enter or Down at the bottom edge) +- **THEN** the viewport glides to the new margin position over the scroll + animation duration instead of jumping, and a rapid run of such moves + (held arrow key) chases smoothly and still lands exactly at the margin + +#### Scenario: Wheel-scrolling away is respected +- **WHEN** the user wheel-scrolls the focused block out of view without + touching the caret +- **THEN** the viewport stays where the user put it until the next caret + movement or edit, which reveals the caret again + +### Requirement: Caret glide +The focused block's caret SHALL animate between positions Neovide-style: +the caret rectangle's corners chase their target with direction-dependent +lag (corners leading the direction of travel move faster than trailing +ones), rendering as a visible stretch toward the destination that settles +crisply. The glide SHALL follow the caret across block boundaries when +focus moves between blocks. Setting the caret animation knob to zero SHALL +disable the effect entirely (reduced motion, and the deterministic mode +used by UI tests). + +#### Scenario: Caret stretches toward its destination +- **WHEN** the caret jumps within a block (e.g. Home from line end) or + focus moves to another block +- **THEN** the caret visibly glides from its previous position to the new + one, stretched along the direction of travel while in flight, arriving + as the normal caret rectangle + ### Requirement: Page root renders as a heading A page's root SHALL render as a large heading — no bullet, no fold affordance — with the page's children rendered from the first indent level diff --git a/openspec/changes/ui-polish/tasks.md b/openspec/changes/ui-polish/tasks.md index a8038d5..bbc5f8b 100644 --- a/openspec/changes/ui-polish/tasks.md +++ b/openspec/changes/ui-polish/tasks.md @@ -30,9 +30,13 @@ ## 6. Smooth scrolling (design D5) -- [ ] 6.1 Scroll animator over `ListState` logical offsets: ease-out over `SCROLL_ANIMATION_MS` (named knob, 0 = disabled), cancelled by wheel input or a superseding programmatic scroll -- [ ] 6.2 Route back/forward restore, follow-reference, zoom, and scroll-to-focus through the animator; leave edit-time scroll preservation as a hard set -- [ ] 6.3 UI tests run with the knob at 0; add one animation test asserting the offset converges to target and wheel cancels +- [x] 6.1 Scroll animator (`animated_scroll_to`): applies the target instantly pre-paint to measure the pixel delta (`scroll_px_offset_for_scrollbar`), jumps back, eases forward (ease-out cubic, `SCROLL_ANIMATION_MS` — an atomic so tests can flip it; 0 = reduced-motion instant jump), re-applies the exact target on the final tick; cancelled by wheel input (`on_scroll_wheel` listener bumps a generation) or a superseding programmatic scroll. Tick-accrued elapsed time (no wall clock) so fake test timers drive it deterministically +- [x] 6.2 Routed: `navigate_to`'s jump-to-top (which zoom and follow-reference flow through) and both history restores; edit-time scroll preservation left as hard sets +- [x] 6.3 `open_app` zeroes the knob for all UI tests; `scroll_animation_converges_and_is_superseded` re-enables it, drives the fake clock, asserts exact landing on target and that a superseding scroll kills an in-flight animation (the same generation mechanism the wheel path uses) +- [x] 6.4 Scroll-to-focus (added in review — "viewport does not follow" report): the focused editor requests native list autoscroll from its own element prepaint whenever its cursor/content changes while focused (`BlockEditor::autoscroll_key`), which the gpui `list` honors synchronously in the same frame. Required registering focus handles with the list via `splice_focusable` everywhere (`replace_list_items`, `focus_block_at`'s single-row splice, the content observer — a plain `splice` drops the handle per keystroke) so focused off-screen rows keep rendering. This also root-caused and fixed the long-standing typing-stall/focus-wedge: focused rows below the fold were never laid out, so keys dispatched nowhere. Verified live with 150 type+enter pairs, zero stalls; `viewport_follows_focus_past_the_fold` pins it +- [x] 6.5 Caret polish (added in review): **typeahead margin** — autoscroll requests target the caret's line plus `TYPEAHEAD_MARGIN` below it (≤ `OVERSCROLL_PX` so document end honors it), so typing never rides the window's bottom edge; initially 240px, reduced to 120px on review feedback (tests derive their assertions from the constant); **Neovide-style caret glide** — the caret quad's four corners chase the target with direction-dependent lag (leading ~3× faster than trailing, `CARET_ANIMATION_MS` knob, 0 = reduced motion/UI tests), painted as a stretched polygon via `paint_path` while in flight, with the departing caret's window rect handed to each freshly constructed `BlockEditor` (`seed_caret_glide`) so the glide crosses block boundaries — except when the destination row sits above the viewport top: it renders at the list's synthetic tail origin until the eased reveal lands, so a glide seeded through that origin smeared the caret to the bottom and back (review-caught); the handoff is skipped there and the viewport's ease carries the eye. Corner math unit-tested (`caret_glide_tests`); both effects captured mid-flight in dev-loop screenshots +- [x] 6.6 Eased scroll-to-focus (added in review, reworked twice on live feedback): with animation on, the editor emits `RevealCaret` carrying the caret line's *element-local* offsets, and `animated_reveal` eases the viewport via index-based logical targets — top-align is an exact `ListOffset`, bottom-align is `ScrollTarget::RevealBottom` (anchor line at top, `scroll_by` down to the margin in the list's own summary space) — never the editor's painted window position, which is synthetic for rows past the rendered range (gpui's tail-origin layout made coordinate-chasing scroll *down* toward a caret that walked off the *top*, and strand carets after key-repeat bursts). Landing re-check via `request_reveal`, attempt-limited (`reveal_attempts` ≥ 2 → native exact fallback). En route this exposed a sign inversion in `animated_scroll_to`'s jump-back (px ruler is negated vs `scroll_by` units) that had every scroll animation double-jumping and easing in reverse since 6.1, masked by the final exact re-apply — the animation test now pins mid-flight direction. History restores pre-mark the fresh editor revealed (`mark_revealed`) so the saved offset beats the attach-time reveal — also fixing a latent knob-0 restore clobber. Both animation knobs became thread-locals: a process-global knob raced parallel test threads. Headless coverage: `caret_reveal_eases_instead_of_jumping`, `caret_reveal_follows_upward_navigation`, `caret_reveal_converges_after_large_jumps` +- [x] 6.7 Reveal smoothness (added in review, from "is there a spring involved?" feedback + a per-frame scroll trace, `TRAWLER_SCROLL_TRACE=1` on devtools builds): three compounding jolt sources fixed. (1) The animator walked a pixel total measured at takeoff while row measurements churned mid-ease — it sailed past the logical target and the final exact apply snapped back ("overshoot then settle"); ticks now re-measure the remaining distance fresh each step and keep the easing curve's share of it — monotonic by construction. (2) Under sustained key repeat each keystroke superseded the previous animation before its first 12ms timer ever fired (frozen viewport, then one catch-up lurch); the first eased step now applies synchronously at takeoff, so every reveal makes immediate progress and rapid navigation chases live. (3) The content observer resplices the focused row on every editor notify (each caret-glide frame), and `splice_focusable` snaps the scroll anchor to the splice start when the anchor sits inside the range — yanking every top-align reveal to the row top the moment the ease arrived; single-row splices now preserve and restore the anchor (`splice_row_preserving_scroll`), like `replace_list_items` always did. (4) A fourth source ("the entire outline dips to the bottom and returns", quick arrow navigation at either reveal edge): the content observer respliced the focused row on *every* editor notify — including every caret-glide animation frame — clearing its cached measurement, so between draws (exactly when animation ticks run) the focused row was height-0 in the list's summary and every pixel walk across it mis-anchored by one row height, which the landing re-check then visibly corrected; the observer now gates the resplice (and query re-eval scheduling) on actual content change, since only content can change a row's height. (5) At 3–4 presses/sec the viewport teleported to the *document end* and recovered top-aligned: `commit_editor` ran its full write-persist-reindex-resplice pipeline on every focus change even with unchanged content, so every arrow press zeroed all row measurements — and an in-flight animation tick's positive pixel seek over the all-zero summary ran past every item to the list end; commits are now no-ops on unchanged content (also eliminating a disk persist per arrow press). (6) Belt-and-braces for real edits (which legitimately full-resplice): animation ticks now detect an unmeasured summary (zero height above a non-first anchor) and skip their movement until the next draw re-measures, with an unmeasured landing always re-checking. Trace-verified at 4–5 presses/sec: strictly monotonic anchor trajectories both directions, settling at exactly the 240px margin; pinned by `caret_reveal_survives_hot_ticks_across_edits` ## 7. Verification and docs