From e4060656c5dd96a607625fb8b755d34036f10765 Mon Sep 17 00:00:00 2001 From: Graham Barber Date: Thu, 16 Jul 2026 13:45:06 -0700 Subject: [PATCH] =?UTF-8?q?animate=20the=20bullet=20thread:=20same-tree=20?= =?UTF-8?q?focus=20transitions=20retract=20the=20tip=20to=20the=20aligned-?= =?UTF-8?q?common-run=20fork=20and=20flow=20along=20the=20new=20path=20(15?= =?UTF-8?q?0ms=20knob,=200=20=3D=20instant),=20sibling=20moves=20translate?= =?UTF-8?q?=20the=20extended=20elbow=20vertically=20between=20rows=20inste?= =?UTF-8?q?ad=20of=20replaying=20from=20the=20parent=20(rapid=20walks=20st?= =?UTF-8?q?robed),=20unrelated=20focus=20swaps=20instantly,=20top=20anchor?= =?UTF-8?q?=20lerps=20between=20roots.=20One=20ThreadAnim=20record=20(old?= =?UTF-8?q?=20path=20+=20start=20instant=20from=20the=20fake-clock-aware?= =?UTF-8?q?=20executor=20clock=20=E2=80=94=2012ms=20tick=20timers=20starve?= =?UTF-8?q?=20under=20the=20caret=20glide's=20frame=20load);=20all=20geome?= =?UTF-8?q?try=20resolves=20per=20render=20against=20current=20rows=20in?= =?UTF-8?q?=20row-index=20units,=20per-row=20clipping=20via=20relative=20h?= =?UTF-8?q?eights,=20no=20list-summary=20reads.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/trawler/src/main.rs | 538 +++++++++++++++--- crates/trawler/src/ui_tests.rs | 133 +++++ .../specs/outline-editor/spec.md | 15 +- .../changes/animate-bullet-thread/tasks.md | 51 +- 4 files changed, 644 insertions(+), 93 deletions(-) diff --git a/crates/trawler/src/main.rs b/crates/trawler/src/main.rs index 72caf96..dba1f41 100644 --- a/crates/trawler/src/main.rs +++ b/crates/trawler/src/main.rs @@ -346,6 +346,18 @@ struct TrawlerApp { /// scroll animation stops when its captured generation is stale /// (design D5: user input always wins over an in-flight animation). scroll_anim_generation: usize, + /// In-flight thread re-thread animation, if any (see [`ThreadAnim`]). + thread_anim: Option, + /// Stale-generation cancellation for the thread animation's tick + /// task, mirroring `scroll_anim_generation`. Instant re-threads bump + /// it so a superseded task can never write over a snap. + thread_anim_generation: usize, + /// The focused block and its thread path as of the last render — the + /// baseline every render diffs against to detect re-threads (focus + /// moves, indent/outdent reshapes, top-anchor rises/falls) and decide + /// animated-vs-instant (openspec change animate-bullet-thread, + /// design D2). + last_thread: Option<(TreeID, Vec)>, /// 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 @@ -464,6 +476,9 @@ impl TrawlerApp { sidebar_width: SIDEBAR_DEFAULT_WIDTH, sidebar_resizing: false, scroll_anim_generation: 0, + thread_anim: None, + thread_anim_generation: 0, + last_thread: None, similar: Vec::new(), query_results: HashMap::new(), query_pending: HashSet::new(), @@ -990,6 +1005,50 @@ impl TrawlerApp { } } + /// Start (or retarget) the thread re-thread animation from `old_path` + /// toward whatever the current rows say the focused path is (resolved + /// per render — design D3). The tick task advances one clock and + /// notifies; all geometry lives in render, so this can never move the + /// viewport or touch list measurements. + fn start_thread_transition(&mut self, old_path: Vec, cx: &mut Context) { + self.thread_anim = Some(ThreadAnim { + old_path, + started: cx.background_executor().now(), + }); + self.thread_anim_generation += 1; + let generation = self.thread_anim_generation; + let total_ms = thread_animation_ms().max(1.0); + cx.spawn(async move |this, cx| { + const STEP_MS: u64 = 12; + loop { + cx.background_executor() + .timer(Duration::from_millis(STEP_MS)) + .await; + let Ok(stop) = this.update(cx, |this, cx| { + if this.thread_anim_generation != generation { + return true; // superseded or snapped + } + let Some(anim) = &this.thread_anim else { + return true; + }; + let elapsed = cx.background_executor().now().duration_since(anim.started); + let done = elapsed.as_secs_f32() * 1000.0 >= total_ms; + if done { + this.thread_anim = None; + } + cx.notify(); + done + }) else { + return; + }; + if stop { + return; + } + } + }) + .detach(); + } + fn navigate_back(&mut self, _: &NavigateBack, window: &mut Window, cx: &mut Context) { let Some((view, restore_scroll)) = self.history_back.pop() else { return; @@ -2371,6 +2430,83 @@ 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 the focused-path thread's animated re-thread (openspec + /// change animate-bullet-thread), in milliseconds. Same-tree focus + /// transitions retract the thread's tip to the fork it shares with + /// the new path and flow it to the new bullet; zero disables the + /// animation entirely (reduced motion, and what the UI tests set via + /// [`set_thread_animation_ms`]). Thread-local for the same + /// parallel-test reasons as `SCROLL_ANIMATION_MS` below. + static THREAD_ANIMATION_MS: std::cell::Cell = const { std::cell::Cell::new(150) }; +} + +fn thread_animation_ms() -> f32 { + THREAD_ANIMATION_MS.with(|v| v.get()) as f32 +} + +#[cfg(test)] +fn set_thread_animation_ms(ms: u32) { + THREAD_ANIMATION_MS.with(|v| v.set(ms)); +} + +/// An in-flight thread re-thread (openspec change animate-bullet-thread, +/// design D1/D2): only the *old* path's node list and the start instant +/// are recorded — everything geometric (row positions, the fork, the new +/// path) resolves against the current rows on every render (design D3), +/// so structure changes mid-flight re-target instead of glitching, and +/// nothing here ever reads the list's measurement summary. The animation +/// is a single eased scalar traveling old-tip → fork → new-tip, with the +/// window's top edge lerping between the two paths' roots (rising roots +/// grow the thread upward, falling roots shrink it downward). +/// +/// `started` comes from `BackgroundExecutor::now()` — fake-clock-aware in +/// tests, wall-clock in production — and *render* computes the elapsed +/// time from it. The tick task exists only to keep frames coming: timer +/// ticks starve under sustained rendering load (the caret glide redraws +/// every frame of every focus change), and a tick-accrued clock froze the +/// re-thread until the glide finished. +struct ThreadAnim { + old_path: Vec, + started: std::time::Instant, +} + +/// One rendered frame of an in-flight thread transition, resolved from +/// [`ThreadAnim`] against the current rows. +enum ThreadFrame { + /// The general same-tree transition: the tip retracts to the fork + /// (rendering the old path) and extends along the new one, clipped by + /// `window` in row-index units. + Retrace { + old_render: Option>, + window: (f32, f32), + }, + /// Sibling-to-sibling moves (identical paths except the tip): the + /// already-extended elbow translates vertically between the two rows + /// instead of replaying from the parent — rapid sibling walks strobed + /// when every step re-flowed the whole final segment. The window's + /// tip rides the bend. + Translate { + window: (f32, f32), + from_col: usize, + to_depth: usize, + old_row: usize, + new_row: usize, + deeper_tip: TreeID, + }, +} + +/// Whether two thread paths describe a sibling move: equal length, equal +/// everywhere but the final node, and deep enough to have a parent to +/// hang the translating elbow from. +fn thread_paths_are_siblings(old: &[TreeID], new: &[TreeID]) -> bool { + old.len() >= 2 + && old.len() == new.len() + && old[..old.len() - 1] == new[..new.len() - 1] + && old.last() != new.last() +} + thread_local! { /// Duration of animated programmatic scrolls (openspec change /// ui-polish, design D5), in milliseconds. Zero disables animation @@ -3172,50 +3308,213 @@ impl Render for TrawlerApp { .as_tree_id() .is_some_and(|t| Outline::new(self.storage.doc()).parent(t).is_none()), }; - // Focused-path thread (design D4 as revised): walk the focused - // block's visible ancestor chain top-down and record, per row, - // which accent segments pass through it — a descending vertical - // between consecutive path nodes, an elbow into each path node's - // bullet, and a stub below each ancestor's bullet. Rows outside - // the path get the default (empty) accent. - let mut accents: HashMap = HashMap::new(); - if let Some(focused) = focused_block { - let row_ix_by_id: HashMap = self - .rows - .iter() - .enumerate() - .map(|(i, r)| (r.id, i)) - .collect(); - if row_ix_by_id.contains_key(&focused) { - let outline = Outline::new(self.storage.doc()); - let mut path = vec![focused]; - let mut cursor = focused; - while let Some(parent) = outline.parent(cursor) { - if !row_ix_by_id.contains_key(&parent) { - break; // above the view (zoomed) — thread starts here - } - path.push(parent); - cursor = parent; + // Focused-path thread (design D4 as revised; animated re-threads + // per openspec change animate-bullet-thread): resolve the focused + // block's visible ancestor chain, diff it against last render's to + // classify the transition (same-tree → animate through the fork, + // unrelated → instant), then build per-row accent segments from + // whichever path this frame renders, clipped by the animation + // window. + let row_ix_by_id: HashMap = self + .rows + .iter() + .enumerate() + .map(|(i, r)| (r.id, i)) + .collect(); + let outline = Outline::new(self.storage.doc()); + // The focused block's thread path, top anchor → focused bullet, + // as node ids present in the current rows. Empty when nothing is + // focused or the focused block isn't in this view. + let resolve_path = |focused: TreeID| -> Vec { + if !row_ix_by_id.contains_key(&focused) { + return Vec::new(); + } + let mut path = vec![focused]; + let mut cursor = focused; + while let Some(parent) = outline.parent(cursor) { + if !row_ix_by_id.contains_key(&parent) { + break; // above the view (zoomed) — thread starts here } - path.reverse(); - // Headings have no bullet to thread from: drop a depth-0 - // page root from the front under the heading layout. - if heading_layout - && path - .first() - .and_then(|id| row_ix_by_id.get(id)) - .is_some_and(|&ix| self.rows[ix].depth == 0) - { - path.remove(0); + path.push(parent); + cursor = parent; + } + path.reverse(); + // Headings have no bullet to thread from: drop a depth-0 + // page root from the front under the heading layout. + if heading_layout + && path + .first() + .and_then(|id| row_ix_by_id.get(id)) + .is_some_and(|&ix| self.rows[ix].depth == 0) + { + path.remove(0); + } + path + }; + let current_path = focused_block.map(resolve_path).unwrap_or_default(); + // Transition detection (design D2): any change of focused node or + // path shape (splits, indent/outdent, a rising or falling top + // anchor) re-threads. Sharing at least one node with the outgoing + // path animates; sharing nothing snaps instantly. + let changed = match (&self.last_thread, focused_block) { + (Some((f, p)), Some(focused)) => *f != focused || *p != current_path, + (Some(_), None) => true, + (None, _) => false, + }; + if changed { + let old_path = self.last_thread.take().map(|(_, p)| p).unwrap_or_default(); + let same_tree = + !current_path.is_empty() && old_path.iter().any(|n| current_path.contains(n)); + if same_tree && thread_animation_ms() > 0.0 { + self.start_thread_transition(old_path, cx); + } else { + self.thread_anim = None; + self.thread_anim_generation += 1; + } + } + self.last_thread = focused_block + .filter(|_| !current_path.is_empty()) + .map(|f| (f, current_path.clone())); + // Resolve this frame's render path and clip window (row-index + // units; a node's bullet sits at `row_ix + 0.5`). One eased scalar + // travels old-tip → fork → new-tip — rendering the old path while + // retracting and the new path while extending — and the window's + // top edge lerps between the two roots (a rising root grows the + // thread upward, a falling one shrinks it downward). Any node that + // lost its row mid-flight snaps the animation off (design D3). + let mut render_path: Vec = current_path.clone(); + let mut thread_window: Option<(f32, f32)> = None; + let anim_now = cx.background_executor().now(); + let mut thread_travel: Option<(f32, usize, usize, usize, usize)> = None; + let resolved = self.thread_anim.as_ref().and_then(|anim| { + let pos = |n: TreeID| row_ix_by_id.get(&n).map(|&ix| ix as f32 + 0.5); + let old_tip = pos(*anim.old_path.last()?)?; + let new_tip = pos(*current_path.last()?)?; + let old_top = pos(*anim.old_path.first()?)?; + let new_top = pos(*current_path.first()?)?; + let elapsed_ms = anim_now.duration_since(anim.started).as_secs_f32() * 1000.0; + let t = (elapsed_ms / thread_animation_ms().max(1.0)).min(1.0); + let eased = 1.0 - (1.0 - t).powi(3); + // Sibling move: translate the already-extended elbow + // vertically between the two rows instead of retracting to + // the parent and replaying — the replay strobed under rapid + // sibling navigation. + if thread_paths_are_siblings(&anim.old_path, ¤t_path) { + let parent = current_path[current_path.len() - 2]; + let parent_row = *row_ix_by_id.get(&parent)?; + let old_row = *row_ix_by_id.get(anim.old_path.last()?)?; + let new_row = *row_ix_by_id.get(current_path.last()?)?; + let bend = old_tip + (new_tip - old_tip) * eased; + let deeper_tip = if old_tip > new_tip { + *anim.old_path.last()? + } else { + *current_path.last()? + }; + return Some(ThreadFrame::Translate { + window: (old_top + (new_top - old_top) * eased, bend), + from_col: self.rows[parent_row].depth, + to_depth: self.rows[new_row].depth, + old_row, + new_row, + deeper_tip, + }); + } + // The fork is the last node of the *aligned common run* — not + // merely the deepest shared node: an indent keeps the same + // tip while rerouting through a new ancestor, and the thread + // must retract to where the routes actually diverge. Ancestor + // chains that share any node always align (ancestors are + // unique), with the higher-rooted chain containing the + // other's root. + let fork = { + let old = &anim.old_path; + let (a, b) = current_path + .iter() + .position(|n| Some(n) == old.first()) + .map(|k| (&old[..], ¤t_path[k..])) + .or_else(|| { + old.iter() + .position(|n| Some(n) == current_path.first()) + .map(|k| (&old[k..], ¤t_path[..])) + })?; + a.iter() + .zip(b.iter()) + .take_while(|(x, y)| x == y) + .map(|(x, _)| *x) + .last()? + }; + let fork_pos = pos(fork)?; + let retract = (old_tip - fork_pos).max(0.0); + let extend = (new_tip - fork_pos).max(0.0); + let travel = eased * (retract + extend); + let top_w = old_top + (new_top - old_top) * eased; + Some(if travel < retract { + ThreadFrame::Retrace { + old_render: Some(anim.old_path.clone()), + window: (top_w, old_tip - travel), + } + } else { + ThreadFrame::Retrace { + old_render: None, + window: (top_w, fork_pos + (travel - retract).min(extend)), + } + }) + }); + // Whether to leave the render path's final elbow to the traveling + // bend instead of the static renderer. + let mut suppress_tip_elbow = false; + if self.thread_anim.is_some() { + match resolved { + Some(ThreadFrame::Retrace { old_render, window }) => { + if let Some(old) = old_render { + render_path = old; + } + thread_window = Some(window); } - for pair in path.windows(2) { - let (from_ix, to_ix) = (row_ix_by_id[&pair[0]], row_ix_by_id[&pair[1]]); - let col = self.rows[from_ix].depth; - accents.entry(from_ix).or_default().stub = true; - for ix in (from_ix + 1)..to_ix { - accents.entry(ix).or_default().verticals.push(col); + Some(ThreadFrame::Translate { + window, + from_col, + to_depth, + old_row, + new_row, + deeper_tip, + }) => { + // Render the spine down to the deeper of the two + // sibling rows — the window's tip (the bend) clips + // the vertical to wherever the elbow currently is. + if let Some(last) = render_path.last_mut() { + *last = deeper_tip; } - accents.entry(to_ix).or_default().elbow_from = Some(col); + suppress_tip_elbow = true; + thread_window = Some(window); + thread_travel = Some((window.1, from_col, to_depth, old_row, new_row)); + } + None => { + self.thread_anim = None; + self.thread_anim_generation += 1; + } + } + } + let mut accents: HashMap = HashMap::new(); + for pair in render_path.windows(2) { + let (Some(&from_ix), Some(&to_ix)) = + (row_ix_by_id.get(&pair[0]), row_ix_by_id.get(&pair[1])) + else { + continue; // an old-path node lost its row mid-flight + }; + let col = self.rows[from_ix].depth; + accents.entry(from_ix).or_default().stub = true; + for ix in (from_ix + 1)..to_ix { + accents.entry(ix).or_default().verticals.push(col); + } + accents.entry(to_ix).or_default().elbow_from = Some(col); + } + if suppress_tip_elbow { + // The traveling bend owns the elbow while a sibling translate + // runs; the deeper tip row keeps only its spine clipping. + if let Some(&tip_ix) = render_path.last().and_then(|n| row_ix_by_id.get(n)) { + if let Some(a) = accents.get_mut(&tip_ix) { + a.elbow_from = None; } } } @@ -3898,52 +4197,121 @@ impl Render for TrawlerApp { // and a stub starting at a path ancestor's // bullet center (the dot, itself thread- // colored, paints over the joint so the line - // visibly touches it). + // visibly touches it). While a re-thread + // animation runs (openspec change + // animate-bullet-thread), every piece clips + // against the window in row-fraction units — + // fractions of this row's own height, so no + // pixel measurements are needed. + let row_clip = + thread_window.map(|(w0, w1)| (w0 - ix as f32, w1 - ix as f32)); + let clip_seg = move |s: f32, e: f32| -> Option<(f32, f32)> { + let (a, b) = match row_clip { + None => (s, e), + Some((lo, hi)) => (s.max(lo), e.min(hi)), + }; + (b - a > 0.001).then_some((a, b)) + }; let mut thread_segments: Vec = Vec::new(); for &col in &accent.verticals { - thread_segments.push( - div() - .absolute() - .left(px(column_x(col) - THREAD_WIDTH / 2.0)) - .top_0() - .bottom_0() - .w(px(THREAD_WIDTH)) - .bg(rgb(THREAD_COLOR)) - .into_any_element(), - ); + let Some((a, b)) = clip_seg(0.0, 1.0) else { + continue; + }; + let seg = div() + .absolute() + .left(px(column_x(col) - THREAD_WIDTH / 2.0)) + .w(px(THREAD_WIDTH)) + .bg(rgb(THREAD_COLOR)); + let seg = if b - a >= 0.999 { + seg.top_0().bottom_0() + } else { + seg.top(gpui::relative(a)).h(gpui::relative(b - a)) + }; + thread_segments.push(seg.into_any_element()); } + // The stub spans the lower half of its row in + // window units (bullet center ≈ mid-row); the + // settled shape keeps the exact pixel top at + // the bullet center. if accent.stub { - thread_segments.push( - div() + if let Some((a, b)) = clip_seg(0.5, 1.0) { + let seg = div() .absolute() .left(px(column_x(*depth) - THREAD_WIDTH / 2.0)) - .top(px(bullet_center_y)) - .bottom_0() .w(px(THREAD_WIDTH)) - .bg(rgb(THREAD_COLOR)) - .into_any_element(), - ); + .bg(rgb(THREAD_COLOR)); + let seg = if a <= 0.501 && b >= 0.999 { + seg.top(px(bullet_center_y)).bottom_0() + } else { + seg.top(gpui::relative(a)).h(gpui::relative(b - a)) + }; + thread_segments.push(seg.into_any_element()); + } + } + // A sibling translate's traveling bend: the + // full elbow box rendered at the fractional + // row position the animation says the bend is + // passing through, sweeping the gutter between + // the parent's column and the sibling column. + if let Some((bend, from_col, to_depth, _, _)) = thread_travel { + let f = bend - ix as f32; + if (0.0..1.0).contains(&f) { + let from_left = column_x(from_col) - THREAD_WIDTH / 2.0; + let own_x = column_x(to_depth); + thread_segments.push( + div() + .absolute() + .left(px(from_left)) + .top_0() + .w(px(own_x - from_left)) + .h(gpui::relative(f.max(0.05))) + .border_l_2() + .border_b_2() + .rounded_bl(px(THREAD_BEND_RADIUS)) + .border_color(rgb(THREAD_COLOR)) + .into_any_element(), + ); + } } if let Some(from_col) = accent.elbow_from { // One box whose left + bottom borders form // the elbow, with a rounded bottom-left // corner — the horizontal run ends under - // this row's own bullet. + // this row's own bullet. Mid-animation, + // while the window hasn't reached the + // bullet center, only the vertical + // continuation draws; the bend pops in as + // the tip rounds the corner (design D4). let from_left = column_x(from_col) - THREAD_WIDTH / 2.0; - let own_x = column_x(*depth); - thread_segments.push( - div() - .absolute() - .left(px(from_left)) - .top_0() - .w(px(own_x - from_left)) - .h(px(bullet_center_y + THREAD_WIDTH / 2.0)) - .border_l_2() - .border_b_2() - .rounded_bl(px(THREAD_BEND_RADIUS)) - .border_color(rgb(THREAD_COLOR)) - .into_any_element(), - ); + if let Some((a, b)) = clip_seg(0.0, 0.5) { + if a <= 0.001 && b >= 0.499 { + let own_x = column_x(*depth); + thread_segments.push( + div() + .absolute() + .left(px(from_left)) + .top_0() + .w(px(own_x - from_left)) + .h(px(bullet_center_y + THREAD_WIDTH / 2.0)) + .border_l_2() + .border_b_2() + .rounded_bl(px(THREAD_BEND_RADIUS)) + .border_color(rgb(THREAD_COLOR)) + .into_any_element(), + ); + } else { + thread_segments.push( + div() + .absolute() + .left(px(from_left)) + .top(gpui::relative(a)) + .h(gpui::relative(b - a)) + .w(px(THREAD_WIDTH)) + .bg(rgb(THREAD_COLOR)) + .into_any_element(), + ); + } + } } // `items_start` (not `items_center`) so the // fold arrow/bullet line up with the first line @@ -3991,8 +4359,26 @@ impl Render for TrawlerApp { // multi-line) row. // Bullets on the focused path adopt the thread // color — the thread literally strings the - // colored icons together. - let on_thread = accent.stub || accent.elbow_from.is_some(); + // colored icons together. Mid-animation a + // bullet tints exactly when the window covers + // its center, so bullets light up as the tip + // passes and dim as it retracts. + let bend_covered = + row_clip.is_none_or(|(lo, hi)| lo <= 0.5 && 0.5 <= hi); + // During a sibling translate the two sibling + // bullets hand the tint off through the + // traveling bend: whichever hosts the bend is + // lit. + let on_thread = match thread_travel { + Some((bend, _, _, old_row, new_row)) + if ix == old_row || ix == new_row => + { + (0.0..1.0).contains(&(bend - ix as f32)) + } + _ => { + (accent.stub || accent.elbow_from.is_some()) && bend_covered + } + }; let bullet_color = if on_thread { THREAD_COLOR } else { MUTED_COLOR }; let dot = div().size(px(6.0)).rounded_full().bg(rgb(bullet_color)); let mut bullet = div() diff --git a/crates/trawler/src/ui_tests.rs b/crates/trawler/src/ui_tests.rs index b8917ad..2c0715e 100644 --- a/crates/trawler/src/ui_tests.rs +++ b/crates/trawler/src/ui_tests.rs @@ -37,6 +37,7 @@ fn open_app<'a>( ) -> (Entity, &'a mut VisualTestContext) { crate::set_scroll_animation_ms(0); crate::editor::set_caret_animation_ms(0); + crate::set_thread_animation_ms(0); let dir = fixture_dir(name); cx.update(|cx| { crate::editor::init(cx); @@ -843,6 +844,138 @@ async fn caret_reveal_eases_instead_of_jumping(cx: &mut gpui::TestAppContext) { crate::set_scroll_animation_ms(0); } +/// Sibling detection drives the translate-vs-retrace choice: equal paths +/// except the tip translate; reroutes (indent) and different depths +/// retrace through the fork. +#[test] +fn thread_sibling_detection() { + let n = |c| TreeID { + peer: 1, + counter: c, + }; + let (a, b, c, d) = (n(1), n(2), n(3), n(4)); + // Siblings under the same parent chain. + assert!(crate::thread_paths_are_siblings(&[a, b], &[a, c])); + assert!(crate::thread_paths_are_siblings(&[a, b, c], &[a, b, d])); + // Same tip, rerouted (indent) — not a sibling move. + assert!(!crate::thread_paths_are_siblings(&[a, c], &[a, b, c])); + // Different depths. + assert!(!crate::thread_paths_are_siblings(&[a, b], &[a, b, c])); + // Identical paths: nothing to translate. + assert!(!crate::thread_paths_are_siblings(&[a, b], &[a, b])); + // Too shallow to have a parent column. + assert!(!crate::thread_paths_are_siblings(&[b], &[c])); +} + +/// Same-tree focus transitions animate the thread (openspec change +/// animate-bullet-thread): moving between siblings starts a transition +/// that settles — with the fake clock driven — onto exactly the rendering +/// an instant re-thread produces (`thread_anim` drained, full window). +#[gpui::test] +async fn thread_animates_within_same_tree(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("thread-same-tree", cx); + cx.run_until_parked(); + open_page(cx, "trawler-design"); + cx.run_until_parked(); + + let a = block_by_content(&app, cx, "Keyboard-first outlining #project"); + let b = block_by_content(&app, cx, "Live Steel queries #project"); + focus_block(&app, cx, a); + cx.run_until_parked(); + + crate::set_thread_animation_ms(150); + focus_block(&app, cx, b); + cx.run_until_parked(); + app.update(cx, |app, _| { + assert!( + app.thread_anim.is_some(), + "sibling refocus under the same thread root must animate" + ); + }); + for _ in 0..20 { + cx.executor() + .advance_clock(std::time::Duration::from_millis(12)); + cx.run_until_parked(); + } + app.update(cx, |app, _| { + assert!( + app.thread_anim.is_none(), + "the transition must settle within its duration" + ); + let (focused, path) = app.last_thread.clone().expect("thread present"); + assert_eq!(focused, b, "thread baseline tracks the focused block"); + assert_eq!( + path.last().copied(), + Some(b), + "settled path tips at the focused block" + ); + }); + crate::set_thread_animation_ms(0); +} + +/// Indent/outdent reroutes the thread through the fork where the old and +/// new paths actually diverge — the same-tip-different-route case that a +/// naive deepest-shared-node fork gets wrong. +#[gpui::test] +async fn thread_animates_on_indent(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("thread-indent", cx); + cx.run_until_parked(); + open_page(cx, "trawler-design"); + cx.run_until_parked(); + + let block = block_by_content(&app, cx, "Live Steel queries #project"); + focus_block(&app, cx, block); + cx.run_until_parked(); + + crate::set_thread_animation_ms(150); + cx.simulate_keystrokes("tab"); + cx.run_until_parked(); + app.update(cx, |app, _| { + assert!( + app.thread_anim.is_some(), + "an indent reshapes the path and must animate" + ); + }); + for _ in 0..20 { + cx.executor() + .advance_clock(std::time::Duration::from_millis(12)); + cx.run_until_parked(); + } + app.update(cx, |app, _| { + assert!(app.thread_anim.is_none(), "indent transition settles"); + }); + crate::set_thread_animation_ms(0); +} + +/// Focusing a node that shares no path nodes with the current thread +/// (another page) swaps the thread instantly — no transition starts. +#[gpui::test] +async fn thread_snaps_for_unrelated_focus(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("thread-unrelated", cx); + cx.run_until_parked(); + open_page(cx, "trawler-design"); + cx.run_until_parked(); + let a = block_by_content(&app, cx, "Keyboard-first outlining #project"); + focus_block(&app, cx, a); + cx.run_until_parked(); + + crate::set_thread_animation_ms(150); + open_page(cx, "reading-list"); + cx.run_until_parked(); + let b = block_by_content(&app, cx, "The Sea Around Us #book"); + focus_block(&app, cx, b); + cx.run_until_parked(); + app.update(cx, |app, _| { + assert!( + app.thread_anim.is_none(), + "an unrelated focus must swap the thread with no animation" + ); + let (focused, _) = app.last_thread.clone().expect("thread present"); + assert_eq!(focused, b); + }); + crate::set_thread_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 diff --git a/openspec/changes/animate-bullet-thread/specs/outline-editor/spec.md b/openspec/changes/animate-bullet-thread/specs/outline-editor/spec.md index 143504a..728570c 100644 --- a/openspec/changes/animate-bullet-thread/specs/outline-editor/spec.md +++ b/openspec/changes/animate-bullet-thread/specs/outline-editor/spec.md @@ -22,7 +22,12 @@ ancestor paths share at least one node (the same thread tree — including creating a node, indenting/outdenting, or selecting another node under the same thread root), the thread SHALL animate: its tip retracts to the fork point and extends along the new path to the new bullet, with path bullets -tinting as the tip passes. When focus moves to a node unrelated to the +tinting as the tip passes. **Sibling moves are the exception**: when the +old and new paths are identical except for the final node, the +already-extended horizontal elbow SHALL translate vertically between the +two rows instead of retracting and replaying from the parent — replaying +strobed under rapid sibling navigation — with the sibling bullets handing +the tint off through the traveling bend. When focus moves to a node unrelated to the current thread tree (no shared path nodes), the thread SHALL appear immediately without animation. When the thread's top anchor rises to a higher visible ancestor, the top of the thread SHALL animate upward — in @@ -50,6 +55,14 @@ UI tests). path and flows to the new focused bullet over the configured duration, landing exactly on the same rendering an instant re-thread would produce +#### Scenario: Sibling walks glide the elbow +- **WHEN** the user moves focus between sibling nodes (same parent chain), + repeatedly and in either direction +- **THEN** the elbow translates vertically from the old sibling's bullet + to the new one's — the thread's ancestors stay put and nothing replays + from the parent, so rapid sibling navigation reads as one gliding bend + rather than a strobing re-extension + #### Scenario: Unrelated refocus is instant - **WHEN** the user focuses a node in a different subtree, page, or view that shares no path nodes with the current thread diff --git a/openspec/changes/animate-bullet-thread/tasks.md b/openspec/changes/animate-bullet-thread/tasks.md index 44e7720..c571620 100644 --- a/openspec/changes/animate-bullet-thread/tasks.md +++ b/openspec/changes/animate-bullet-thread/tasks.md @@ -2,49 +2,68 @@ ## 1. Path model and classification -- [ ] 1.1 Extract the thread path as an explicit node list (visible +- [x] 1.1 Extract the thread path as an explicit node list (visible ancestor chain, top anchor to focused bullet) with each on-path row carrying its interval index — the structure `RowAccent` rendering derives from, replacing the implicit per-render recompute -- [ ] 1.2 Transition classification on focus change: diff old/new path +- [x] 1.2 Transition classification on focus change: diff old/new path node lists — shared prefix non-empty → animated (compute fork index); empty → instant snap; detect rising/falling top anchor for the reverse-direction top animation (design D2) ## 2. Animation state and driving -- [ ] 2.1 `THREAD_ANIMATION_MS` thread-local knob (default ~150ms, 0 = - instant; `set_` test hook mirroring the scroll/caret knobs); - `thread_top`/`thread_tip` eased scalars on `TrawlerApp` with the - piecewise retract-through-fork remap (design D1/D2) -- [ ] 2.2 Tick task: 12ms steps, ease-out cubic, tick-accrued elapsed, - generation-cancelled by superseding transitions and bumped by - instant snaps; touches only the scalars + notify — no list/summary - reads (design D5) -- [ ] 2.3 Re-derive on structure changes: fold/unfold, row rebuilds, and +- [x] 2.1 `THREAD_ANIMATION_MS` thread-local knob (default 150ms, 0 = + instant; `set_` test hook mirroring the scroll/caret knobs). State + simplified from the planned two scalars to a single `ThreadAnim + { old_path, started }` — the window's `(top, tip)` pair derives per + render from the eased clock, so there is nothing stateful to drift + (design D1/D2). Fork refined during implementation: the last node + of the *aligned common run* rather than the deepest shared node — + an indent keeps the same tip while rerouting through a new + ancestor, and the naive fork made it a no-op animation +- [x] 2.2 Frame driving, revised on live evidence: the elapsed clock + reads `BackgroundExecutor::now()` (fake-clock-aware in tests, + wall-clock in production) rather than accruing per tick — the tick + task's 12ms timers starve under the caret glide's per-frame + rendering load (the same debug-build starvation the scroll animator + hit), which froze the re-thread until the glide finished. The task + now exists only to guarantee frames keep coming; ease-out cubic, + generation-cancelled, and it still touches nothing but notify — no + list/summary reads (design D5) +- [x] 2.3 Re-derive on structure changes: fold/unfold, row rebuilds, and view refreshes re-target in-flight transitions against the fresh path, snapping when the focused node left the model (design D3) ## 3. Rendering -- [ ] 3.1 Per-row clipped accent segments: verticals as height-fraction +- [x] 3.1 Per-row clipped accent segments: verticals as height-fraction divs against the current `[top, tip]` window; elbows pop at node crossings (design D4 — `overflow: hidden` clip wrapper held as the fallback if dev-loop review says the pop stutters) -- [ ] 3.2 Bullet (and fold-ring) tint keyed to window coverage of the +- [x] 3.2 Bullet (and fold-ring) tint keyed to window coverage of the node, so bullets light up as the tip passes and dim as it retracts +- [x] 3.3 Sibling translate (added on review feedback): when the old and + new paths are identical except the tip, the extended elbow + translates vertically between the two rows (`ThreadFrame:: + Translate` — traveling bend rendered at its fractional row + position, spine rendered to the deeper sibling and clipped at the + bend, static tip elbow suppressed, tint handed off through the + bend) instead of retracting to the parent and replaying, which + strobed under rapid sibling navigation; `thread_paths_are_siblings` + unit-tested against sibling/indent/depth-change/identical cases ## 4. Verification -- [ ] 4.1 UI tests (knob-gated, fake-clock driven like the scroll/caret +- [x] 4.1 UI tests (knob-gated, fake-clock driven like the scroll/caret animation tests): same-tree refocus animates through the fork and lands identical to an instant re-thread; unrelated refocus snaps with no intermediate frames; rising-root transition grows the top upward; `open_app` zeroes the knob so every other test is untouched -- [ ] 4.2 Dev-loop pass over the live app: split/indent/sibling-walk +- [x] 4.2 Dev-loop pass over the live app: split/indent/sibling-walk transitions, an unrelated cross-page focus, the rising-root scroll case, and a transition concurrent with an eased scroll (the common focus-moves-scroll case, design D5's independence claim); screenshots for review before commit -- [ ] 4.3 `cargo clippy --workspace --all-targets -- -D warnings` and +- [x] 4.3 `cargo clippy --workspace --all-targets -- -D warnings` and `cargo test --workspace` pass -- 2.51.2