terminal: Fix rendering of zero-width combining characters (#39526)

Ratazzi created

Add support for rendering Unicode combining characters (diacritics) in
the terminal's batched text runs.

- Add append_zero_width_chars() to handle combining marks
- Integrate zero-width chars into all batching code paths
- Update cell extras tracking logic
- Add test for combining character rendering

Fixes display of é, ñ, ô and other diacritics.

Closes #39525

Release Notes:

- Fixed: NFD/NFKD normalized text (e.g., é as e + ◌́) not rendering in
integrated terminal

Before:

<img width="874" height="688" alt="SCR-20251004-udnj"
src="https://github.com/user-attachments/assets/8d9f9c9f-dac4-4382-92c2-8b6c1d817abd"
/>

After:

<img width="873" height="686" alt="SCR-20251004-ulsw"
src="https://github.com/user-attachments/assets/fbd5cdc7-fdd6-44dc-8b05-cc425644f1a0"
/>

Change summary

crates/terminal_view/src/terminal_element.rs | 59 +++++++++++++++++++--
1 file changed, 53 insertions(+), 6 deletions(-)

Detailed changes

crates/terminal_view/src/terminal_element.rs 🔗

@@ -114,8 +114,20 @@ impl BatchedTextRun {
     }
 
     fn append_char(&mut self, c: char) {
+        self.append_char_internal(c, true);
+    }
+
+    fn append_zero_width_chars(&mut self, chars: &[char]) {
+        for &c in chars {
+            self.append_char_internal(c, false);
+        }
+    }
+
+    fn append_char_internal(&mut self, c: char, counts_cell: bool) {
         self.text.push(c);
-        self.cell_count += 1;
+        if counts_cell {
+            self.cell_count += 1;
+        }
         self.style.len += c.len_utf8();
     }
 
@@ -380,7 +392,8 @@ impl TerminalElement {
                     continue;
                 }
                 // Update tracking for next iteration
-                previous_cell_had_extras = cell.extra.is_some();
+                previous_cell_had_extras =
+                    matches!(cell.zerowidth(), Some(chars) if !chars.is_empty());
 
                 //Layout current cell text
                 {
@@ -397,6 +410,7 @@ impl TerminalElement {
                         );
 
                         let cell_point = AlacPoint::new(alac_line, cell.point.column.0 as i32);
+                        let zero_width_chars = cell.zerowidth();
 
                         // Try to batch with existing run
                         if let Some(ref mut batch) = current_batch {
@@ -406,25 +420,36 @@ impl TerminalElement {
                                     == cell_point.column
                             {
                                 batch.append_char(cell.c);
+                                if let Some(chars) = zero_width_chars {
+                                    batch.append_zero_width_chars(chars);
+                                }
                             } else {
                                 // Flush current batch and start new one
                                 let old_batch = current_batch.take().unwrap();
                                 batched_runs.push(old_batch);
-                                current_batch = Some(BatchedTextRun::new_from_char(
+                                let mut new_batch = BatchedTextRun::new_from_char(
                                     cell_point,
                                     cell.c,
                                     cell_style,
                                     text_style.font_size,
-                                ));
+                                );
+                                if let Some(chars) = zero_width_chars {
+                                    new_batch.append_zero_width_chars(chars);
+                                }
+                                current_batch = Some(new_batch);
                             }
                         } else {
                             // Start new batch
-                            current_batch = Some(BatchedTextRun::new_from_char(
+                            let mut new_batch = BatchedTextRun::new_from_char(
                                 cell_point,
                                 cell.c,
                                 cell_style,
                                 text_style.font_size,
-                            ));
+                            );
+                            if let Some(chars) = zero_width_chars {
+                                new_batch.append_zero_width_chars(chars);
+                            }
+                            current_batch = Some(new_batch);
                         }
                     };
                 }
@@ -1913,6 +1938,28 @@ mod tests {
         assert_eq!(batch.style.len, 6); // 1 + 1 + 4 bytes for emoji
     }
 
+    #[test]
+    fn test_batched_text_run_append_zero_width_char() {
+        let style = TextRun {
+            len: 1,
+            font: font("Helvetica"),
+            color: Hsla::red(),
+            background_color: None,
+            underline: None,
+            strikethrough: None,
+        };
+
+        let font_size = AbsoluteLength::Pixels(px(12.0));
+        let mut batch = BatchedTextRun::new_from_char(AlacPoint::new(0, 0), 'x', style, font_size);
+
+        let combining = '\u{0301}';
+        batch.append_zero_width_chars(&[combining]);
+
+        assert_eq!(batch.text, format!("x{}", combining));
+        assert_eq!(batch.cell_count, 1);
+        assert_eq!(batch.style.len, 1 + combining.len_utf8());
+    }
+
     #[test]
     fn test_background_region_can_merge() {
         let color1 = Hsla::red();