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:
1 parent
ca6d2e219e
commit
5490869f23
9 files changed
+260
-23
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user