BlockEditor selects across blocks: drag or Shift-press, then type, paste, delete, cut, copy as Markdown or break, one undo step (#7)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 21:47:08 -04:00
1 parent f5eb82ee1b
commit d24984cc38
12 files changed
+797 -10

No files matched your search

+1
View File
@@ -177,6 +177,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- A component that fills its container takes no width on its root: a block or a flex column stretches it, in a flex row the host gives it `flex_1`, and as a window's root it fills the window, since gpui sets a window root's auto sides to the window's size. A percent width ignores the root's own margins, so they push it past its container, and in a flex row it takes the whole row as its basis and leaves a `flex_1().min_w_0()` neighbor nothing. A root centered under a cap keeps `w_full`, since a flex column stretches no child with an auto side margin, and `mx_auto` needs a width to fill (`layout::Container`). So does a box that keeps a ratio: a block follows its ratio from a stretch, but a flex or grid box only from a set width, and callers may make one flex (`layout::AspectRatio`, and `primitives::framed` under VideoPlayer, CameraPreview, ModelViewer, ParticipantTile and InpaintCanvas). `Button::full_width` is its host asking, since a button takes no style. Each chapter's test holds its filling roots at the full width of a flex column in a padded block through `layout::tests::narrow_width`, which reads a root by its `debug_selector`.
- A field's `Highlight` sets color, wash, weight, slant and strike per span; measuring and painting share one set of runs, so wrapped heights match.
- A block editor keeps one undo for the document: text and structure together, as snapshots through `forms::History`; it takes Undo and Redo before its fields do.
- A selection across a BlockEditor's fields lives in the editor (`blocks::span`): a press in a field drags into others, Shift-press reaches from the caret's field. The pressed field selects its own part; the editor sets the other end's and washes blocks between. The editor watches the focused end through `TextInput::edits`, which counts every edit, text changed or not: an edit there, once an input method commits, is rebuilt from the last snapshot as head, typed and tail in one undo step; a caret move or lost focus lets the span go, since gpui runs bindings before key listeners. Backspace, Delete, Cut and Enter take the span before the field acts; copy writes it as Markdown.
- gpui calls focus listeners only while the window is active. A test that needs focus events activates its window with `window.activate_window()`.
- A terminal takes Tab and Shift-Tab through bindings under `ElyTerminal`, ahead of focus moves; other keys go through `terminal::keys` in a key listener, and typed text through its input handler.
- A shell in the gallery starts without startup files and with a fixed prompt, so captures show no user or host.
+2
View File
@@ -76,6 +76,7 @@ impl BlockEditor {
}
pub(crate) fn undo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.clear_span(window, cx);
self.settle(true, cx);
if let Some(previous) = self.history.undo(self.now(cx)) {
log::info!("block editor: undo");
@@ -84,6 +85,7 @@ impl BlockEditor {
}
pub(crate) fn redo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.clear_span(window, cx);
if let Some(next) = self.history.redo(self.now(cx)) {
log::info!("block editor: redo");
self.restore(next, window, cx);
+1 -1
View File
@@ -39,7 +39,7 @@ impl BlockEditor {
}
/// The fields in reading order, as `(block, field)`: a closed toggle hides its body.
fn reading_order(&self) -> Vec<(u64, usize)> {
pub(super) fn reading_order(&self) -> Vec<(u64, usize)> {
self.blocks
.iter()
.flat_map(|block| {
+7
View File
@@ -5,6 +5,7 @@ mod import;
mod keys;
mod kind;
mod media;
mod span;
mod table;
#[cfg(all(test, feature = "test-support"))]
mod tests;
@@ -86,6 +87,7 @@ pub struct BlockEditor {
pub(crate) people: Vec<SharedString>,
pub(crate) diagram: Option<RenderDiagram>,
scroll: ScrollHandle,
across: span::Across,
/// The caret last brought into view: field, selection and length.
revealed: Option<(EntityId, std::ops::Range<usize>, usize)>,
_synced: Subscription,
@@ -109,6 +111,7 @@ impl BlockEditor {
people: Vec::new(),
diagram: None,
scroll: ScrollHandle::new(),
across: span::Across::default(),
revealed: None,
_synced: synced,
};
@@ -268,6 +271,10 @@ impl BlockEditor {
};
match event {
InputEvent::Changed => {
let at = self.blocks[ix].fields.iter().position(|each| each == field);
if at.is_some_and(|at| self.span_takes(key, at, window, cx)) {
return;
}
let typed = field.focus_handle(cx).is_focused(window);
if typed
&& self.blocks[ix].kind == BlockKind::Paragraph
+482
View File
@@ -0,0 +1,482 @@
use gpui::{
ClipboardItem, Context, DispatchPhase, Focusable, IntoElement, MouseDownEvent, MouseMoveEvent,
MouseUpEvent, Pixels, Point, Styled, Subscription, Window, canvas,
};
use super::{BlockData, BlockEditor};
/// A place in a field: its block, the field, and a byte offset.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Spot {
pub key: u64,
pub field: usize,
pub at: usize,
}
/// A press in a field that may drag out of it, and a selection across fields as anchor and head.
#[derive(Default)]
pub(crate) struct Across {
press: Option<Spot>,
span: Option<(Spot, Spot)>,
/// Watches the focused end: an edit there takes the selection's place, a caret move lets it go.
held: Option<Subscription>,
/// The focused end, and how many edits it had taken.
holder: Option<((u64, usize), u64)>,
}
/// What takes a selection's place: an edit already made at one end, nothing, or a break.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Put {
Typed(u64, usize),
Nothing,
Break,
}
impl BlockEditor {
fn rank(&self, order: &[(u64, usize)], spot: Spot) -> Option<(usize, usize)> {
let at = order
.iter()
.position(|each| *each == (spot.key, spot.field))?;
Some((at, spot.at))
}
/// The selection across fields, start first in reading order.
pub(crate) fn span(&self) -> Option<(Spot, Spot)> {
let (anchor, head) = self.across.span?;
let order = self.reading_order();
let forward = self.rank(&order, anchor)? <= self.rank(&order, head)?;
Some(if forward {
(anchor, head)
} else {
(head, anchor)
})
}
pub(crate) fn pressing(&self) -> bool {
self.across.press.is_some()
}
/// The blocks wholly inside the selection, by place.
pub(crate) fn washed(&self) -> Option<std::ops::Range<usize>> {
let (start, end) = self.span()?;
Some(self.index(start.key)? + 1..self.index(end.key)?)
}
fn spot(
&self,
key: u64,
field: usize,
position: Point<Pixels>,
cx: &Context<Self>,
) -> Option<Spot> {
let input = self.blocks[self.place(key, field)?].fields[field].read(cx);
Some(Spot {
key,
field,
at: input.offset_for_point(position),
})
}
/// A press in a field: with Shift, it selects from the caret's field to here; else a drag may start.
pub(crate) fn pressed(
&mut self,
key: u64,
field: usize,
event: &MouseDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(here) = self.spot(key, field, event.position, cx) else {
return;
};
let prior = self
.caret
.filter(|caret| *caret != (key, field))
.and_then(|(key, field)| {
let input = self.blocks.get(self.index(key)?)?.fields.get(field)?;
let at = input.read(cx).cursor();
Some(Spot { key, field, at })
});
self.clear_span(window, cx);
self.across.press = None;
match prior.filter(|_| event.modifiers.shift) {
Some(prior) => {
log::info!(
"block editor: Shift selects from block {} to {key}",
prior.key
);
self.across.span = Some((prior, here));
self.show_span(window, cx);
self.hold(window, cx);
}
None => self.across.press = Some(here),
}
cx.notify();
}
/// The shown field nearest `position`: inside it, else closest above or below, then across.
fn field_at(&self, position: Point<Pixels>, cx: &Context<Self>) -> Option<(u64, usize)> {
let outside =
|near: Pixels, far: Pixels, at: Pixels| (near - at).max(at - far).max(Pixels::ZERO);
self.reading_order()
.into_iter()
.filter_map(|(key, field)| {
let block = &self.blocks[self.index(key)?];
let bounds = block.fields[field].read(cx).layout.as_ref()?.bounds;
let dy = outside(bounds.top(), bounds.bottom(), position.y);
let dx = outside(bounds.left(), bounds.right(), position.x);
Some(((dy, dx), (key, field)))
})
.min_by(|one, two| one.0.partial_cmp(&two.0).expect("pixels are finite"))
.map(|(_, field)| field)
}
fn drag_to(&mut self, position: Point<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
let Some(press) = self.across.press else {
return;
};
let Some((key, field)) = self.field_at(position, cx) else {
return;
};
if (key, field) == (press.key, press.field) {
return self.clear_span(window, cx);
}
let Some(head) = self.spot(key, field, position, cx) else {
return;
};
if self.across.span != Some((press, head)) {
if self.across.span.is_none() {
log::info!("block editor: a drag selects across blocks");
}
self.across.span = Some((press, head));
self.show_span(window, cx);
self.hold(window, cx);
}
}
/// Follows a drag that began in a field until the button lifts.
pub(crate) fn drag_listener(&self, cx: &mut Context<Self>) -> impl IntoElement + use<> {
let editor = cx.entity();
canvas(
|_, _, _| {},
move |_, _, window, _| {
let moved = editor.clone();
window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, cx| {
if phase == DispatchPhase::Bubble && event.dragging() {
moved.update(cx, |editor, cx| editor.drag_to(event.position, window, cx));
}
});
let lifted = editor.clone();
window.on_mouse_event(move |_: &MouseUpEvent, phase, _, cx| {
if phase == DispatchPhase::Bubble {
lifted.update(cx, |editor, cx| {
editor.across.press = None;
cx.notify();
});
}
});
},
)
.absolute()
}
/// Selects each end field's part and whole fields between in the end blocks; the pressed field selects itself.
fn show_span(&mut self, window: &Window, cx: &mut Context<Self>) {
let Some((start, end)) = self.span() else {
return;
};
let order = self.reading_order();
let (from, to) = (self.rank(&order, start), self.rank(&order, end));
let pressed = self.across.press.map(|press| (press.key, press.field));
for block in &self.blocks {
for (ix, field) in block.fields.iter().enumerate() {
if pressed == Some((block.key, ix)) {
continue;
}
let len = field.read(cx).text().len();
let rank = order.iter().position(|each| *each == (block.key, ix));
let ends = block.key == start.key || block.key == end.key;
let want = match rank {
Some(rank) if Some((rank, start.at)) == from => Some(start.at..len),
Some(rank) if Some((rank, end.at)) == to => Some(0..end.at),
Some(rank)
if ends
&& from
.zip(to)
.is_some_and(|(from, to)| from.0 < rank && rank < to.0) =>
{
Some(0..len)
}
_ => None,
};
let now = field.read(cx).selection();
match want {
Some(range) if range != now => {
field.update(cx, |field, cx| field.select(range, cx))
}
None if !now.is_empty() && !field.focus_handle(cx).is_focused(window) => {
let cursor = field.read(cx).cursor();
field.update(cx, |field, cx| field.select(cursor..cursor, cx));
}
_ => {}
}
}
}
cx.notify();
}
/// Lets the selection across fields go, and the end fields' parts with it.
pub(crate) fn clear_span(&mut self, window: &Window, cx: &mut Context<Self>) {
(self.across.held, self.across.holder) = (None, None);
if self.across.span.take().is_none() {
return;
}
log::debug!("block editor: the selection across blocks lets go");
for field in self.blocks.iter().flat_map(|block| &block.fields) {
let now = field.read(cx).selection();
if !now.is_empty() && !field.focus_handle(cx).is_focused(window) {
let cursor = field.read(cx).cursor();
field.update(cx, |field, cx| field.select(cursor..cursor, cx));
}
}
cx.notify();
}
/// Watches the focused end field from the moment a selection spans fields.
fn hold(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.across.held.is_some() {
return;
}
let Some((key, field)) = self.focused(window, cx) else {
return;
};
let input =
self.blocks[self.index(key).expect("a focused block is drawn")].fields[field].clone();
let (edits, _) = input.read(cx).edits();
self.across.holder = Some(((key, field), edits));
self.across.held = Some(cx.observe_in(&input, window, move |editor, _, window, cx| {
editor.held(key, field, window, cx)
}));
}
/// The focused end changed: an edit takes the selection's place once composing ends; focus or a caret leaving lets it go.
fn held(&mut self, key: u64, field: usize, window: &mut Window, cx: &mut Context<Self>) {
let (Some((start, end)), Some(ix)) = (self.span(), self.index(key)) else {
return;
};
let input = self.blocks[ix].fields[field].read(cx);
let ((edits, composing), counted) = (input.edits(), self.across.holder.map(|(_, n)| n));
if !input.focus_handle(cx).is_focused(window) {
return self.clear_span(window, cx);
}
if Some(edits) != counted {
if !composing {
self.collapse_span(Put::Typed(key, field), window, cx);
}
return;
}
let expected = match (key, field) {
at if at == (start.key, start.field) => start.at..input.text().len(),
at if at == (end.key, end.field) => 0..end.at,
_ => 0..0,
};
if input.selection() != expected {
self.clear_span(window, cx);
}
}
/// The selection across fields as Markdown on the clipboard; false when there is none.
pub(crate) fn copy_span(&self, cx: &mut Context<Self>) -> bool {
let Some((start, end)) = self.span() else {
return false;
};
let order = self.reading_order();
let (Some(from), Some(to)) = (self.rank(&order, start), self.rank(&order, end)) else {
return false;
};
let (Some(first), Some(last)) = (self.index(start.key), self.index(end.key)) else {
return false;
};
let snapshot = self.snapshot(cx);
let blocks: Option<Vec<BlockData>> = snapshot.blocks[first..=last]
.iter()
.map(|(key, data)| {
let mut data = data.clone();
for (ix, text) in data.texts.iter_mut().enumerate() {
let rank = order.iter().position(|each| *each == (*key, ix));
*text = match rank {
Some(rank) if rank == from.0 => text.get(start.at..)?.to_string(),
Some(rank) if rank == to.0 => text.get(..end.at)?.to_string(),
Some(rank) if rank < from.0 || rank > to.0 => String::new(),
_ => std::mem::take(text),
};
}
Some(data)
})
.collect();
let Some(blocks) = blocks else {
log::error!("block editor: the selection across blocks no longer fits its text");
return false;
};
let markdown = BlockData::to_markdown(&blocks);
cx.write_to_clipboard(ClipboardItem::new_string(markdown.trim_end().to_string()));
log::info!("block editor: {} blocks copied as markdown", blocks.len());
true
}
}
impl BlockEditor {
/// A field changed while a selection spans fields: the held end's edit is the selection's to take; another lets it go.
pub(crate) fn span_takes(
&mut self,
key: u64,
field: usize,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
if self.across.span.is_none() {
return false;
}
if self.across.holder.is_some_and(|(at, _)| at == (key, field)) {
return true;
}
log::info!(
"block editor: field {field} of block {key} changed; the selection across blocks lets go"
);
self.clear_span(window, cx);
false
}
/// Replaces the selection across fields with `put`, as one undo step. Two prose ends join, or part at a break.
pub(crate) fn collapse_span(
&mut self,
put: Put,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Spot> {
let (start, end) = self.span()?;
let typed_at = match put {
Put::Typed(key, field) => Some((key, field)),
Put::Nothing | Put::Break => None,
};
let was = |spot: Spot| {
let (_, data) = self
.current
.blocks
.iter()
.find(|(key, _)| *key == spot.key)?;
data.texts.get(spot.field).cloned()
};
let now = |spot: Spot| {
let block = &self.blocks[self.place(spot.key, spot.field)?];
Some(block.fields[spot.field].read(cx).text().to_string())
};
let parts = was(start).zip(was(end)).and_then(|(first, last)| {
Some((
first.get(..start.at)?.to_string(),
last.get(end.at..)?.to_string(),
))
});
let typed = parts.as_ref().and_then(|(head, tail)| match typed_at {
Some(at) if at == (start.key, start.field) => {
now(start)?.strip_prefix(head.as_str()).map(str::to_string)
}
Some(_) => now(end)?.strip_suffix(tail.as_str()).map(str::to_string),
None => Some(String::new()),
});
let (Some((head, tail)), Some(typed)) = (parts, typed) else {
log::error!("block editor: an edit across blocks landed outside its selection");
self.clear_span(window, cx);
return None;
};
let (first, last) = (self.index(start.key)?, self.index(end.key)?);
let mut before = self.current.clone();
before.caret = Some((start.key, start.field, start.at..start.at));
self.history.record(before, false);
let prose = |ix: usize| self.blocks[ix].kind.is_prose();
let joins = first != last && prose(first) && prose(last);
let at_end = typed_at == Some((end.key, end.field));
let order = self.reading_order();
let (from, to) = (self.rank(&order, start)?, self.rank(&order, end)?);
let set = |spot: Spot, text: String, cx: &mut Context<Self>| {
if let Some(ix) = self.place(spot.key, spot.field) {
self.blocks[ix].fields[spot.field].update(cx, |field, cx| field.set_text(text, cx));
}
};
let parts = joins && put == Put::Break;
let caret = if parts {
set(start, head.clone(), cx);
None
} else if joins {
set(start, format!("{head}{typed}{tail}"), cx);
Some(Spot {
at: head.len() + typed.len(),
..start
})
} else {
for (key, field) in order.iter().take(to.0).skip(from.0 + 1) {
if *key == start.key || *key == end.key {
set(
Spot {
key: *key,
field: *field,
at: 0,
},
String::new(),
cx,
);
}
}
match at_end {
true => {
set(start, head.clone(), cx);
set(end, format!("{typed}{tail}"), cx);
Some(Spot {
at: typed.len(),
..end
})
}
false => {
set(start, format!("{head}{typed}"), cx);
set(end, tail.clone(), cx);
Some(Spot {
at: head.len() + typed.len(),
..start
})
}
}
};
let gone = if joins {
first + 1..last + 1
} else {
first + 1..last.max(first + 1)
};
log::info!(
"block editor: a selection across {} blocks is replaced",
last - first + 1
);
self.blocks.drain(gone);
let caret = match caret {
Some(caret) => caret,
None => {
let key = self.fresh_key();
let kind = self.blocks[first].kind.next();
let data = BlockData {
kind,
texts: vec![tail],
};
let block = self.build(data, key, window, cx);
self.blocks.insert(first + 1, block);
Spot {
key,
field: 0,
at: 0,
}
}
};
self.across = Across::default();
self.after_change(cx);
self.focus_field(caret.key, caret.field, Some(caret.at), window, cx);
Some(caret)
}
}
+1
View File
@@ -5,6 +5,7 @@ use crate::{forms, theme::Theme};
mod markdown;
mod scroll;
mod span;
mod stale;
fn open(
+238
View File
@@ -0,0 +1,238 @@
use gpui::{
Entity, EntityInputHandler, Modifiers, MouseButton, Pixels, Point, TestAppContext,
VisualTestContext, point, px,
};
use super::{BlockData, BlockEditor, BlockKind, blocks, caret, open, settle, text};
use crate::theme::Theme;
/// Three paragraphs, settled where they stand: reduced motion ends the rows' first glide.
fn three(cx: &mut TestAppContext) -> (Entity<BlockEditor>, &mut VisualTestContext) {
let paragraph = |text: &str| BlockData::new(BlockKind::Paragraph, [text]);
let blocks = vec![paragraph("alpha"), paragraph("beta"), paragraph("gamma")];
let (editor, cx) = open(blocks, cx);
cx.update(|_, cx| Theme::update(cx, |theme| theme.reduced_motion = true));
std::thread::sleep(std::time::Duration::from_millis(5));
settle(cx);
(editor, cx)
}
/// Where byte `at` of block `ix`'s first field sits in the window.
fn at(
editor: &Entity<BlockEditor>,
ix: usize,
at: usize,
cx: &mut VisualTestContext,
) -> Point<Pixels> {
cx.update(|_, cx| {
let field = editor.read(cx).blocks[ix].fields[0].read(cx);
let caret = field.bounds_for(at).expect("laid out");
caret.origin + point(px(0.5), caret.size.height / 2.0)
})
}
/// Drags from `alpha` after two letters to `gamma` after three.
fn drag(editor: &Entity<BlockEditor>, cx: &mut VisualTestContext) {
let (from, to) = (at(editor, 0, 2, cx), at(editor, 2, 3, cx));
cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_move(to, Some(MouseButton::Left), Modifiers::none());
cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none());
settle(cx);
}
fn spanned(editor: &Entity<BlockEditor>, cx: &mut VisualTestContext) -> Option<(usize, usize)> {
cx.update(|_, cx| {
editor
.read(cx)
.span()
.map(|(start, end)| (start.at, end.at))
})
}
#[gpui::test]
fn a_drag_selects_across_blocks_and_typing_takes_its_place(cx: &mut TestAppContext) {
let (editor, cx) = three(cx);
drag(&editor, cx);
assert_eq!(spanned(&editor, cx), Some((2, 3)));
assert_eq!(cx.update(|_, cx| editor.read(cx).washed()), Some(1..2));
let selected = |ix: usize, cx: &mut VisualTestContext| {
cx.update(|_, cx| editor.read(cx).blocks[ix].fields[0].read(cx).selection())
};
assert_eq!((selected(0, cx), selected(2, cx)), (2..5, 0..3));
cx.simulate_input("X");
settle(cx);
assert_eq!(
blocks(&editor, cx),
vec![text(BlockKind::Paragraph, "alXma")]
);
cx.simulate_keystrokes("secondary-z");
settle(cx);
assert_eq!(
blocks(&editor, cx),
vec![
text(BlockKind::Paragraph, "alpha"),
text(BlockKind::Paragraph, "beta"),
text(BlockKind::Paragraph, "gamma"),
]
);
}
#[gpui::test]
fn shift_press_selects_from_the_caret_and_backspace_joins(cx: &mut TestAppContext) {
let (editor, cx) = three(cx);
caret(&editor, 0, 1, cx);
let to = at(&editor, 2, 3, cx);
cx.simulate_mouse_down(to, MouseButton::Left, Modifiers::shift());
cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::shift());
settle(cx);
assert_eq!(spanned(&editor, cx), Some((1, 3)));
cx.simulate_keystrokes("backspace");
settle(cx);
assert_eq!(blocks(&editor, cx), vec![text(BlockKind::Paragraph, "ama")]);
}
#[gpui::test]
fn copy_takes_the_selection_as_markdown(cx: &mut TestAppContext) {
let (editor, cx) = three(cx);
drag(&editor, cx);
cx.simulate_keystrokes("secondary-c");
let copied = cx.read_from_clipboard().and_then(|item| item.text());
assert_eq!(copied.as_deref(), Some("pha\n\nbeta\n\ngam"));
cx.simulate_keystrokes("secondary-x");
settle(cx);
assert_eq!(
blocks(&editor, cx),
vec![text(BlockKind::Paragraph, "alma")]
);
}
#[gpui::test]
fn a_key_that_moves_the_caret_lets_the_selection_go(cx: &mut TestAppContext) {
let (editor, cx) = three(cx);
drag(&editor, cx);
cx.simulate_keystrokes("left");
settle(cx);
assert_eq!(spanned(&editor, cx), None);
let gamma = cx.update(|_, cx| editor.read(cx).blocks[2].fields[0].read(cx).selection());
assert!(gamma.is_empty(), "{gamma:?}");
cx.simulate_input("Y");
settle(cx);
assert_eq!(blocks(&editor, cx).len(), 3);
}
#[gpui::test]
fn enter_replaces_the_selection_with_a_break(cx: &mut TestAppContext) {
let (editor, cx) = three(cx);
drag(&editor, cx);
cx.simulate_keystrokes("enter");
settle(cx);
assert_eq!(
blocks(&editor, cx),
vec![
text(BlockKind::Paragraph, "al"),
text(BlockKind::Paragraph, "ma"),
]
);
}
fn original() -> Vec<(BlockKind, Vec<String>)> {
["alpha", "beta", "gamma"]
.map(|words| text(BlockKind::Paragraph, words))
.to_vec()
}
#[gpui::test]
fn enter_over_a_selection_undoes_in_one_step(cx: &mut TestAppContext) {
let (editor, cx) = three(cx);
drag(&editor, cx);
cx.simulate_keystrokes("enter");
settle(cx);
cx.simulate_keystrokes("secondary-z");
settle(cx);
assert_eq!(blocks(&editor, cx), original());
}
#[gpui::test]
fn pasting_what_was_selected_still_takes_the_blocks_between(cx: &mut TestAppContext) {
let (editor, cx) = three(cx);
drag(&editor, cx);
cx.write_to_clipboard(gpui::ClipboardItem::new_string("pha".into()));
cx.simulate_keystrokes("secondary-v");
settle(cx);
assert_eq!(
blocks(&editor, cx),
vec![text(BlockKind::Paragraph, "alphama")]
);
}
#[gpui::test]
fn backspace_from_the_end_of_a_block_takes_the_selection(cx: &mut TestAppContext) {
let (editor, cx) = three(cx);
let (from, to) = (at(&editor, 0, 5, cx), at(&editor, 2, 3, cx));
cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_move(to, Some(MouseButton::Left), Modifiers::none());
cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none());
settle(cx);
assert_eq!(spanned(&editor, cx), Some((5, 3)));
cx.simulate_keystrokes("backspace");
settle(cx);
assert_eq!(
blocks(&editor, cx),
vec![text(BlockKind::Paragraph, "alphama")]
);
}
#[gpui::test]
fn a_composition_takes_the_selection_once_it_commits(cx: &mut TestAppContext) {
let (editor, cx) = three(cx);
drag(&editor, cx);
let field = cx.update(|_, cx| editor.read(cx).blocks[0].fields[0].clone());
cx.update(|window, cx| {
field.update(cx, |input, cx| {
input.replace_and_mark_text_in_range(None, "n", None, window, cx);
input.replace_and_mark_text_in_range(None, "ni", None, window, cx);
})
});
settle(cx);
assert_eq!(blocks(&editor, cx).len(), 3, "composing waits");
cx.update(|window, cx| {
field.update(cx, |input, cx| {
input.replace_text_in_range(None, "你", window, cx)
})
});
settle(cx);
assert_eq!(
blocks(&editor, cx),
vec![text(BlockKind::Paragraph, "al你ma")]
);
}
#[gpui::test]
fn typing_after_shift_press_or_an_upward_drag_lands_once(cx: &mut TestAppContext) {
let (editor, cx) = three(cx);
caret(&editor, 0, 1, cx);
let to = at(&editor, 2, 3, cx);
cx.simulate_mouse_down(to, MouseButton::Left, Modifiers::shift());
cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::shift());
settle(cx);
cx.simulate_input("Z");
settle(cx);
assert_eq!(
blocks(&editor, cx),
vec![text(BlockKind::Paragraph, "aZma")]
);
cx.simulate_keystrokes("secondary-z");
settle(cx);
let (from, to) = (at(&editor, 2, 3, cx), at(&editor, 0, 2, cx));
cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_move(to, Some(MouseButton::Left), Modifiers::none());
cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none());
settle(cx);
assert_eq!(spanned(&editor, cx), Some((2, 3)));
cx.simulate_input("Q");
settle(cx);
assert_eq!(
blocks(&editor, cx),
vec![text(BlockKind::Paragraph, "alQma")]
);
}
+49 -6
View File
@@ -2,15 +2,19 @@ use std::rc::Rc;
use gpui::{
AnyElement, App, Axis, Context, ElementId, InteractiveElement, IntoElement, MouseButton,
ParentElement, Rems, Render, SharedString, Styled, Window, canvas, div, prelude::*,
MouseDownEvent, ParentElement, Rems, Render, SharedString, Styled, Window, canvas, div,
prelude::*,
};
use super::{Block, BlockData, BlockEditor, BlockKind, matching};
use super::{Block, BlockData, BlockEditor, BlockKind, matching, span::Put};
use crate::{
buttons::{ButtonVariant, IconButton},
documents::suggest::{Offer, offers},
forms::replace_trigger,
forms::{Backspace, Choice, Down, Enter, Pick, Redo, Submit, Undo, Up},
forms::{
Backspace, Choice, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Pick, Redo, Submit,
Undo, Up,
},
layout::{ScrollArea, bring_into_view},
menus::{Menu, MenuItem, OverflowMenu},
motion::Reorder,
@@ -95,12 +99,17 @@ impl BlockEditor {
let keys = div()
.w_full()
.capture_action(cx.listener(move |editor, _: &Enter, window, cx| {
if editor.collapse_span(Put::Break, window, cx).is_some() {
return cx.stop_propagation();
}
if editor.on_enter(key, ix, window, cx) {
cx.stop_propagation();
}
}))
.capture_action(cx.listener(move |editor, _: &Backspace, window, cx| {
if editor.on_backspace(key, ix, window, cx) {
if editor.collapse_span(Put::Nothing, window, cx).is_some()
|| editor.on_backspace(key, ix, window, cx)
{
cx.stop_propagation();
}
}))
@@ -118,7 +127,13 @@ impl BlockEditor {
cx.stop_propagation();
editor.on_submit(key, window, cx);
}))
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.on_mouse_down(
MouseButton::Left,
cx.listener(move |editor, event: &MouseDownEvent, window, cx| {
editor.pressed(key, ix, event, window, cx);
cx.stop_propagation();
}),
)
.child(field.clone());
let slash = move |query: &str, at: usize, caret: usize| -> (Vec<Choice>, Pick) {
let kinds = matching(query);
@@ -211,6 +226,7 @@ impl Render for BlockEditor {
let look = Look::new(cx);
let colors = look.colors.clone();
let focused = self.focused(window, cx).map(|(key, _)| key);
let washed = self.washed().unwrap_or_default();
let mut number = 0;
let mut rows: Vec<(SharedString, AnyElement)> = Vec::new();
for ix in 0..self.blocks.len() {
@@ -262,6 +278,9 @@ impl Render for BlockEditor {
.group(group.clone())
.relative()
.pl_16()
.when(washed.contains(&ix), |row| {
row.bg(colors.selection).rounded(look.radius)
})
.py_0p5()
.child(content)
.child(
@@ -293,6 +312,27 @@ impl Render for BlockEditor {
let column = div()
.flex()
.flex_col()
.capture_action(cx.listener(|editor, _: &Copy, _, cx| {
if editor.copy_span(cx) {
cx.stop_propagation();
}
}))
.capture_action(cx.listener(|editor, _: &Cut, window, cx| {
if editor.copy_span(cx) {
cx.stop_propagation();
editor.collapse_span(Put::Nothing, window, cx);
}
}))
.capture_action(cx.listener(|editor, _: &Delete, window, cx| {
if editor.collapse_span(Put::Nothing, window, cx).is_some() {
cx.stop_propagation();
}
}))
.capture_action(cx.listener(|editor, _: &DeleteWordLeft, window, cx| {
if editor.collapse_span(Put::Nothing, window, cx).is_some() {
cx.stop_propagation();
}
}))
.capture_action(cx.listener(|editor, _: &Undo, window, cx| {
cx.stop_propagation();
editor.undo(window, cx);
@@ -322,7 +362,10 @@ impl Render for BlockEditor {
});
}),
)
.child(caret);
.child(caret)
.when(self.pressing(), |column| {
column.child(self.drag_listener(cx))
});
ScrollArea::new(id)
.size_full()
.track_scroll(&self.scroll)
+2 -1
View File
@@ -114,7 +114,8 @@ pub use structure::{
pub use switch::Switch;
pub use tags::TagInput;
pub(crate) use text::{
Backspace, Down, Enter, Redo, Submit, Undo, Up, bind_keys, from_utf16, to_utf16,
Backspace, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Redo, Submit, Undo, Up, bind_keys,
from_utf16, to_utf16,
};
pub use text::{Highlight, History, InputEvent, TextInput};
pub use transfer::TransferList;
+1
View File
@@ -107,6 +107,7 @@ impl EntityInputHandler for TextInput {
if self.marked.is_none() {
self.composing = Some(self.snapshot());
}
self.edits += 1;
self.text.replace_range(range.clone(), text);
self.marked = (!text.is_empty()).then(|| range.start..range.start + text.len());
if self.marked.is_none() {
+12 -1
View File
@@ -12,7 +12,9 @@ use gpui::{
Subscription, Task, Window, WrappedLine,
};
pub(crate) use actions::{Backspace, Down, Enter, Redo, Submit, Undo, Up, bind_keys};
pub(crate) use actions::{
Backspace, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Redo, Submit, Undo, Up, bind_keys,
};
pub use edit::History;
use edit::Snapshot;
pub(crate) use edit::{from_utf16, to_utf16};
@@ -66,6 +68,8 @@ pub struct TextInput {
history: History<Snapshot>,
/// The selection the last edit replaced.
edited_from: Range<usize>,
/// Edits applied, text changed or not.
edits: u64,
pub(crate) layout: Option<Layout>,
pub(crate) scroll: Point<Pixels>,
selecting: bool,
@@ -120,6 +124,7 @@ impl TextInput {
disabled: false,
history: History::default(),
edited_from: 0..0,
edits: 0,
layout: None,
scroll: Point::default(),
selecting: false,
@@ -311,6 +316,11 @@ impl TextInput {
self.edited_from.clone()
}
/// How many edits it took, and whether an input method is composing.
pub(crate) fn edits(&self) -> (u64, bool) {
(self.edits, self.marked.is_some())
}
pub(crate) fn replace(
&mut self,
range: Range<usize>,
@@ -332,6 +342,7 @@ impl TextInput {
if incoming.is_empty() && range.is_empty() {
return;
}
self.edits += 1;
let composed = self.composing.take();
let before = composed.clone().unwrap_or_else(|| self.snapshot());
let caret = match &self.fit {
+1 -1
View File
@@ -21,5 +21,5 @@
- [x] G13 Issue #12: a floating option list occludes, so the wheel scrolls the list and not the page under it; 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
- [x] G15 Issue #7: BlockEditor turns into Markdown and back; tested. Five reviews, the cap: the fifth finding (a slash ended an unquoted HTML value) is fixed and tested, unreviewed
- [ ] G16 Issue #7: a selection across blocks: drag or Shift-press selects, copy, cut, delete and typing act on it, undo restores; tested
- [x] 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