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:
1 parent
f5eb82ee1b
commit
d24984cc38
12 files changed
+797
-10
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use crate::{forms, theme::Theme};
|
||||
|
||||
mod markdown;
|
||||
mod scroll;
|
||||
mod span;
|
||||
mod stale;
|
||||
|
||||
fn open(
|
||||
|
||||
@@ -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")]
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user