diff --git a/AGENTS.md b/AGENTS.md index bdc37af..a1beca1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -182,7 +182,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A shell in the gallery starts without startup files and with a fixed prompt, so captures show no user or host. - gpui has no `(ElementId, usize)` id. A child id names its index: `(self.id.clone(), format!("row-{ix}"))`. - Clippy's `single_range_in_vec_init` rejects `[a..b]` and `vec![a..b]`; bind the range first. -- A component built on `lists::Tree` fills its box; the host gives it a height. A fixed-width column beside a `flex_1` one takes `flex_none`, or it shrinks. +- A component built on `lists::Tree` fills its box; the host gives it a height. So does `documents::BlockEditor`, which scrolls in a `ScrollArea` through its own handle (`ScrollArea::track_scroll`) and brings the caret into view each time it moves, not each frame, so a wheel moves past a still caret. A fixed-width column beside a `flex_1` one takes `flex_none`, or it shrinks. - Editor rows read their text through `CodeEditor::row_text`: a multi-line ghost cuts its line, and the rest follows the ghost's last line. - gpui clears `debug_bounds` each frame: a test sees an element leave as well as appear. - Time reads `web_time::Instant`: std's compiles for wasm32 and panics in the browser. `scripts/check.sh` refuses std's in `src` and the gallery. @@ -202,7 +202,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A header that holds a name beside counts and actions wraps: `flex_wrap`, the name `flex_1` with `min_w(label_width)`, the rest one `flex_none` group that drops below. - Every header, row and strip works at 280px: a name keeps its minimum width, the rest wraps below or scrolls, and nothing draws past its box. - gpui takes a scroll box's reach from its direct children's bounds, and a block child stretches to the box. Content that scrolls sideways sets its least width on that child (`tables::body::least`). -- gpui turns a wheel along the other axis onto a box that scrolls one way, and the page around it scrolls too. A box inside a scrolling page keeps its wheel to its axis with `layout::on_axis`, and so does the page. +- gpui turns a wheel along the other axis onto a box that scrolls one way, and the page around it scrolls too. A box inside a scrolling page keeps its wheel to its axis with `layout::on_axis`, and so does the page. gpui also hands a wheel to every scroll box under the pointer, so a `ScrollArea` stops a wheel along an axis it has room on and passes one it cannot use, and a floating option list occludes (`forms::options::Popup`). - The focus color belongs to the focus ring. A chosen or shown item takes accent, as `forms::picks` and `lists::ListItem` do. - A box that shows a host's picture takes the picture's shape from the host (`ratio`), so nothing is cropped and a point given as a share lands on the picture. A focusable one keeps its focus border on an outer box, so the picture keeps that shape exactly (`media::ImageCropper`). - A default only the component can work out, such as a crop fitted to a picture it decodes, reaches the owner once through `window.defer` after render (`media::ImageUpload`). diff --git a/examples/gallery/pages/documents/editing.rs b/examples/gallery/pages/documents/editing.rs index e7a0e74..79b40dd 100644 --- a/examples/gallery/pages/documents/editing.rs +++ b/examples/gallery/pages/documents/editing.rs @@ -202,7 +202,10 @@ pub fn editing(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { "Blocks like a notebook's. Hover one for its handle: drag it to move the block, press it to turn, copy or delete it. / on an empty line lists every kind, and an empty line says so. Undo covers text and blocks alike.", cx, ) - .child(probe("doc-blocks", div().w(px(760.)).child(notes))), + .child(probe( + "doc-blocks", + div().w(px(760.)).h(px(360.)).child(notes), + )), ) .child( section( @@ -210,7 +213,7 @@ pub fn editing(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { "Code colored as code, math set as it is typed, diagrams the app draws from their source, dividers, tables that grow and merge cells, pictures to resize and align, pages to open, columns, and synced copies that change together.", cx, ) - .child(div().w(px(760.)).child(kinds)), + .child(div().w(px(760.)).h(px(560.)).child(kinds)), ) .child( section( diff --git a/src/documents/blocks/mod.rs b/src/documents/blocks/mod.rs index c7c1e40..75ac910 100644 --- a/src/documents/blocks/mod.rs +++ b/src/documents/blocks/mod.rs @@ -11,8 +11,8 @@ mod view; use std::{collections::HashMap, rc::Rc}; use gpui::{ - App, AppContext as _, Context, Entity, EventEmitter, Focusable, Global, SharedString, - Subscription, Window, + App, AppContext as _, Context, Entity, EntityId, EventEmitter, Focusable, Global, ScrollHandle, + SharedString, Subscription, Window, }; pub use kind::{Align, BlockKind, Media}; @@ -71,7 +71,7 @@ struct Snapshot { caret: Option<(u64, usize, std::ops::Range)>, } -/// A document of blocks, Notion-like: prose, lists and to-dos, quotes and callouts, toggles, code, math, diagrams, dividers, tables, media, columns and synced blocks. Markdown at a paragraph's start changes its kind; `/` offers every kind; `@` and `:` suggest people and emoji; blocks drag by their handle. Undo covers text and structure alike. +/// A document of blocks, Notion-like, that fills its box and scrolls, keeping the caret in view: prose, lists and to-dos, quotes and callouts, toggles, code, math, diagrams, dividers, tables, media, columns and synced blocks. Markdown at a paragraph's start changes its kind; `/` offers every kind; `@` and `:` suggest people and emoji; blocks drag by their handle. Undo covers text and structure alike. pub struct BlockEditor { pub(crate) blocks: Vec, next: u64, @@ -83,6 +83,9 @@ pub struct BlockEditor { typed_from: Option<(u64, usize, std::ops::Range)>, pub(crate) people: Vec, pub(crate) diagram: Option, + scroll: ScrollHandle, + /// The caret last brought into view: field, selection and length. + revealed: Option<(EntityId, std::ops::Range, usize)>, _synced: Subscription, } @@ -103,6 +106,8 @@ impl BlockEditor { typed_from: None, people: Vec::new(), diagram: None, + scroll: ScrollHandle::new(), + revealed: None, _synced: synced, }; for data in blocks { diff --git a/src/documents/blocks/tests/mod.rs b/src/documents/blocks/tests/mod.rs index f0a3ef4..28da1ba 100644 --- a/src/documents/blocks/tests/mod.rs +++ b/src/documents/blocks/tests/mod.rs @@ -3,6 +3,7 @@ use gpui::{AppContext as _, Entity, Focusable, TestAppContext, VisualTestContext use super::{BlockData, BlockEditor, BlockKind}; use crate::{forms, theme::Theme}; +mod scroll; mod stale; fn open( diff --git a/src/documents/blocks/tests/scroll.rs b/src/documents/blocks/tests/scroll.rs new file mode 100644 index 0000000..09b861f --- /dev/null +++ b/src/documents/blocks/tests/scroll.rs @@ -0,0 +1,110 @@ +use gpui::{ + AppContext as _, Entity, Modifiers, ParentElement, Pixels, Render, ScrollDelta, ScrollHandle, + ScrollWheelEvent, Styled, TestAppContext, TouchPhase, VisualTestContext, div, point, + prelude::*, px, +}; + +use super::{BlockData, BlockEditor, BlockKind, caret, settle}; +use crate::{forms, theme::Theme}; + +const TALL: Pixels = px(200.0); + +/// The editor in a box of its own at the top of a page that scrolls. +struct Page(Entity, ScrollHandle); + +impl Render for Page { + fn render(&mut self, _: &mut gpui::Window, _: &mut gpui::Context) -> impl IntoElement { + div() + .id("page") + .size_full() + .overflow_y_scroll() + .track_scroll(&self.1) + .child(div().h(TALL).child(self.0.clone())) + .child(div().h(px(2000.0))) + } +} + +fn open(cx: &mut TestAppContext) -> (Entity, ScrollHandle, &mut VisualTestContext) { + cx.update(|cx| { + Theme::init(cx); + Theme::update(cx, |theme| theme.reduced_motion = true); + forms::bind_keys(cx); + crate::documents::bind_keys(cx); + }); + let blocks = (0..30).map(|ix| BlockData::new(BlockKind::Paragraph, [format!("line {ix}")])); + let page = ScrollHandle::new(); + let (view, cx) = cx.add_window_view({ + let page = page.clone(); + |window, cx| { + Page( + cx.new(|cx| BlockEditor::new(blocks.collect(), window, cx)), + page, + ) + } + }); + let editor = view.read_with(cx, |view, _| view.0.clone()); + cx.update(|window, _| window.activate_window()); + still(cx); + (editor, page, cx) +} + +/// Settles past reduced motion's 1 ms glides on the wall clock. +fn still(cx: &mut VisualTestContext) { + settle(cx); + std::thread::sleep(std::time::Duration::from_millis(5)); + settle(cx); +} + +/// The focused caret's box in the window. +fn shown(editor: &Entity, cx: &mut VisualTestContext) -> gpui::Bounds { + cx.update(|window, cx| { + let (key, ix) = editor + .read(cx) + .focused(window, cx) + .expect("a field has focus"); + let at = editor.read(cx).index(key).expect("the block is there"); + editor.read(cx).blocks[at].fields[ix] + .read(cx) + .caret_bounds() + .expect("laid out") + }) +} + +fn inside(caret: gpui::Bounds) -> bool { + caret.top() >= px(0.0) && caret.bottom() <= TALL +} + +#[gpui::test] +fn the_caret_stays_in_view_as_blocks_are_added_at_the_end(cx: &mut TestAppContext) { + let (editor, page, cx) = open(cx); + caret(&editor, 29, 7, cx); + still(cx); + assert!(inside(shown(&editor, cx)), "{:?}", shown(&editor, cx)); + for _ in 0..4 { + cx.simulate_keystrokes("enter"); + still(cx); + assert!(inside(shown(&editor, cx)), "{:?}", shown(&editor, cx)); + } + assert_eq!(page.offset().y, px(0.0), "the page stayed"); +} + +#[gpui::test] +fn a_wheel_scrolls_the_editor_and_leaves_its_caret(cx: &mut TestAppContext) { + let (editor, page, cx) = open(cx); + caret(&editor, 0, 0, cx); + still(cx); + let before = shown(&editor, cx); + cx.simulate_event(ScrollWheelEvent { + position: point(px(100.0), px(100.0)), + delta: ScrollDelta::Pixels(point(px(0.0), px(-120.0))), + modifiers: Modifiers::none(), + touch_phase: TouchPhase::Moved, + }); + still(cx); + assert_eq!( + shown(&editor, cx).top(), + before.top() - px(120.0), + "the editor scrolled" + ); + assert_eq!(page.offset().y, px(0.0), "the page stayed"); +} diff --git a/src/documents/blocks/view.rs b/src/documents/blocks/view.rs index 0956030..67e0e7d 100644 --- a/src/documents/blocks/view.rs +++ b/src/documents/blocks/view.rs @@ -1,8 +1,8 @@ use std::rc::Rc; use gpui::{ - AnyElement, App, Context, ElementId, InteractiveElement, IntoElement, MouseButton, - ParentElement, Rems, Render, SharedString, Styled, Window, div, prelude::*, + AnyElement, App, Axis, Context, ElementId, InteractiveElement, IntoElement, MouseButton, + ParentElement, Rems, Render, SharedString, Styled, Window, canvas, div, prelude::*, }; use super::{Block, BlockData, BlockEditor, BlockKind, matching}; @@ -11,6 +11,7 @@ use crate::{ documents::suggest::{Offer, offers}, forms::replace_trigger, forms::{Backspace, Choice, Down, Enter, Pick, Redo, Submit, Undo, Up}, + layout::{ScrollArea, bring_into_view}, menus::{Menu, MenuItem, OverflowMenu}, motion::Reorder, primitives::IconName, @@ -141,6 +142,34 @@ impl BlockEditor { .into_any_element() } + /// Scrolls the focused caret into view once each time it moves; a wheel moves freely past a still one. + fn reveal_caret(&mut self, window: &mut Window, cx: &mut Context) { + let Some((key, ix)) = self.focused(window, cx) else { + self.revealed = None; + return; + }; + let Some(block) = self.index(key).map(|at| &self.blocks[at]) else { + return; + }; + let field = block.fields[ix].read(cx); + let at = ( + block.fields[ix].entity_id(), + field.selection(), + field.text().len(), + ); + if self.revealed.as_ref() == Some(&at) { + return; + } + let Some(caret) = field.caret_bounds() else { + return; + }; + self.revealed = Some(at); + if bring_into_view(&self.scroll, caret, Axis::Vertical) { + log::debug!("block editor: the caret came into view"); + window.request_animation_frame(); + } + } + /// The handle's menu: turn into another kind, duplicate or delete. fn menu(&self, key: u64, cx: &mut Context) -> Menu { let entity = cx.entity(); @@ -255,9 +284,13 @@ impl Render for BlockEditor { }), |list, (key, row)| list.row(key, row), ); - let start = cx.entity(); - div() - .id(id.clone()) + let (start, editor) = (cx.entity(), cx.entity()); + let caret = canvas( + move |_, window, cx| editor.update(cx, |editor, cx| editor.reveal_caret(window, cx)), + |_, _, _, _| {}, + ) + .absolute(); + let column = div() .flex() .flex_col() .capture_action(cx.listener(|editor, _: &Undo, window, cx| { @@ -271,7 +304,7 @@ impl Render for BlockEditor { .child(list) .child( div() - .id((id, "end")) + .id((id.clone(), "end")) .min_h_8() .cursor_text() .text_color(colors.fg_subtle) @@ -289,5 +322,10 @@ impl Render for BlockEditor { }); }), ) + .child(caret); + ScrollArea::new(id) + .size_full() + .track_scroll(&self.scroll) + .child(column) } } diff --git a/src/layout/scroll.rs b/src/layout/scroll.rs index 561f86a..3f2acf7 100644 --- a/src/layout/scroll.rs +++ b/src/layout/scroll.rs @@ -228,6 +228,7 @@ pub struct ScrollArea { base: Div, shadows: bool, vertical: bool, + handle: Option, body: SmallVec<[AnyElement; 2]>, } @@ -238,10 +239,17 @@ impl ScrollArea { base: div(), shadows: false, vertical: false, + handle: None, body: SmallVec::new(), } } + /// Scrolls through the owner's handle, so the owner can move it. + pub fn track_scroll(mut self, handle: &ScrollHandle) -> Self { + self.handle = Some(handle.clone()); + self + } + /// Soft edges where content continues. pub fn shadows(mut self) -> Self { self.shadows = true; @@ -267,8 +275,13 @@ impl ParentElement for ScrollArea { } } -fn track_activity(state: &Entity, window: &mut Window, cx: &mut App) -> f32 { - let offset = state.read(cx).handle.offset(); +fn track_activity( + state: &Entity, + handle: &ScrollHandle, + window: &mut Window, + cx: &mut App, +) -> f32 { + let offset = handle.offset(); if state.read(cx).last != offset { state.update(cx, |activity, _| { activity.last = offset; @@ -291,8 +304,11 @@ impl RenderOnce for ScrollArea { active_at: None, hovered: false, }); - let handle = state.read(cx).handle.clone(); - let presence = track_activity(&state, window, cx); + let handle = match self.handle { + Some(handle) => handle, + None => state.read(cx).handle.clone(), + }; + let presence = track_activity(&state, &handle, window, cx); self.base .id(self.id) .relative() @@ -308,13 +324,28 @@ impl RenderOnce for ScrollArea { div() .id("scroll-body") .size_full() - .when(self.vertical, |body| body.overflow_y_scroll().overflow_x_hidden()) + .when(self.vertical, |body| { + body.overflow_y_scroll().overflow_x_hidden() + }) .when(!self.vertical, |body| body.overflow_scroll()) .track_scroll(&handle) + .on_scroll_wheel({ + let handle = handle.clone(); + move |event, window, cx| { + let delta = event.delta.pixel_delta(window.line_height()); + let reach = handle.max_offset(); + let vertical = delta.y != Pixels::ZERO && reach.y > Pixels::ZERO; + if vertical || (delta.x != Pixels::ZERO && reach.x > Pixels::ZERO) { + cx.stop_propagation(); + } + } + }) .map(|body| match self.vertical { // Children fill and never widen, so rows wrap. true => body.flex().flex_col().children( - self.body.into_iter().map(|child| div().w_full().min_w_0().child(child)), + self.body + .into_iter() + .map(|child| div().w_full().min_w_0().child(child)), ), false => body.children(self.body), }), @@ -322,7 +353,9 @@ impl RenderOnce for ScrollArea { .when(self.shadows, |area| area.child(ScrollShadow::new(&handle))) .child(Scrollbar::new("scrollbar-y", &handle, Axis::Vertical).presence(presence)) .when(!self.vertical, |area| { - area.child(Scrollbar::new("scrollbar-x", &handle, Axis::Horizontal).presence(presence)) + area.child( + Scrollbar::new("scrollbar-x", &handle, Axis::Horizontal).presence(presence), + ) }) } } diff --git a/src/layout/tests.rs b/src/layout/tests.rs index 10410dd..3b76093 100644 --- a/src/layout/tests.rs +++ b/src/layout/tests.rs @@ -2,8 +2,8 @@ use std::time::Duration; use gpui::{ AnyElement, App, AppContext, Axis, Bounds, Context, InteractiveElement, IntoElement, Modifiers, - MouseButton, ParentElement, Pixels, Point, Render, Styled, TestAppContext, VisualTestContext, - Window, div, point, px, + MouseButton, ParentElement, Pixels, Point, Render, StatefulInteractiveElement, Styled, + TestAppContext, VisualTestContext, Window, div, point, px, }; use super::{Drawer, PaneGroup, SplitPane}; @@ -254,3 +254,50 @@ fn invalid_sizes_after_a_drag_split_evenly(cx: &mut TestAppContext) { let second = cx.debug_bounds("second").expect("second").size.width; assert_eq!(first, second); } + +/// A ScrollArea of `inner` content, 100px tall, in a page that scrolls. +struct Nested(Pixels, gpui::ScrollHandle); + +impl Render for Nested { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .id("page") + .size_full() + .overflow_y_scroll() + .track_scroll(&self.1) + .child( + super::ScrollArea::new("area") + .h(px(100.0)) + .child(div().h(self.0)), + ) + .child(div().h(px(2000.0))) + } +} + +fn wheel_page(inner: Pixels, cx: &mut TestAppContext) -> Pixels { + cx.update(Theme::init); + let page = gpui::ScrollHandle::new(); + let (_, cx) = cx.add_window_view({ + let page = page.clone(); + move |_, _| Nested(inner, page) + }); + cx.run_until_parked(); + cx.simulate_event(gpui::ScrollWheelEvent { + position: point(px(50.0), px(50.0)), + delta: gpui::ScrollDelta::Pixels(point(px(0.0), px(-40.0))), + modifiers: Modifiers::none(), + touch_phase: gpui::TouchPhase::Moved, + }); + cx.run_until_parked(); + page.offset().y +} + +#[gpui::test] +fn a_scroll_area_keeps_a_wheel_it_can_use(cx: &mut TestAppContext) { + assert_eq!(wheel_page(px(500.0), cx), px(0.0)); +} + +#[gpui::test] +fn a_scroll_area_with_no_room_passes_the_wheel_on(cx: &mut TestAppContext) { + assert_eq!(wheel_page(px(50.0), cx), px(-40.0)); +} diff --git a/tasks/github.md b/tasks/github.md index f6562ef..02163dd 100644 --- a/tasks/github.md +++ b/tasks/github.md @@ -19,7 +19,7 @@ - [x] G11 Issue #7: a caret is a whole number of device pixels wide at a device pixel, so it never shows thin then thick; tested - [x] G12 Issue #7: an IME asks for its candidate box past the laid-out text; the box clamps to the last layout, as `bounds_for` does; tested - [x] G13 Issue #12: a floating option list occludes, so the wheel scrolls the list and not the page under it; tested -- [ ] G14 Issue #7: BlockEditor scrolls in its own `ScrollArea` with its scrollbar and keeps the caret in view as blocks are added; tested +- [x] G14 Issue #7: BlockEditor scrolls in its own `ScrollArea` with its scrollbar and keeps the caret in view as blocks are added; tested - [ ] G15 Issue #7: BlockEditor turns into Markdown and back; tested - [ ] G16 Issue #7: a selection across blocks: drag or Shift-press selects, copy, cut, delete and typing act on it, undo restores; tested - [ ] G17 Push, reply on #7 and #12