Personal outliner built with Rust.
4

Configure Feed

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

ui-polish group 5: bullet threading — static indent guides (per-row ancestor data, inset starts, terminal segments through the last child's line) plus the focused-path accent thread à la the Logseq plugin: rounded elbows threading the bullet icons from the highest visible ancestor to the focused block, path bullets adopting the thread color.

Also fixes backspace-at-start merge (found in review, pre-existing): the target is now the visually previous row — a previous sibling's deepest visible descendant, or the parent for first children (previously undeletable) — guarded so a non-empty block never merges into a page title; three UI tests pin the cases.

graham.systems (Jul 15, 2026, 6:24 PM -0700) 81cc75ef 314f3b52

+488 -27
+291 -18
crates/trawler/src/main.rs
··· 179 179 /// source, shown verbatim rather than through Markdown) and cheap 180 180 /// enough to always fill in. 181 181 raw_content: String, 182 + /// Threading data (openspec change ui-polish, design D4): one entry per 183 + /// ancestor-path level below the pushed root — entry `j` is whether the 184 + /// path node at relative depth `j + 1` has a following sibling, i.e. 185 + /// whether that level's guide line continues below this row. The last 186 + /// entry describes this row itself. 187 + guides: Vec<bool>, 188 + /// Whether this row is its parent's first child — where its parent's 189 + /// guide line begins, which gets a small top inset. 190 + is_first_child: bool, 182 191 } 183 192 184 193 /// One entry in the backlinks panel: a referencing block, its containing ··· 1465 1474 } 1466 1475 1467 1476 /// Backspace at the start of a block (no selection): merge it into the 1468 - /// end of its previous sibling's content and focus that sibling there 1469 - /// (spec: "delete/merge with previous"). No-op if there's no previous 1470 - /// sibling, the block has children (matching `Outline::merge_block`'s 1471 - /// precondition — merging a block with descendants would orphan them), 1472 - /// or the block is a page root (its "siblings" are other top-level 1473 - /// pages — merging those together would silently delete a whole page 1474 - /// into another page's title, not merge outline content). 1477 + /// end of the *visually previous* block — the row directly above in 1478 + /// the rendered outline (a previous sibling's deepest visible 1479 + /// descendant, or the parent when this is a first child), matching the 1480 + /// Logseq convention (spec: "delete/merge with previous"). No-op if the 1481 + /// block has children (matching `Outline::merge_block`'s precondition — 1482 + /// merging a block with descendants would orphan them), if the block is 1483 + /// a page root, or if the target would be a page root while this block 1484 + /// still has content — appending text into a page *title* would 1485 + /// silently rename the page; an empty block, though, merges harmlessly 1486 + /// (pure deletion), which is how the first block of a page is removed. 1487 + /// 1488 + /// Fold-awareness falls out of using `self.rows`: a folded sibling's 1489 + /// hidden descendants are never rows, so the merge target is exactly 1490 + /// the block the user sees above the cursor. 1475 1491 /// 1476 1492 /// `BlockEditor` itself already confirmed the cursor was at position 0 1477 1493 /// with no selection before emitting `MergeWithPreviousRequested` — ··· 1487 1503 if !outline.children(Some(block)).is_empty() { 1488 1504 return; 1489 1505 } 1490 - let parent = outline.parent(block); 1491 - if parent.is_none() { 1506 + if outline.parent(block).is_none() { 1492 1507 return; 1493 1508 } 1494 - let siblings = outline.children(parent); 1495 - let Some(pos) = siblings.iter().position(|&s| s == block) else { 1509 + let Some(row_ix) = self.rows.iter().position(|r| r.id == block) else { 1496 1510 return; 1497 1511 }; 1498 - let Some(prev) = pos.checked_sub(1).map(|i| siblings[i]) else { 1512 + let Some(prev) = row_ix.checked_sub(1).map(|i| self.rows[i].id) else { 1499 1513 return; 1500 1514 }; 1515 + if outline.parent(prev).is_none() && !live_content.is_empty() { 1516 + return; 1517 + } 1501 1518 1502 1519 // `merge_block` reads content from the doc, not the live editor 1503 1520 // buffer — sync first so nothing typed since focus is lost. ··· 1821 1838 folded: &HashSet<TreeID>, 1822 1839 rows: &mut Vec<VisibleRow>, 1823 1840 ) { 1841 + let mut continues = Vec::new(); 1842 + push_subtree_inner(outline, id, depth, folded, rows, &mut continues, false); 1843 + } 1844 + 1845 + #[allow(clippy::too_many_arguments)] 1846 + fn push_subtree_inner( 1847 + outline: &Outline, 1848 + id: TreeID, 1849 + depth: usize, 1850 + folded: &HashSet<TreeID>, 1851 + rows: &mut Vec<VisibleRow>, 1852 + continues: &mut Vec<bool>, 1853 + is_first_child: bool, 1854 + ) { 1824 1855 let content = outline.content(id).unwrap_or_default(); 1825 1856 let children = outline.children(Some(id)); 1826 1857 let is_query = is_query_block(outline, id); ··· 1836 1867 folded: folded.contains(&id), 1837 1868 is_query, 1838 1869 raw_content: content, 1870 + guides: continues.clone(), 1871 + is_first_child, 1839 1872 }); 1840 1873 if folded.contains(&id) { 1841 1874 return; 1842 1875 } 1843 - for child in children { 1844 - push_subtree(outline, child, depth + 1, folded, rows); 1876 + let last = children.len().saturating_sub(1); 1877 + for (i, child) in children.into_iter().enumerate() { 1878 + // Threading data (openspec change ui-polish, design D4): entry `j` 1879 + // of a row's `guides` says whether the node at depth 1880 + // `root_depth + j + 1` on this row's ancestor path has a following 1881 + // sibling — i.e. whether the guide line through that ancestor's 1882 + // indent column continues below this row. 1883 + continues.push(i < last); 1884 + push_subtree_inner(outline, child, depth + 1, folded, rows, continues, i == 0); 1885 + continues.pop(); 1845 1886 } 1846 1887 } 1847 1888 ··· 1890 1931 /// Vertical padding above/below a page-root heading row. 1891 1932 const PAGE_HEADING_PAD_TOP: f32 = 10.0; 1892 1933 const PAGE_HEADING_PAD_BOTTOM: f32 = 2.0; 1934 + /// Bullet-threading guide lines (openspec change ui-polish, design D4): 1935 + /// hairlines in the indent columns connecting each parent's bullet to its 1936 + /// descendants. Width and color are the knobs; the x position derives from 1937 + /// the bullet column (each guide runs through its ancestor's bullet 1938 + /// center, half the 14px bullet box). 1939 + const GUIDE_WIDTH: f32 = 1.0; 1940 + const GUIDE_COLOR: u32 = 0x3a3a3a; 1941 + /// Inset below the first child's row top where its parent's guide begins. 1942 + const GUIDE_TOP_INSET: f32 = 4.0; 1943 + /// Horizontal center of the bullet column (half its 14px width) — where a 1944 + /// guide line vertically aligns under its bullet. 1945 + const BULLET_COLUMN_CENTER: f32 = 7.0; 1946 + /// The focused-path thread (design D4 as revised in review): an accent 1947 + /// line threading the bullet icons from the highest visible ancestor down 1948 + /// to the focused block's bullet, over the quiet gray guides. Bullets on 1949 + /// the path adopt the thread color. `THREAD_WIDTH` must stay in step with 1950 + /// the elbow's `border_l_2`/`border_b_2` calls (gpui's per-side border 1951 + /// helpers are fixed-step). 1952 + const THREAD_WIDTH: f32 = 2.0; 1953 + const THREAD_COLOR: u32 = 0x7c9dd9; 1954 + /// Corner radius of the thread's elbow bend into a bullet. 1955 + const THREAD_BEND_RADIUS: f32 = 6.0; 1893 1956 /// A query block's own row gets this background tint — without it a query 1894 1957 /// block was indistinguishable from an ordinary one. Deliberately applied 1895 1958 /// only to the row, not the result section below it too: tinting both ··· 2521 2584 content: RowContent, 2522 2585 is_query: bool, 2523 2586 query_outcome: Option<QueryOutcome>, 2587 + guides: Vec<bool>, 2588 + is_first_child: bool, 2589 + accent: RowAccent, 2590 + } 2591 + 2592 + /// The focused-path thread's contribution to one row (design D4 as 2593 + /// revised): precomputed in `render` from the focused block's ancestor 2594 + /// chain, so each virtualized row draws its accent segments without 2595 + /// knowing the rest of the path. 2596 + #[derive(Clone, Default)] 2597 + struct RowAccent { 2598 + /// Tree-depth columns whose accent line passes full-height through 2599 + /// this row (the thread descending between two path nodes). 2600 + verticals: Vec<usize>, 2601 + /// This row is a path node: the thread arrives from its parent's 2602 + /// column (the value, a tree depth) and elbows horizontally into this 2603 + /// row's own bullet. 2604 + elbow_from: Option<usize>, 2605 + /// This row is a path ancestor: the thread starts below its bullet. 2606 + stub: bool, 2524 2607 } 2525 2608 2526 2609 /// A window caption button (min/max/close) for the client-drawn titlebar. ··· 2564 2647 .as_tree_id() 2565 2648 .is_some_and(|t| Outline::new(self.storage.doc()).parent(t).is_none()), 2566 2649 }; 2650 + // Focused-path thread (design D4 as revised): walk the focused 2651 + // block's visible ancestor chain top-down and record, per row, 2652 + // which accent segments pass through it — a descending vertical 2653 + // between consecutive path nodes, an elbow into each path node's 2654 + // bullet, and a stub below each ancestor's bullet. Rows outside 2655 + // the path get the default (empty) accent. 2656 + let mut accents: HashMap<usize, RowAccent> = HashMap::new(); 2657 + if let Some(focused) = focused_block { 2658 + let row_ix_by_id: HashMap<TreeID, usize> = self 2659 + .rows 2660 + .iter() 2661 + .enumerate() 2662 + .map(|(i, r)| (r.id, i)) 2663 + .collect(); 2664 + if row_ix_by_id.contains_key(&focused) { 2665 + let outline = Outline::new(self.storage.doc()); 2666 + let mut path = vec![focused]; 2667 + let mut cursor = focused; 2668 + while let Some(parent) = outline.parent(cursor) { 2669 + if !row_ix_by_id.contains_key(&parent) { 2670 + break; // above the view (zoomed) — thread starts here 2671 + } 2672 + path.push(parent); 2673 + cursor = parent; 2674 + } 2675 + path.reverse(); 2676 + // Headings have no bullet to thread from: drop a depth-0 2677 + // page root from the front under the heading layout. 2678 + if heading_layout 2679 + && path 2680 + .first() 2681 + .and_then(|id| row_ix_by_id.get(id)) 2682 + .is_some_and(|&ix| self.rows[ix].depth == 0) 2683 + { 2684 + path.remove(0); 2685 + } 2686 + for pair in path.windows(2) { 2687 + let (from_ix, to_ix) = (row_ix_by_id[&pair[0]], row_ix_by_id[&pair[1]]); 2688 + let col = self.rows[from_ix].depth; 2689 + accents.entry(from_ix).or_default().stub = true; 2690 + for ix in (from_ix + 1)..to_ix { 2691 + accents.entry(ix).or_default().verticals.push(col); 2692 + } 2693 + accents.entry(to_ix).or_default().elbow_from = Some(col); 2694 + } 2695 + } 2696 + } 2567 2697 let rows: Vec<RowSnapshot> = self 2568 2698 .rows 2569 2699 .iter() 2570 - .map(|r| { 2700 + .enumerate() 2701 + .map(|(row_ix, r)| { 2571 2702 let content = if Some(r.id) == focused_block { 2572 2703 RowContent::Editor(editor_input.clone().expect("focused block has an editor")) 2573 2704 } else if r.is_query { ··· 2583 2714 content, 2584 2715 is_query: r.is_query, 2585 2716 query_outcome: self.query_results.get(&r.id).cloned(), 2717 + guides: r.guides.clone(), 2718 + is_first_child: r.is_first_child, 2719 + accent: accents.get(&row_ix).cloned().unwrap_or_default(), 2586 2720 } 2587 2721 }) 2588 2722 .collect(); ··· 3073 3207 content, 3074 3208 is_query, 3075 3209 query_outcome, 3210 + guides, 3211 + is_first_child, 3212 + accent, 3076 3213 } = &rows[ix]; 3077 3214 let id = *id; 3078 3215 let is_query = *is_query; ··· 3142 3279 } else { 3143 3280 *depth 3144 3281 }; 3282 + // Threading guides (design D4): a hairline in 3283 + // each indent column whose ancestor chain 3284 + // continues below this row, computed per row so 3285 + // virtualization never needs a whole-tree pass. 3286 + // Bullets at tree depth `t` sit in visual 3287 + // column `t - shift` (headings occupy no 3288 + // column); the guide through them is drawn on 3289 + // this row iff the path node at depth `t + 1` 3290 + // (`guides[t]`) has a following sibling — 3291 + // except the innermost (parent) column, which 3292 + // always draws: full-height when this row has 3293 + // later siblings, half-height into a last 3294 + // child's bullet line so the thread visibly 3295 + // terminates there. 3296 + let shift = usize::from(heading_layout); 3297 + // A terminating guide runs to the bottom of the 3298 + // last child's first text line (through its 3299 + // bullet — the dot paints over the line), not 3300 + // just to its center. 3301 + let terminal_guide_height = 3302 + f32::from(line_height) + BULLET_OPTICAL_NUDGE + 4.0; 3303 + // X of the bullet-dot center for a bullet at 3304 + // `tree_depth`; every line centers on this 3305 + // (subtracting half its own width). 3306 + let column_x = |tree_depth: usize| { 3307 + INDENT_PER_LEVEL * tree_depth.saturating_sub(shift) as f32 3308 + + OUTLINE_LEFT_PAD 3309 + + BULLET_COLUMN_CENTER 3310 + }; 3311 + // Y of the bullet-dot center within the row 3312 + // (py_1 top padding + half the first line box). 3313 + let bullet_center_y = 3314 + 4.0 + BULLET_OPTICAL_NUDGE + f32::from(line_height) / 2.0; 3315 + // Guides start at the top edge of the first 3316 + // child's row (no stub below the parent's own 3317 + // bullet), and end through the last child's 3318 + // first line. 3319 + let guide_segments: Vec<gpui::AnyElement> = (shift..*depth) 3320 + .filter_map(|t| { 3321 + let continues = guides.get(t).copied().unwrap_or(false); 3322 + let is_parent_col = t == depth - 1; 3323 + if !continues && !is_parent_col { 3324 + return None; 3325 + } 3326 + // Where the thread's elbow lands on a 3327 + // terminating line, the accent bend 3328 + // replaces the gray tail entirely. 3329 + if !continues && accent.elbow_from == Some(t) { 3330 + return None; 3331 + } 3332 + // The guide's very first segment (on 3333 + // the parent's first child) starts a 3334 + // touch below the row edge. 3335 + let top = if is_parent_col && *is_first_child { 3336 + GUIDE_TOP_INSET 3337 + } else { 3338 + 0.0 3339 + }; 3340 + let seg = div() 3341 + .absolute() 3342 + .left(px(column_x(t) - GUIDE_WIDTH / 2.0)) 3343 + .top(px(top)) 3344 + .w(px(GUIDE_WIDTH)) 3345 + .bg(rgb(GUIDE_COLOR)); 3346 + Some( 3347 + if continues { 3348 + seg.bottom_0() 3349 + } else { 3350 + seg.h(px(terminal_guide_height - top)) 3351 + } 3352 + .into_any_element(), 3353 + ) 3354 + }) 3355 + .collect(); 3356 + // Focused-path thread (design D4 as revised): 3357 + // accent segments over the gray guides — a 3358 + // descending vertical per traversed column, a 3359 + // rounded elbow into a path node's own bullet, 3360 + // and a stub starting at a path ancestor's 3361 + // bullet center (the dot, itself thread- 3362 + // colored, paints over the joint so the line 3363 + // visibly touches it). 3364 + let mut thread_segments: Vec<gpui::AnyElement> = Vec::new(); 3365 + for &col in &accent.verticals { 3366 + thread_segments.push( 3367 + div() 3368 + .absolute() 3369 + .left(px(column_x(col) - THREAD_WIDTH / 2.0)) 3370 + .top_0() 3371 + .bottom_0() 3372 + .w(px(THREAD_WIDTH)) 3373 + .bg(rgb(THREAD_COLOR)) 3374 + .into_any_element(), 3375 + ); 3376 + } 3377 + if accent.stub { 3378 + thread_segments.push( 3379 + div() 3380 + .absolute() 3381 + .left(px(column_x(*depth) - THREAD_WIDTH / 2.0)) 3382 + .top(px(bullet_center_y)) 3383 + .bottom_0() 3384 + .w(px(THREAD_WIDTH)) 3385 + .bg(rgb(THREAD_COLOR)) 3386 + .into_any_element(), 3387 + ); 3388 + } 3389 + if let Some(from_col) = accent.elbow_from { 3390 + // One box whose left + bottom borders form 3391 + // the elbow, with a rounded bottom-left 3392 + // corner — the horizontal run ends under 3393 + // this row's own bullet. 3394 + let from_left = column_x(from_col) - THREAD_WIDTH / 2.0; 3395 + let own_x = column_x(*depth); 3396 + thread_segments.push( 3397 + div() 3398 + .absolute() 3399 + .left(px(from_left)) 3400 + .top_0() 3401 + .w(px(own_x - from_left)) 3402 + .h(px(bullet_center_y + THREAD_WIDTH / 2.0)) 3403 + .border_l_2() 3404 + .border_b_2() 3405 + .rounded_bl(px(THREAD_BEND_RADIUS)) 3406 + .border_color(rgb(THREAD_COLOR)) 3407 + .into_any_element(), 3408 + ); 3409 + } 3145 3410 // `items_start` (not `items_center`) so the 3146 3411 // fold arrow/bullet line up with the first line 3147 3412 // of the content instead of the vertical center ··· 3150 3415 // block, a code fence, a heading). 3151 3416 let mut row = div() 3152 3417 .id(ix) 3418 + .relative() 3153 3419 .w_full() 3154 3420 .pl(px(INDENT_PER_LEVEL * indent_depth as f32 + OUTLINE_LEFT_PAD)) 3155 3421 .pr_2() ··· 3157 3423 .flex() 3158 3424 .flex_row() 3159 3425 .items_start() 3160 - .gap(px(BULLET_TEXT_GAP)); 3426 + .gap(px(BULLET_TEXT_GAP)) 3427 + .children(guide_segments) 3428 + .children(thread_segments); 3161 3429 if is_query { 3162 3430 // A query block otherwise looks exactly 3163 3431 // like a plain outline row containing ··· 3183 3451 // height so the dot centers on the row's first 3184 3452 // text line, not the whole (possibly 3185 3453 // multi-line) row. 3186 - let dot = div().size(px(6.0)).rounded_full().bg(rgb(MUTED_COLOR)); 3454 + // Bullets on the focused path adopt the thread 3455 + // color — the thread literally strings the 3456 + // colored icons together. 3457 + let on_thread = accent.stub || accent.elbow_from.is_some(); 3458 + let bullet_color = if on_thread { THREAD_COLOR } else { MUTED_COLOR }; 3459 + let dot = div().size(px(6.0)).rounded_full().bg(rgb(bullet_color)); 3187 3460 let mut bullet = div() 3188 3461 .id(("bullet", ix)) 3189 3462 .w(px(14.0)) ··· 3206 3479 .size(px(12.0)) 3207 3480 .rounded_full() 3208 3481 .border_1() 3209 - .border_color(rgb(MUTED_COLOR)) 3482 + .border_color(rgb(bullet_color)) 3210 3483 .flex() 3211 3484 .items_center() 3212 3485 .justify_center()
+134
crates/trawler/src/ui_tests.rs
··· 515 515 ); 516 516 }); 517 517 } 518 + 519 + // --- bullet threading (openspec change ui-polish, design D4) --------------- 520 + 521 + /// The per-row guide data (which ancestor chains continue below each row) 522 + /// computed for the fixture journal page's known shape. Rendering is 523 + /// checked via dev-loop screenshots; this pins the data the hairlines are 524 + /// drawn from. 525 + #[gpui::test] 526 + async fn threading_guides_match_fixture_structure(cx: &mut gpui::TestAppContext) { 527 + let (app, cx) = open_app("threading-guides", cx); 528 + cx.run_until_parked(); 529 + 530 + let guides_of = |app: &Entity<TrawlerApp>, cx: &mut VisualTestContext, content: &str| { 531 + app.update(cx, |app, _| { 532 + let row = app 533 + .rows 534 + .iter() 535 + .find(|r| r.raw_content == content) 536 + .unwrap_or_else(|| panic!("row {content:?} not visible")); 537 + row.guides.clone() 538 + }) 539 + }; 540 + 541 + // "Started..." is the journal page's first child with siblings after it. 542 + assert_eq!( 543 + guides_of(&app, cx, "Started the trawler dogfood log #trawler"), 544 + vec![true] 545 + ); 546 + // "Outlined..." sits under Deep work (the page's LAST child → false) 547 + // and is followed by a sibling (→ true). 548 + assert_eq!( 549 + guides_of(&app, cx, "Outlined the fixture graph #project"), 550 + vec![false, true] 551 + ); 552 + // "Follow up..." is a last child of a last child of a last child. 553 + assert_eq!( 554 + guides_of(&app, cx, "Follow up in [[reading-list]]"), 555 + vec![false, false, false] 556 + ); 557 + } 558 + 559 + // --- visually-previous merge (backspace at start) --------------------------- 560 + 561 + /// Backspace at the start of a block whose previous *sibling* has visible 562 + /// descendants merges into the deepest such descendant — the row the user 563 + /// sees directly above — not into the sibling itself. 564 + #[gpui::test] 565 + async fn backspace_merges_into_visually_previous_row(cx: &mut gpui::TestAppContext) { 566 + let (app, cx) = open_app("backspace-visual-prev", cx); 567 + open_page(cx, "trawler-design"); 568 + 569 + // The query block follows "Tasks", whose last child is "Wire dev 570 + // automation #project" — the visually previous row. 571 + let query = block_by_content(&app, cx, trawler_core::fixtures::FIXTURE_QUERY_EXPR); 572 + let target = block_by_content(&app, cx, "Wire dev automation #project"); 573 + 574 + focus_block(&app, cx, query); 575 + cx.simulate_keystrokes("home"); 576 + cx.simulate_keystrokes("backspace"); 577 + 578 + assert_eq!( 579 + stored_content(&app, cx, target), 580 + format!( 581 + "Wire dev automation #project{}", 582 + trawler_core::fixtures::FIXTURE_QUERY_EXPR 583 + ), 584 + "content joins the visually previous row, not the previous sibling" 585 + ); 586 + assert_eq!(focused_block(&app, cx), target); 587 + assert_eq!( 588 + focused_cursor_offset(&app, cx), 589 + "Wire dev automation #project".len() 590 + ); 591 + } 592 + 593 + /// Backspace at the start of a *first child* merges into its parent — the 594 + /// visually previous row — instead of no-op'ing (previously undeletable). 595 + #[gpui::test] 596 + async fn backspace_on_first_child_merges_into_parent(cx: &mut gpui::TestAppContext) { 597 + let (app, cx) = open_app("backspace-first-child", cx); 598 + open_page(cx, "trawler-design"); 599 + 600 + let first_child = block_by_content(&app, cx, "Keyboard-first outlining #project"); 601 + let parent = block_by_content(&app, cx, "Goals"); 602 + 603 + focus_block(&app, cx, first_child); 604 + cx.simulate_keystrokes("home"); 605 + cx.simulate_keystrokes("backspace"); 606 + 607 + assert_eq!( 608 + stored_content(&app, cx, parent), 609 + "GoalsKeyboard-first outlining #project" 610 + ); 611 + assert_eq!(focused_block(&app, cx), parent); 612 + } 613 + 614 + /// The first block of a page: an *empty* one deletes into the page root 615 + /// (harmless — nothing is appended to the title); a non-empty one is a 616 + /// no-op, since merging real content into a page title would silently 617 + /// rename the page. 618 + #[gpui::test] 619 + async fn backspace_on_page_first_block_deletes_only_when_empty(cx: &mut gpui::TestAppContext) { 620 + let (app, cx) = open_app("backspace-into-page-root", cx); 621 + cx.run_until_parked(); 622 + 623 + // The app opens focused on today's page's single empty block. 624 + let block = focused_block(&app, cx); 625 + let page = parent_of(&app, cx, block).expect("today's block has a page parent"); 626 + 627 + // Non-empty: refused. 628 + cx.simulate_input("keep me"); 629 + cx.simulate_keystrokes("home"); 630 + cx.simulate_keystrokes("backspace"); 631 + assert!( 632 + children_of(&app, cx, Some(page)).contains(&block), 633 + "non-empty first block must not merge into the page title" 634 + ); 635 + 636 + // Emptied: backspace deletes the block and lands on the page root. 637 + cx.simulate_keystrokes("end"); 638 + for _ in 0.."keep me".len() { 639 + cx.simulate_keystrokes("backspace"); 640 + } 641 + cx.simulate_keystrokes("backspace"); // now at start, empty 642 + assert!( 643 + children_of(&app, cx, Some(page)).is_empty(), 644 + "empty first block deletes into the page root" 645 + ); 646 + assert_eq!(focused_block(&app, cx), page); 647 + assert!( 648 + !stored_content(&app, cx, page).contains("keep me"), 649 + "page title must be untouched" 650 + ); 651 + }
+14
openspec/changes/ui-polish/design.md
··· 82 82 83 83 ### D4 — Threading: per-row ancestor guides, virtualization-friendly 84 84 85 + > **Revised during review.** The static guides below shipped as the quiet 86 + > background layer, refined per review: guides start slightly inset below 87 + > the first child's row top (`GUIDE_TOP_INSET`), terminating segments run 88 + > through the last child's bullet to the bottom of its first line, and the 89 + > gray tail is suppressed where the thread's elbow terminates. The 90 + > centerpiece the proposer actually intended (à la the Logseq 91 + > bullet-threading plugin) was added on top: a **focused-path thread** — 92 + > an accent line (`THREAD_COLOR`/`THREAD_WIDTH`, rounded elbows via 93 + > `THREAD_BEND_RADIUS`) threading the bullet icons from the highest 94 + > visible bulleted ancestor to the focused block's bullet, with path 95 + > bullets adopting the thread color. Precomputed per render from the 96 + > focused block's visible ancestor chain into per-row segments 97 + > (`RowAccent`), so virtualization still needs no whole-tree pass. 98 + 85 99 Standard tree-guide algorithm, computed per row so it works inside the 86 100 virtualized `list`: for each visible row, for each ancestor level, draw a 87 101 vertical segment in that level's indent column if the ancestor chain
+46 -6
openspec/changes/ui-polish/specs/outline-editor/spec.md
··· 2 2 3 3 ## ADDED Requirements 4 4 5 - ### Requirement: Hierarchy guides 6 - Rendered outline rows SHALL display vertical guide lines in their indent 7 - columns connecting each parent's bullet to its descendant rows, so a block's 8 - ancestor chain is visually traceable at any depth. Guides MUST render 5 + ### Requirement: Hierarchy guides and the focused-path thread 6 + Rendered outline rows SHALL display two layers of threading (revised during 7 + review from a single-guide design). **Static guides**: vertical hairlines in 8 + each indent column whose ancestor chain continues below the row, starting 9 + slightly inset below the first child's row top and, on a terminating line, 10 + running through the last child's bullet to the bottom of its first text 11 + line. **Focused-path thread**: when a block is focused, an accent-colored 12 + line SHALL thread the bullet icons from the highest visible bulleted 13 + ancestor down to the focused block's bullet — descending verticals through 14 + traversed columns, a rounded elbow into each path node's bullet, the line 15 + visibly touching the bullets it connects — and bullets on the path SHALL 16 + adopt the thread color. Where a thread elbow lands on a terminating guide, 17 + the gray tail is suppressed in favor of the bend. Both layers MUST render 9 18 correctly under virtualization (computed per visible row, no whole-tree 10 19 pass) and respect the outline's named layout knobs. 11 20 12 21 #### Scenario: Deep block is visually anchored 13 22 - **WHEN** a block nested four levels deep is visible 14 23 - **THEN** each of its four indent columns shows a guide segment exactly when 15 - the corresponding ancestor chain continues at that level, and the innermost 16 - guide connects toward its parent's bullet 24 + the corresponding ancestor chain continues at that level 25 + 26 + #### Scenario: Thread follows focus 27 + - **WHEN** a block nested several levels deep is focused 28 + - **THEN** an accent line runs from the highest visible bulleted ancestor 29 + through each intermediate ancestor's bullet, bending into the focused 30 + block's bullet, with every bullet on that path tinted the thread color — 31 + and moving focus elsewhere re-threads accordingly 32 + 33 + ## MODIFIED Requirements 34 + 35 + ### Requirement: Keyboard-complete outline manipulation 36 + All outline operations SHALL be executable without the mouse: create sibling (Enter), insert a newline within the block (Shift+Enter), split block at cursor, indent/outdent (Tab/Shift+Tab), move block up/down among siblings, delete/merge with previous (Backspace at start), and fold/unfold subtree. Backspace at the start of a childless block SHALL merge it into the *visually previous* row — a previous sibling's deepest visible descendant, or the parent when the block is a first child — with the exception that merging into a page root is permitted only when the block is empty (pure deletion), never appending content into a page title. 37 + 38 + #### Scenario: Indent under previous sibling 39 + - **WHEN** the cursor is in a block and the user presses Tab 40 + - **THEN** the block (with its subtree) becomes the last child of its previous sibling, and the cursor position within the text is preserved 41 + 42 + #### Scenario: Newline within a block vs. new block 43 + - **WHEN** the user presses Shift+Enter mid-block, types a second paragraph, then presses Enter 44 + - **THEN** the block contains both paragraphs, and a new empty sibling block is created and focused 45 + 46 + #### Scenario: Fold hides descendants 47 + - **WHEN** the user folds a block with descendants 48 + - **THEN** descendants are hidden, a fold indicator is shown, and keyboard navigation skips the hidden blocks 49 + 50 + #### Scenario: Merge follows the eye 51 + - **WHEN** the cursor is at the start of a block whose previous sibling has visible descendants and the user presses Backspace 52 + - **THEN** the block's content joins the end of the deepest visible descendant — the row rendered directly above — and focus lands at the join point 53 + 54 + #### Scenario: First children are deletable 55 + - **WHEN** the cursor is at the start of a first child block and the user presses Backspace 56 + - **THEN** the block merges into its parent (an empty block simply disappears), except when the parent is a page root and the block still has content, which is refused rather than renaming the page 17 57 18 58 ### Requirement: Animated programmatic scrolling 19 59 Programmatic scroll changes (navigation history restore, follow-reference,
+3 -3
openspec/changes/ui-polish/tasks.md
··· 23 23 - [x] 4.2 Heading is the same editable root block: renames render in the heading at heading scale; quick-open navigation focuses it; row order unchanged so Up from first child lands on it. Fixed a latent BlockEditor bug this exposed: the Taffy measure closure read `window.text_style()` outside the ancestor style scope, so an editor in any styled wrapper measured at the base style while painting at the cascaded one (~8px row shrink on heading focus, children shifting up) — request_layout now captures the style inside the scope and the closure uses the captured values 24 24 - [x] 4.3 No UI-test assertion churn needed: depth stays tree depth everywhere (`VisibleRow`, dump) because the shift is render-only — verified via dump (pages 0, children 1); dev-loop screenshots of journal and page views, including focused-vs-unfocused layout stability 25 25 26 - ## 5. Bullet threading (design D4) — after the heading change, so guides are built against final indent geometry 26 + ## 5. Bullet threading (design D4, revised in review) — after the heading change, so guides are built against final indent geometry 27 27 28 - - [ ] 5.1 Per-row ancestor-guide computation (segment per indent column where the chain continues below) and rendering in the indent columns; named knobs for guide color/width; innermost segment meets the parent bullet position 29 - - [ ] 5.2 UI test / dump-based assertions over a fixture page with known nesting; visual check via dev-loop screenshots at several depths 28 + - [x] 5.1 Two layers shipped. **Static guides**: per-row ancestor-guide computation (`VisibleRow.guides` from `push_subtree`), hairlines centered on bullet columns, start inset below the first child's row top (`GUIDE_TOP_INSET`), terminating segments through the last child's bullet to its line bottom, tails suppressed under thread elbows. **Focused-path thread** (the proposer's actual intent, à la the Logseq plugin): accent line threading the bullet icons from the highest visible bulleted ancestor to the focused bullet — per-render precomputed `RowAccent` segments, rounded elbows (`border_l_2`+`border_b_2`+`rounded_bl`), path bullets (and fold rings) adopt `THREAD_COLOR`. Knobs: `GUIDE_WIDTH/COLOR/TOP_INSET`, `THREAD_WIDTH/COLOR/BEND_RADIUS`, `BULLET_COLUMN_CENTER` 29 + - [x] 5.2 UI test pins the guide data against the fixture's known shape (`threading_guides_match_fixture_structure`); dev-loop verification at depth 4 with a live-built nest covering continuing, terminating, and focused-thread cases across five review iterations of screenshots 30 30 31 31 ## 6. Smooth scrolling (design D5) 32 32