Fix whitespace rendering in Zed (#54798)

Closes https://github.com/zed-industries/zed/issues/49186

<img width="403" height="136" alt="first"
src="https://github.com/user-attachments/assets/dafaa681-b921-4411-a453-8857681e50f8"
/>

Closes https://github.com/zed-industries/zed/issues/45775

<img width="405" height="104" alt="second"
src="https://github.com/user-attachments/assets/17e3b5df-0d61-47f3-88ff-8aeeafae1425"
/>

Release Notes:

- Fixed whitespace rendering in Zed
This commit is contained in:
Kirill Bulatov authored and GitHub committed 2026-04-25 06:11:27 +00:00
1 parent 83830be2b1
commit cecd991263
1 file changed
+78 -20
+78 -20
View File
@@ -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<ScrollOffset>,
pub scroll_pixel_position: gpui::Point<ScrollPixelOffset>,
pub scroll_max: gpui::Point<ScrollOffset>,
pub em_width: Pixels,
pub em_advance: Pixels,
pub em_layout_width: Pixels,
pub visible_row_range: Range<DisplayRow>,
@@ -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)