diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index d97db9695a..1d6deb910a 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -5,22 +5,24 @@ mod tab_map; mod wrap_map; use crate::{ - link_go_to_definition::InlayHighlight, Anchor, AnchorRangeExt, InlayId, MultiBuffer, - MultiBufferSnapshot, ToOffset, ToPoint, + link_go_to_definition::InlayHighlight, movement::TextLayoutDetails, Anchor, AnchorRangeExt, + EditorStyle, InlayId, MultiBuffer, MultiBufferSnapshot, ToOffset, ToPoint, }; pub use block_map::{BlockMap, BlockPoint}; use collections::{BTreeMap, HashMap, HashSet}; use fold_map::FoldMap; use gpui::{ color::Color, - fonts::{FontId, HighlightStyle}, + fonts::{FontId, HighlightStyle, Underline}, + text_layout::{Line, RunStyle}, Entity, ModelContext, ModelHandle, }; use inlay_map::InlayMap; use language::{ language_settings::language_settings, OffsetUtf16, Point, Subscription as BufferSubscription, }; -use std::{any::TypeId, fmt::Debug, num::NonZeroU32, ops::Range, sync::Arc}; +use lsp::DiagnosticSeverity; +use std::{any::TypeId, borrow::Cow, fmt::Debug, num::NonZeroU32, ops::Range, sync::Arc}; use sum_tree::{Bias, TreeMap}; use tab_map::TabMap; use wrap_map::WrapMap; @@ -316,6 +318,12 @@ pub struct Highlights<'a> { pub suggestion_highlight_style: Option, } +pub struct HighlightedChunk<'a> { + pub chunk: &'a str, + pub style: Option, + pub is_tab: bool, +} + pub struct DisplaySnapshot { pub buffer_snapshot: MultiBufferSnapshot, pub fold_snapshot: fold_map::FoldSnapshot, @@ -485,7 +493,7 @@ impl DisplaySnapshot { language_aware: bool, inlay_highlight_style: Option, suggestion_highlight_style: Option, - ) -> DisplayChunks<'_> { + ) -> DisplayChunks<'a> { self.block_snapshot.chunks( display_rows, language_aware, @@ -498,6 +506,140 @@ impl DisplaySnapshot { ) } + pub fn highlighted_chunks<'a>( + &'a self, + display_rows: Range, + language_aware: bool, + style: &'a EditorStyle, + ) -> impl Iterator> { + self.chunks( + display_rows, + language_aware, + Some(style.theme.hint), + Some(style.theme.suggestion), + ) + .map(|chunk| { + let mut highlight_style = chunk + .syntax_highlight_id + .and_then(|id| id.style(&style.syntax)); + + if let Some(chunk_highlight) = chunk.highlight_style { + if let Some(highlight_style) = highlight_style.as_mut() { + highlight_style.highlight(chunk_highlight); + } else { + highlight_style = Some(chunk_highlight); + } + } + + let mut diagnostic_highlight = HighlightStyle::default(); + + if chunk.is_unnecessary { + diagnostic_highlight.fade_out = Some(style.unnecessary_code_fade); + } + + if let Some(severity) = chunk.diagnostic_severity { + // Omit underlines for HINT/INFO diagnostics on 'unnecessary' code. + if severity <= DiagnosticSeverity::WARNING || !chunk.is_unnecessary { + let diagnostic_style = super::diagnostic_style(severity, true, style); + diagnostic_highlight.underline = Some(Underline { + color: Some(diagnostic_style.message.text.color), + thickness: 1.0.into(), + squiggly: true, + }); + } + } + + if let Some(highlight_style) = highlight_style.as_mut() { + highlight_style.highlight(diagnostic_highlight); + } else { + highlight_style = Some(diagnostic_highlight); + } + + HighlightedChunk { + chunk: chunk.text, + style: highlight_style, + is_tab: chunk.is_tab, + } + }) + } + + pub fn lay_out_line_for_row( + &self, + display_row: u32, + TextLayoutDetails { + font_cache, + text_layout_cache, + editor_style, + }: &TextLayoutDetails, + ) -> Line { + let mut styles = Vec::new(); + let mut line = String::new(); + let mut ended_in_newline = false; + + let range = display_row..display_row + 1; + for chunk in self.highlighted_chunks(range, false, editor_style) { + line.push_str(chunk.chunk); + + let text_style = if let Some(style) = chunk.style { + editor_style + .text + .clone() + .highlight(style, font_cache) + .map(Cow::Owned) + .unwrap_or_else(|_| Cow::Borrowed(&editor_style.text)) + } else { + Cow::Borrowed(&editor_style.text) + }; + ended_in_newline = chunk.chunk.ends_with("\n"); + + styles.push(( + chunk.chunk.len(), + RunStyle { + font_id: text_style.font_id, + color: text_style.color, + underline: text_style.underline, + }, + )); + } + + // our pixel positioning logic assumes each line ends in \n, + // this is almost always true except for the last line which + // may have no trailing newline. + if !ended_in_newline && display_row == self.max_point().row() { + line.push_str("\n"); + + styles.push(( + "\n".len(), + RunStyle { + font_id: editor_style.text.font_id, + color: editor_style.text_color, + underline: editor_style.text.underline, + }, + )); + } + + text_layout_cache.layout_str(&line, editor_style.text.font_size, &styles) + } + + pub fn x_for_point( + &self, + display_point: DisplayPoint, + text_layout_details: &TextLayoutDetails, + ) -> f32 { + let layout_line = self.lay_out_line_for_row(display_point.row(), text_layout_details); + layout_line.x_for_index(display_point.column() as usize) + } + + pub fn column_for_x( + &self, + display_row: u32, + x_coordinate: f32, + text_layout_details: &TextLayoutDetails, + ) -> u32 { + let layout_line = self.lay_out_line_for_row(display_row, text_layout_details); + layout_line.closest_index_for_x(x_coordinate) as u32 + } + pub fn chars_at( &self, mut point: DisplayPoint, @@ -869,12 +1011,16 @@ pub fn next_rows(display_row: u32, display_map: &DisplaySnapshot) -> impl Iterat #[cfg(test)] pub mod tests { use super::*; - use crate::{movement, test::marked_display_snapshot}; + use crate::{ + movement, + test::{editor_test_context::EditorTestContext, marked_display_snapshot}, + }; use gpui::{color::Color, elements::*, test::observe, AppContext}; use language::{ language_settings::{AllLanguageSettings, AllLanguageSettingsContent}, Buffer, Language, LanguageConfig, SelectionGoal, }; + use project::Project; use rand::{prelude::*, Rng}; use settings::SettingsStore; use smol::stream::StreamExt; @@ -1148,95 +1294,120 @@ pub mod tests { } #[gpui::test(retries = 5)] - fn test_soft_wraps(cx: &mut AppContext) { + async fn test_soft_wraps(cx: &mut gpui::TestAppContext) { cx.foreground().set_block_on_ticks(usize::MAX..=usize::MAX); - init_test(cx, |_| {}); - - let font_cache = cx.font_cache(); - - let family_id = font_cache - .load_family(&["Helvetica"], &Default::default()) - .unwrap(); - let font_id = font_cache - .select_font(family_id, &Default::default()) - .unwrap(); - let font_size = 12.0; - let wrap_width = Some(64.); - - let text = "one two three four five\nsix seven eight"; - let buffer = MultiBuffer::build_simple(text, cx); - let map = cx.add_model(|cx| { - DisplayMap::new(buffer.clone(), font_id, font_size, wrap_width, 1, 1, cx) + cx.update(|cx| { + init_test(cx, |_| {}); }); - let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); - assert_eq!( - snapshot.text_chunks(0).collect::(), - "one two \nthree four \nfive\nsix seven \neight" - ); - assert_eq!( - snapshot.clip_point(DisplayPoint::new(0, 8), Bias::Left), - DisplayPoint::new(0, 7) - ); - assert_eq!( - snapshot.clip_point(DisplayPoint::new(0, 8), Bias::Right), - DisplayPoint::new(1, 0) - ); - assert_eq!( - movement::right(&snapshot, DisplayPoint::new(0, 7)), - DisplayPoint::new(1, 0) - ); - assert_eq!( - movement::left(&snapshot, DisplayPoint::new(1, 0)), - DisplayPoint::new(0, 7) - ); - assert_eq!( - movement::up( - &snapshot, - DisplayPoint::new(1, 10), - SelectionGoal::None, - false - ), - (DisplayPoint::new(0, 7), SelectionGoal::Column(10)) - ); - assert_eq!( - movement::down( - &snapshot, - DisplayPoint::new(0, 7), - SelectionGoal::Column(10), - false - ), - (DisplayPoint::new(1, 10), SelectionGoal::Column(10)) - ); - assert_eq!( - movement::down( - &snapshot, - DisplayPoint::new(1, 10), - SelectionGoal::Column(10), - false - ), - (DisplayPoint::new(2, 4), SelectionGoal::Column(10)) - ); + let mut cx = EditorTestContext::new(cx).await; + let editor = cx.editor.clone(); + let window = cx.window.clone(); - let ix = snapshot.buffer_snapshot.text().find("seven").unwrap(); - buffer.update(cx, |buffer, cx| { - buffer.edit([(ix..ix, "and ")], None, cx); + cx.update_window(window, |cx| { + let text_layout_details = + editor.read_with(cx, |editor, cx| editor.text_layout_details(cx)); + + let font_cache = cx.font_cache().clone(); + + let family_id = font_cache + .load_family(&["Helvetica"], &Default::default()) + .unwrap(); + let font_id = font_cache + .select_font(family_id, &Default::default()) + .unwrap(); + let font_size = 12.0; + let wrap_width = Some(64.); + + let text = "one two three four five\nsix seven eight"; + let buffer = MultiBuffer::build_simple(text, cx); + let map = cx.add_model(|cx| { + DisplayMap::new(buffer.clone(), font_id, font_size, wrap_width, 1, 1, cx) + }); + + let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + assert_eq!( + snapshot.text_chunks(0).collect::(), + "one two \nthree four \nfive\nsix seven \neight" + ); + assert_eq!( + snapshot.clip_point(DisplayPoint::new(0, 8), Bias::Left), + DisplayPoint::new(0, 7) + ); + assert_eq!( + snapshot.clip_point(DisplayPoint::new(0, 8), Bias::Right), + DisplayPoint::new(1, 0) + ); + assert_eq!( + movement::right(&snapshot, DisplayPoint::new(0, 7)), + DisplayPoint::new(1, 0) + ); + assert_eq!( + movement::left(&snapshot, DisplayPoint::new(1, 0)), + DisplayPoint::new(0, 7) + ); + + let x = snapshot.x_for_point(DisplayPoint::new(1, 10), &text_layout_details); + assert_eq!( + movement::up( + &snapshot, + DisplayPoint::new(1, 10), + SelectionGoal::None, + false, + &text_layout_details, + ), + ( + DisplayPoint::new(0, 7), + SelectionGoal::HorizontalPosition(x) + ) + ); + assert_eq!( + movement::down( + &snapshot, + DisplayPoint::new(0, 7), + SelectionGoal::HorizontalPosition(x), + false, + &text_layout_details + ), + ( + DisplayPoint::new(1, 10), + SelectionGoal::HorizontalPosition(x) + ) + ); + assert_eq!( + movement::down( + &snapshot, + DisplayPoint::new(1, 10), + SelectionGoal::HorizontalPosition(x), + false, + &text_layout_details + ), + ( + DisplayPoint::new(2, 4), + SelectionGoal::HorizontalPosition(x) + ) + ); + + let ix = snapshot.buffer_snapshot.text().find("seven").unwrap(); + buffer.update(cx, |buffer, cx| { + buffer.edit([(ix..ix, "and ")], None, cx); + }); + + let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + assert_eq!( + snapshot.text_chunks(1).collect::(), + "three four \nfive\nsix and \nseven eight" + ); + + // Re-wrap on font size changes + map.update(cx, |map, cx| map.set_font(font_id, font_size + 3., cx)); + + let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + assert_eq!( + snapshot.text_chunks(1).collect::(), + "three \nfour five\nsix and \nseven \neight" + ) }); - - let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); - assert_eq!( - snapshot.text_chunks(1).collect::(), - "three four \nfive\nsix and \nseven eight" - ); - - // Re-wrap on font size changes - map.update(cx, |map, cx| map.set_font(font_id, font_size + 3., cx)); - - let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); - assert_eq!( - snapshot.text_chunks(1).collect::(), - "three \nfour five\nsix and \nseven \neight" - ) } #[gpui::test] @@ -1731,6 +1902,9 @@ pub mod tests { cx.foreground().forbid_parking(); cx.set_global(SettingsStore::test(cx)); language::init(cx); + crate::init(cx); + Project::init_settings(cx); + theme::init((), cx); cx.update_global::(|store, cx| { store.update_user_settings::(cx, f); }); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index d755723085..82945fc00b 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -71,6 +71,7 @@ use link_go_to_definition::{ }; use log::error; use lsp::LanguageServerId; +use movement::TextLayoutDetails; use multi_buffer::ToOffsetUtf16; pub use multi_buffer::{ Anchor, AnchorRangeExt, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, ToOffset, @@ -3476,6 +3477,14 @@ impl Editor { .collect() } + pub fn text_layout_details(&self, cx: &WindowContext) -> TextLayoutDetails { + TextLayoutDetails { + font_cache: cx.font_cache().clone(), + text_layout_cache: cx.text_layout_cache().clone(), + editor_style: self.style(cx), + } + } + fn splice_inlay_hints( &self, to_remove: Vec, @@ -5410,6 +5419,7 @@ impl Editor { } pub fn transpose(&mut self, _: &Transpose, cx: &mut ViewContext) { + let text_layout_details = &self.text_layout_details(cx); self.transact(cx, |this, cx| { let edits = this.change_selections(Some(Autoscroll::fit()), cx, |s| { let mut edits: Vec<(Range, String)> = Default::default(); @@ -5433,7 +5443,10 @@ impl Editor { *head.column_mut() += 1; head = display_map.clip_point(head, Bias::Right); - selection.collapse_to(head, SelectionGoal::Column(head.column())); + let goal = SelectionGoal::HorizontalPosition( + display_map.x_for_point(head, &text_layout_details), + ); + selection.collapse_to(head, goal); let transpose_start = display_map .buffer_snapshot @@ -5697,13 +5710,21 @@ impl Editor { return; } + let text_layout_details = &self.text_layout_details(cx); + self.change_selections(Some(Autoscroll::fit()), cx, |s| { let line_mode = s.line_mode; s.move_with(|map, selection| { if !selection.is_empty() && !line_mode { selection.goal = SelectionGoal::None; } - let (cursor, goal) = movement::up(map, selection.start, selection.goal, false); + let (cursor, goal) = movement::up( + map, + selection.start, + selection.goal, + false, + &text_layout_details, + ); selection.collapse_to(cursor, goal); }); }) @@ -5731,22 +5752,33 @@ impl Editor { Autoscroll::fit() }; + let text_layout_details = &self.text_layout_details(cx); + self.change_selections(Some(autoscroll), cx, |s| { let line_mode = s.line_mode; s.move_with(|map, selection| { if !selection.is_empty() && !line_mode { selection.goal = SelectionGoal::None; } - let (cursor, goal) = - movement::up_by_rows(map, selection.end, row_count, selection.goal, false); + let (cursor, goal) = movement::up_by_rows( + map, + selection.end, + row_count, + selection.goal, + false, + &text_layout_details, + ); selection.collapse_to(cursor, goal); }); }); } pub fn select_up(&mut self, _: &SelectUp, cx: &mut ViewContext) { + let text_layout_details = &self.text_layout_details(cx); self.change_selections(Some(Autoscroll::fit()), cx, |s| { - s.move_heads_with(|map, head, goal| movement::up(map, head, goal, false)) + s.move_heads_with(|map, head, goal| { + movement::up(map, head, goal, false, &text_layout_details) + }) }) } @@ -5758,13 +5790,20 @@ impl Editor { return; } + let text_layout_details = &self.text_layout_details(cx); self.change_selections(Some(Autoscroll::fit()), cx, |s| { let line_mode = s.line_mode; s.move_with(|map, selection| { if !selection.is_empty() && !line_mode { selection.goal = SelectionGoal::None; } - let (cursor, goal) = movement::down(map, selection.end, selection.goal, false); + let (cursor, goal) = movement::down( + map, + selection.end, + selection.goal, + false, + &text_layout_details, + ); selection.collapse_to(cursor, goal); }); }); @@ -5802,22 +5841,32 @@ impl Editor { Autoscroll::fit() }; + let text_layout_details = &self.text_layout_details(cx); self.change_selections(Some(autoscroll), cx, |s| { let line_mode = s.line_mode; s.move_with(|map, selection| { if !selection.is_empty() && !line_mode { selection.goal = SelectionGoal::None; } - let (cursor, goal) = - movement::down_by_rows(map, selection.end, row_count, selection.goal, false); + let (cursor, goal) = movement::down_by_rows( + map, + selection.end, + row_count, + selection.goal, + false, + &text_layout_details, + ); selection.collapse_to(cursor, goal); }); }); } pub fn select_down(&mut self, _: &SelectDown, cx: &mut ViewContext) { + let text_layout_details = &self.text_layout_details(cx); self.change_selections(Some(Autoscroll::fit()), cx, |s| { - s.move_heads_with(|map, head, goal| movement::down(map, head, goal, false)) + s.move_heads_with(|map, head, goal| { + movement::down(map, head, goal, false, &text_layout_details) + }) }); } @@ -6336,11 +6385,14 @@ impl Editor { fn add_selection(&mut self, above: bool, cx: &mut ViewContext) { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let mut selections = self.selections.all::(cx); + let text_layout_details = self.text_layout_details(cx); let mut state = self.add_selections_state.take().unwrap_or_else(|| { let oldest_selection = selections.iter().min_by_key(|s| s.id).unwrap().clone(); let range = oldest_selection.display_range(&display_map).sorted(); - let columns = cmp::min(range.start.column(), range.end.column()) - ..cmp::max(range.start.column(), range.end.column()); + + let start_x = display_map.x_for_point(range.start, &text_layout_details); + let end_x = display_map.x_for_point(range.end, &text_layout_details); + let positions = start_x.min(end_x)..start_x.max(end_x); selections.clear(); let mut stack = Vec::new(); @@ -6348,8 +6400,9 @@ impl Editor { if let Some(selection) = self.selections.build_columnar_selection( &display_map, row, - &columns, + &positions, oldest_selection.reversed, + &text_layout_details, ) { stack.push(selection.id); selections.push(selection); @@ -6377,12 +6430,15 @@ impl Editor { let range = selection.display_range(&display_map).sorted(); debug_assert_eq!(range.start.row(), range.end.row()); let mut row = range.start.row(); - let columns = if let SelectionGoal::ColumnRange { start, end } = selection.goal + let positions = if let SelectionGoal::HorizontalRange { start, end } = + selection.goal { start..end } else { - cmp::min(range.start.column(), range.end.column()) - ..cmp::max(range.start.column(), range.end.column()) + let start_x = display_map.x_for_point(range.start, &text_layout_details); + let end_x = display_map.x_for_point(range.end, &text_layout_details); + + start_x.min(end_x)..start_x.max(end_x) }; while row != end_row { @@ -6395,8 +6451,9 @@ impl Editor { if let Some(new_selection) = self.selections.build_columnar_selection( &display_map, row, - &columns, + &positions, selection.reversed, + &text_layout_details, ) { state.stack.push(new_selection.id); if above { @@ -6690,6 +6747,7 @@ impl Editor { } pub fn toggle_comments(&mut self, action: &ToggleComments, cx: &mut ViewContext) { + let text_layout_details = &self.text_layout_details(cx); self.transact(cx, |this, cx| { let mut selections = this.selections.all::(cx); let mut edits = Vec::new(); @@ -6932,7 +6990,10 @@ impl Editor { point.row += 1; point = snapshot.clip_point(point, Bias::Left); let display_point = point.to_display_point(display_snapshot); - (display_point, SelectionGoal::Column(display_point.column())) + let goal = SelectionGoal::HorizontalPosition( + display_snapshot.x_for_point(display_point, &text_layout_details), + ); + (display_point, goal) }) }); } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 435e05018c..8104b0ee9b 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -851,7 +851,7 @@ fn test_move_cursor_multibyte(cx: &mut TestAppContext) { let view = cx .add_window(|cx| { - let buffer = MultiBuffer::build_simple("ⓐⓑⓒⓓⓔ\nabcde\nαβγδε\n", cx); + let buffer = MultiBuffer::build_simple("ⓐⓑⓒⓓⓔ\nabcde\nαβγδε", cx); build_editor(buffer.clone(), cx) }) .root(cx); @@ -869,7 +869,7 @@ fn test_move_cursor_multibyte(cx: &mut TestAppContext) { true, cx, ); - assert_eq!(view.display_text(cx), "ⓐⓑ⋯ⓔ\nab⋯e\nαβ⋯ε\n"); + assert_eq!(view.display_text(cx), "ⓐⓑ⋯ⓔ\nab⋯e\nαβ⋯ε"); view.move_right(&MoveRight, cx); assert_eq!( @@ -888,6 +888,11 @@ fn test_move_cursor_multibyte(cx: &mut TestAppContext) { ); view.move_down(&MoveDown, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(1, "ab⋯e".len())] + ); + view.move_left(&MoveLeft, cx); assert_eq!( view.selections.display_ranges(cx), &[empty_range(1, "ab⋯".len())] @@ -929,17 +934,18 @@ fn test_move_cursor_multibyte(cx: &mut TestAppContext) { view.selections.display_ranges(cx), &[empty_range(1, "ab⋯e".len())] ); + view.move_down(&MoveDown, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(2, "αβ⋯ε".len())] + ); view.move_up(&MoveUp, cx); assert_eq!( view.selections.display_ranges(cx), - &[empty_range(0, "ⓐⓑ⋯ⓔ".len())] + &[empty_range(1, "ab⋯e".len())] ); - view.move_left(&MoveLeft, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[empty_range(0, "ⓐⓑ⋯".len())] - ); - view.move_left(&MoveLeft, cx); + + view.move_up(&MoveUp, cx); assert_eq!( view.selections.display_ranges(cx), &[empty_range(0, "ⓐⓑ".len())] @@ -949,6 +955,11 @@ fn test_move_cursor_multibyte(cx: &mut TestAppContext) { view.selections.display_ranges(cx), &[empty_range(0, "ⓐ".len())] ); + view.move_left(&MoveLeft, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(0, "".len())] + ); }); } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 00c8508b6c..7b1155890f 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -4,7 +4,7 @@ use super::{ MAX_LINE_LEN, }; use crate::{ - display_map::{BlockStyle, DisplaySnapshot, FoldStatus, TransformBlock}, + display_map::{BlockStyle, DisplaySnapshot, FoldStatus, HighlightedChunk, TransformBlock}, editor_settings::ShowScrollbar, git::{diff_hunk_to_display, DisplayDiffHunk}, hover_popover::{ @@ -22,7 +22,7 @@ use git::diff::DiffHunkStatus; use gpui::{ color::Color, elements::*, - fonts::{HighlightStyle, TextStyle, Underline}, + fonts::TextStyle, geometry::{ rect::RectF, vector::{vec2f, Vector2F}, @@ -37,8 +37,7 @@ use gpui::{ use itertools::Itertools; use json::json; use language::{ - language_settings::ShowWhitespaceSetting, Bias, CursorShape, DiagnosticSeverity, OffsetUtf16, - Selection, + language_settings::ShowWhitespaceSetting, Bias, CursorShape, OffsetUtf16, Selection, }; use project::{ project_settings::{GitGutterSetting, ProjectSettings}, @@ -1584,56 +1583,7 @@ impl EditorElement { .collect() } else { let style = &self.style; - let chunks = snapshot - .chunks( - rows.clone(), - true, - Some(style.theme.hint), - Some(style.theme.suggestion), - ) - .map(|chunk| { - let mut highlight_style = chunk - .syntax_highlight_id - .and_then(|id| id.style(&style.syntax)); - - if let Some(chunk_highlight) = chunk.highlight_style { - if let Some(highlight_style) = highlight_style.as_mut() { - highlight_style.highlight(chunk_highlight); - } else { - highlight_style = Some(chunk_highlight); - } - } - - let mut diagnostic_highlight = HighlightStyle::default(); - - if chunk.is_unnecessary { - diagnostic_highlight.fade_out = Some(style.unnecessary_code_fade); - } - - if let Some(severity) = chunk.diagnostic_severity { - // Omit underlines for HINT/INFO diagnostics on 'unnecessary' code. - if severity <= DiagnosticSeverity::WARNING || !chunk.is_unnecessary { - let diagnostic_style = super::diagnostic_style(severity, true, style); - diagnostic_highlight.underline = Some(Underline { - color: Some(diagnostic_style.message.text.color), - thickness: 1.0.into(), - squiggly: true, - }); - } - } - - if let Some(highlight_style) = highlight_style.as_mut() { - highlight_style.highlight(diagnostic_highlight); - } else { - highlight_style = Some(diagnostic_highlight); - } - - HighlightedChunk { - chunk: chunk.text, - style: highlight_style, - is_tab: chunk.is_tab, - } - }); + let chunks = snapshot.highlighted_chunks(rows.clone(), true, style); LineWithInvisibles::from_chunks( chunks, @@ -1870,12 +1820,6 @@ impl EditorElement { } } -struct HighlightedChunk<'a> { - chunk: &'a str, - style: Option, - is_tab: bool, -} - #[derive(Debug)] pub struct LineWithInvisibles { pub line: Line, diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 245c2d9977..580faf1050 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -1,7 +1,8 @@ use super::{Bias, DisplayPoint, DisplaySnapshot, SelectionGoal, ToDisplayPoint}; -use crate::{char_kind, CharKind, ToOffset, ToPoint}; +use crate::{char_kind, CharKind, EditorStyle, ToOffset, ToPoint}; +use gpui::{FontCache, TextLayoutCache}; use language::Point; -use std::ops::Range; +use std::{ops::Range, sync::Arc}; #[derive(Debug, PartialEq)] pub enum FindRange { @@ -9,6 +10,14 @@ pub enum FindRange { MultiLine, } +/// TextLayoutDetails encompasses everything we need to move vertically +/// taking into account variable width characters. +pub struct TextLayoutDetails { + pub font_cache: Arc, + pub text_layout_cache: Arc, + pub editor_style: EditorStyle, +} + pub fn left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint { if point.column() > 0 { *point.column_mut() -= 1; @@ -47,8 +56,16 @@ pub fn up( start: DisplayPoint, goal: SelectionGoal, preserve_column_at_start: bool, + text_layout_details: &TextLayoutDetails, ) -> (DisplayPoint, SelectionGoal) { - up_by_rows(map, start, 1, goal, preserve_column_at_start) + up_by_rows( + map, + start, + 1, + goal, + preserve_column_at_start, + text_layout_details, + ) } pub fn down( @@ -56,8 +73,16 @@ pub fn down( start: DisplayPoint, goal: SelectionGoal, preserve_column_at_end: bool, + text_layout_details: &TextLayoutDetails, ) -> (DisplayPoint, SelectionGoal) { - down_by_rows(map, start, 1, goal, preserve_column_at_end) + down_by_rows( + map, + start, + 1, + goal, + preserve_column_at_end, + text_layout_details, + ) } pub fn up_by_rows( @@ -66,11 +91,13 @@ pub fn up_by_rows( row_count: u32, goal: SelectionGoal, preserve_column_at_start: bool, + text_layout_details: &TextLayoutDetails, ) -> (DisplayPoint, SelectionGoal) { - let mut goal_column = match goal { - SelectionGoal::Column(column) => column, - SelectionGoal::ColumnRange { end, .. } => end, - _ => map.column_to_chars(start.row(), start.column()), + let mut goal_x = match goal { + SelectionGoal::HorizontalPosition(x) => x, + SelectionGoal::WrappedHorizontalPosition((_, x)) => x, + SelectionGoal::HorizontalRange { end, .. } => end, + _ => map.x_for_point(start, text_layout_details), }; let prev_row = start.row().saturating_sub(row_count); @@ -79,19 +106,19 @@ pub fn up_by_rows( Bias::Left, ); if point.row() < start.row() { - *point.column_mut() = map.column_from_chars(point.row(), goal_column); + *point.column_mut() = map.column_for_x(point.row(), goal_x, text_layout_details) } else if preserve_column_at_start { return (start, goal); } else { point = DisplayPoint::new(0, 0); - goal_column = 0; + goal_x = 0.0; } let mut clipped_point = map.clip_point(point, Bias::Left); if clipped_point.row() < point.row() { clipped_point = map.clip_point(point, Bias::Right); } - (clipped_point, SelectionGoal::Column(goal_column)) + (clipped_point, SelectionGoal::HorizontalPosition(goal_x)) } pub fn down_by_rows( @@ -100,29 +127,31 @@ pub fn down_by_rows( row_count: u32, goal: SelectionGoal, preserve_column_at_end: bool, + text_layout_details: &TextLayoutDetails, ) -> (DisplayPoint, SelectionGoal) { - let mut goal_column = match goal { - SelectionGoal::Column(column) => column, - SelectionGoal::ColumnRange { end, .. } => end, - _ => map.column_to_chars(start.row(), start.column()), + let mut goal_x = match goal { + SelectionGoal::HorizontalPosition(x) => x, + SelectionGoal::WrappedHorizontalPosition((_, x)) => x, + SelectionGoal::HorizontalRange { end, .. } => end, + _ => map.x_for_point(start, text_layout_details), }; let new_row = start.row() + row_count; let mut point = map.clip_point(DisplayPoint::new(new_row, 0), Bias::Right); if point.row() > start.row() { - *point.column_mut() = map.column_from_chars(point.row(), goal_column); + *point.column_mut() = map.column_for_x(point.row(), goal_x, text_layout_details) } else if preserve_column_at_end { return (start, goal); } else { point = map.max_point(); - goal_column = map.column_to_chars(point.row(), point.column()) + goal_x = map.x_for_point(point, text_layout_details) } let mut clipped_point = map.clip_point(point, Bias::Right); if clipped_point.row() > point.row() { clipped_point = map.clip_point(point, Bias::Left); } - (clipped_point, SelectionGoal::Column(goal_column)) + (clipped_point, SelectionGoal::HorizontalPosition(goal_x)) } pub fn line_beginning( @@ -396,9 +425,11 @@ pub fn split_display_range_by_lines( mod tests { use super::*; use crate::{ - display_map::Inlay, test::marked_display_snapshot, Buffer, DisplayMap, ExcerptRange, - InlayId, MultiBuffer, + display_map::Inlay, + test::{editor_test_context::EditorTestContext, marked_display_snapshot}, + Buffer, DisplayMap, ExcerptRange, InlayId, MultiBuffer, }; + use project::Project; use settings::SettingsStore; use util::post_inc; @@ -691,123 +722,173 @@ mod tests { } #[gpui::test] - fn test_move_up_and_down_with_excerpts(cx: &mut gpui::AppContext) { - init_test(cx); - - let family_id = cx - .font_cache() - .load_family(&["Helvetica"], &Default::default()) - .unwrap(); - let font_id = cx - .font_cache() - .select_font(family_id, &Default::default()) - .unwrap(); - - let buffer = - cx.add_model(|cx| Buffer::new(0, cx.model_id() as u64, "abc\ndefg\nhijkl\nmn")); - let multibuffer = cx.add_model(|cx| { - let mut multibuffer = MultiBuffer::new(0); - multibuffer.push_excerpts( - buffer.clone(), - [ - ExcerptRange { - context: Point::new(0, 0)..Point::new(1, 4), - primary: None, - }, - ExcerptRange { - context: Point::new(2, 0)..Point::new(3, 2), - primary: None, - }, - ], - cx, - ); - multibuffer + async fn test_move_up_and_down_with_excerpts(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + init_test(cx); }); - let display_map = - cx.add_model(|cx| DisplayMap::new(multibuffer, font_id, 14.0, None, 2, 2, cx)); - let snapshot = display_map.update(cx, |map, cx| map.snapshot(cx)); - assert_eq!(snapshot.text(), "\n\nabc\ndefg\n\n\nhijkl\nmn"); + let mut cx = EditorTestContext::new(cx).await; + let editor = cx.editor.clone(); + let window = cx.window.clone(); + cx.update_window(window, |cx| { + let text_layout_details = + editor.read_with(cx, |editor, cx| editor.text_layout_details(cx)); - // Can't move up into the first excerpt's header - assert_eq!( - up( - &snapshot, - DisplayPoint::new(2, 2), - SelectionGoal::Column(2), - false - ), - (DisplayPoint::new(2, 0), SelectionGoal::Column(0)), - ); - assert_eq!( - up( - &snapshot, - DisplayPoint::new(2, 0), - SelectionGoal::None, - false - ), - (DisplayPoint::new(2, 0), SelectionGoal::Column(0)), - ); + let family_id = cx + .font_cache() + .load_family(&["Helvetica"], &Default::default()) + .unwrap(); + let font_id = cx + .font_cache() + .select_font(family_id, &Default::default()) + .unwrap(); - // Move up and down within first excerpt - assert_eq!( - up( - &snapshot, - DisplayPoint::new(3, 4), - SelectionGoal::Column(4), - false - ), - (DisplayPoint::new(2, 3), SelectionGoal::Column(4)), - ); - assert_eq!( - down( - &snapshot, - DisplayPoint::new(2, 3), - SelectionGoal::Column(4), - false - ), - (DisplayPoint::new(3, 4), SelectionGoal::Column(4)), - ); + let buffer = + cx.add_model(|cx| Buffer::new(0, cx.model_id() as u64, "abc\ndefg\nhijkl\nmn")); + let multibuffer = cx.add_model(|cx| { + let mut multibuffer = MultiBuffer::new(0); + multibuffer.push_excerpts( + buffer.clone(), + [ + ExcerptRange { + context: Point::new(0, 0)..Point::new(1, 4), + primary: None, + }, + ExcerptRange { + context: Point::new(2, 0)..Point::new(3, 2), + primary: None, + }, + ], + cx, + ); + multibuffer + }); + let display_map = + cx.add_model(|cx| DisplayMap::new(multibuffer, font_id, 14.0, None, 2, 2, cx)); + let snapshot = display_map.update(cx, |map, cx| map.snapshot(cx)); - // Move up and down across second excerpt's header - assert_eq!( - up( - &snapshot, - DisplayPoint::new(6, 5), - SelectionGoal::Column(5), - false - ), - (DisplayPoint::new(3, 4), SelectionGoal::Column(5)), - ); - assert_eq!( - down( - &snapshot, - DisplayPoint::new(3, 4), - SelectionGoal::Column(5), - false - ), - (DisplayPoint::new(6, 5), SelectionGoal::Column(5)), - ); + assert_eq!(snapshot.text(), "\n\nabc\ndefg\n\n\nhijkl\nmn"); - // Can't move down off the end - assert_eq!( - down( - &snapshot, - DisplayPoint::new(7, 0), - SelectionGoal::Column(0), - false - ), - (DisplayPoint::new(7, 2), SelectionGoal::Column(2)), - ); - assert_eq!( - down( - &snapshot, - DisplayPoint::new(7, 2), - SelectionGoal::Column(2), - false - ), - (DisplayPoint::new(7, 2), SelectionGoal::Column(2)), - ); + let col_2_x = snapshot.x_for_point(DisplayPoint::new(2, 2), &text_layout_details); + + // Can't move up into the first excerpt's header + assert_eq!( + up( + &snapshot, + DisplayPoint::new(2, 2), + SelectionGoal::HorizontalPosition(col_2_x), + false, + &text_layout_details + ), + ( + DisplayPoint::new(2, 0), + SelectionGoal::HorizontalPosition(0.0) + ), + ); + assert_eq!( + up( + &snapshot, + DisplayPoint::new(2, 0), + SelectionGoal::None, + false, + &text_layout_details + ), + ( + DisplayPoint::new(2, 0), + SelectionGoal::HorizontalPosition(0.0) + ), + ); + + let col_4_x = snapshot.x_for_point(DisplayPoint::new(3, 4), &text_layout_details); + + // Move up and down within first excerpt + assert_eq!( + up( + &snapshot, + DisplayPoint::new(3, 4), + SelectionGoal::HorizontalPosition(col_4_x), + false, + &text_layout_details + ), + ( + DisplayPoint::new(2, 3), + SelectionGoal::HorizontalPosition(col_4_x) + ), + ); + assert_eq!( + down( + &snapshot, + DisplayPoint::new(2, 3), + SelectionGoal::HorizontalPosition(col_4_x), + false, + &text_layout_details + ), + ( + DisplayPoint::new(3, 4), + SelectionGoal::HorizontalPosition(col_4_x) + ), + ); + + let col_5_x = snapshot.x_for_point(DisplayPoint::new(6, 5), &text_layout_details); + + // Move up and down across second excerpt's header + assert_eq!( + up( + &snapshot, + DisplayPoint::new(6, 5), + SelectionGoal::HorizontalPosition(col_5_x), + false, + &text_layout_details + ), + ( + DisplayPoint::new(3, 4), + SelectionGoal::HorizontalPosition(col_5_x) + ), + ); + assert_eq!( + down( + &snapshot, + DisplayPoint::new(3, 4), + SelectionGoal::HorizontalPosition(col_5_x), + false, + &text_layout_details + ), + ( + DisplayPoint::new(6, 5), + SelectionGoal::HorizontalPosition(col_5_x) + ), + ); + + let max_point_x = snapshot.x_for_point(DisplayPoint::new(7, 2), &text_layout_details); + + // Can't move down off the end + assert_eq!( + down( + &snapshot, + DisplayPoint::new(7, 0), + SelectionGoal::HorizontalPosition(0.0), + false, + &text_layout_details + ), + ( + DisplayPoint::new(7, 2), + SelectionGoal::HorizontalPosition(max_point_x) + ), + ); + assert_eq!( + down( + &snapshot, + DisplayPoint::new(7, 2), + SelectionGoal::HorizontalPosition(max_point_x), + false, + &text_layout_details + ), + ( + DisplayPoint::new(7, 2), + SelectionGoal::HorizontalPosition(max_point_x) + ), + ); + }); } fn init_test(cx: &mut gpui::AppContext) { @@ -815,5 +896,6 @@ mod tests { theme::init((), cx); language::init(cx); crate::init(cx); + Project::init_settings(cx); } } diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index 6a21c898ef..4b2dc855c3 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -1,6 +1,6 @@ use std::{ cell::Ref, - cmp, iter, mem, + iter, mem, ops::{Deref, DerefMut, Range, Sub}, sync::Arc, }; @@ -13,6 +13,7 @@ use util::post_inc; use crate::{ display_map::{DisplayMap, DisplaySnapshot, ToDisplayPoint}, + movement::TextLayoutDetails, Anchor, DisplayPoint, ExcerptId, MultiBuffer, MultiBufferSnapshot, SelectMode, ToOffset, }; @@ -305,23 +306,29 @@ impl SelectionsCollection { &mut self, display_map: &DisplaySnapshot, row: u32, - columns: &Range, + positions: &Range, reversed: bool, + text_layout_details: &TextLayoutDetails, ) -> Option> { - let is_empty = columns.start == columns.end; + let is_empty = positions.start == positions.end; let line_len = display_map.line_len(row); - if columns.start < line_len || (is_empty && columns.start == line_len) { - let start = DisplayPoint::new(row, columns.start); - let end = DisplayPoint::new(row, cmp::min(columns.end, line_len)); + + let layed_out_line = display_map.lay_out_line_for_row(row, &text_layout_details); + + let start_col = layed_out_line.closest_index_for_x(positions.start) as u32; + if start_col < line_len || (is_empty && positions.start == layed_out_line.width()) { + let start = DisplayPoint::new(row, start_col); + let end_col = layed_out_line.closest_index_for_x(positions.end) as u32; + let end = DisplayPoint::new(row, end_col); Some(Selection { id: post_inc(&mut self.next_selection_id), start: start.to_point(display_map), end: end.to_point(display_map), reversed, - goal: SelectionGoal::ColumnRange { - start: columns.start, - end: columns.end, + goal: SelectionGoal::HorizontalRange { + start: positions.start, + end: positions.end, }, }) } else { diff --git a/crates/gpui/src/text_layout.rs b/crates/gpui/src/text_layout.rs index 97f4b7a12d..7fb87b10df 100644 --- a/crates/gpui/src/text_layout.rs +++ b/crates/gpui/src/text_layout.rs @@ -266,6 +266,8 @@ impl Line { self.layout.len == 0 } + /// index_for_x returns the character containing the given x coordinate. + /// (e.g. to handle a mouse-click) pub fn index_for_x(&self, x: f32) -> Option { if x >= self.layout.width { None @@ -281,6 +283,28 @@ impl Line { } } + /// closest_index_for_x returns the character boundary closest to the given x coordinate + /// (e.g. to handle aligning up/down arrow keys) + pub fn closest_index_for_x(&self, x: f32) -> usize { + let mut prev_index = 0; + let mut prev_x = 0.0; + + for run in self.layout.runs.iter() { + for glyph in run.glyphs.iter() { + if glyph.position.x() >= x { + if glyph.position.x() - x < x - prev_x { + return glyph.index; + } else { + return prev_index; + } + } + prev_index = glyph.index; + prev_x = glyph.position.x(); + } + } + prev_index + } + pub fn paint( &self, origin: Vector2F, diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 78562ba8c4..063c7616a8 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -201,7 +201,7 @@ pub struct CodeAction { pub lsp_action: lsp::CodeAction, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub enum Operation { Buffer(text::Operation), @@ -224,7 +224,7 @@ pub enum Operation { }, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub enum Event { Operation(Operation), Edited, diff --git a/crates/text/src/selection.rs b/crates/text/src/selection.rs index 205c27239d..480cb99d74 100644 --- a/crates/text/src/selection.rs +++ b/crates/text/src/selection.rs @@ -2,14 +2,15 @@ use crate::{Anchor, BufferSnapshot, TextDimension}; use std::cmp::Ordering; use std::ops::Range; -#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[derive(Copy, Clone, Debug, PartialEq)] pub enum SelectionGoal { None, - Column(u32), - ColumnRange { start: u32, end: u32 }, + HorizontalPosition(f32), + HorizontalRange { start: f32, end: f32 }, + WrappedHorizontalPosition((u32, f32)), } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct Selection { pub id: usize, pub start: T, diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index a197121626..e8d954bc13 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -1,9 +1,7 @@ -use std::cmp; - use editor::{ char_kind, display_map::{DisplaySnapshot, FoldPoint, ToDisplayPoint}, - movement::{self, find_boundary, find_preceding_boundary, FindRange}, + movement::{self, find_boundary, find_preceding_boundary, FindRange, TextLayoutDetails}, Bias, CharKind, DisplayPoint, ToOffset, }; use gpui::{actions, impl_actions, AppContext, WindowContext}; @@ -361,6 +359,7 @@ impl Motion { point: DisplayPoint, goal: SelectionGoal, maybe_times: Option, + text_layout_details: &TextLayoutDetails, ) -> Option<(DisplayPoint, SelectionGoal)> { let times = maybe_times.unwrap_or(1); use Motion::*; @@ -370,16 +369,16 @@ impl Motion { Backspace => (backspace(map, point, times), SelectionGoal::None), Down { display_lines: false, - } => down(map, point, goal, times), + } => up_down_buffer_rows(map, point, goal, times as isize, &text_layout_details), Down { display_lines: true, - } => down_display(map, point, goal, times), + } => down_display(map, point, goal, times, &text_layout_details), Up { display_lines: false, - } => up(map, point, goal, times), + } => up_down_buffer_rows(map, point, goal, 0 - times as isize, &text_layout_details), Up { display_lines: true, - } => up_display(map, point, goal, times), + } => up_display(map, point, goal, times, &text_layout_details), Right => (right(map, point, times), SelectionGoal::None), NextWordStart { ignore_punctuation } => ( next_word_start(map, point, *ignore_punctuation, times), @@ -442,10 +441,15 @@ impl Motion { selection: &mut Selection, times: Option, expand_to_surrounding_newline: bool, + text_layout_details: &TextLayoutDetails, ) -> bool { - if let Some((new_head, goal)) = - self.move_point(map, selection.head(), selection.goal, times) - { + if let Some((new_head, goal)) = self.move_point( + map, + selection.head(), + selection.goal, + times, + &text_layout_details, + ) { selection.set_head(new_head, goal); if self.linewise() { @@ -530,35 +534,85 @@ fn backspace(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> Di point } -fn down( +pub(crate) fn start_of_relative_buffer_row( + map: &DisplaySnapshot, + point: DisplayPoint, + times: isize, +) -> DisplayPoint { + let start = map.display_point_to_fold_point(point, Bias::Left); + let target = start.row() as isize + times; + let new_row = (target.max(0) as u32).min(map.fold_snapshot.max_point().row()); + + map.clip_point( + map.fold_point_to_display_point( + map.fold_snapshot + .clip_point(FoldPoint::new(new_row, 0), Bias::Right), + ), + Bias::Right, + ) +} + +fn up_down_buffer_rows( map: &DisplaySnapshot, point: DisplayPoint, mut goal: SelectionGoal, - times: usize, + times: isize, + text_layout_details: &TextLayoutDetails, ) -> (DisplayPoint, SelectionGoal) { let start = map.display_point_to_fold_point(point, Bias::Left); + let begin_folded_line = map.fold_point_to_display_point( + map.fold_snapshot + .clip_point(FoldPoint::new(start.row(), 0), Bias::Left), + ); + let select_nth_wrapped_row = point.row() - begin_folded_line.row(); - let goal_column = match goal { - SelectionGoal::Column(column) => column, - SelectionGoal::ColumnRange { end, .. } => end, + let (goal_wrap, goal_x) = match goal { + SelectionGoal::WrappedHorizontalPosition((row, x)) => (row, x), + SelectionGoal::HorizontalRange { end, .. } => (select_nth_wrapped_row, end), + SelectionGoal::HorizontalPosition(x) => (select_nth_wrapped_row, x), _ => { - goal = SelectionGoal::Column(start.column()); - start.column() + let x = map.x_for_point(point, text_layout_details); + goal = SelectionGoal::WrappedHorizontalPosition((select_nth_wrapped_row, x)); + (select_nth_wrapped_row, x) } }; - let new_row = cmp::min( - start.row() + times as u32, - map.fold_snapshot.max_point().row(), - ); - let new_col = cmp::min(goal_column, map.fold_snapshot.line_len(new_row)); - let point = map.fold_point_to_display_point( + let target = start.row() as isize + times; + let new_row = (target.max(0) as u32).min(map.fold_snapshot.max_point().row()); + + let mut begin_folded_line = map.fold_point_to_display_point( map.fold_snapshot - .clip_point(FoldPoint::new(new_row, new_col), Bias::Left), + .clip_point(FoldPoint::new(new_row, 0), Bias::Left), ); - // clip twice to "clip at end of line" - (map.clip_point(point, Bias::Left), goal) + let mut i = 0; + while i < goal_wrap && begin_folded_line.row() < map.max_point().row() { + let next_folded_line = DisplayPoint::new(begin_folded_line.row() + 1, 0); + if map + .display_point_to_fold_point(next_folded_line, Bias::Right) + .row() + == new_row + { + i += 1; + begin_folded_line = next_folded_line; + } else { + break; + } + } + + let new_col = if i == goal_wrap { + map.column_for_x(begin_folded_line.row(), goal_x, text_layout_details) + } else { + map.line_len(begin_folded_line.row()) + }; + + ( + map.clip_point( + DisplayPoint::new(begin_folded_line.row(), new_col), + Bias::Left, + ), + goal, + ) } fn down_display( @@ -566,49 +620,24 @@ fn down_display( mut point: DisplayPoint, mut goal: SelectionGoal, times: usize, + text_layout_details: &TextLayoutDetails, ) -> (DisplayPoint, SelectionGoal) { for _ in 0..times { - (point, goal) = movement::down(map, point, goal, true); + (point, goal) = movement::down(map, point, goal, true, text_layout_details); } (point, goal) } -pub(crate) fn up( - map: &DisplaySnapshot, - point: DisplayPoint, - mut goal: SelectionGoal, - times: usize, -) -> (DisplayPoint, SelectionGoal) { - let start = map.display_point_to_fold_point(point, Bias::Left); - - let goal_column = match goal { - SelectionGoal::Column(column) => column, - SelectionGoal::ColumnRange { end, .. } => end, - _ => { - goal = SelectionGoal::Column(start.column()); - start.column() - } - }; - - let new_row = start.row().saturating_sub(times as u32); - let new_col = cmp::min(goal_column, map.fold_snapshot.line_len(new_row)); - let point = map.fold_point_to_display_point( - map.fold_snapshot - .clip_point(FoldPoint::new(new_row, new_col), Bias::Left), - ); - - (map.clip_point(point, Bias::Left), goal) -} - fn up_display( map: &DisplaySnapshot, mut point: DisplayPoint, mut goal: SelectionGoal, times: usize, + text_layout_details: &TextLayoutDetails, ) -> (DisplayPoint, SelectionGoal) { for _ in 0..times { - (point, goal) = movement::up(map, point, goal, true); + (point, goal) = movement::up(map, point, goal, true, &text_layout_details); } (point, goal) @@ -707,7 +736,7 @@ fn previous_word_start( point } -fn first_non_whitespace( +pub(crate) fn first_non_whitespace( map: &DisplaySnapshot, display_lines: bool, from: DisplayPoint, @@ -886,13 +915,17 @@ fn find_backward( } fn next_line_start(map: &DisplaySnapshot, point: DisplayPoint, times: usize) -> DisplayPoint { - let correct_line = down(map, point, SelectionGoal::None, times).0; + let correct_line = start_of_relative_buffer_row(map, point, times as isize); first_non_whitespace(map, false, correct_line) } -fn next_line_end(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint { +pub(crate) fn next_line_end( + map: &DisplaySnapshot, + mut point: DisplayPoint, + times: usize, +) -> DisplayPoint { if times > 1 { - point = down(map, point, SelectionGoal::None, times - 1).0; + point = start_of_relative_buffer_row(map, point, times as isize - 1); } end_of_line(map, false, point) } diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index c8b517edd0..6151b8c041 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -12,7 +12,7 @@ mod yank; use std::sync::Arc; use crate::{ - motion::{self, Motion}, + motion::{self, first_non_whitespace, next_line_end, right, Motion}, object::Object, state::{Mode, Operator}, Vim, @@ -179,10 +179,11 @@ pub(crate) fn move_cursor( cx: &mut WindowContext, ) { vim.update_active_editor(cx, |editor, cx| { + let text_layout_details = editor.text_layout_details(cx); editor.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_cursors_with(|map, cursor, goal| { motion - .move_point(map, cursor, goal, times) + .move_point(map, cursor, goal, times, &text_layout_details) .unwrap_or((cursor, goal)) }) }) @@ -195,9 +196,7 @@ fn insert_after(_: &mut Workspace, _: &InsertAfter, cx: &mut ViewContext, cx: &m | Motion::StartOfLine { .. } ); vim.update_active_editor(cx, |editor, cx| { + let text_layout_details = editor.text_layout_details(cx); editor.transact(cx, |editor, cx| { // We are swapping to insert mode anyway. Just set the line end clipping behavior now editor.set_clip_at_line_ends(false, cx); @@ -27,9 +28,15 @@ pub fn change_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &m s.move_with(|map, selection| { motion_succeeded |= if let Motion::NextWordStart { ignore_punctuation } = motion { - expand_changed_word_selection(map, selection, times, ignore_punctuation) + expand_changed_word_selection( + map, + selection, + times, + ignore_punctuation, + &text_layout_details, + ) } else { - motion.expand_selection(map, selection, times, false) + motion.expand_selection(map, selection, times, false, &text_layout_details) }; }); }); @@ -81,6 +88,7 @@ fn expand_changed_word_selection( selection: &mut Selection, times: Option, ignore_punctuation: bool, + text_layout_details: &TextLayoutDetails, ) -> bool { if times.is_none() || times.unwrap() == 1 { let scope = map @@ -103,11 +111,22 @@ fn expand_changed_word_selection( }); true } else { - Motion::NextWordStart { ignore_punctuation } - .expand_selection(map, selection, None, false) + Motion::NextWordStart { ignore_punctuation }.expand_selection( + map, + selection, + None, + false, + &text_layout_details, + ) } } else { - Motion::NextWordStart { ignore_punctuation }.expand_selection(map, selection, times, false) + Motion::NextWordStart { ignore_punctuation }.expand_selection( + map, + selection, + times, + false, + &text_layout_details, + ) } } diff --git a/crates/vim/src/normal/delete.rs b/crates/vim/src/normal/delete.rs index 517ece8033..77e0e47be5 100644 --- a/crates/vim/src/normal/delete.rs +++ b/crates/vim/src/normal/delete.rs @@ -7,6 +7,7 @@ use language::Point; pub fn delete_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &mut WindowContext) { vim.stop_recording(); vim.update_active_editor(cx, |editor, cx| { + let text_layout_details = editor.text_layout_details(cx); editor.transact(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); let mut original_columns: HashMap<_, _> = Default::default(); @@ -14,7 +15,7 @@ pub fn delete_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &m s.move_with(|map, selection| { let original_head = selection.head(); original_columns.insert(selection.id, original_head.column()); - motion.expand_selection(map, selection, times, true); + motion.expand_selection(map, selection, times, true, &text_layout_details); // Motion::NextWordStart on an empty line should delete it. if let Motion::NextWordStart { diff --git a/crates/vim/src/normal/increment.rs b/crates/vim/src/normal/increment.rs index 9d62f8ab7b..ee70ab1f5d 100644 --- a/crates/vim/src/normal/increment.rs +++ b/crates/vim/src/normal/increment.rs @@ -255,8 +255,18 @@ mod test { 4 5"}) .await; - cx.simulate_shared_keystrokes(["shift-g", "ctrl-v", "g", "g", "g", "ctrl-x"]) + + cx.simulate_shared_keystrokes(["shift-g", "ctrl-v", "g", "g"]) .await; + cx.assert_shared_state(indoc! {" + «1ˇ» + «2ˇ» + «3ˇ» 2 + «4ˇ» + «5ˇ»"}) + .await; + + cx.simulate_shared_keystrokes(["g", "ctrl-x"]).await; cx.assert_shared_state(indoc! {" ˇ0 0 diff --git a/crates/vim/src/normal/paste.rs b/crates/vim/src/normal/paste.rs index dda8dea1e4..6141e7c66f 100644 --- a/crates/vim/src/normal/paste.rs +++ b/crates/vim/src/normal/paste.rs @@ -30,6 +30,7 @@ fn paste(_: &mut Workspace, action: &Paste, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { vim.record_current_action(cx); vim.update_active_editor(cx, |editor, cx| { + let text_layout_details = editor.text_layout_details(cx); editor.transact(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); @@ -168,8 +169,14 @@ fn paste(_: &mut Workspace, action: &Paste, cx: &mut ViewContext) { let mut cursor = anchor.to_display_point(map); if *line_mode { if !before { - cursor = - movement::down(map, cursor, SelectionGoal::None, false).0; + cursor = movement::down( + map, + cursor, + SelectionGoal::None, + false, + &text_layout_details, + ) + .0; } cursor = movement::indented_line_beginning(map, cursor, true); } else if !is_multiline { diff --git a/crates/vim/src/normal/substitute.rs b/crates/vim/src/normal/substitute.rs index bb6e1abf92..f0369a89bf 100644 --- a/crates/vim/src/normal/substitute.rs +++ b/crates/vim/src/normal/substitute.rs @@ -32,10 +32,17 @@ pub fn substitute(vim: &mut Vim, count: Option, line_mode: bool, cx: &mut vim.update_active_editor(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); editor.transact(cx, |editor, cx| { + let text_layout_details = editor.text_layout_details(cx); editor.change_selections(None, cx, |s| { s.move_with(|map, selection| { if selection.start == selection.end { - Motion::Right.expand_selection(map, selection, count, true); + Motion::Right.expand_selection( + map, + selection, + count, + true, + &text_layout_details, + ); } if line_mode { // in Visual mode when the selection contains the newline at the end @@ -43,7 +50,13 @@ pub fn substitute(vim: &mut Vim, count: Option, line_mode: bool, cx: &mut if !selection.is_empty() && selection.end.column() == 0 { selection.end = movement::left(map, selection.end); } - Motion::CurrentLine.expand_selection(map, selection, None, false); + Motion::CurrentLine.expand_selection( + map, + selection, + None, + false, + &text_layout_details, + ); if let Some((point, _)) = (Motion::FirstNonWhitespace { display_lines: false, }) @@ -52,6 +65,7 @@ pub fn substitute(vim: &mut Vim, count: Option, line_mode: bool, cx: &mut selection.start, selection.goal, None, + &text_layout_details, ) { selection.start = point; } diff --git a/crates/vim/src/normal/yank.rs b/crates/vim/src/normal/yank.rs index 7212a865bd..33833500fa 100644 --- a/crates/vim/src/normal/yank.rs +++ b/crates/vim/src/normal/yank.rs @@ -4,6 +4,7 @@ use gpui::WindowContext; pub fn yank_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &mut WindowContext) { vim.update_active_editor(cx, |editor, cx| { + let text_layout_details = editor.text_layout_details(cx); editor.transact(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); let mut original_positions: HashMap<_, _> = Default::default(); @@ -11,7 +12,7 @@ pub fn yank_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &mut s.move_with(|map, selection| { let original_position = (selection.head(), selection.goal); original_positions.insert(selection.id, original_position); - motion.expand_selection(map, selection, times, true); + motion.expand_selection(map, selection, times, true, &text_layout_details); }); }); copy_selections_content(editor, motion.linewise(), cx); diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 34b9e38768..4fb87e70a0 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -653,6 +653,63 @@ async fn test_selection_goal(cx: &mut gpui::TestAppContext) { .await; } +#[gpui::test] +async fn test_wrapped_motions(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_wrap(12).await; + + cx.set_shared_state(indoc! {" + aaˇaa + 😃😃" + }) + .await; + cx.simulate_shared_keystrokes(["j"]).await; + cx.assert_shared_state(indoc! {" + aaaa + 😃ˇ😃" + }) + .await; + + cx.set_shared_state(indoc! {" + 123456789012aaˇaa + 123456789012😃😃" + }) + .await; + cx.simulate_shared_keystrokes(["j"]).await; + cx.assert_shared_state(indoc! {" + 123456789012aaaa + 123456789012😃ˇ😃" + }) + .await; + + cx.set_shared_state(indoc! {" + 123456789012aaˇaa + 123456789012😃😃" + }) + .await; + cx.simulate_shared_keystrokes(["j"]).await; + cx.assert_shared_state(indoc! {" + 123456789012aaaa + 123456789012😃ˇ😃" + }) + .await; + + cx.set_shared_state(indoc! {" + 123456789012aaaaˇaaaaaaaa123456789012 + wow + 123456789012😃😃😃😃😃😃123456789012" + }) + .await; + cx.simulate_shared_keystrokes(["j", "j"]).await; + cx.assert_shared_state(indoc! {" + 123456789012aaaaaaaaaaaa123456789012 + wow + 123456789012😃😃ˇ😃😃😃😃123456789012" + }) + .await; +} + #[gpui::test] async fn test_paragraphs_dont_wrap(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index c34607d1c0..8eee654331 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -590,7 +590,7 @@ impl Setting for VimModeSetting { fn local_selections_changed(newest: Selection, cx: &mut WindowContext) { Vim::update(cx, |vim, cx| { if vim.enabled && vim.state().mode == Mode::Normal && !newest.is_empty() { - if matches!(newest.goal, SelectionGoal::ColumnRange { .. }) { + if matches!(newest.goal, SelectionGoal::HorizontalRange { .. }) { vim.switch_mode(Mode::VisualBlock, false, cx); } else { vim.switch_mode(Mode::Visual, false, cx) diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index eac823de61..5d6477ff5b 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -57,6 +57,7 @@ pub fn init(cx: &mut AppContext) { pub fn visual_motion(motion: Motion, times: Option, cx: &mut WindowContext) { Vim::update(cx, |vim, cx| { vim.update_active_editor(cx, |editor, cx| { + let text_layout_details = editor.text_layout_details(cx); if vim.state().mode == Mode::VisualBlock && !matches!( motion, @@ -67,7 +68,7 @@ pub fn visual_motion(motion: Motion, times: Option, cx: &mut WindowContex { let is_up_or_down = matches!(motion, Motion::Up { .. } | Motion::Down { .. }); visual_block_motion(is_up_or_down, editor, cx, |map, point, goal| { - motion.move_point(map, point, goal, times) + motion.move_point(map, point, goal, times, &text_layout_details) }) } else { editor.change_selections(Some(Autoscroll::fit()), cx, |s| { @@ -89,9 +90,13 @@ pub fn visual_motion(motion: Motion, times: Option, cx: &mut WindowContex current_head = movement::left(map, selection.end) } - let Some((new_head, goal)) = - motion.move_point(map, current_head, selection.goal, times) - else { + let Some((new_head, goal)) = motion.move_point( + map, + current_head, + selection.goal, + times, + &text_layout_details, + ) else { return; }; @@ -135,19 +140,23 @@ pub fn visual_block_motion( SelectionGoal, ) -> Option<(DisplayPoint, SelectionGoal)>, ) { + let text_layout_details = editor.text_layout_details(cx); editor.change_selections(Some(Autoscroll::fit()), cx, |s| { let map = &s.display_map(); let mut head = s.newest_anchor().head().to_display_point(map); let mut tail = s.oldest_anchor().tail().to_display_point(map); - let (start, end) = match s.newest_anchor().goal { - SelectionGoal::ColumnRange { start, end } if preserve_goal => (start, end), - SelectionGoal::Column(start) if preserve_goal => (start, start + 1), - _ => (tail.column(), head.column()), - }; - let goal = SelectionGoal::ColumnRange { start, end }; + let mut head_x = map.x_for_point(head, &text_layout_details); + let mut tail_x = map.x_for_point(tail, &text_layout_details); - let was_reversed = tail.column() > head.column(); + let (start, end) = match s.newest_anchor().goal { + SelectionGoal::HorizontalRange { start, end } if preserve_goal => (start, end), + SelectionGoal::HorizontalPosition(start) if preserve_goal => (start, start), + _ => (tail_x, head_x), + }; + let mut goal = SelectionGoal::HorizontalRange { start, end }; + + let was_reversed = tail_x > head_x; if !was_reversed && !preserve_goal { head = movement::saturating_left(map, head); } @@ -156,32 +165,56 @@ pub fn visual_block_motion( return; }; head = new_head; + head_x = map.x_for_point(head, &text_layout_details); - let is_reversed = tail.column() > head.column(); + let is_reversed = tail_x > head_x; if was_reversed && !is_reversed { - tail = movement::left(map, tail) + tail = movement::saturating_left(map, tail); + tail_x = map.x_for_point(tail, &text_layout_details); } else if !was_reversed && is_reversed { - tail = movement::right(map, tail) + tail = movement::saturating_right(map, tail); + tail_x = map.x_for_point(tail, &text_layout_details); } if !is_reversed && !preserve_goal { - head = movement::saturating_right(map, head) + head = movement::saturating_right(map, head); + head_x = map.x_for_point(head, &text_layout_details); } - let columns = if is_reversed { - head.column()..tail.column() - } else if head.column() == tail.column() { - head.column()..(head.column() + 1) + let positions = if is_reversed { + head_x..tail_x } else { - tail.column()..head.column() + tail_x..head_x }; + if !preserve_goal { + goal = SelectionGoal::HorizontalRange { + start: positions.start, + end: positions.end, + }; + } + let mut selections = Vec::new(); let mut row = tail.row(); loop { - let start = map.clip_point(DisplayPoint::new(row, columns.start), Bias::Left); - let end = map.clip_point(DisplayPoint::new(row, columns.end), Bias::Left); - if columns.start <= map.line_len(row) { + let layed_out_line = map.lay_out_line_for_row(row, &text_layout_details); + let start = DisplayPoint::new( + row, + layed_out_line.closest_index_for_x(positions.start) as u32, + ); + let mut end = DisplayPoint::new( + row, + layed_out_line.closest_index_for_x(positions.end) as u32, + ); + if end <= start { + if start.column() == map.line_len(start.row()) { + end = start; + } else { + end = movement::saturating_right(map, start); + } + } + + if positions.start <= layed_out_line.width() { let selection = Selection { id: s.new_selection_id(), start: start.to_point(map), @@ -888,6 +921,28 @@ mod test { .await; } + #[gpui::test] + async fn test_visual_block_issue_2123(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! { + "The ˇquick brown + fox jumps over + the lazy dog + " + }) + .await; + cx.simulate_shared_keystrokes(["ctrl-v", "right", "down"]) + .await; + cx.assert_shared_state(indoc! { + "The «quˇ»ick brown + fox «juˇ»mps over + the lazy dog + " + }) + .await; + } + #[gpui::test] async fn test_visual_block_insert(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/test_data/test_increment_steps.json b/crates/vim/test_data/test_increment_steps.json index fffaf1fd29..2e8711d1cc 100644 --- a/crates/vim/test_data/test_increment_steps.json +++ b/crates/vim/test_data/test_increment_steps.json @@ -9,6 +9,7 @@ {"Key":"ctrl-v"} {"Key":"g"} {"Key":"g"} +{"Get":{"state":"«1ˇ»\n«2ˇ»\n«3ˇ» 2\n«4ˇ»\n«5ˇ»","mode":"VisualBlock"}} {"Key":"g"} {"Key":"ctrl-x"} {"Get":{"state":"ˇ0\n0\n0 2\n0\n0","mode":"Normal"}} diff --git a/crates/vim/test_data/test_j.json b/crates/vim/test_data/test_j.json index 64aaf65ef8..703f69d22c 100644 --- a/crates/vim/test_data/test_j.json +++ b/crates/vim/test_data/test_j.json @@ -1,3 +1,6 @@ +{"Put":{"state":"aaˇaa\n😃😃"}} +{"Key":"j"} +{"Get":{"state":"aaaa\n😃ˇ😃","mode":"Normal"}} {"Put":{"state":"ˇThe quick brown\nfox jumps"}} {"Key":"j"} {"Get":{"state":"The quick brown\nˇfox jumps","mode":"Normal"}} diff --git a/crates/vim/test_data/test_visual_block_issue_2123.json b/crates/vim/test_data/test_visual_block_issue_2123.json new file mode 100644 index 0000000000..0f48bcc890 --- /dev/null +++ b/crates/vim/test_data/test_visual_block_issue_2123.json @@ -0,0 +1,5 @@ +{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog\n"}} +{"Key":"ctrl-v"} +{"Key":"right"} +{"Key":"down"} +{"Get":{"state":"The «quˇ»ick brown\nfox «juˇ»mps over\nthe lazy dog\n","mode":"VisualBlock"}} diff --git a/crates/vim/test_data/test_wrapped_motions.json b/crates/vim/test_data/test_wrapped_motions.json new file mode 100644 index 0000000000..195a58f6b5 --- /dev/null +++ b/crates/vim/test_data/test_wrapped_motions.json @@ -0,0 +1,15 @@ +{"SetOption":{"value":"wrap"}} +{"SetOption":{"value":"columns=12"}} +{"Put":{"state":"aaˇaa\n😃😃"}} +{"Key":"j"} +{"Get":{"state":"aaaa\n😃ˇ😃","mode":"Normal"}} +{"Put":{"state":"123456789012aaˇaa\n123456789012😃😃"}} +{"Key":"j"} +{"Get":{"state":"123456789012aaaa\n123456789012😃ˇ😃","mode":"Normal"}} +{"Put":{"state":"123456789012aaˇaa\n123456789012😃😃"}} +{"Key":"j"} +{"Get":{"state":"123456789012aaaa\n123456789012😃ˇ😃","mode":"Normal"}} +{"Put":{"state":"123456789012aaaaˇaaaaaaaa123456789012\nwow\n123456789012😃😃😃😃😃😃123456789012"}} +{"Key":"j"} +{"Key":"j"} +{"Get":{"state":"123456789012aaaaaaaaaaaa123456789012\nwow\n123456789012😃😃ˇ😃😃😃😃123456789012","mode":"Normal"}}