diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 31ddc4b5c490a1..584f19ebfa441c 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -6211,10 +6211,33 @@ impl Editor { } let display_snapshot = self.display_snapshot(cx); + let text_layout_details = self.text_layout_details(window, cx); + + let font_id = text_layout_details + .text_system + .resolve_font(&text_layout_details.editor_style.text.font()); + let font_size = text_layout_details + .editor_style + .text + .font_size + .to_pixels(text_layout_details.rem_size); + let Ok(space_width) = text_layout_details + .text_system + .advance(font_id, font_size, ' ') + .map(|advance| advance.width) + else { + return; + }; + if space_width <= px(0.) { + return; + } + let buffer_snapshot = display_snapshot.buffer_snapshot(); + let tab_size = display_snapshot.tab_snapshot().tab_size.get(); struct CursorData { anchor: Anchor, - point: Point, + row: u32, + x: Pixels, } let cursor_data: Vec = self .selections @@ -6226,29 +6249,50 @@ impl Editor { } else { selection.tail() }; + let point = anchor.to_point(buffer_snapshot); + let mut prefix = String::new(); + let mut column = 0; + for chunk in buffer_snapshot.text_for_range(Point::new(point.row, 0)..point) { + for ch in chunk.chars() { + if ch == '\t' { + let tab_len = tab_size - column % tab_size; + prefix.extend(iter::repeat_n(' ', tab_len as usize)); + column += tab_len; + } else { + prefix.push(ch); + column += 1; + } + } + } + let run = text_layout_details.editor_style.text.to_run(prefix.len()); + let x = text_layout_details + .text_system + .layout_line(&prefix, font_size, &[run], None) + .width; CursorData { - anchor: anchor, - point: anchor.to_point(&display_snapshot.buffer_snapshot()), + anchor, + row: point.row, + x, } }) .collect(); let rows_anchors_count: Vec = cursor_data .iter() - .map(|cursor| cursor.point.row) + .map(|cursor| cursor.row) .chunk_by(|&row| row) .into_iter() .map(|(_, group)| group.count()) .collect(); let max_columns = rows_anchors_count.iter().max().copied().unwrap_or(0); - let mut rows_column_offset = vec![0; rows_anchors_count.len()]; + let mut rows_x_offset = vec![px(0.); rows_anchors_count.len()]; let mut edits = Vec::new(); for column_idx in 0..max_columns { let mut cursor_index = 0; - // Calculate target_column => position that the selections will go - let mut target_column = 0; + // Calculate target_x => position that the selections will go + let mut target_x = px(0.); for (row_idx, cursor_count) in rows_anchors_count.iter().enumerate() { // Skip rows that don't have this column if column_idx >= *cursor_count { @@ -6256,10 +6300,9 @@ impl Editor { continue; } - let point = &cursor_data[cursor_index + column_idx].point; - let adjusted_column = point.column + rows_column_offset[row_idx]; - if adjusted_column > target_column { - target_column = adjusted_column; + let adjusted_x = cursor_data[cursor_index + column_idx].x + rows_x_offset[row_idx]; + if adjusted_x > target_x { + target_x = adjusted_x; } cursor_index += cursor_count; } @@ -6273,15 +6316,15 @@ impl Editor { continue; } - let point = &cursor_data[cursor_index + column_idx].point; - let spaces_needed = target_column - point.column - rows_column_offset[row_idx]; + let cursor = &cursor_data[cursor_index + column_idx]; + let spaces_needed = ((target_x - cursor.x - rows_x_offset[row_idx]) / space_width) + .round() + .max(0.) as u32; if spaces_needed > 0 { - let anchor = cursor_data[cursor_index + column_idx] - .anchor - .bias_left(&display_snapshot); + let anchor = cursor.anchor.bias_left(&display_snapshot); edits.push((anchor..anchor, " ".repeat(spaces_needed as usize))); } - rows_column_offset[row_idx] += spaces_needed; + rows_x_offset[row_idx] += space_width * spaces_needed as f32; cursor_index += *cursor_count; } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 56905d38172744..c6e7847ecd0f0c 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -59,6 +59,7 @@ use settings::{ }; use std::{ borrow::Cow, + cmp::Ordering, sync::{Arc, atomic}, }; use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant}; @@ -3332,6 +3333,44 @@ async fn test_scroll_page_up_page_down(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_newest_selection_on_screen_with_multibyte_chars(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let multibyte_line = "ã".repeat(40); + cx.set_state(&format!("{multibyte_line}ˇx\n")); + cx.update_editor(|editor, window, cx| { + editor.set_visible_line_count(50., window, cx); + editor.set_visible_column_count(60.); + assert_eq!( + editor.newest_selection_on_screen(window, cx), + Ordering::Equal + ); + editor.set_visible_column_count(20.); + assert_eq!( + editor.newest_selection_on_screen(window, cx), + Ordering::Greater + ); + }); + + let ascii_line = "a".repeat(40); + cx.set_state(&format!("{ascii_line}ˇx\n")); + cx.update_editor(|editor, window, cx| { + editor.set_visible_line_count(50., window, cx); + editor.set_visible_column_count(60.); + assert_eq!( + editor.newest_selection_on_screen(window, cx), + Ordering::Equal + ); + editor.set_visible_column_count(20.); + assert_eq!( + editor.newest_selection_on_screen(window, cx), + Ordering::Greater + ); + }); +} + #[gpui::test] async fn test_scroll_line_up_down_cursor_margin(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -41838,6 +41877,66 @@ async fn test_align_selections_multicolumn(cx: &mut TestAppContext) { cx.assert_editor_state(after); } +#[gpui::test] +async fn test_align_selections_with_multibyte_chars(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + // The reported case: `←` and `π` take more bytes than they take columns, so + // aligning on the buffer column padded the first row one space too far. + let before = "a ← 1 ˇ# one\nbc ← π ˇ# two"; + let after = "a ← 1 ˇ# one\nbc ← π ˇ# two"; + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(after); + + // A multi-byte character before the first column also has to shift the + // offset that is carried into the second column. + let before = "π aˇ bbbˇc\nxy aˇ bˇc"; + let after = "π a ˇ bbbˇc\nxy aˇ b ˇc"; + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(after); + + // The display map expands tabs before the row is laid out, so a leading tab + // counts as its expanded width rather than as one byte. + let before = "\taˇbc\nxyzaˇbc"; + let after = "\taˇbc\nxyza ˇbc"; + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(after); + + // A non-BMP character advances two columns, so counting characters rather + // than measuring advances would pad this row twice as far as it needs. + let before = "😀ˇb\nxyzˇb"; + let after = "😀 ˇb\nxyzˇb"; + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(after); + + // Multi-byte characters after the cursors do not move them. + let before = "abˇ←z\ncdˇqz"; + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(before); +} + +#[gpui::test] +async fn test_align_selections_with_soft_wrap(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let before = "aaaaaaaaaaaaˇx\nbbbbbˇy"; + let after = "aaaaaaaaaaaaˇx\nbbbbb ˇy"; + cx.set_state(before); + cx.update_editor(|e, _, cx| e.set_wrap_width(Some(100.0.into()), cx)); + cx.update_editor(|e, window, cx| { + assert!(e.display_text(cx).lines().count() > 2); + e.align_selections(&AlignSelections, window, cx) + }); + cx.assert_editor_state(after); +} + #[gpui::test] async fn test_custom_fallback_highlights(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs index 829d3d05f802b8..41833650a2bc5f 100644 --- a/crates/editor/src/scroll.rs +++ b/crates/editor/src/scroll.rs @@ -922,7 +922,7 @@ impl Editor { /// Ordering::Equal => on screen /// Ordering::Less => above or to the left of the screen /// Ordering::Greater => below or to the right of the screen - pub fn newest_selection_on_screen(&self, cx: &mut App) -> Ordering { + pub fn newest_selection_on_screen(&self, window: &mut Window, cx: &mut App) -> Ordering { let snapshot = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let newest_head = self .selections @@ -938,9 +938,32 @@ impl Editor { if let (Some(visible_lines), Some(visible_columns)) = (self.visible_line_count(), self.visible_column_count()) && newest_head.row() <= DisplayRow(screen_top.row().0 + visible_lines as u32) - && newest_head.column() <= screen_top.column() + visible_columns as u32 { - return Ordering::Equal; + let text_layout_details = self.text_layout_details(window, cx); + let font_id = text_layout_details + .text_system + .resolve_font(&text_layout_details.editor_style.text.font()); + let font_size = text_layout_details + .editor_style + .text + .font_size + .to_pixels(text_layout_details.rem_size); + let on_screen = match text_layout_details + .text_system + .em_advance(font_id, font_size) + .log_err() + { + Some(em_advance) => { + let head_x = snapshot.x_for_display_point(newest_head, &text_layout_details); + let screen_left_x = + snapshot.x_for_display_point(screen_top, &text_layout_details); + head_x <= screen_left_x + em_advance * visible_columns as f32 + } + None => newest_head.column() <= screen_top.column() + visible_columns as u32, + }; + if on_screen { + return Ordering::Equal; + } } Ordering::Greater diff --git a/crates/vim/src/normal/scroll.rs b/crates/vim/src/normal/scroll.rs index befaacf31c7dac..f47eb83dcffad5 100644 --- a/crates/vim/src/normal/scroll.rs +++ b/crates/vim/src/normal/scroll.rs @@ -107,7 +107,7 @@ impl Vim { cx: &mut Context, ) { self.update_editor(cx, |vim, editor, cx| { - let should_move_cursor = editor.newest_selection_on_screen(cx).is_eq(); + let should_move_cursor = editor.newest_selection_on_screen(window, cx).is_eq(); let display_snapshot = editor.display_map.update(cx, |map, cx| map.snapshot(cx)); let old_top = editor.scroll_top_display_point(&display_snapshot, cx); diff --git a/typos.toml b/typos.toml index b1efdf2f7b0926..0a0635ea551ac0 100644 --- a/typos.toml +++ b/typos.toml @@ -107,6 +107,12 @@ extend-ignore-re = [ # Mermaid CSS class name for state diagram composites "composit", # Used in truncating tests to ensure byte-length vs char like in café - "caf…" + "caf…", + '\[elete\]', + "GHIzJ", ] check-filename = true + +[default.extend-words] +scap = "scap" +writeable = "writeable"