@@ -1,5 +1,5 @@
use super::{BoolExt as _, Dispatcher, FontSystem, Window};
-use crate::{executor, keymap::Keystroke, platform, Event, Menu, MenuItem};
+use crate::{executor, keymap::Keystroke, platform, ClipboardItem, Event, Menu, MenuItem};
use cocoa::{
appkit::{
NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular,
@@ -21,11 +21,13 @@ use ptr::null_mut;
use std::{
any::Any,
cell::RefCell,
+ convert::TryInto,
ffi::{c_void, CStr},
os::raw::c_char,
path::PathBuf,
ptr,
rc::Rc,
+ slice, str,
sync::Arc,
};
@@ -77,6 +79,9 @@ pub struct MacPlatform {
fonts: Arc<FontSystem>,
callbacks: RefCell<Callbacks>,
menu_item_actions: RefCell<Vec<(String, Option<Box<dyn Any>>)>>,
+ pasteboard: id,
+ text_hash_pasteboard_type: id,
+ metadata_pasteboard_type: id,
}
#[derive(Default)]
@@ -96,6 +101,9 @@ impl MacPlatform {
fonts: Arc::new(FontSystem::new()),
callbacks: Default::default(),
menu_item_actions: Default::default(),
+ pasteboard: unsafe { NSPasteboard::generalPasteboard(nil) },
+ text_hash_pasteboard_type: unsafe { ns_string("zed-text-hash") },
+ metadata_pasteboard_type: unsafe { ns_string("zed-metadata") },
}
}
@@ -176,6 +184,18 @@ impl MacPlatform {
menu_bar
}
+
+ unsafe fn read_from_pasteboard(&self, kind: id) -> Option<&[u8]> {
+ let data = self.pasteboard.dataForType(kind);
+ if data == nil {
+ None
+ } else {
+ Some(slice::from_raw_parts(
+ data.bytes() as *mut u8,
+ data.length() as usize,
+ ))
+ }
+ }
}
impl platform::Platform for MacPlatform {
@@ -286,16 +306,72 @@ impl platform::Platform for MacPlatform {
}
}
- fn copy(&self, text: &str) {
+ fn write_to_clipboard(&self, item: ClipboardItem) {
unsafe {
- let data = NSData::dataWithBytes_length_(
+ self.pasteboard.clearContents();
+
+ let text_bytes = NSData::dataWithBytes_length_(
nil,
- text.as_ptr() as *const c_void,
- text.len() as u64,
+ item.text.as_ptr() as *const c_void,
+ item.text.len() as u64,
);
- let pasteboard = NSPasteboard::generalPasteboard(nil);
- pasteboard.clearContents();
- pasteboard.setData_forType(data, NSPasteboardTypeString);
+ self.pasteboard
+ .setData_forType(text_bytes, NSPasteboardTypeString);
+
+ if let Some(metadata) = item.metadata.as_ref() {
+ let hash_bytes = ClipboardItem::text_hash(&item.text).to_be_bytes();
+ let hash_bytes = NSData::dataWithBytes_length_(
+ nil,
+ hash_bytes.as_ptr() as *const c_void,
+ hash_bytes.len() as u64,
+ );
+ self.pasteboard
+ .setData_forType(hash_bytes, self.text_hash_pasteboard_type);
+
+ let metadata_bytes = NSData::dataWithBytes_length_(
+ nil,
+ metadata.as_ptr() as *const c_void,
+ metadata.len() as u64,
+ );
+ self.pasteboard
+ .setData_forType(metadata_bytes, self.metadata_pasteboard_type);
+ }
+ }
+ }
+
+ fn read_from_clipboard(&self) -> Option<ClipboardItem> {
+ unsafe {
+ if let Some(text_bytes) = self.read_from_pasteboard(NSPasteboardTypeString) {
+ let text = String::from_utf8_lossy(&text_bytes).to_string();
+ let hash_bytes = self
+ .read_from_pasteboard(self.text_hash_pasteboard_type)
+ .and_then(|bytes| bytes.try_into().ok())
+ .map(u64::from_be_bytes);
+ let metadata_bytes = self
+ .read_from_pasteboard(self.metadata_pasteboard_type)
+ .and_then(|bytes| String::from_utf8(bytes.to_vec()).ok());
+
+ if let Some((hash, metadata)) = hash_bytes.zip(metadata_bytes) {
+ if hash == ClipboardItem::text_hash(&text) {
+ Some(ClipboardItem {
+ text,
+ metadata: Some(metadata),
+ })
+ } else {
+ Some(ClipboardItem {
+ text,
+ metadata: None,
+ })
+ }
+ } else {
+ Some(ClipboardItem {
+ text,
+ metadata: None,
+ })
+ }
+ } else {
+ None
+ }
}
}
@@ -392,3 +468,46 @@ extern "C" fn handle_menu_item(this: &mut Object, _: Sel, item: id) {
unsafe fn ns_string(string: &str) -> id {
NSString::alloc(nil).init_str(string).autorelease()
}
+
+#[cfg(test)]
+mod tests {
+ use crate::platform::Platform;
+
+ use super::*;
+
+ #[test]
+ fn test_clipboard() {
+ let platform = build_platform();
+ assert_eq!(platform.read_from_clipboard(), None);
+
+ let item = ClipboardItem::new("1".to_string());
+ platform.write_to_clipboard(item.clone());
+ assert_eq!(platform.read_from_clipboard(), Some(item));
+
+ let item = ClipboardItem::new("2".to_string()).with_metadata(vec![3, 4]);
+ platform.write_to_clipboard(item.clone());
+ assert_eq!(platform.read_from_clipboard(), Some(item));
+
+ let text_from_other_app = "text from other app";
+ unsafe {
+ let bytes = NSData::dataWithBytes_length_(
+ nil,
+ text_from_other_app.as_ptr() as *const c_void,
+ text_from_other_app.len() as u64,
+ );
+ platform
+ .pasteboard
+ .setData_forType(bytes, NSPasteboardTypeString);
+ }
+ assert_eq!(
+ platform.read_from_clipboard(),
+ Some(ClipboardItem::new(text_from_other_app.to_string()))
+ );
+ }
+
+ fn build_platform() -> MacPlatform {
+ let mut platform = MacPlatform::new();
+ platform.pasteboard = unsafe { NSPasteboard::pasteboardWithUniqueName(nil) };
+ platform
+ }
+}
@@ -1,22 +1,24 @@
use super::{
buffer, movement, Anchor, Bias, Buffer, BufferElement, DisplayMap, DisplayPoint, Point,
- Selection, SelectionSetId, ToOffset,
+ Selection, SelectionSetId, ToOffset, ToPoint,
};
use crate::{settings::Settings, watch, workspace};
use anyhow::Result;
use futures_core::future::LocalBoxFuture;
use gpui::{
- fonts::Properties as FontProperties, keymap::Binding, text_layout, AppContext, Element,
- ElementBox, Entity, FontCache, ModelHandle, MutableAppContext, View, ViewContext,
+ fonts::Properties as FontProperties, keymap::Binding, text_layout, AppContext, ClipboardItem,
+ Element, ElementBox, Entity, FontCache, ModelHandle, MutableAppContext, View, ViewContext,
WeakViewHandle,
};
use gpui::{geometry::vector::Vector2F, TextLayoutCache};
use parking_lot::Mutex;
+use serde::{Deserialize, Serialize};
use smallvec::SmallVec;
use smol::Timer;
use std::{
cmp::{self, Ordering},
fmt::Write,
+ iter::FromIterator,
ops::Range,
sync::Arc,
time::Duration,
@@ -28,6 +30,9 @@ pub fn init(app: &mut MutableAppContext) {
app.add_bindings(vec![
Binding::new("backspace", "buffer:backspace", Some("BufferView")),
Binding::new("enter", "buffer:newline", Some("BufferView")),
+ Binding::new("cmd-x", "buffer:cut", Some("BufferView")),
+ Binding::new("cmd-c", "buffer:copy", Some("BufferView")),
+ Binding::new("cmd-v", "buffer:paste", Some("BufferView")),
Binding::new("cmd-z", "buffer:undo", Some("BufferView")),
Binding::new("cmd-shift-Z", "buffer:redo", Some("BufferView")),
Binding::new("up", "buffer:move_up", Some("BufferView")),
@@ -54,6 +59,9 @@ pub fn init(app: &mut MutableAppContext) {
app.add_action("buffer:insert", BufferView::insert);
app.add_action("buffer:newline", BufferView::newline);
app.add_action("buffer:backspace", BufferView::backspace);
+ app.add_action("buffer:cut", BufferView::cut);
+ app.add_action("buffer:copy", BufferView::copy);
+ app.add_action("buffer:paste", BufferView::paste);
app.add_action("buffer:undo", BufferView::undo);
app.add_action("buffer:redo", BufferView::redo);
app.add_action("buffer:move_up", BufferView::move_up);
@@ -102,6 +110,12 @@ pub struct BufferView {
single_line: bool,
}
+#[derive(Serialize, Deserialize)]
+struct ClipboardSelection {
+ len: usize,
+ is_entire_line: bool,
+}
+
impl BufferView {
pub fn single_line(settings: watch::Receiver<Settings>, ctx: &mut ViewContext<Self>) -> Self {
let buffer = ctx.add_model(|_| Buffer::new(0, String::new()));
@@ -354,6 +368,25 @@ impl BufferView {
#[cfg(test)]
fn select_ranges<'a, T>(&mut self, ranges: T, ctx: &mut ViewContext<Self>) -> Result<()>
+ where
+ T: IntoIterator<Item = &'a Range<usize>>,
+ {
+ let buffer = self.buffer.read(ctx);
+ let mut selections = Vec::new();
+ for range in ranges {
+ selections.push(Selection {
+ start: buffer.anchor_after(range.start)?,
+ end: buffer.anchor_before(range.end)?,
+ reversed: false,
+ goal_column: None,
+ });
+ }
+ self.update_selections(selections, ctx);
+ Ok(())
+ }
+
+ #[cfg(test)]
+ fn select_display_ranges<'a, T>(&mut self, ranges: T, ctx: &mut ViewContext<Self>) -> Result<()>
where
T: IntoIterator<Item = &'a Range<DisplayPoint>>,
{
@@ -454,6 +487,161 @@ impl BufferView {
self.end_transaction(ctx);
}
+ pub fn cut(&mut self, _: &(), ctx: &mut ViewContext<Self>) {
+ self.start_transaction(ctx);
+ let mut text = String::new();
+ let mut selections = self.selections(ctx.as_ref()).to_vec();
+ let mut clipboard_selections = Vec::with_capacity(selections.len());
+ {
+ let buffer = self.buffer.read(ctx);
+ let max_point = buffer.max_point();
+ for selection in &mut selections {
+ let mut start = selection.start.to_point(buffer).expect("invalid start");
+ let mut end = selection.end.to_point(buffer).expect("invalid end");
+ let is_entire_line = start == end;
+ if is_entire_line {
+ start = Point::new(start.row, 0);
+ end = cmp::min(max_point, Point::new(start.row + 1, 0));
+ selection.start = buffer.anchor_before(start).unwrap();
+ selection.end = buffer.anchor_after(end).unwrap();
+ }
+ let mut len = 0;
+ for ch in buffer.text_for_range(start..end).unwrap() {
+ text.push(ch);
+ len += 1;
+ }
+ clipboard_selections.push(ClipboardSelection {
+ len,
+ is_entire_line,
+ });
+ }
+ }
+ self.update_selections(selections, ctx);
+ self.changed_selections(ctx);
+ self.insert(&String::new(), ctx);
+ self.end_transaction(ctx);
+
+ ctx.as_mut()
+ .write_to_clipboard(ClipboardItem::new(text).with_metadata(clipboard_selections));
+ }
+
+ pub fn copy(&mut self, _: &(), ctx: &mut ViewContext<Self>) {
+ let buffer = self.buffer.read(ctx);
+ let max_point = buffer.max_point();
+ let mut text = String::new();
+ let selections = self.selections(ctx.as_ref());
+ let mut clipboard_selections = Vec::with_capacity(selections.len());
+ for selection in selections {
+ let mut start = selection.start.to_point(buffer).expect("invalid start");
+ let mut end = selection.end.to_point(buffer).expect("invalid end");
+ let is_entire_line = start == end;
+ if is_entire_line {
+ start = Point::new(start.row, 0);
+ end = cmp::min(max_point, Point::new(start.row + 1, 0));
+ }
+ let mut len = 0;
+ for ch in buffer.text_for_range(start..end).unwrap() {
+ text.push(ch);
+ len += 1;
+ }
+ clipboard_selections.push(ClipboardSelection {
+ len,
+ is_entire_line,
+ });
+ }
+
+ ctx.as_mut()
+ .write_to_clipboard(ClipboardItem::new(text).with_metadata(clipboard_selections));
+ }
+
+ pub fn paste(&mut self, _: &(), ctx: &mut ViewContext<Self>) {
+ if let Some(item) = ctx.as_mut().read_from_clipboard() {
+ let clipboard_text = item.text();
+ if let Some(clipboard_selections) = item.metadata::<Vec<ClipboardSelection>>() {
+ let selections_len = self.selections(ctx.as_ref()).len();
+ if clipboard_selections.len() == selections_len {
+ // If there are the same number of selections as there were at the time that
+ // this clipboard data was written, then paste one slice of the clipboard text
+ // into each of the current selections.
+ self.multiline_paste(clipboard_text.chars(), clipboard_selections.iter(), ctx);
+ } else if clipboard_selections.len() == 1 && clipboard_selections[0].is_entire_line
+ {
+ // If there was only one selection in the clipboard but it spanned the whole
+ // line, then paste it into each of the current selections so that we can
+ // position it before those selections that are empty.
+ self.multiline_paste(
+ clipboard_text.chars().cycle(),
+ clipboard_selections.iter().cycle(),
+ ctx,
+ );
+ } else {
+ self.insert(clipboard_text, ctx);
+ }
+ } else {
+ self.insert(clipboard_text, ctx);
+ }
+ }
+ }
+
+ fn multiline_paste<'a>(
+ &mut self,
+ mut clipboard_text: impl Iterator<Item = char>,
+ clipboard_selections: impl Iterator<Item = &'a ClipboardSelection>,
+ ctx: &mut ViewContext<Self>,
+ ) {
+ self.start_transaction(ctx);
+ let selections = self.selections(ctx.as_ref()).to_vec();
+ let mut new_selections = Vec::with_capacity(selections.len());
+ let mut clipboard_offset = 0;
+ for (selection, clipboard_selection) in selections.iter().zip(clipboard_selections) {
+ let clipboard_slice =
+ String::from_iter(clipboard_text.by_ref().take(clipboard_selection.len));
+ clipboard_offset = clipboard_offset + clipboard_selection.len;
+
+ self.buffer.update(ctx, |buffer, ctx| {
+ let selection_start = selection.start.to_point(buffer).unwrap();
+ let selection_end = selection.end.to_point(buffer).unwrap();
+ let max_point = buffer.max_point();
+
+ // If the corresponding selection was empty when this slice of the
+ // clipboard text was written, then the entire line containing the
+ // selection was copied. If this selection is also currently empty,
+ // then paste the line before the current line of the buffer.
+ let anchor;
+ if selection_start == selection_end && clipboard_selection.is_entire_line {
+ let start_point = Point::new(selection_start.row, 0);
+ let start = start_point.to_offset(buffer).unwrap();
+ let new_position = cmp::min(
+ max_point,
+ Point::new(start_point.row + 1, selection_start.column),
+ );
+ buffer
+ .edit(Some(start..start), clipboard_slice, Some(ctx))
+ .unwrap();
+ anchor = buffer.anchor_before(new_position).unwrap();
+ } else {
+ let start = selection.start.to_offset(buffer).unwrap();
+ let end = selection.end.to_offset(buffer).unwrap();
+ buffer
+ .edit(Some(start..end), clipboard_slice, Some(ctx))
+ .unwrap();
+ anchor = buffer
+ .anchor_before(start + clipboard_selection.len)
+ .unwrap();
+ }
+
+ new_selections.push(Selection {
+ start: anchor.clone(),
+ end: anchor,
+ reversed: false,
+ goal_column: None,
+ });
+ });
+ }
+ self.update_selections(new_selections, ctx);
+ self.end_transaction(ctx);
+ }
+
pub fn undo(&mut self, _: &(), ctx: &mut ViewContext<Self>) {
self.buffer
.update(ctx, |buffer, ctx| buffer.undo(Some(ctx)));
@@ -1417,8 +1605,11 @@ mod tests {
app.add_window(|ctx| BufferView::for_buffer(buffer.clone(), settings, ctx));
view.update(app, |view, ctx| {
- view.select_ranges(&[DisplayPoint::new(8, 0)..DisplayPoint::new(12, 0)], ctx)
- .unwrap();
+ view.select_display_ranges(
+ &[DisplayPoint::new(8, 0)..DisplayPoint::new(12, 0)],
+ ctx,
+ )
+ .unwrap();
view.fold(&(), ctx);
assert_eq!(
view.text(ctx.as_ref()),
@@ -1525,7 +1716,7 @@ mod tests {
app.add_window(|ctx| BufferView::for_buffer(buffer.clone(), settings, ctx));
view.update(app, |view, ctx| {
- view.select_ranges(
+ view.select_display_ranges(
&[
// an empty selection - the preceding character is deleted
DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2),
@@ -1547,6 +1738,147 @@ mod tests {
})
}
+ #[test]
+ fn test_clipboard() {
+ App::test((), |app| {
+ let buffer = app.add_model(|_| Buffer::new(0, "one two three four five six "));
+ let settings = settings::channel(&app.font_cache()).unwrap().1;
+ let view = app
+ .add_window(|ctx| BufferView::for_buffer(buffer.clone(), settings, ctx))
+ .1;
+
+ // Cut with three selections. Clipboard text is divided into three slices.
+ view.update(app, |view, ctx| {
+ view.select_ranges(&[0..4, 8..14, 19..24], ctx).unwrap();
+ view.cut(&(), ctx);
+ });
+ assert_eq!(view.read(app).text(app.as_ref()), "two four six ");
+
+ // Paste with three cursors. Each cursor pastes one slice of the clipboard text.
+ view.update(app, |view, ctx| {
+ view.select_ranges(&[4..4, 9..9, 13..13], ctx).unwrap();
+ view.paste(&(), ctx);
+ });
+ assert_eq!(
+ view.read(app).text(app.as_ref()),
+ "two one four three six five "
+ );
+ assert_eq!(
+ view.read(app).selection_ranges(app.as_ref()),
+ &[
+ DisplayPoint::new(0, 8)..DisplayPoint::new(0, 8),
+ DisplayPoint::new(0, 19)..DisplayPoint::new(0, 19),
+ DisplayPoint::new(0, 28)..DisplayPoint::new(0, 28)
+ ]
+ );
+
+ // Paste again but with only two cursors. Since the number of cursors doesn't
+ // match the number of slices in the clipboard, the entire clipboard text
+ // is pasted at each cursor.
+ view.update(app, |view, ctx| {
+ view.select_ranges(&[0..0, 28..28], ctx).unwrap();
+ view.insert(&"( ".to_string(), ctx);
+ view.paste(&(), ctx);
+ view.insert(&") ".to_string(), ctx);
+ });
+ assert_eq!(
+ view.read(app).text(app.as_ref()),
+ "( one three five ) two one four three six five ( one three five ) "
+ );
+
+ view.update(app, |view, ctx| {
+ view.select_ranges(&[0..0], ctx).unwrap();
+ view.insert(&"123\n4567\n89\n".to_string(), ctx);
+ });
+ assert_eq!(
+ view.read(app).text(app.as_ref()),
+ "123\n4567\n89\n( one three five ) two one four three six five ( one three five ) "
+ );
+
+ // Cut with three selections, one of which is full-line.
+ view.update(app, |view, ctx| {
+ view.select_display_ranges(
+ &[
+ DisplayPoint::new(0, 1)..DisplayPoint::new(0, 2),
+ DisplayPoint::new(1, 1)..DisplayPoint::new(1, 1),
+ DisplayPoint::new(2, 0)..DisplayPoint::new(2, 1),
+ ],
+ ctx,
+ )
+ .unwrap();
+ view.cut(&(), ctx);
+ });
+ assert_eq!(
+ view.read(app).text(app.as_ref()),
+ "13\n9\n( one three five ) two one four three six five ( one three five ) "
+ );
+
+ // Paste with three selections, noticing how the copied selection that was full-line
+ // gets inserted before the second cursor.
+ view.update(app, |view, ctx| {
+ view.select_display_ranges(
+ &[
+ DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1),
+ DisplayPoint::new(1, 1)..DisplayPoint::new(1, 1),
+ DisplayPoint::new(2, 2)..DisplayPoint::new(2, 3),
+ ],
+ ctx,
+ )
+ .unwrap();
+ view.paste(&(), ctx);
+ });
+ assert_eq!(
+ view.read(app).text(app.as_ref()),
+ "123\n4567\n9\n( 8ne three five ) two one four three six five ( one three five ) "
+ );
+ assert_eq!(
+ view.read(app).selection_ranges(app.as_ref()),
+ &[
+ DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2),
+ DisplayPoint::new(2, 1)..DisplayPoint::new(2, 1),
+ DisplayPoint::new(3, 3)..DisplayPoint::new(3, 3),
+ ]
+ );
+
+ // Copy with a single cursor only, which writes the whole line into the clipboard.
+ view.update(app, |view, ctx| {
+ view.select_display_ranges(
+ &[DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1)],
+ ctx,
+ )
+ .unwrap();
+ view.copy(&(), ctx);
+ });
+
+ // Paste with three selections, noticing how the copied full-line selection is inserted
+ // before the empty selections but replaces the selection that is non-empty.
+ view.update(app, |view, ctx| {
+ view.select_display_ranges(
+ &[
+ DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1),
+ DisplayPoint::new(1, 0)..DisplayPoint::new(1, 2),
+ DisplayPoint::new(2, 1)..DisplayPoint::new(2, 1),
+ ],
+ ctx,
+ )
+ .unwrap();
+ view.paste(&(), ctx);
+ });
+ assert_eq!(
+ view.read(app).text(app.as_ref()),
+ "123\n123\n123\n67\n123\n9\n( 8ne three five ) two one four three six five ( one three five ) "
+ );
+ assert_eq!(
+ view.read(app).selection_ranges(app.as_ref()),
+ &[
+ DisplayPoint::new(1, 1)..DisplayPoint::new(1, 1),
+ DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0),
+ DisplayPoint::new(5, 1)..DisplayPoint::new(5, 1),
+ ]
+ );
+ });
+ }
+
impl BufferView {
fn selection_ranges(&self, app: &AppContext) -> Vec<Range<DisplayPoint>> {
self.selections_in_range(DisplayPoint::zero()..self.max_point(app), app)