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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user