diff --git a/AGENTS.md b/AGENTS.md index c8684dd..4a0c47b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/src/documents/blocks/edit.rs b/src/documents/blocks/edit.rs index 39cbb78..55c0c71 100644 --- a/src/documents/blocks/edit.rs +++ b/src/documents/blocks/edit.rs @@ -76,6 +76,7 @@ impl BlockEditor { } pub(crate) fn undo(&mut self, window: &mut Window, cx: &mut Context) { + 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.clear_span(window, cx); if let Some(next) = self.history.redo(self.now(cx)) { log::info!("block editor: redo"); self.restore(next, window, cx); diff --git a/src/documents/blocks/keys.rs b/src/documents/blocks/keys.rs index 91d67ea..48cd1d2 100644 --- a/src/documents/blocks/keys.rs +++ b/src/documents/blocks/keys.rs @@ -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| { diff --git a/src/documents/blocks/mod.rs b/src/documents/blocks/mod.rs index be72bce..6f897b9 100644 --- a/src/documents/blocks/mod.rs +++ b/src/documents/blocks/mod.rs @@ -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, pub(crate) diagram: Option, scroll: ScrollHandle, + across: span::Across, /// The caret last brought into view: field, selection and length. revealed: Option<(EntityId, std::ops::Range, 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 diff --git a/src/documents/blocks/span.rs b/src/documents/blocks/span.rs new file mode 100644 index 0000000..a55dbe4 --- /dev/null +++ b/src/documents/blocks/span.rs @@ -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, + span: Option<(Spot, Spot)>, + /// Watches the focused end: an edit there takes the selection's place, a caret move lets it go. + held: Option, + /// 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> { + let (start, end) = self.span()?; + Some(self.index(start.key)? + 1..self.index(end.key)?) + } + + fn spot( + &self, + key: u64, + field: usize, + position: Point, + cx: &Context, + ) -> Option { + 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, + ) { + 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, cx: &Context) -> 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, window: &mut Window, cx: &mut Context) { + 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) -> 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) { + 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.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) { + 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) { + 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) -> 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> = 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, + ) -> 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, + ) -> Option { + 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| { + 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) + } +} diff --git a/src/documents/blocks/tests/mod.rs b/src/documents/blocks/tests/mod.rs index ac1c96c..53f649a 100644 --- a/src/documents/blocks/tests/mod.rs +++ b/src/documents/blocks/tests/mod.rs @@ -5,6 +5,7 @@ use crate::{forms, theme::Theme}; mod markdown; mod scroll; +mod span; mod stale; fn open( diff --git a/src/documents/blocks/tests/span.rs b/src/documents/blocks/tests/span.rs new file mode 100644 index 0000000..5bdfdba --- /dev/null +++ b/src/documents/blocks/tests/span.rs @@ -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, &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, + ix: usize, + at: usize, + cx: &mut VisualTestContext, +) -> Point { + 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, 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, 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)> { + ["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")] + ); +} diff --git a/src/documents/blocks/view.rs b/src/documents/blocks/view.rs index 67e0e7d..eb7372e 100644 --- a/src/documents/blocks/view.rs +++ b/src/documents/blocks/view.rs @@ -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, 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) diff --git a/src/forms/mod.rs b/src/forms/mod.rs index 64c4407..8cbdae6 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -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; diff --git a/src/forms/text/ime.rs b/src/forms/text/ime.rs index f5357f8..0a9af3d 100644 --- a/src/forms/text/ime.rs +++ b/src/forms/text/ime.rs @@ -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() { diff --git a/src/forms/text/mod.rs b/src/forms/text/mod.rs index 5b11379..5655c9a 100644 --- a/src/forms/text/mod.rs +++ b/src/forms/text/mod.rs @@ -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, /// The selection the last edit replaced. edited_from: Range, + /// Edits applied, text changed or not. + edits: u64, pub(crate) layout: Option, pub(crate) scroll: Point, 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, @@ -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 { diff --git a/tasks/github.md b/tasks/github.md index 57e493e..86cab37 100644 --- a/tasks/github.md +++ b/tasks/github.md @@ -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