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)
}
}