diff --git a/.claude/skills/dev-loop/SKILL.md b/.claude/skills/dev-loop/SKILL.md index 5c852e9..f1502b0 100644 --- a/.claude/skills/dev-loop/SKILL.md +++ b/.claude/skills/dev-loop/SKILL.md @@ -82,6 +82,14 @@ cross-platform Rust — no OS-specific automation tools. for a partial name may be a tag, not the page — type the full page name. - The fixture's journal page is `2026-07-10`; the app also creates an empty journal page for the real today and focuses it at launch. +- There is no `click`/mouse command, and one cannot be added on the current + gpui pin (0.2.2's `Window::dispatch_event` returns a `pub(crate)` type — + uncallable outside the crate). Drive mouse affordances through their + keyboard equivalents (e.g. `ctrl-shift-f` = clicking a bullet to fold). +- Keystrokes whose effect is deferred (focus movement via `up`/`down`) do + NOT compose within one `keys` command: `"down down down"` moves one row, + because each dispatch computes from the same pre-defer state. Send such + keystrokes as separate commands. - Malformed/unknown commands return `{"ok":false,...}` and are harmless. - Screenshot failing (`ok:false`) does not break the session — it's best-effort per platform (Wayland especially); `dump` always works. diff --git a/README.md b/README.md index d2d4dc7..e2ae416 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,12 @@ CRDT document — there is no separate database. Deleting any file *except* | `Ctrl+Enter` | Create a page from an uncreated tag/page/date view | | `Ctrl+Shift+Q` | Toggle the focused block as a query block | | `Ctrl+Shift+C` | Open the calendar picker | +| `Ctrl+Shift+F` | Fold/unfold the focused block's children | + +Every block renders a single Logseq-style bullet: clicking the bullet of a +block with children folds/unfolds them (same as `Ctrl+Shift+F`), and a +folded block's bullet gains an outline ring so hidden children stay +discoverable. Back/forward and the calendar are also available as clickable buttons in the header (◀ / ▶ / "Calendar") for mouse-driven navigation — the calendar opens diff --git a/crates/trawler/src/devtools.rs b/crates/trawler/src/devtools.rs index bf93d06..49009b3 100644 --- a/crates/trawler/src/devtools.rs +++ b/crates/trawler/src/devtools.rs @@ -259,6 +259,13 @@ fn dispatch_text(text: &str, window: WindowHandle, cx: &mut AsyncApp } } +// NOTE: there is deliberately no `click` command. gpui 0.2.2's +// `Window::dispatch_event` returns a `pub(crate)` type, making it +// uncallable from outside the crate — mouse events cannot be injected +// through public API. Drive the UI through keyboard equivalents instead +// (every mouse affordance is required to have one; see the outline-editor +// spec's mouseless-operations requirement). + fn bounds(window: WindowHandle, cx: &mut AsyncApp) -> Value { match window.update(cx, |_, window, _| bounds_dump(window)) { Ok(bounds) => match serde_json::to_value(&bounds) { diff --git a/crates/trawler/src/main.rs b/crates/trawler/src/main.rs index 46ee374..40dd6c4 100644 --- a/crates/trawler/src/main.rs +++ b/crates/trawler/src/main.rs @@ -72,7 +72,8 @@ actions!( CreatePage, SearchOpen, ToggleQueryBlock, - ToggleCalendar + ToggleCalendar, + ToggleFold ] ); @@ -86,6 +87,7 @@ fn init_keymap(cx: &mut App) { KeyBinding::new("ctrl-,", ZoomOut, Some(APP_CONTEXT)), KeyBinding::new("ctrl-k", QuickOpen, Some(APP_CONTEXT)), KeyBinding::new("ctrl-f", SearchOpen, Some(APP_CONTEXT)), + KeyBinding::new("ctrl-shift-f", ToggleFold, Some(APP_CONTEXT)), // Deliberately not bound to plain "enter": the quick-open overlay // handles "enter" itself via a raw `on_key_down` listener (see // `quick_open_key_down`), and it isn't scoped to its own key @@ -491,6 +493,30 @@ impl TrawlerApp { cx.notify(); } + /// Ctrl+Shift+F: fold/unfold the focused block — the keyboard + /// equivalent of clicking its bullet (spec: outline-editor "Mouseless + /// outline operations" lists fold/unfold; the bullet click is the + /// mouse affordance, this is the keyboard one). No-op on a childless + /// block, mirroring the bullet, so the folded set can't accumulate + /// entries that change nothing. + fn toggle_fold_focused( + &mut self, + _: &ToggleFold, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(block) = self.editor.as_ref().map(|e| e.block) else { + return; + }; + if Outline::new(self.storage.doc()) + .children(Some(block)) + .is_empty() + { + return; + } + self.toggle_fold(block, cx); + } + /// Navigate to a new view, recording history (spec: "Navigation /// history") and focusing `focus_hint` if given, else the first block /// of the new view. @@ -1652,6 +1678,14 @@ const MUTED_COLOR: u32 = 0x9a9a9a; /// zero rendered children) still occupies real, clickable space instead of /// collapsing to nothing. const ROW_MIN_HEIGHT: f32 = 22.0; +/// Downward nudge applied to the bullet column so its dot centers on the +/// text's x-height rather than the geometric center of its `ROW_MIN_HEIGHT` +/// box. Text line metrics reserve more space above the glyphs (ascent) than +/// below (descent), so a box centered by height alone lands the bullet near +/// the x-height *top*, reading as visibly high next to the text. Pixel +/// measurement against the default row font put exact x-height-center at +/// 3.5px; tuned down slightly from there by eye. +const BULLET_OPTICAL_NUDGE: f32 = 2.4; /// Approximate height of the header bar (`.p_2()` padding plus one line of /// text) — generous on purpose. Used to keep the calendar's click-away /// backdrop out of the header entirely; see `calendar_backdrop`'s doc for @@ -1669,13 +1703,6 @@ const HEADER_HEIGHT: f32 = 48.0; /// width sibling rows don't have and throw off alignment. const QUERY_BG: u32 = 0x22252e; const QUERY_ACCENT_COLOR: u32 = 0x7c9dd9; -/// Width a row's fold-arrow column plus its gap reserves before the -/// bullet (see the row-building code below: `w(px(14.0))` + `gap_1()`), -/// present even on a foldless row as blank space. A query's result section -/// has no fold column of its own, so its left padding adds this back in — -/// otherwise each result's bullet would land under the *query's* bullet -/// instead of clearly nested beneath it. -const FOLD_COLUMN_INSET: f32 = 18.0; /// Blank space rendered as a synthetic trailing row after the real outline /// rows, so the last block can be scrolled up away from the window's bottom /// edge instead of staying glued to it while editing. @@ -2549,6 +2576,7 @@ impl Render for TrawlerApp { .on_action(cx.listener(Self::create_page_for_view)) .on_action(cx.listener(Self::zoom_in)) .on_action(cx.listener(Self::zoom_out)) + .on_action(cx.listener(Self::toggle_fold_focused)) .bg(rgb(0x1e1e1e)) .text_color(rgb(0xffffff)) .child( @@ -2686,35 +2714,51 @@ impl Render for TrawlerApp { // alignment with everything else. row = row.bg(rgb(QUERY_BG)); } - let fold_indicator: gpui::AnyElement = if *has_children { - div() - .id(("fold", ix)) - .cursor_pointer() - .w(px(14.0)) - .text_color(rgb(MUTED_COLOR)) - .on_click(cx.listener( - move |this, _event: &ClickEvent, _window, cx| { - this.toggle_fold(id, cx); - }, - )) - .child(if *folded { "▶" } else { "▼" }) - .into_any_element() - } else { - div().w(px(14.0)).into_any_element() - }; - // A persistent bullet per row (not just the - // fold arrow, which only appears when a block - // has children) so every block — including an - // empty leaf, whose Markdown view otherwise - // renders zero children — stays visible. - let bullet = div() + // One unified bullet per row (Logseq-style) + // instead of a fold triangle plus a separate + // dot: clicking a bullet that has children + // toggles fold, and a collapsed parent renders + // its dot inside an outline ring so the hidden + // children stay discoverable. Every block — + // including an empty leaf, whose Markdown view + // otherwise renders zero children — keeps a + // visible bullet. The column height matches + // ROW_MIN_HEIGHT so the dot centers on the + // row's first text line, not the whole + // (possibly multi-line) row. + let dot = div().size(px(6.0)).rounded_full().bg(rgb(MUTED_COLOR)); + let mut bullet = div() + .id(("bullet", ix)) .w(px(14.0)) + .h(px(ROW_MIN_HEIGHT)) + .mt(px(BULLET_OPTICAL_NUDGE)) .flex() + .items_center() .justify_center() - .text_color(rgb(MUTED_COLOR)) - .child("•") - .into_any_element(); - let row = row.child(fold_indicator).child(bullet); + .flex_none(); + if *has_children { + bullet = bullet.cursor_pointer().on_click(cx.listener( + move |this, _event: &ClickEvent, _window, cx| { + this.toggle_fold(id, cx); + }, + )); + } + let bullet = if *folded { + bullet.child( + div() + .size(px(12.0)) + .rounded_full() + .border_1() + .border_color(rgb(MUTED_COLOR)) + .flex() + .items_center() + .justify_center() + .child(dot), + ) + } else { + bullet.child(dot) + }; + let row = row.child(bullet); let row = match content { RowContent::Editor(input) => row.child( div() @@ -2790,19 +2834,8 @@ impl Render for TrawlerApp { // box, so the background only covers // the indented region itself. // - // `+ FOLD_COLUMN_INSET`: a sibling row - // at this depth reserves a fold-arrow - // column before its own bullet even - // when foldless (see the row-building - // code above) — without matching that - // offset, each result's own bullet - // landed almost under the *query's* - // bullet instead of clearly nested - // under it. div() - .ml(px(16.0 * (*depth as f32 + 1.0) - + 8.0 - + FOLD_COLUMN_INSET)) + .ml(px(16.0 * (*depth as f32 + 1.0) + 8.0)) .mr_2() .mb_2() .bg(rgb(QUERY_BG)) diff --git a/crates/trawler/src/ui_tests.rs b/crates/trawler/src/ui_tests.rs index e6f4325..9c71699 100644 --- a/crates/trawler/src/ui_tests.rs +++ b/crates/trawler/src/ui_tests.rs @@ -314,6 +314,47 @@ fn alt_up_down_reorders_among_siblings(cx: &mut TestAppContext) { assert_eq!(children_of(&app, cx, Some(parent)), original); } +#[gpui::test] +fn ctrl_shift_f_folds_and_unfolds_focused_block(cx: &mut TestAppContext) { + let (app, cx) = open_app("fold", cx); + let deep_work = block_by_content(&app, cx, "Deep work"); + let child = block_by_content(&app, cx, "Outlined the fixture graph #project"); + + let visible = |app: &Entity, cx: &mut VisualTestContext, id| { + app.update(cx, |app, _cx| app.rows.iter().any(|r| r.id == id)) + }; + assert!(visible(&app, cx, child)); + + focus_block(&app, cx, deep_work); + cx.simulate_keystrokes("ctrl-shift-f"); + + // Descendants disappear from the visible rows; the folded block stays. + assert!(!visible(&app, cx, child)); + assert!(visible(&app, cx, deep_work)); + app.update(cx, |app, _cx| assert!(app.folded.contains(&deep_work))); + + cx.simulate_keystrokes("ctrl-shift-f"); + + assert!(visible(&app, cx, child)); + app.update(cx, |app, _cx| assert!(!app.folded.contains(&deep_work))); +} + +#[gpui::test] +fn fold_is_a_noop_on_childless_blocks(cx: &mut TestAppContext) { + let (app, cx) = open_app("fold-noop", cx); + let leaf = block_by_content(&app, cx, "Started the trawler dogfood log #trawler"); + + focus_block(&app, cx, leaf); + cx.simulate_keystrokes("ctrl-shift-f"); + + app.update(cx, |app, _cx| { + assert!( + app.folded.is_empty(), + "folding a childless block should change nothing" + ); + }); +} + // --- 2.6 focus movement ------------------------------------------------- #[gpui::test]