BlockEditor fills its box and scrolls with a scrollbar, bringing the caret into view as it moves; a ScrollArea keeps a wheel it can use, so the page behind stays (#7)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 21:47:07 -04:00
1 parent ca6d2e219e
commit 5490869f23
9 files changed
+260 -23

No files matched your search

+8 -3
View File
@@ -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<usize>)>,
}
/// 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<Block>,
next: u64,
@@ -83,6 +83,9 @@ pub struct BlockEditor {
typed_from: Option<(u64, usize, std::ops::Range<usize>)>,
pub(crate) people: Vec<SharedString>,
pub(crate) diagram: Option<RenderDiagram>,
scroll: ScrollHandle,
/// The caret last brought into view: field, selection and length.
revealed: Option<(EntityId, std::ops::Range<usize>, 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 {
+1
View File
@@ -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(
+110
View File
@@ -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<BlockEditor>, ScrollHandle);
impl Render for Page {
fn render(&mut self, _: &mut gpui::Window, _: &mut gpui::Context<Self>) -> 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<BlockEditor>, 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<BlockEditor>, cx: &mut VisualTestContext) -> gpui::Bounds<Pixels> {
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<Pixels>) -> 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");
}
+44 -6
View File
@@ -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<Self>) {
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<Self>) -> 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)
}
}
+40 -7
View File
@@ -228,6 +228,7 @@ pub struct ScrollArea {
base: Div,
shadows: bool,
vertical: bool,
handle: Option<ScrollHandle>,
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<Activity>, window: &mut Window, cx: &mut App) -> f32 {
let offset = state.read(cx).handle.offset();
fn track_activity(
state: &Entity<Activity>,
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),
)
})
}
}
+49 -2
View File
@@ -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<Self>) -> 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));
}