diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b177817a36..20b194966b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ env: jobs: tests: name: Tests - runs-on: macos-latest + runs-on: self-hosted steps: - name: Checkout repo uses: actions/checkout@v2 diff --git a/Cargo.lock b/Cargo.lock index 15558eaa3f..5b6551603a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -968,6 +968,7 @@ dependencies = [ "async-std", "async-task", "bindgen", + "block", "cc", "cocoa", "core-foundation", @@ -990,6 +991,7 @@ dependencies = [ "replace_with", "resvg", "scoped-pool", + "seahash", "serde", "serde_json", "simplelog", @@ -1837,6 +1839,20 @@ name = "serde" version = "1.0.125" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "558dc50e1a5a5fa7112ca2ce4effcb321b0300c0d4ccf0776a9f60cd89031171" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b093b7a2bb58203b5da3056c05b4ec1fed827dcfdb37347a8841695263b3d06d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "serde_json" @@ -2429,6 +2445,7 @@ dependencies = [ "rand 0.8.3", "rust-embed", "seahash", + "serde", "serde_json", "simplelog", "smallvec", diff --git a/gpui/Cargo.toml b/gpui/Cargo.toml index 9fd7ce036a..0f11bd1de2 100644 --- a/gpui/Cargo.toml +++ b/gpui/Cargo.toml @@ -19,7 +19,8 @@ rand = "0.8.3" replace_with = "0.1.7" resvg = "0.14" scoped-pool = "1.0.0" -serde = "1.0.125" +seahash = "4.1" +serde = {version = "1.0.125", features = ["derive"]} serde_json = "1.0.64" smallvec = "1.6.1" smol = "1.2" @@ -37,6 +38,7 @@ simplelog = "0.9" [target.'cfg(target_os = "macos")'.dependencies] anyhow = "1" +block = "0.1" cocoa = "0.24" core-foundation = "0.9" core-graphics = "0.22.2" diff --git a/gpui/src/app.rs b/gpui/src/app.rs index 7a9975096d..910489a4d9 100644 --- a/gpui/src/app.rs +++ b/gpui/src/app.rs @@ -5,7 +5,7 @@ use crate::{ platform::{self, WindowOptions}, presenter::Presenter, util::post_inc, - AssetCache, AssetSource, FontCache, TextLayoutCache, + AssetCache, AssetSource, ClipboardItem, FontCache, PathPromptOptions, TextLayoutCache, }; use anyhow::{anyhow, Result}; use async_std::sync::Condvar; @@ -141,12 +141,13 @@ impl App { { let presenter = presenter.clone(); let path = presenter.borrow().dispatch_path(ctx.as_ref()); - if ctx.dispatch_action_any(key_window_id, &path, command, arg.unwrap_or(&())) { - return; - } + ctx.dispatch_action_any(key_window_id, &path, command, arg.unwrap_or(&())); + } else { + ctx.dispatch_global_action_any(command, arg.unwrap_or(&())); } + } else { + ctx.dispatch_global_action_any(command, arg.unwrap_or(&())); } - ctx.dispatch_global_action_any(command, arg.unwrap_or(&())); })); app.0.borrow_mut().weak_self = Some(Rc::downgrade(&app.0)); @@ -570,6 +571,22 @@ impl MutableAppContext { self.platform.set_menus(menus); } + pub fn prompt_for_paths(&self, options: PathPromptOptions, done_fn: F) + where + F: 'static + FnOnce(Option>, &mut MutableAppContext), + { + let app = self.weak_self.as_ref().unwrap().upgrade().unwrap(); + let foreground = self.foreground.clone(); + self.platform().prompt_for_paths( + options, + Box::new(move |paths| { + foreground + .spawn(async move { (done_fn)(paths, &mut *app.borrow_mut()) }) + .detach(); + }), + ); + } + pub fn dispatch_action( &mut self, window_id: usize, @@ -1213,8 +1230,12 @@ impl MutableAppContext { } } - pub fn copy(&self, text: &str) { - self.platform.copy(text); + pub fn write_to_clipboard(&self, item: ClipboardItem) { + self.platform.write_to_clipboard(item); + } + + pub fn read_from_clipboard(&self) -> Option { + self.platform.read_from_clipboard() } } diff --git a/gpui/src/clipboard.rs b/gpui/src/clipboard.rs new file mode 100644 index 0000000000..b7e4b600ee --- /dev/null +++ b/gpui/src/clipboard.rs @@ -0,0 +1,42 @@ +use seahash::SeaHasher; +use serde::{Deserialize, Serialize}; +use std::hash::{Hash, Hasher}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClipboardItem { + pub(crate) text: String, + pub(crate) metadata: Option, +} + +impl ClipboardItem { + pub fn new(text: String) -> Self { + Self { + text, + metadata: None, + } + } + + pub fn with_metadata(mut self, metadata: T) -> Self { + self.metadata = Some(serde_json::to_string(&metadata).unwrap()); + self + } + + pub fn text(&self) -> &String { + &self.text + } + + pub fn metadata(&self) -> Option + where + T: for<'a> Deserialize<'a>, + { + self.metadata + .as_ref() + .and_then(|m| serde_json::from_str(m).ok()) + } + + pub(crate) fn text_hash(text: &str) -> u64 { + let mut hasher = SeaHasher::new(); + text.hash(&mut hasher); + hasher.finish() + } +} diff --git a/gpui/src/elements/align.rs b/gpui/src/elements/align.rs index a879870cab..8bb59169e2 100644 --- a/gpui/src/elements/align.rs +++ b/gpui/src/elements/align.rs @@ -3,7 +3,7 @@ use crate::{ LayoutContext, PaintContext, SizeConstraint, }; use json::ToJson; -use pathfinder_geometry::vector::{vec2f, Vector2F}; +use pathfinder_geometry::vector::Vector2F; use serde_json::json; pub struct Align { @@ -19,8 +19,13 @@ impl Align { } } - pub fn top_center(mut self) -> Self { - self.alignment = vec2f(0.0, -1.0); + pub fn top(mut self) -> Self { + self.alignment.set_y(-1.0); + self + } + + pub fn right(mut self) -> Self { + self.alignment.set_x(1.0); self } } diff --git a/gpui/src/elements/constrained_box.rs b/gpui/src/elements/constrained_box.rs index 95b6d29637..a705be3612 100644 --- a/gpui/src/elements/constrained_box.rs +++ b/gpui/src/elements/constrained_box.rs @@ -23,6 +23,11 @@ impl ConstrainedBox { } } + pub fn with_min_width(mut self, min_width: f32) -> Self { + self.constraint.min.set_x(min_width); + self + } + pub fn with_max_width(mut self, max_width: f32) -> Self { self.constraint.max.set_x(max_width); self @@ -33,6 +38,12 @@ impl ConstrainedBox { self } + pub fn with_width(mut self, width: f32) -> Self { + self.constraint.min.set_x(width); + self.constraint.max.set_x(width); + self + } + pub fn with_height(mut self, height: f32) -> Self { self.constraint.min.set_y(height); self.constraint.max.set_y(height); @@ -51,6 +62,7 @@ impl Element for ConstrainedBox { ) -> (Vector2F, Self::LayoutState) { constraint.min = constraint.min.max(self.constraint.min); constraint.max = constraint.max.min(self.constraint.max); + constraint.max = constraint.max.max(constraint.min); let size = self.child.layout(constraint, ctx); (size, ()) } @@ -91,6 +103,6 @@ impl Element for ConstrainedBox { _: &Self::PaintState, ctx: &DebugContext, ) -> json::Value { - json!({"type": "ConstrainedBox", "constraint": self.constraint.to_json(), "child": self.child.debug(ctx)}) + json!({"type": "ConstrainedBox", "set_constraint": self.constraint.to_json(), "child": self.child.debug(ctx)}) } } diff --git a/gpui/src/elements/container.rs b/gpui/src/elements/container.rs index 9554f3b353..d228c54d07 100644 --- a/gpui/src/elements/container.rs +++ b/gpui/src/elements/container.rs @@ -43,6 +43,18 @@ impl Container { self } + pub fn with_horizontal_padding(mut self, padding: f32) -> Self { + self.padding.left = padding; + self.padding.right = padding; + self + } + + pub fn with_vertical_padding(mut self, padding: f32) -> Self { + self.padding.top = padding; + self.padding.bottom = padding; + self + } + pub fn with_uniform_padding(mut self, padding: f32) -> Self { self.padding = Padding { top: padding, diff --git a/gpui/src/elements/flex.rs b/gpui/src/elements/flex.rs index db87a80dbf..cbce89ed2f 100644 --- a/gpui/src/elements/flex.rs +++ b/gpui/src/elements/flex.rs @@ -1,4 +1,4 @@ -use std::any::Any; +use std::{any::Any, f32::INFINITY}; use crate::{ json::{self, ToJson, Value}, @@ -64,8 +64,16 @@ impl Element for Flex { if let Some(flex) = Self::child_flex(&child) { total_flex += flex; } else { - let child_constraint = - SizeConstraint::strict_along(cross_axis, constraint.max_along(cross_axis)); + let child_constraint = match self.axis { + Axis::Horizontal => SizeConstraint::new( + vec2f(0.0, constraint.min.y()), + vec2f(INFINITY, constraint.max.y()), + ), + Axis::Vertical => SizeConstraint::new( + vec2f(constraint.min.x(), 0.0), + vec2f(constraint.max.x(), INFINITY), + ), + }; let size = child.layout(child_constraint, ctx); fixed_space += size.along(self.axis); cross_axis_max = cross_axis_max.max(size.along(cross_axis)); @@ -80,16 +88,20 @@ impl Element for Flex { let mut remaining_space = constraint.max_along(self.axis) - fixed_space; let mut remaining_flex = total_flex; for child in &mut self.children { - let space_per_flex = remaining_space / remaining_flex; if let Some(flex) = Self::child_flex(&child) { - let child_max = space_per_flex * flex; + let child_max = if remaining_flex == 0.0 { + remaining_space + } else { + let space_per_flex = remaining_space / remaining_flex; + space_per_flex * flex + }; let child_constraint = match self.axis { Axis::Horizontal => SizeConstraint::new( - vec2f(0.0, constraint.max.y()), + vec2f(0.0, constraint.min.y()), vec2f(child_max, constraint.max.y()), ), Axis::Vertical => SizeConstraint::new( - vec2f(constraint.max.x(), 0.0), + vec2f(constraint.min.x(), 0.0), vec2f(constraint.max.x(), child_max), ), }; diff --git a/gpui/src/elements/new.rs b/gpui/src/elements/new.rs index 3521a5fab0..e45a71e16a 100644 --- a/gpui/src/elements/new.rs +++ b/gpui/src/elements/new.rs @@ -4,6 +4,7 @@ use crate::{ SizeConstraint, }; use core::panic; +use json::ToJson; use replace_with::replace_with_or_abort; use std::{any::Any, borrow::Cow}; @@ -90,11 +91,13 @@ pub enum Lifecycle { }, PostLayout { element: T, + constraint: SizeConstraint, size: Vector2F, layout: T::LayoutState, }, PostPaint { element: T, + constraint: SizeConstraint, bounds: RectF, layout: T::LayoutState, paint: T::PaintState, @@ -119,6 +122,7 @@ impl AnyElement for Lifecycle { result = Some(size); Lifecycle::PostLayout { element, + constraint, size, layout, } @@ -132,6 +136,7 @@ impl AnyElement for Lifecycle { element, size, layout, + .. } = self { element.after_layout(*size, layout, ctx); @@ -144,6 +149,7 @@ impl AnyElement for Lifecycle { replace_with_or_abort(self, |me| { if let Lifecycle::PostLayout { mut element, + constraint, size, mut layout, } = me @@ -152,6 +158,7 @@ impl AnyElement for Lifecycle { let paint = element.paint(bounds, &mut layout, ctx); Lifecycle::PostPaint { element, + constraint, bounds, layout, paint, @@ -168,6 +175,7 @@ impl AnyElement for Lifecycle { bounds, layout, paint, + .. } = self { element.dispatch_event(event, *bounds, layout, paint, ctx) @@ -196,10 +204,25 @@ impl AnyElement for Lifecycle { match self { Lifecycle::PostPaint { element, + constraint, bounds, layout, paint, - } => element.debug(*bounds, layout, paint, ctx), + } => { + let mut value = element.debug(*bounds, layout, paint, ctx); + if let json::Value::Object(map) = &mut value { + let mut new_map: crate::json::Map = + Default::default(); + if let Some(typ) = map.remove("type") { + new_map.insert("type".into(), typ); + } + new_map.insert("constraint".into(), constraint.to_json()); + new_map.append(map); + json::Value::Object(new_map) + } else { + value + } + } _ => panic!("invalid element lifecycle state"), } } diff --git a/gpui/src/lib.rs b/gpui/src/lib.rs index 342a6bf7ee..edf14bc65c 100644 --- a/gpui/src/lib.rs +++ b/gpui/src/lib.rs @@ -7,6 +7,8 @@ pub use assets::*; pub mod elements; pub mod font_cache; pub use font_cache::FontCache; +mod clipboard; +pub use clipboard::ClipboardItem; pub mod fonts; pub mod geometry; mod presenter; diff --git a/gpui/src/platform/mac/platform.rs b/gpui/src/platform/mac/platform.rs index 4fc0f160a3..e9bf34d684 100644 --- a/gpui/src/platform/mac/platform.rs +++ b/gpui/src/platform/mac/platform.rs @@ -1,5 +1,6 @@ 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 block::ConcreteBlock; use cocoa::{ appkit::{ NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular, @@ -20,12 +21,14 @@ use objc::{ use ptr::null_mut; use std::{ any::Any, - cell::RefCell, + cell::{Cell, RefCell}, + convert::TryInto, ffi::{c_void, CStr}, os::raw::c_char, path::PathBuf, ptr, rc::Rc, + slice, str, sync::Arc, }; @@ -77,6 +80,9 @@ pub struct MacPlatform { fonts: Arc, callbacks: RefCell, menu_item_actions: RefCell>)>>, + pasteboard: id, + text_hash_pasteboard_type: id, + metadata_pasteboard_type: id, } #[derive(Default)] @@ -96,6 +102,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 +185,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 { @@ -247,31 +268,40 @@ impl platform::Platform for MacPlatform { fn prompt_for_paths( &self, options: platform::PathPromptOptions, - ) -> Option> { + done_fn: Box>)>, + ) { unsafe { let panel = NSOpenPanel::openPanel(nil); panel.setCanChooseDirectories_(options.directories.to_objc()); panel.setCanChooseFiles_(options.files.to_objc()); panel.setAllowsMultipleSelection_(options.multiple.to_objc()); panel.setResolvesAliases_(false.to_objc()); - let response = panel.runModal(); - if response == NSModalResponse::NSModalResponseOk { - let mut result = Vec::new(); - let urls = panel.URLs(); - for i in 0..urls.count() { - let url = urls.objectAtIndex(i); - let string = url.absoluteString(); - let string = std::ffi::CStr::from_ptr(string.UTF8String()) - .to_string_lossy() - .to_string(); - if let Some(path) = string.strip_prefix("file://") { - result.push(PathBuf::from(path)); + let done_fn = Cell::new(Some(done_fn)); + let block = ConcreteBlock::new(move |response: NSModalResponse| { + let result = if response == NSModalResponse::NSModalResponseOk { + let mut result = Vec::new(); + let urls = panel.URLs(); + for i in 0..urls.count() { + let url = urls.objectAtIndex(i); + let string = url.absoluteString(); + let string = std::ffi::CStr::from_ptr(string.UTF8String()) + .to_string_lossy() + .to_string(); + if let Some(path) = string.strip_prefix("file://") { + result.push(PathBuf::from(path)); + } } + Some(result) + } else { + None + }; + + if let Some(done_fn) = done_fn.take() { + (done_fn)(result); } - Some(result) - } else { - None - } + }); + let block = block.copy(); + let _: () = msg_send![panel, beginWithCompletionHandler: block]; } } @@ -286,16 +316,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 { + 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 +478,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 + } +} diff --git a/gpui/src/platform/mod.rs b/gpui/src/platform/mod.rs index 52148ee27c..b98d3a687b 100644 --- a/gpui/src/platform/mod.rs +++ b/gpui/src/platform/mod.rs @@ -15,7 +15,7 @@ use crate::{ vector::Vector2F, }, text_layout::Line, - Menu, Scene, + ClipboardItem, Menu, Scene, }; use async_task::Runnable; pub use event::Event; @@ -40,9 +40,14 @@ pub trait Platform { executor: Rc, ) -> Box; fn key_window_id(&self) -> Option; - fn prompt_for_paths(&self, options: PathPromptOptions) -> Option>; + fn prompt_for_paths( + &self, + options: PathPromptOptions, + done_fn: Box>)>, + ); fn quit(&self); - fn copy(&self, text: &str); + fn write_to_clipboard(&self, item: ClipboardItem); + fn read_from_clipboard(&self) -> Option; fn set_menus(&self, menus: Vec); } diff --git a/gpui/src/platform/test.rs b/gpui/src/platform/test.rs index eed3709cba..878449a021 100644 --- a/gpui/src/platform/test.rs +++ b/gpui/src/platform/test.rs @@ -1,10 +1,11 @@ +use crate::ClipboardItem; use pathfinder_geometry::vector::Vector2F; -use std::sync::Arc; -use std::{any::Any, rc::Rc}; +use std::{any::Any, cell::RefCell, rc::Rc, sync::Arc}; struct Platform { dispatcher: Arc, fonts: Arc, + current_clipboard_item: RefCell>, } struct Dispatcher; @@ -22,6 +23,7 @@ impl Platform { Self { dispatcher: Arc::new(Dispatcher), fonts: Arc::new(super::current::FontSystem::new()), + current_clipboard_item: RefCell::new(None), } } } @@ -68,11 +70,20 @@ impl super::Platform for Platform { fn quit(&self) {} - fn prompt_for_paths(&self, _: super::PathPromptOptions) -> Option> { - None + fn prompt_for_paths( + &self, + _: super::PathPromptOptions, + _: Box>)>, + ) { } - fn copy(&self, _: &str) {} + fn write_to_clipboard(&self, item: ClipboardItem) { + *self.current_clipboard_item.borrow_mut() = Some(item); + } + + fn read_from_clipboard(&self) -> Option { + self.current_clipboard_item.borrow().clone() + } } impl Window { diff --git a/zed/Cargo.toml b/zed/Cargo.toml index 1f6cec6e9b..846d6d729e 100644 --- a/zed/Cargo.toml +++ b/zed/Cargo.toml @@ -31,6 +31,7 @@ rand = "0.8.3" rust-embed = "5.9.0" seahash = "4.1" simplelog = "0.9" +serde = { version = "1", features = ["derive"] } smallvec = "1.6.1" smol = "1.2.5" diff --git a/zed/src/editor/buffer/anchor.rs b/zed/src/editor/buffer/anchor.rs index b0e2f0269c..60106daba5 100644 --- a/zed/src/editor/buffer/anchor.rs +++ b/zed/src/editor/buffer/anchor.rs @@ -69,6 +69,28 @@ impl Anchor { .then_with(|| self_bias.cmp(other_bias)), }) } + + pub fn bias_left(&self, buffer: &Buffer) -> Result { + match self { + Anchor::Start + | Anchor::Middle { + bias: AnchorBias::Left, + .. + } => Ok(self.clone()), + _ => buffer.anchor_before(self), + } + } + + pub fn bias_right(&self, buffer: &Buffer) -> Result { + match self { + Anchor::End + | Anchor::Middle { + bias: AnchorBias::Right, + .. + } => Ok(self.clone()), + _ => buffer.anchor_after(self), + } + } } pub trait AnchorRangeExt { diff --git a/zed/src/editor/buffer/mod.rs b/zed/src/editor/buffer/mod.rs index 8c44f9768f..22dc32d33f 100644 --- a/zed/src/editor/buffer/mod.rs +++ b/zed/src/editor/buffer/mod.rs @@ -563,10 +563,13 @@ impl Buffer { self.chars().collect() } - pub fn text_for_range(&self, range: Range) -> Result { + pub fn text_for_range<'a, T: ToOffset>( + &'a self, + range: Range, + ) -> Result> { let start = range.start.to_offset(self)?; let end = range.end.to_offset(self)?; - Ok(self.chars_at(start)?.take(end - start).collect()) + Ok(self.chars_at(start)?.take(end - start)) } pub fn chars(&self) -> CharIter { @@ -2261,6 +2264,12 @@ impl ToOffset for Anchor { } } +impl<'a> ToOffset for &'a Anchor { + fn to_offset(&self, buffer: &Buffer) -> Result { + Ok(buffer.summary_for_anchor(self)?.chars) + } +} + pub trait ToPoint { fn to_point(&self, buffer: &Buffer) -> Result; } @@ -2470,13 +2479,9 @@ mod tests { let old_len = old_range.end - old_range.start; let new_len = new_range.end - new_range.start; let old_start = (old_range.start as isize + delta) as usize; - + let new_text: String = buffer.text_for_range(new_range).unwrap().collect(); old_buffer - .edit( - Some(old_start..old_start + old_len), - buffer.text_for_range(new_range).unwrap(), - None, - ) + .edit(Some(old_start..old_start + old_len), new_text, None) .unwrap(); delta += new_len as isize - old_len as isize; diff --git a/zed/src/editor/buffer_view.rs b/zed/src/editor/buffer_view.rs index e366c72bf3..63a2f278fa 100644 --- a/zed/src/editor/buffer_view.rs +++ b/zed/src/editor/buffer_view.rs @@ -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, ctx: &mut ViewContext) -> 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) -> Result<()> + where + T: IntoIterator>, + { + let buffer = self.buffer.read(ctx); + let mut selections = Vec::new(); + for range in ranges { + selections.push(Selection { + start: buffer.anchor_before(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) -> Result<()> where T: IntoIterator>, { @@ -361,7 +394,7 @@ impl BufferView { let mut selections = Vec::new(); for range in ranges { selections.push(Selection { - start: map.anchor_after(range.start, Bias::Left, ctx.as_ref())?, + start: map.anchor_before(range.start, Bias::Left, ctx.as_ref())?, end: map.anchor_before(range.end, Bias::Left, ctx.as_ref())?, reversed: false, goal_column: None, @@ -454,6 +487,133 @@ impl BufferView { self.end_transaction(ctx); } + pub fn cut(&mut self, _: &(), ctx: &mut ViewContext) { + 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_before(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) { + 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) { + if let Some(item) = ctx.as_mut().read_from_clipboard() { + let clipboard_text = item.text(); + if let Some(mut clipboard_selections) = item.metadata::>() { + let selections = self.selections(ctx.as_ref()).to_vec(); + if clipboard_selections.len() != selections.len() { + let merged_selection = ClipboardSelection { + len: clipboard_selections.iter().map(|s| s.len).sum(), + is_entire_line: clipboard_selections.iter().all(|s| s.is_entire_line), + }; + clipboard_selections.clear(); + clipboard_selections.push(merged_selection); + } + + self.start_transaction(ctx); + let mut new_selections = Vec::with_capacity(selections.len()); + let mut clipboard_chars = clipboard_text.chars().cycle(); + for (selection, clipboard_selection) in + selections.iter().zip(clipboard_selections.iter().cycle()) + { + let to_insert = + String::from_iter(clipboard_chars.by_ref().take(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(); + + // 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 new_selection_start = selection.end.bias_right(buffer).unwrap(); + if selection_start == selection_end && clipboard_selection.is_entire_line { + let line_start = Point::new(selection_start.row, 0); + buffer + .edit(Some(line_start..line_start), to_insert, Some(ctx)) + .unwrap(); + } else { + buffer + .edit(Some(&selection.start..&selection.end), to_insert, Some(ctx)) + .unwrap(); + }; + + let new_selection_start = new_selection_start.bias_left(buffer).unwrap(); + new_selections.push(Selection { + start: new_selection_start.clone(), + end: new_selection_start, + reversed: false, + goal_column: None, + }); + }); + } + self.update_selections(new_selections, ctx); + self.end_transaction(ctx); + } else { + self.insert(clipboard_text, ctx); + } + } + } + pub fn undo(&mut self, _: &(), ctx: &mut ViewContext) { self.buffer .update(ctx, |buffer, ctx| buffer.undo(Some(ctx))); @@ -1417,8 +1577,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 +1688,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 +1710,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> { self.selections_in_range(DisplayPoint::zero()..self.max_point(app), app) diff --git a/zed/src/file_finder.rs b/zed/src/file_finder.rs index 36f182086d..b95832d668 100644 --- a/zed/src/file_finder.rs +++ b/zed/src/file_finder.rs @@ -77,7 +77,7 @@ impl View for FileFinder { .with_max_height(400.0) .boxed(), ) - .top_center() + .top() .named("file finder") } diff --git a/zed/src/workspace/mod.rs b/zed/src/workspace/mod.rs index d9e9039994..005b4b2435 100644 --- a/zed/src/workspace/mod.rs +++ b/zed/src/workspace/mod.rs @@ -29,19 +29,19 @@ pub struct OpenParams { } fn open(settings: &Receiver, ctx: &mut MutableAppContext) { - if let Some(paths) = ctx.platform().prompt_for_paths(PathPromptOptions { - files: true, - directories: true, - multiple: true, - }) { - ctx.dispatch_global_action( - "workspace:open_paths", - OpenParams { - paths, - settings: settings.clone(), - }, - ); - } + let settings = settings.clone(); + ctx.prompt_for_paths( + PathPromptOptions { + files: true, + directories: true, + multiple: true, + }, + move |paths, ctx| { + if let Some(paths) = paths { + ctx.dispatch_global_action("workspace:open_paths", OpenParams { paths, settings }); + } + }, + ); } fn open_paths(params: &OpenParams, app: &mut MutableAppContext) { diff --git a/zed/src/workspace/pane.rs b/zed/src/workspace/pane.rs index bb52a6da5a..c352a522eb 100644 --- a/zed/src/workspace/pane.rs +++ b/zed/src/workspace/pane.rs @@ -189,33 +189,28 @@ impl Pane { let padding = 6.; let mut container = Container::new( - Align::new( - Flex::row() - .with_child( + Stack::new() + .with_child( + Align::new( Label::new(title, settings.ui_font_family, settings.ui_font_size) .boxed(), ) - .with_child( - Container::new( - LineBox::new( - settings.ui_font_family, - settings.ui_font_size, - ConstrainedBox::new(Self::render_modified_icon( - item.is_dirty(app), - )) - .with_max_width(12.) - .boxed(), - ) + .boxed(), + ) + .with_child( + LineBox::new( + settings.ui_font_family, + settings.ui_font_size, + Align::new(Self::render_modified_icon(item.is_dirty(app))) + .right() .boxed(), - ) - .with_margin_left(20.) - .boxed(), ) .boxed(), - ) - .boxed(), + ) + .boxed(), ) - .with_uniform_padding(padding) + .with_vertical_padding(padding) + .with_horizontal_padding(10.) .with_border(border); if ix == self.active_item { @@ -237,6 +232,7 @@ impl Pane { }) .boxed(), ) + .with_min_width(80.0) .with_max_width(264.0) .boxed(), ) @@ -244,9 +240,29 @@ impl Pane { ); } + // Ensure there's always a minimum amount of space after the last tab, + // so that the tab's border doesn't abut the window's border. + row.add_child( + ConstrainedBox::new( + Container::new( + LineBox::new( + settings.ui_font_family, + settings.ui_font_size, + Empty::new().boxed(), + ) + .boxed(), + ) + .with_uniform_padding(6.0) + .with_border(Border::bottom(1.0, border_color)) + .boxed(), + ) + .with_min_width(20.) + .named("fixed-filler"), + ); + row.add_child( Expanded::new( - 1.0, + 0.0, Container::new( LineBox::new( settings.ui_font_family, @@ -266,23 +282,24 @@ impl Pane { } fn render_modified_icon(is_modified: bool) -> ElementBox { - Canvas::new(move |bounds, ctx| { - if is_modified { - let padding = if bounds.height() < bounds.width() { - vec2f(bounds.width() - bounds.height(), 0.0) - } else { - vec2f(0.0, bounds.height() - bounds.width()) - }; - let square = RectF::new(bounds.origin() + padding / 2., bounds.size() - padding); - ctx.scene.push_quad(Quad { - bounds: square, - background: Some(ColorF::new(0.639, 0.839, 1.0, 1.0).to_u8()), - border: Default::default(), - corner_radius: square.width() / 2., - }); - } - }) - .boxed() + let diameter = 8.; + ConstrainedBox::new( + Canvas::new(move |bounds, ctx| { + if is_modified { + let square = RectF::new(bounds.origin(), vec2f(diameter, diameter)); + ctx.scene.push_quad(Quad { + bounds: square, + background: Some(ColorF::new(0.639, 0.839, 1.0, 1.0).to_u8()), + border: Default::default(), + corner_radius: diameter / 2., + }); + } + }) + .boxed(), + ) + .with_width(diameter) + .with_height(diameter) + .named("tab-right-icon") } } diff --git a/zed/src/workspace/workspace_view.rs b/zed/src/workspace/workspace_view.rs index ac3a7b4307..11b5856d75 100644 --- a/zed/src/workspace/workspace_view.rs +++ b/zed/src/workspace/workspace_view.rs @@ -3,7 +3,7 @@ use crate::{settings::Settings, watch}; use futures_core::future::LocalBoxFuture; use gpui::{ color::rgbu, elements::*, json::to_string_pretty, keymap::Binding, AnyViewHandle, AppContext, - Entity, ModelHandle, MutableAppContext, View, ViewContext, ViewHandle, + ClipboardItem, Entity, ModelHandle, MutableAppContext, View, ViewContext, ViewHandle, }; use log::{error, info}; use std::{collections::HashSet, path::PathBuf}; @@ -258,10 +258,11 @@ impl WorkspaceView { pub fn debug_elements(&mut self, _: &(), ctx: &mut ViewContext) { match to_string_pretty(&ctx.debug_elements()) { Ok(json) => { - ctx.as_mut().copy(&json); + let kib = json.len() as f32 / 1024.; + ctx.as_mut().write_to_clipboard(ClipboardItem::new(json)); log::info!( "copied {:.1} KiB of element debug JSON to the clipboard", - json.len() as f32 / 1024. + kib ); } Err(error) => {