diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index c52a0d76bd..d980984c72 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -8963,6 +8963,10 @@ impl LineWithInvisibles { let mut layouts = Vec::with_capacity(max_line_count); let mut fragments: SmallVec<[LineFragment; 1]> = SmallVec::new(); let mut line = String::new(); + // Byte offset into the logical line used to position invisible markers. + // Unlike `line`, this is not cleared when we flush `shape_line` for + // mid-line inlays/replacements, so marker offsets stay correct in that case. + let mut line_byte_offset: usize = 0; let mut invisibles = Vec::new(); let mut width = Pixels::ZERO; let mut len = 0; @@ -9036,6 +9040,7 @@ impl LineWithInvisibles { width += size.width; len += highlighted_chunk.text.len(); + line_byte_offset += highlighted_chunk.text.len(); fragments.push(LineFragment::Element { id: renderer.id, element: Some(element), @@ -9065,6 +9070,7 @@ impl LineWithInvisibles { width += line_layout.width; len += highlighted_chunk.text.len(); + line_byte_offset += highlighted_chunk.text.len(); fragments.push(LineFragment::Text(line_layout)) } } @@ -9095,6 +9101,7 @@ impl LineWithInvisibles { }); line.clear(); + line_byte_offset = 0; styles.clear(); row += 1; line_exceeded_max_len = false; @@ -9136,8 +9143,8 @@ impl LineWithInvisibles { if highlighted_chunk.is_tab { if non_whitespace_added || !is_soft_wrapped { invisibles.push(Invisible::Tab { - line_start_offset: line.len(), - line_end_offset: line.len() + line_chunk.len(), + line_start_offset: line_byte_offset, + line_end_offset: line_byte_offset + line_chunk.len(), }); } } else { @@ -9149,7 +9156,10 @@ impl LineWithInvisibles { && (non_whitespace_added || !is_soft_wrapped) { Some(Invisible::Whitespace { - line_offset: line.len() + index, + line_start_offset: line_byte_offset + index, + line_end_offset: line_byte_offset + + index + + c.len_utf8(), }) } else { None @@ -9160,6 +9170,7 @@ impl LineWithInvisibles { } line.push_str(line_chunk); + line_byte_offset += line_chunk.len(); } } } @@ -9431,15 +9442,23 @@ impl LineWithInvisibles { line_start_offset, line_end_offset, } => (*line_start_offset, *line_end_offset, &layout.tab_invisible), - Invisible::Whitespace { line_offset } => { - (*line_offset, line_offset + 1, &layout.space_invisible) - } + Invisible::Whitespace { + line_start_offset, + line_end_offset, + } => ( + *line_start_offset, + *line_end_offset, + &layout.space_invisible, + ), }; - let x_offset: ScrollPixelOffset = self.x_for_index(token_offset).into(); + let token_x = self.x_for_index(token_offset); + // Center the marker inside the actual glyph's width so it lines up with + // proportional fonts instead of assuming a monospace `em_width` cell. + let glyph_width = (self.x_for_index(token_end_offset) - token_x).max(Pixels::ZERO); + let x_offset: ScrollPixelOffset = token_x.into(); let invisible_offset: ScrollPixelOffset = - ((layout.position_map.em_width - invisible_symbol.width).max(Pixels::ZERO) / 2.0) - .into(); + ((glyph_width - invisible_symbol.width).max(Pixels::ZERO) / 2.0).into(); let origin = content_origin + gpui::point( Pixels::from( @@ -9635,8 +9654,13 @@ enum Invisible { line_start_offset: usize, line_end_offset: usize, }, + /// A whitespace character (ASCII space or any other Unicode whitespace). + /// + /// Storing both offsets correctly accounts for multi-byte whitespace characters + /// such as U+00A0 NO-BREAK SPACE, keeping adjacency checks correct. Whitespace { - line_offset: usize, + line_start_offset: usize, + line_end_offset: usize, }, } @@ -11201,7 +11225,6 @@ impl Element for EditorElement { scroll_max, line_layouts, line_height, - em_width, em_advance, em_layout_width, snapshot, @@ -12085,7 +12108,6 @@ pub(crate) struct PositionMap { pub scroll_position: gpui::Point, pub scroll_pixel_position: gpui::Point, pub scroll_max: gpui::Point, - pub em_width: Pixels, pub em_advance: Pixels, pub em_layout_width: Pixels, pub visible_row_range: Range, @@ -13424,7 +13446,8 @@ mod tests { line_end_offset: TAB_SIZE as usize, }, Invisible::Whitespace { - line_offset: TAB_SIZE as usize, + line_start_offset: TAB_SIZE as usize, + line_end_offset: TAB_SIZE as usize + 1, }, Invisible::Tab { line_start_offset: TAB_SIZE as usize + 1, @@ -13435,10 +13458,12 @@ mod tests { line_end_offset: TAB_SIZE as usize * 3, }, Invisible::Whitespace { - line_offset: TAB_SIZE as usize * 3 + 1, + line_start_offset: TAB_SIZE as usize * 3 + 1, + line_end_offset: TAB_SIZE as usize * 3 + 2, }, Invisible::Whitespace { - line_offset: TAB_SIZE as usize * 3 + 3, + line_start_offset: TAB_SIZE as usize * 3 + 3, + line_end_offset: TAB_SIZE as usize * 3 + 4, }, ]; assert_eq!( @@ -13468,6 +13493,34 @@ mod tests { } } + #[gpui::test] + fn test_multibyte_whitespace_uses_utf8_byte_offsets(cx: &mut TestAppContext) { + init_test(cx, |s| { + s.defaults.show_whitespaces = Some(ShowWhitespaceSetting::All); + }); + + // Regression test for #49186. NBSP (U+00A0) is rendered via the invisible + // character `replacement` pipeline, which flushes the internal `line` + // scratch buffer mid-line. Any whitespace invisible that follows must use + // the absolute byte offset within the logical line (here: byte 4 for the + // trailing ASCII space), not an offset relative to the post-flush buffer. + let actual_invisibles = collect_invisibles_from_new_editor( + cx, + EditorMode::full(), + "a\u{00A0}b ", + px(500.0), + false, + ); + + assert_eq!( + actual_invisibles, + vec![Invisible::Whitespace { + line_start_offset: 4, + line_end_offset: 5, + }] + ); + } + #[gpui::test] fn test_invisibles_dont_appear_in_certain_editors(cx: &mut TestAppContext) { init_test(cx, |s| { @@ -13508,19 +13561,24 @@ mod tests { line_end_offset: tab_size as usize, }, Invisible::Whitespace { - line_offset: tab_size as usize + 3, + line_start_offset: tab_size as usize + 3, + line_end_offset: tab_size as usize + 4, }, Invisible::Whitespace { - line_offset: tab_size as usize + 4, + line_start_offset: tab_size as usize + 4, + line_end_offset: tab_size as usize + 5, }, Invisible::Whitespace { - line_offset: tab_size as usize + 5, + line_start_offset: tab_size as usize + 5, + line_end_offset: tab_size as usize + 6, }, Invisible::Whitespace { - line_offset: tab_size as usize + 6, + line_start_offset: tab_size as usize + 6, + line_end_offset: tab_size as usize + 7, }, Invisible::Whitespace { - line_offset: tab_size as usize + 7, + line_start_offset: tab_size as usize + 7, + line_end_offset: tab_size as usize + 8, }, ]; let expected_invisibles = std::iter::once(repeated_invisibles)