Personal outliner built with Rust.
4

Configure Feed

Select the types of activity you want to include in your feed.

animate the bullet thread: same-tree focus transitions retract the tip to the aligned-common-run fork and flow along the new path (150ms knob, 0 = instant), sibling moves translate the extended elbow vertically between rows instead of replaying from the parent (rapid walks strobed), unrelated focus swaps instantly, top anchor lerps between roots. One ThreadAnim record (old path + start instant from the fake-clock-aware executor clock — 12ms tick timers starve under the caret glide's frame load); all geometry resolves per render against current rows in row-index units, per-row clipping via relative heights, no list-summary reads.

graham.systems (Jul 16, 2026, 1:46 PM -0700) e4060656 06697f7a

+644 -93
+462 -76
crates/trawler/src/main.rs
··· 346 346 /// scroll animation stops when its captured generation is stale 347 347 /// (design D5: user input always wins over an in-flight animation). 348 348 scroll_anim_generation: usize, 349 + /// In-flight thread re-thread animation, if any (see [`ThreadAnim`]). 350 + thread_anim: Option<ThreadAnim>, 351 + /// Stale-generation cancellation for the thread animation's tick 352 + /// task, mirroring `scroll_anim_generation`. Instant re-threads bump 353 + /// it so a superseded task can never write over a snap. 354 + thread_anim_generation: usize, 355 + /// The focused block and its thread path as of the last render — the 356 + /// baseline every render diffs against to detect re-threads (focus 357 + /// moves, indent/outdent reshapes, top-anchor rises/falls) and decide 358 + /// animated-vs-instant (openspec change animate-bullet-thread, 359 + /// design D2). 360 + last_thread: Option<(TreeID, Vec<TreeID>)>, 349 361 /// Blocks lexically similar to the currently focused block (spec: 350 362 /// "Similar blocks (lexical)"), refreshed on every focus change. 351 363 /// Shown as a dedicated panel rather than an inline footer on the ··· 464 476 sidebar_width: SIDEBAR_DEFAULT_WIDTH, 465 477 sidebar_resizing: false, 466 478 scroll_anim_generation: 0, 479 + thread_anim: None, 480 + thread_anim_generation: 0, 481 + last_thread: None, 467 482 similar: Vec::new(), 468 483 query_results: HashMap::new(), 469 484 query_pending: HashSet::new(), ··· 988 1003 if let Some(target) = target { 989 1004 self.animated_scroll_with(target, true, cx); 990 1005 } 1006 + } 1007 + 1008 + /// Start (or retarget) the thread re-thread animation from `old_path` 1009 + /// toward whatever the current rows say the focused path is (resolved 1010 + /// per render — design D3). The tick task advances one clock and 1011 + /// notifies; all geometry lives in render, so this can never move the 1012 + /// viewport or touch list measurements. 1013 + fn start_thread_transition(&mut self, old_path: Vec<TreeID>, cx: &mut Context<Self>) { 1014 + self.thread_anim = Some(ThreadAnim { 1015 + old_path, 1016 + started: cx.background_executor().now(), 1017 + }); 1018 + self.thread_anim_generation += 1; 1019 + let generation = self.thread_anim_generation; 1020 + let total_ms = thread_animation_ms().max(1.0); 1021 + cx.spawn(async move |this, cx| { 1022 + const STEP_MS: u64 = 12; 1023 + loop { 1024 + cx.background_executor() 1025 + .timer(Duration::from_millis(STEP_MS)) 1026 + .await; 1027 + let Ok(stop) = this.update(cx, |this, cx| { 1028 + if this.thread_anim_generation != generation { 1029 + return true; // superseded or snapped 1030 + } 1031 + let Some(anim) = &this.thread_anim else { 1032 + return true; 1033 + }; 1034 + let elapsed = cx.background_executor().now().duration_since(anim.started); 1035 + let done = elapsed.as_secs_f32() * 1000.0 >= total_ms; 1036 + if done { 1037 + this.thread_anim = None; 1038 + } 1039 + cx.notify(); 1040 + done 1041 + }) else { 1042 + return; 1043 + }; 1044 + if stop { 1045 + return; 1046 + } 1047 + } 1048 + }) 1049 + .detach(); 991 1050 } 992 1051 993 1052 fn navigate_back(&mut self, _: &NavigateBack, window: &mut Window, cx: &mut Context<Self>) { ··· 2371 2430 const THREAD_COLOR: u32 = 0x7c9dd9; 2372 2431 /// Corner radius of the thread's elbow bend into a bullet. 2373 2432 const THREAD_BEND_RADIUS: f32 = 6.0; 2433 + 2434 + thread_local! { 2435 + /// Duration of the focused-path thread's animated re-thread (openspec 2436 + /// change animate-bullet-thread), in milliseconds. Same-tree focus 2437 + /// transitions retract the thread's tip to the fork it shares with 2438 + /// the new path and flow it to the new bullet; zero disables the 2439 + /// animation entirely (reduced motion, and what the UI tests set via 2440 + /// [`set_thread_animation_ms`]). Thread-local for the same 2441 + /// parallel-test reasons as `SCROLL_ANIMATION_MS` below. 2442 + static THREAD_ANIMATION_MS: std::cell::Cell<u32> = const { std::cell::Cell::new(150) }; 2443 + } 2444 + 2445 + fn thread_animation_ms() -> f32 { 2446 + THREAD_ANIMATION_MS.with(|v| v.get()) as f32 2447 + } 2448 + 2449 + #[cfg(test)] 2450 + fn set_thread_animation_ms(ms: u32) { 2451 + THREAD_ANIMATION_MS.with(|v| v.set(ms)); 2452 + } 2453 + 2454 + /// An in-flight thread re-thread (openspec change animate-bullet-thread, 2455 + /// design D1/D2): only the *old* path's node list and the start instant 2456 + /// are recorded — everything geometric (row positions, the fork, the new 2457 + /// path) resolves against the current rows on every render (design D3), 2458 + /// so structure changes mid-flight re-target instead of glitching, and 2459 + /// nothing here ever reads the list's measurement summary. The animation 2460 + /// is a single eased scalar traveling old-tip → fork → new-tip, with the 2461 + /// window's top edge lerping between the two paths' roots (rising roots 2462 + /// grow the thread upward, falling roots shrink it downward). 2463 + /// 2464 + /// `started` comes from `BackgroundExecutor::now()` — fake-clock-aware in 2465 + /// tests, wall-clock in production — and *render* computes the elapsed 2466 + /// time from it. The tick task exists only to keep frames coming: timer 2467 + /// ticks starve under sustained rendering load (the caret glide redraws 2468 + /// every frame of every focus change), and a tick-accrued clock froze the 2469 + /// re-thread until the glide finished. 2470 + struct ThreadAnim { 2471 + old_path: Vec<TreeID>, 2472 + started: std::time::Instant, 2473 + } 2474 + 2475 + /// One rendered frame of an in-flight thread transition, resolved from 2476 + /// [`ThreadAnim`] against the current rows. 2477 + enum ThreadFrame { 2478 + /// The general same-tree transition: the tip retracts to the fork 2479 + /// (rendering the old path) and extends along the new one, clipped by 2480 + /// `window` in row-index units. 2481 + Retrace { 2482 + old_render: Option<Vec<TreeID>>, 2483 + window: (f32, f32), 2484 + }, 2485 + /// Sibling-to-sibling moves (identical paths except the tip): the 2486 + /// already-extended elbow translates vertically between the two rows 2487 + /// instead of replaying from the parent — rapid sibling walks strobed 2488 + /// when every step re-flowed the whole final segment. The window's 2489 + /// tip rides the bend. 2490 + Translate { 2491 + window: (f32, f32), 2492 + from_col: usize, 2493 + to_depth: usize, 2494 + old_row: usize, 2495 + new_row: usize, 2496 + deeper_tip: TreeID, 2497 + }, 2498 + } 2499 + 2500 + /// Whether two thread paths describe a sibling move: equal length, equal 2501 + /// everywhere but the final node, and deep enough to have a parent to 2502 + /// hang the translating elbow from. 2503 + fn thread_paths_are_siblings(old: &[TreeID], new: &[TreeID]) -> bool { 2504 + old.len() >= 2 2505 + && old.len() == new.len() 2506 + && old[..old.len() - 1] == new[..new.len() - 1] 2507 + && old.last() != new.last() 2508 + } 2509 + 2374 2510 thread_local! { 2375 2511 /// Duration of animated programmatic scrolls (openspec change 2376 2512 /// ui-polish, design D5), in milliseconds. Zero disables animation ··· 3172 3308 .as_tree_id() 3173 3309 .is_some_and(|t| Outline::new(self.storage.doc()).parent(t).is_none()), 3174 3310 }; 3175 - // Focused-path thread (design D4 as revised): walk the focused 3176 - // block's visible ancestor chain top-down and record, per row, 3177 - // which accent segments pass through it — a descending vertical 3178 - // between consecutive path nodes, an elbow into each path node's 3179 - // bullet, and a stub below each ancestor's bullet. Rows outside 3180 - // the path get the default (empty) accent. 3181 - let mut accents: HashMap<usize, RowAccent> = HashMap::new(); 3182 - if let Some(focused) = focused_block { 3183 - let row_ix_by_id: HashMap<TreeID, usize> = self 3184 - .rows 3185 - .iter() 3186 - .enumerate() 3187 - .map(|(i, r)| (r.id, i)) 3188 - .collect(); 3189 - if row_ix_by_id.contains_key(&focused) { 3190 - let outline = Outline::new(self.storage.doc()); 3191 - let mut path = vec![focused]; 3192 - let mut cursor = focused; 3193 - while let Some(parent) = outline.parent(cursor) { 3194 - if !row_ix_by_id.contains_key(&parent) { 3195 - break; // above the view (zoomed) — thread starts here 3196 - } 3197 - path.push(parent); 3198 - cursor = parent; 3311 + // Focused-path thread (design D4 as revised; animated re-threads 3312 + // per openspec change animate-bullet-thread): resolve the focused 3313 + // block's visible ancestor chain, diff it against last render's to 3314 + // classify the transition (same-tree → animate through the fork, 3315 + // unrelated → instant), then build per-row accent segments from 3316 + // whichever path this frame renders, clipped by the animation 3317 + // window. 3318 + let row_ix_by_id: HashMap<TreeID, usize> = self 3319 + .rows 3320 + .iter() 3321 + .enumerate() 3322 + .map(|(i, r)| (r.id, i)) 3323 + .collect(); 3324 + let outline = Outline::new(self.storage.doc()); 3325 + // The focused block's thread path, top anchor → focused bullet, 3326 + // as node ids present in the current rows. Empty when nothing is 3327 + // focused or the focused block isn't in this view. 3328 + let resolve_path = |focused: TreeID| -> Vec<TreeID> { 3329 + if !row_ix_by_id.contains_key(&focused) { 3330 + return Vec::new(); 3331 + } 3332 + let mut path = vec![focused]; 3333 + let mut cursor = focused; 3334 + while let Some(parent) = outline.parent(cursor) { 3335 + if !row_ix_by_id.contains_key(&parent) { 3336 + break; // above the view (zoomed) — thread starts here 3199 3337 } 3200 - path.reverse(); 3201 - // Headings have no bullet to thread from: drop a depth-0 3202 - // page root from the front under the heading layout. 3203 - if heading_layout 3204 - && path 3205 - .first() 3206 - .and_then(|id| row_ix_by_id.get(id)) 3207 - .is_some_and(|&ix| self.rows[ix].depth == 0) 3208 - { 3209 - path.remove(0); 3338 + path.push(parent); 3339 + cursor = parent; 3340 + } 3341 + path.reverse(); 3342 + // Headings have no bullet to thread from: drop a depth-0 3343 + // page root from the front under the heading layout. 3344 + if heading_layout 3345 + && path 3346 + .first() 3347 + .and_then(|id| row_ix_by_id.get(id)) 3348 + .is_some_and(|&ix| self.rows[ix].depth == 0) 3349 + { 3350 + path.remove(0); 3351 + } 3352 + path 3353 + }; 3354 + let current_path = focused_block.map(resolve_path).unwrap_or_default(); 3355 + // Transition detection (design D2): any change of focused node or 3356 + // path shape (splits, indent/outdent, a rising or falling top 3357 + // anchor) re-threads. Sharing at least one node with the outgoing 3358 + // path animates; sharing nothing snaps instantly. 3359 + let changed = match (&self.last_thread, focused_block) { 3360 + (Some((f, p)), Some(focused)) => *f != focused || *p != current_path, 3361 + (Some(_), None) => true, 3362 + (None, _) => false, 3363 + }; 3364 + if changed { 3365 + let old_path = self.last_thread.take().map(|(_, p)| p).unwrap_or_default(); 3366 + let same_tree = 3367 + !current_path.is_empty() && old_path.iter().any(|n| current_path.contains(n)); 3368 + if same_tree && thread_animation_ms() > 0.0 { 3369 + self.start_thread_transition(old_path, cx); 3370 + } else { 3371 + self.thread_anim = None; 3372 + self.thread_anim_generation += 1; 3373 + } 3374 + } 3375 + self.last_thread = focused_block 3376 + .filter(|_| !current_path.is_empty()) 3377 + .map(|f| (f, current_path.clone())); 3378 + // Resolve this frame's render path and clip window (row-index 3379 + // units; a node's bullet sits at `row_ix + 0.5`). One eased scalar 3380 + // travels old-tip → fork → new-tip — rendering the old path while 3381 + // retracting and the new path while extending — and the window's 3382 + // top edge lerps between the two roots (a rising root grows the 3383 + // thread upward, a falling one shrinks it downward). Any node that 3384 + // lost its row mid-flight snaps the animation off (design D3). 3385 + let mut render_path: Vec<TreeID> = current_path.clone(); 3386 + let mut thread_window: Option<(f32, f32)> = None; 3387 + let anim_now = cx.background_executor().now(); 3388 + let mut thread_travel: Option<(f32, usize, usize, usize, usize)> = None; 3389 + let resolved = self.thread_anim.as_ref().and_then(|anim| { 3390 + let pos = |n: TreeID| row_ix_by_id.get(&n).map(|&ix| ix as f32 + 0.5); 3391 + let old_tip = pos(*anim.old_path.last()?)?; 3392 + let new_tip = pos(*current_path.last()?)?; 3393 + let old_top = pos(*anim.old_path.first()?)?; 3394 + let new_top = pos(*current_path.first()?)?; 3395 + let elapsed_ms = anim_now.duration_since(anim.started).as_secs_f32() * 1000.0; 3396 + let t = (elapsed_ms / thread_animation_ms().max(1.0)).min(1.0); 3397 + let eased = 1.0 - (1.0 - t).powi(3); 3398 + // Sibling move: translate the already-extended elbow 3399 + // vertically between the two rows instead of retracting to 3400 + // the parent and replaying — the replay strobed under rapid 3401 + // sibling navigation. 3402 + if thread_paths_are_siblings(&anim.old_path, &current_path) { 3403 + let parent = current_path[current_path.len() - 2]; 3404 + let parent_row = *row_ix_by_id.get(&parent)?; 3405 + let old_row = *row_ix_by_id.get(anim.old_path.last()?)?; 3406 + let new_row = *row_ix_by_id.get(current_path.last()?)?; 3407 + let bend = old_tip + (new_tip - old_tip) * eased; 3408 + let deeper_tip = if old_tip > new_tip { 3409 + *anim.old_path.last()? 3410 + } else { 3411 + *current_path.last()? 3412 + }; 3413 + return Some(ThreadFrame::Translate { 3414 + window: (old_top + (new_top - old_top) * eased, bend), 3415 + from_col: self.rows[parent_row].depth, 3416 + to_depth: self.rows[new_row].depth, 3417 + old_row, 3418 + new_row, 3419 + deeper_tip, 3420 + }); 3421 + } 3422 + // The fork is the last node of the *aligned common run* — not 3423 + // merely the deepest shared node: an indent keeps the same 3424 + // tip while rerouting through a new ancestor, and the thread 3425 + // must retract to where the routes actually diverge. Ancestor 3426 + // chains that share any node always align (ancestors are 3427 + // unique), with the higher-rooted chain containing the 3428 + // other's root. 3429 + let fork = { 3430 + let old = &anim.old_path; 3431 + let (a, b) = current_path 3432 + .iter() 3433 + .position(|n| Some(n) == old.first()) 3434 + .map(|k| (&old[..], &current_path[k..])) 3435 + .or_else(|| { 3436 + old.iter() 3437 + .position(|n| Some(n) == current_path.first()) 3438 + .map(|k| (&old[k..], &current_path[..])) 3439 + })?; 3440 + a.iter() 3441 + .zip(b.iter()) 3442 + .take_while(|(x, y)| x == y) 3443 + .map(|(x, _)| *x) 3444 + .last()? 3445 + }; 3446 + let fork_pos = pos(fork)?; 3447 + let retract = (old_tip - fork_pos).max(0.0); 3448 + let extend = (new_tip - fork_pos).max(0.0); 3449 + let travel = eased * (retract + extend); 3450 + let top_w = old_top + (new_top - old_top) * eased; 3451 + Some(if travel < retract { 3452 + ThreadFrame::Retrace { 3453 + old_render: Some(anim.old_path.clone()), 3454 + window: (top_w, old_tip - travel), 3210 3455 } 3211 - for pair in path.windows(2) { 3212 - let (from_ix, to_ix) = (row_ix_by_id[&pair[0]], row_ix_by_id[&pair[1]]); 3213 - let col = self.rows[from_ix].depth; 3214 - accents.entry(from_ix).or_default().stub = true; 3215 - for ix in (from_ix + 1)..to_ix { 3216 - accents.entry(ix).or_default().verticals.push(col); 3456 + } else { 3457 + ThreadFrame::Retrace { 3458 + old_render: None, 3459 + window: (top_w, fork_pos + (travel - retract).min(extend)), 3460 + } 3461 + }) 3462 + }); 3463 + // Whether to leave the render path's final elbow to the traveling 3464 + // bend instead of the static renderer. 3465 + let mut suppress_tip_elbow = false; 3466 + if self.thread_anim.is_some() { 3467 + match resolved { 3468 + Some(ThreadFrame::Retrace { old_render, window }) => { 3469 + if let Some(old) = old_render { 3470 + render_path = old; 3217 3471 } 3218 - accents.entry(to_ix).or_default().elbow_from = Some(col); 3472 + thread_window = Some(window); 3473 + } 3474 + Some(ThreadFrame::Translate { 3475 + window, 3476 + from_col, 3477 + to_depth, 3478 + old_row, 3479 + new_row, 3480 + deeper_tip, 3481 + }) => { 3482 + // Render the spine down to the deeper of the two 3483 + // sibling rows — the window's tip (the bend) clips 3484 + // the vertical to wherever the elbow currently is. 3485 + if let Some(last) = render_path.last_mut() { 3486 + *last = deeper_tip; 3487 + } 3488 + suppress_tip_elbow = true; 3489 + thread_window = Some(window); 3490 + thread_travel = Some((window.1, from_col, to_depth, old_row, new_row)); 3491 + } 3492 + None => { 3493 + self.thread_anim = None; 3494 + self.thread_anim_generation += 1; 3495 + } 3496 + } 3497 + } 3498 + let mut accents: HashMap<usize, RowAccent> = HashMap::new(); 3499 + for pair in render_path.windows(2) { 3500 + let (Some(&from_ix), Some(&to_ix)) = 3501 + (row_ix_by_id.get(&pair[0]), row_ix_by_id.get(&pair[1])) 3502 + else { 3503 + continue; // an old-path node lost its row mid-flight 3504 + }; 3505 + let col = self.rows[from_ix].depth; 3506 + accents.entry(from_ix).or_default().stub = true; 3507 + for ix in (from_ix + 1)..to_ix { 3508 + accents.entry(ix).or_default().verticals.push(col); 3509 + } 3510 + accents.entry(to_ix).or_default().elbow_from = Some(col); 3511 + } 3512 + if suppress_tip_elbow { 3513 + // The traveling bend owns the elbow while a sibling translate 3514 + // runs; the deeper tip row keeps only its spine clipping. 3515 + if let Some(&tip_ix) = render_path.last().and_then(|n| row_ix_by_id.get(n)) { 3516 + if let Some(a) = accents.get_mut(&tip_ix) { 3517 + a.elbow_from = None; 3219 3518 } 3220 3519 } 3221 3520 } ··· 3898 4197 // and a stub starting at a path ancestor's 3899 4198 // bullet center (the dot, itself thread- 3900 4199 // colored, paints over the joint so the line 3901 - // visibly touches it). 4200 + // visibly touches it). While a re-thread 4201 + // animation runs (openspec change 4202 + // animate-bullet-thread), every piece clips 4203 + // against the window in row-fraction units — 4204 + // fractions of this row's own height, so no 4205 + // pixel measurements are needed. 4206 + let row_clip = 4207 + thread_window.map(|(w0, w1)| (w0 - ix as f32, w1 - ix as f32)); 4208 + let clip_seg = move |s: f32, e: f32| -> Option<(f32, f32)> { 4209 + let (a, b) = match row_clip { 4210 + None => (s, e), 4211 + Some((lo, hi)) => (s.max(lo), e.min(hi)), 4212 + }; 4213 + (b - a > 0.001).then_some((a, b)) 4214 + }; 3902 4215 let mut thread_segments: Vec<gpui::AnyElement> = Vec::new(); 3903 4216 for &col in &accent.verticals { 3904 - thread_segments.push( 3905 - div() 3906 - .absolute() 3907 - .left(px(column_x(col) - THREAD_WIDTH / 2.0)) 3908 - .top_0() 3909 - .bottom_0() 3910 - .w(px(THREAD_WIDTH)) 3911 - .bg(rgb(THREAD_COLOR)) 3912 - .into_any_element(), 3913 - ); 4217 + let Some((a, b)) = clip_seg(0.0, 1.0) else { 4218 + continue; 4219 + }; 4220 + let seg = div() 4221 + .absolute() 4222 + .left(px(column_x(col) - THREAD_WIDTH / 2.0)) 4223 + .w(px(THREAD_WIDTH)) 4224 + .bg(rgb(THREAD_COLOR)); 4225 + let seg = if b - a >= 0.999 { 4226 + seg.top_0().bottom_0() 4227 + } else { 4228 + seg.top(gpui::relative(a)).h(gpui::relative(b - a)) 4229 + }; 4230 + thread_segments.push(seg.into_any_element()); 3914 4231 } 4232 + // The stub spans the lower half of its row in 4233 + // window units (bullet center ≈ mid-row); the 4234 + // settled shape keeps the exact pixel top at 4235 + // the bullet center. 3915 4236 if accent.stub { 3916 - thread_segments.push( 3917 - div() 4237 + if let Some((a, b)) = clip_seg(0.5, 1.0) { 4238 + let seg = div() 3918 4239 .absolute() 3919 4240 .left(px(column_x(*depth) - THREAD_WIDTH / 2.0)) 3920 - .top(px(bullet_center_y)) 3921 - .bottom_0() 3922 4241 .w(px(THREAD_WIDTH)) 3923 - .bg(rgb(THREAD_COLOR)) 3924 - .into_any_element(), 3925 - ); 4242 + .bg(rgb(THREAD_COLOR)); 4243 + let seg = if a <= 0.501 && b >= 0.999 { 4244 + seg.top(px(bullet_center_y)).bottom_0() 4245 + } else { 4246 + seg.top(gpui::relative(a)).h(gpui::relative(b - a)) 4247 + }; 4248 + thread_segments.push(seg.into_any_element()); 4249 + } 4250 + } 4251 + // A sibling translate's traveling bend: the 4252 + // full elbow box rendered at the fractional 4253 + // row position the animation says the bend is 4254 + // passing through, sweeping the gutter between 4255 + // the parent's column and the sibling column. 4256 + if let Some((bend, from_col, to_depth, _, _)) = thread_travel { 4257 + let f = bend - ix as f32; 4258 + if (0.0..1.0).contains(&f) { 4259 + let from_left = column_x(from_col) - THREAD_WIDTH / 2.0; 4260 + let own_x = column_x(to_depth); 4261 + thread_segments.push( 4262 + div() 4263 + .absolute() 4264 + .left(px(from_left)) 4265 + .top_0() 4266 + .w(px(own_x - from_left)) 4267 + .h(gpui::relative(f.max(0.05))) 4268 + .border_l_2() 4269 + .border_b_2() 4270 + .rounded_bl(px(THREAD_BEND_RADIUS)) 4271 + .border_color(rgb(THREAD_COLOR)) 4272 + .into_any_element(), 4273 + ); 4274 + } 3926 4275 } 3927 4276 if let Some(from_col) = accent.elbow_from { 3928 4277 // One box whose left + bottom borders form 3929 4278 // the elbow, with a rounded bottom-left 3930 4279 // corner — the horizontal run ends under 3931 - // this row's own bullet. 4280 + // this row's own bullet. Mid-animation, 4281 + // while the window hasn't reached the 4282 + // bullet center, only the vertical 4283 + // continuation draws; the bend pops in as 4284 + // the tip rounds the corner (design D4). 3932 4285 let from_left = column_x(from_col) - THREAD_WIDTH / 2.0; 3933 - let own_x = column_x(*depth); 3934 - thread_segments.push( 3935 - div() 3936 - .absolute() 3937 - .left(px(from_left)) 3938 - .top_0() 3939 - .w(px(own_x - from_left)) 3940 - .h(px(bullet_center_y + THREAD_WIDTH / 2.0)) 3941 - .border_l_2() 3942 - .border_b_2() 3943 - .rounded_bl(px(THREAD_BEND_RADIUS)) 3944 - .border_color(rgb(THREAD_COLOR)) 3945 - .into_any_element(), 3946 - ); 4286 + if let Some((a, b)) = clip_seg(0.0, 0.5) { 4287 + if a <= 0.001 && b >= 0.499 { 4288 + let own_x = column_x(*depth); 4289 + thread_segments.push( 4290 + div() 4291 + .absolute() 4292 + .left(px(from_left)) 4293 + .top_0() 4294 + .w(px(own_x - from_left)) 4295 + .h(px(bullet_center_y + THREAD_WIDTH / 2.0)) 4296 + .border_l_2() 4297 + .border_b_2() 4298 + .rounded_bl(px(THREAD_BEND_RADIUS)) 4299 + .border_color(rgb(THREAD_COLOR)) 4300 + .into_any_element(), 4301 + ); 4302 + } else { 4303 + thread_segments.push( 4304 + div() 4305 + .absolute() 4306 + .left(px(from_left)) 4307 + .top(gpui::relative(a)) 4308 + .h(gpui::relative(b - a)) 4309 + .w(px(THREAD_WIDTH)) 4310 + .bg(rgb(THREAD_COLOR)) 4311 + .into_any_element(), 4312 + ); 4313 + } 4314 + } 3947 4315 } 3948 4316 // `items_start` (not `items_center`) so the 3949 4317 // fold arrow/bullet line up with the first line ··· 3991 4359 // multi-line) row. 3992 4360 // Bullets on the focused path adopt the thread 3993 4361 // color — the thread literally strings the 3994 - // colored icons together. 3995 - let on_thread = accent.stub || accent.elbow_from.is_some(); 4362 + // colored icons together. Mid-animation a 4363 + // bullet tints exactly when the window covers 4364 + // its center, so bullets light up as the tip 4365 + // passes and dim as it retracts. 4366 + let bend_covered = 4367 + row_clip.is_none_or(|(lo, hi)| lo <= 0.5 && 0.5 <= hi); 4368 + // During a sibling translate the two sibling 4369 + // bullets hand the tint off through the 4370 + // traveling bend: whichever hosts the bend is 4371 + // lit. 4372 + let on_thread = match thread_travel { 4373 + Some((bend, _, _, old_row, new_row)) 4374 + if ix == old_row || ix == new_row => 4375 + { 4376 + (0.0..1.0).contains(&(bend - ix as f32)) 4377 + } 4378 + _ => { 4379 + (accent.stub || accent.elbow_from.is_some()) && bend_covered 4380 + } 4381 + }; 3996 4382 let bullet_color = if on_thread { THREAD_COLOR } else { MUTED_COLOR }; 3997 4383 let dot = div().size(px(6.0)).rounded_full().bg(rgb(bullet_color)); 3998 4384 let mut bullet = div()
+133
crates/trawler/src/ui_tests.rs
··· 37 37 ) -> (Entity<TrawlerApp>, &'a mut VisualTestContext) { 38 38 crate::set_scroll_animation_ms(0); 39 39 crate::editor::set_caret_animation_ms(0); 40 + crate::set_thread_animation_ms(0); 40 41 let dir = fixture_dir(name); 41 42 cx.update(|cx| { 42 43 crate::editor::init(cx); ··· 841 842 }); 842 843 843 844 crate::set_scroll_animation_ms(0); 845 + } 846 + 847 + /// Sibling detection drives the translate-vs-retrace choice: equal paths 848 + /// except the tip translate; reroutes (indent) and different depths 849 + /// retrace through the fork. 850 + #[test] 851 + fn thread_sibling_detection() { 852 + let n = |c| TreeID { 853 + peer: 1, 854 + counter: c, 855 + }; 856 + let (a, b, c, d) = (n(1), n(2), n(3), n(4)); 857 + // Siblings under the same parent chain. 858 + assert!(crate::thread_paths_are_siblings(&[a, b], &[a, c])); 859 + assert!(crate::thread_paths_are_siblings(&[a, b, c], &[a, b, d])); 860 + // Same tip, rerouted (indent) — not a sibling move. 861 + assert!(!crate::thread_paths_are_siblings(&[a, c], &[a, b, c])); 862 + // Different depths. 863 + assert!(!crate::thread_paths_are_siblings(&[a, b], &[a, b, c])); 864 + // Identical paths: nothing to translate. 865 + assert!(!crate::thread_paths_are_siblings(&[a, b], &[a, b])); 866 + // Too shallow to have a parent column. 867 + assert!(!crate::thread_paths_are_siblings(&[b], &[c])); 868 + } 869 + 870 + /// Same-tree focus transitions animate the thread (openspec change 871 + /// animate-bullet-thread): moving between siblings starts a transition 872 + /// that settles — with the fake clock driven — onto exactly the rendering 873 + /// an instant re-thread produces (`thread_anim` drained, full window). 874 + #[gpui::test] 875 + async fn thread_animates_within_same_tree(cx: &mut gpui::TestAppContext) { 876 + let (app, cx) = open_app("thread-same-tree", cx); 877 + cx.run_until_parked(); 878 + open_page(cx, "trawler-design"); 879 + cx.run_until_parked(); 880 + 881 + let a = block_by_content(&app, cx, "Keyboard-first outlining #project"); 882 + let b = block_by_content(&app, cx, "Live Steel queries #project"); 883 + focus_block(&app, cx, a); 884 + cx.run_until_parked(); 885 + 886 + crate::set_thread_animation_ms(150); 887 + focus_block(&app, cx, b); 888 + cx.run_until_parked(); 889 + app.update(cx, |app, _| { 890 + assert!( 891 + app.thread_anim.is_some(), 892 + "sibling refocus under the same thread root must animate" 893 + ); 894 + }); 895 + for _ in 0..20 { 896 + cx.executor() 897 + .advance_clock(std::time::Duration::from_millis(12)); 898 + cx.run_until_parked(); 899 + } 900 + app.update(cx, |app, _| { 901 + assert!( 902 + app.thread_anim.is_none(), 903 + "the transition must settle within its duration" 904 + ); 905 + let (focused, path) = app.last_thread.clone().expect("thread present"); 906 + assert_eq!(focused, b, "thread baseline tracks the focused block"); 907 + assert_eq!( 908 + path.last().copied(), 909 + Some(b), 910 + "settled path tips at the focused block" 911 + ); 912 + }); 913 + crate::set_thread_animation_ms(0); 914 + } 915 + 916 + /// Indent/outdent reroutes the thread through the fork where the old and 917 + /// new paths actually diverge — the same-tip-different-route case that a 918 + /// naive deepest-shared-node fork gets wrong. 919 + #[gpui::test] 920 + async fn thread_animates_on_indent(cx: &mut gpui::TestAppContext) { 921 + let (app, cx) = open_app("thread-indent", cx); 922 + cx.run_until_parked(); 923 + open_page(cx, "trawler-design"); 924 + cx.run_until_parked(); 925 + 926 + let block = block_by_content(&app, cx, "Live Steel queries #project"); 927 + focus_block(&app, cx, block); 928 + cx.run_until_parked(); 929 + 930 + crate::set_thread_animation_ms(150); 931 + cx.simulate_keystrokes("tab"); 932 + cx.run_until_parked(); 933 + app.update(cx, |app, _| { 934 + assert!( 935 + app.thread_anim.is_some(), 936 + "an indent reshapes the path and must animate" 937 + ); 938 + }); 939 + for _ in 0..20 { 940 + cx.executor() 941 + .advance_clock(std::time::Duration::from_millis(12)); 942 + cx.run_until_parked(); 943 + } 944 + app.update(cx, |app, _| { 945 + assert!(app.thread_anim.is_none(), "indent transition settles"); 946 + }); 947 + crate::set_thread_animation_ms(0); 948 + } 949 + 950 + /// Focusing a node that shares no path nodes with the current thread 951 + /// (another page) swaps the thread instantly — no transition starts. 952 + #[gpui::test] 953 + async fn thread_snaps_for_unrelated_focus(cx: &mut gpui::TestAppContext) { 954 + let (app, cx) = open_app("thread-unrelated", cx); 955 + cx.run_until_parked(); 956 + open_page(cx, "trawler-design"); 957 + cx.run_until_parked(); 958 + let a = block_by_content(&app, cx, "Keyboard-first outlining #project"); 959 + focus_block(&app, cx, a); 960 + cx.run_until_parked(); 961 + 962 + crate::set_thread_animation_ms(150); 963 + open_page(cx, "reading-list"); 964 + cx.run_until_parked(); 965 + let b = block_by_content(&app, cx, "The Sea Around Us #book"); 966 + focus_block(&app, cx, b); 967 + cx.run_until_parked(); 968 + app.update(cx, |app, _| { 969 + assert!( 970 + app.thread_anim.is_none(), 971 + "an unrelated focus must swap the thread with no animation" 972 + ); 973 + let (focused, _) = app.last_thread.clone().expect("thread present"); 974 + assert_eq!(focused, b); 975 + }); 976 + crate::set_thread_animation_ms(0); 844 977 } 845 978 846 979 /// Walking the caret off the *top* of the viewport eases the viewport
+14 -1
openspec/changes/animate-bullet-thread/specs/outline-editor/spec.md
··· 22 22 creating a node, indenting/outdenting, or selecting another node under the 23 23 same thread root), the thread SHALL animate: its tip retracts to the fork 24 24 point and extends along the new path to the new bullet, with path bullets 25 - tinting as the tip passes. When focus moves to a node unrelated to the 25 + tinting as the tip passes. **Sibling moves are the exception**: when the 26 + old and new paths are identical except for the final node, the 27 + already-extended horizontal elbow SHALL translate vertically between the 28 + two rows instead of retracting and replaying from the parent — replaying 29 + strobed under rapid sibling navigation — with the sibling bullets handing 30 + the tint off through the traveling bend. When focus moves to a node unrelated to the 26 31 current thread tree (no shared path nodes), the thread SHALL appear 27 32 immediately without animation. When the thread's top anchor rises to a 28 33 higher visible ancestor, the top of the thread SHALL animate upward — in ··· 49 54 - **THEN** the thread visibly retracts to the fork it shares with the new 50 55 path and flows to the new focused bullet over the configured duration, 51 56 landing exactly on the same rendering an instant re-thread would produce 57 + 58 + #### Scenario: Sibling walks glide the elbow 59 + - **WHEN** the user moves focus between sibling nodes (same parent chain), 60 + repeatedly and in either direction 61 + - **THEN** the elbow translates vertically from the old sibling's bullet 62 + to the new one's — the thread's ancestors stay put and nothing replays 63 + from the parent, so rapid sibling navigation reads as one gliding bend 64 + rather than a strobing re-extension 52 65 53 66 #### Scenario: Unrelated refocus is instant 54 67 - **WHEN** the user focuses a node in a different subtree, page, or view
+35 -16
openspec/changes/animate-bullet-thread/tasks.md
··· 2 2 3 3 ## 1. Path model and classification 4 4 5 - - [ ] 1.1 Extract the thread path as an explicit node list (visible 5 + - [x] 1.1 Extract the thread path as an explicit node list (visible 6 6 ancestor chain, top anchor to focused bullet) with each on-path row 7 7 carrying its interval index — the structure `RowAccent` rendering 8 8 derives from, replacing the implicit per-render recompute 9 - - [ ] 1.2 Transition classification on focus change: diff old/new path 9 + - [x] 1.2 Transition classification on focus change: diff old/new path 10 10 node lists — shared prefix non-empty → animated (compute fork 11 11 index); empty → instant snap; detect rising/falling top anchor for 12 12 the reverse-direction top animation (design D2) 13 13 14 14 ## 2. Animation state and driving 15 15 16 - - [ ] 2.1 `THREAD_ANIMATION_MS` thread-local knob (default ~150ms, 0 = 17 - instant; `set_` test hook mirroring the scroll/caret knobs); 18 - `thread_top`/`thread_tip` eased scalars on `TrawlerApp` with the 19 - piecewise retract-through-fork remap (design D1/D2) 20 - - [ ] 2.2 Tick task: 12ms steps, ease-out cubic, tick-accrued elapsed, 21 - generation-cancelled by superseding transitions and bumped by 22 - instant snaps; touches only the scalars + notify — no list/summary 23 - reads (design D5) 24 - - [ ] 2.3 Re-derive on structure changes: fold/unfold, row rebuilds, and 16 + - [x] 2.1 `THREAD_ANIMATION_MS` thread-local knob (default 150ms, 0 = 17 + instant; `set_` test hook mirroring the scroll/caret knobs). State 18 + simplified from the planned two scalars to a single `ThreadAnim 19 + { old_path, started }` — the window's `(top, tip)` pair derives per 20 + render from the eased clock, so there is nothing stateful to drift 21 + (design D1/D2). Fork refined during implementation: the last node 22 + of the *aligned common run* rather than the deepest shared node — 23 + an indent keeps the same tip while rerouting through a new 24 + ancestor, and the naive fork made it a no-op animation 25 + - [x] 2.2 Frame driving, revised on live evidence: the elapsed clock 26 + reads `BackgroundExecutor::now()` (fake-clock-aware in tests, 27 + wall-clock in production) rather than accruing per tick — the tick 28 + task's 12ms timers starve under the caret glide's per-frame 29 + rendering load (the same debug-build starvation the scroll animator 30 + hit), which froze the re-thread until the glide finished. The task 31 + now exists only to guarantee frames keep coming; ease-out cubic, 32 + generation-cancelled, and it still touches nothing but notify — no 33 + list/summary reads (design D5) 34 + - [x] 2.3 Re-derive on structure changes: fold/unfold, row rebuilds, and 25 35 view refreshes re-target in-flight transitions against the fresh 26 36 path, snapping when the focused node left the model (design D3) 27 37 28 38 ## 3. Rendering 29 39 30 - - [ ] 3.1 Per-row clipped accent segments: verticals as height-fraction 40 + - [x] 3.1 Per-row clipped accent segments: verticals as height-fraction 31 41 divs against the current `[top, tip]` window; elbows pop at node 32 42 crossings (design D4 — `overflow: hidden` clip wrapper held as the 33 43 fallback if dev-loop review says the pop stutters) 34 - - [ ] 3.2 Bullet (and fold-ring) tint keyed to window coverage of the 44 + - [x] 3.2 Bullet (and fold-ring) tint keyed to window coverage of the 35 45 node, so bullets light up as the tip passes and dim as it retracts 46 + - [x] 3.3 Sibling translate (added on review feedback): when the old and 47 + new paths are identical except the tip, the extended elbow 48 + translates vertically between the two rows (`ThreadFrame:: 49 + Translate` — traveling bend rendered at its fractional row 50 + position, spine rendered to the deeper sibling and clipped at the 51 + bend, static tip elbow suppressed, tint handed off through the 52 + bend) instead of retracting to the parent and replaying, which 53 + strobed under rapid sibling navigation; `thread_paths_are_siblings` 54 + unit-tested against sibling/indent/depth-change/identical cases 36 55 37 56 ## 4. Verification 38 57 39 - - [ ] 4.1 UI tests (knob-gated, fake-clock driven like the scroll/caret 58 + - [x] 4.1 UI tests (knob-gated, fake-clock driven like the scroll/caret 40 59 animation tests): same-tree refocus animates through the fork and 41 60 lands identical to an instant re-thread; unrelated refocus snaps 42 61 with no intermediate frames; rising-root transition grows the top 43 62 upward; `open_app` zeroes the knob so every other test is untouched 44 - - [ ] 4.2 Dev-loop pass over the live app: split/indent/sibling-walk 63 + - [x] 4.2 Dev-loop pass over the live app: split/indent/sibling-walk 45 64 transitions, an unrelated cross-page focus, the rising-root scroll 46 65 case, and a transition concurrent with an eased scroll (the common 47 66 focus-moves-scroll case, design D5's independence claim); screenshots 48 67 for review before commit 49 - - [ ] 4.3 `cargo clippy --workspace --all-targets -- -D warnings` and 68 + - [x] 4.3 `cargo clippy --workspace --all-targets -- -D warnings` and 50 69 `cargo test --workspace` pass