editor: Fix APCA contrast split text runs offset (#38751)

Smit Barmase created

Closes #38576

In case of inline element rendering, we can have multiple text runs on
the same display row. There was a bug in
https://github.com/zed-industries/zed/pull/37165 which doesn't consider
this multiple text runs case. This PR fixes that and adds a test for it.

Before:

<img width="600" alt="image"
src="https://github.com/user-attachments/assets/3bdf5f14-988b-45dc-bc8e-c5d61ab35a93"
/>

After:

<img width="600" alt="image"
src="https://github.com/user-attachments/assets/0e1a45ff-c521-4994-b259-3a054d89c4df"
/>

Release Notes:

- Fixed an issue where text could be incorrectly highlighted during
search when a line contained an inline color preview.

Change summary

crates/editor/src/element.rs | 196 +++++++++++++++++++++++--------------
1 file changed, 119 insertions(+), 77 deletions(-)

Detailed changes

crates/editor/src/element.rs πŸ”—

@@ -7616,7 +7616,7 @@ impl LineWithInvisibles {
                     let text_runs: &[TextRun] = if segments.is_empty() {
                         &styles
                     } else {
-                        &Self::split_runs_by_bg_segments(&styles, segments, min_contrast)
+                        &Self::split_runs_by_bg_segments(&styles, segments, min_contrast, len)
                     };
                     let shaped_line = window.text_system().shape_line(
                         line.clone().into(),
@@ -7703,7 +7703,7 @@ impl LineWithInvisibles {
                         let text_runs = if segments.is_empty() {
                             &styles
                         } else {
-                            &Self::split_runs_by_bg_segments(&styles, segments, min_contrast)
+                            &Self::split_runs_by_bg_segments(&styles, segments, min_contrast, len)
                         };
                         let shaped_line = window.text_system().shape_line(
                             line.clone().into(),
@@ -7802,9 +7802,10 @@ impl LineWithInvisibles {
         text_runs: &[TextRun],
         bg_segments: &[(Range<DisplayPoint>, Hsla)],
         min_contrast: f32,
+        start_col_offset: usize,
     ) -> Vec<TextRun> {
         let mut output_runs: Vec<TextRun> = Vec::with_capacity(text_runs.len());
-        let mut line_col = 0usize;
+        let mut line_col = start_col_offset;
         let mut segment_ix = 0usize;
 
         for text_run in text_runs.iter() {
@@ -11254,102 +11255,143 @@ mod tests {
     fn test_split_runs_by_bg_segments(cx: &mut gpui::TestAppContext) {
         init_test(cx, |_| {});
 
+        let dx = |start: u32, end: u32| {
+            DisplayPoint::new(DisplayRow(0), start)..DisplayPoint::new(DisplayRow(0), end)
+        };
+
         let text_color = Hsla {
             h: 210.0,
             s: 0.1,
             l: 0.4,
             a: 1.0,
         };
-        let bg1 = Hsla {
+        let bg_1 = Hsla {
             h: 30.0,
             s: 0.6,
             l: 0.8,
             a: 1.0,
         };
-        let bg2 = Hsla {
+        let bg_2 = Hsla {
             h: 200.0,
             s: 0.6,
             l: 0.2,
             a: 1.0,
         };
         let min_contrast = 45.0;
+        let adjusted_bg1 = ensure_minimum_contrast(text_color, bg_1, min_contrast);
+        let adjusted_bg2 = ensure_minimum_contrast(text_color, bg_2, min_contrast);
 
         // Case A: single run; disjoint segments inside the run
-        let runs = vec![generate_test_run(20, text_color)];
-        let segs = vec![
-            (
-                DisplayPoint::new(DisplayRow(0), 5)..DisplayPoint::new(DisplayRow(0), 10),
-                bg1,
-            ),
-            (
-                DisplayPoint::new(DisplayRow(0), 12)..DisplayPoint::new(DisplayRow(0), 16),
-                bg2,
-            ),
-        ];
-        let out = LineWithInvisibles::split_runs_by_bg_segments(&runs, &segs, min_contrast);
-        // Expected slices: [0,5) [5,10) [10,12) [12,16) [16,20)
-        assert_eq!(
-            out.iter().map(|r| r.len).collect::<Vec<_>>(),
-            vec![5, 5, 2, 4, 4]
-        );
-        assert_eq!(out[0].color, text_color);
-        assert_eq!(
-            out[1].color,
-            ensure_minimum_contrast(text_color, bg1, min_contrast)
-        );
-        assert_eq!(out[2].color, text_color);
-        assert_eq!(
-            out[3].color,
-            ensure_minimum_contrast(text_color, bg2, min_contrast)
-        );
-        assert_eq!(out[4].color, text_color);
+        {
+            let runs = vec![generate_test_run(20, text_color)];
+            let segs = vec![(dx(5, 10), bg_1), (dx(12, 16), bg_2)];
+            let out = LineWithInvisibles::split_runs_by_bg_segments(&runs, &segs, min_contrast, 0);
+            // Expected slices: [0,5) [5,10) [10,12) [12,16) [16,20)
+            assert_eq!(
+                out.iter().map(|r| r.len).collect::<Vec<_>>(),
+                vec![5, 5, 2, 4, 4]
+            );
+            assert_eq!(out[0].color, text_color);
+            assert_eq!(out[1].color, adjusted_bg1);
+            assert_eq!(out[2].color, text_color);
+            assert_eq!(out[3].color, adjusted_bg2);
+            assert_eq!(out[4].color, text_color);
+        }
 
         // Case B: multiple runs; segment extends to end of line (u32::MAX)
-        let runs = vec![
-            generate_test_run(8, text_color),
-            generate_test_run(7, text_color),
-        ];
-        let segs = vec![(
-            DisplayPoint::new(DisplayRow(0), 6)..DisplayPoint::new(DisplayRow(0), u32::MAX),
-            bg1,
-        )];
-        let out = LineWithInvisibles::split_runs_by_bg_segments(&runs, &segs, min_contrast);
-        // Expected slices across runs: [0,6) [6,8) | [0,7)
-        assert_eq!(out.iter().map(|r| r.len).collect::<Vec<_>>(), vec![6, 2, 7]);
-        let adjusted = ensure_minimum_contrast(text_color, bg1, min_contrast);
-        assert_eq!(out[0].color, text_color);
-        assert_eq!(out[1].color, adjusted);
-        assert_eq!(out[2].color, adjusted);
+        {
+            let runs = vec![
+                generate_test_run(8, text_color),
+                generate_test_run(7, text_color),
+            ];
+            let segs = vec![(dx(6, u32::MAX), bg_1)];
+            let out = LineWithInvisibles::split_runs_by_bg_segments(&runs, &segs, min_contrast, 0);
+            // Expected slices across runs: [0,6) [6,8) | [0,7)
+            assert_eq!(out.iter().map(|r| r.len).collect::<Vec<_>>(), vec![6, 2, 7]);
+            assert_eq!(out[0].color, text_color);
+            assert_eq!(out[1].color, adjusted_bg1);
+            assert_eq!(out[2].color, adjusted_bg1);
+        }
 
         // Case C: multi-byte characters
-        // for text: "Hello 🌍 δΈ–η•Œ!"
-        let runs = vec![
-            generate_test_run(5, text_color), // "Hello"
-            generate_test_run(6, text_color), // " 🌍 "
-            generate_test_run(6, text_color), // "δΈ–η•Œ"
-            generate_test_run(1, text_color), // "!"
-        ];
-        // selecting "🌍 δΈ–"
-        let segs = vec![(
-            DisplayPoint::new(DisplayRow(0), 6)..DisplayPoint::new(DisplayRow(0), 14),
-            bg1,
-        )];
-        let out = LineWithInvisibles::split_runs_by_bg_segments(&runs, &segs, min_contrast);
-        // "Hello" | " " | "🌍 " | "δΈ–" | "η•Œ" | "!"
-        assert_eq!(
-            out.iter().map(|r| r.len).collect::<Vec<_>>(),
-            vec![5, 1, 5, 3, 3, 1]
-        );
-        assert_eq!(out[0].color, text_color); // "Hello"
-        assert_eq!(
-            out[2].color,
-            ensure_minimum_contrast(text_color, bg1, min_contrast)
-        ); // "🌍 "
-        assert_eq!(
-            out[3].color,
-            ensure_minimum_contrast(text_color, bg1, min_contrast)
-        ); // "δΈ–"
-        assert_eq!(out[4].color, text_color); // "η•Œ"
-        assert_eq!(out[5].color, text_color); // "!"
+        {
+            // for text: "Hello 🌍 δΈ–η•Œ!"
+            let runs = vec![
+                generate_test_run(5, text_color), // "Hello"
+                generate_test_run(6, text_color), // " 🌍 "
+                generate_test_run(6, text_color), // "δΈ–η•Œ"
+                generate_test_run(1, text_color), // "!"
+            ];
+            // selecting "🌍 δΈ–"
+            let segs = vec![(dx(6, 14), bg_1)];
+            let out = LineWithInvisibles::split_runs_by_bg_segments(&runs, &segs, min_contrast, 0);
+            // "Hello" | " " | "🌍 " | "δΈ–" | "η•Œ" | "!"
+            assert_eq!(
+                out.iter().map(|r| r.len).collect::<Vec<_>>(),
+                vec![5, 1, 5, 3, 3, 1]
+            );
+            assert_eq!(out[0].color, text_color); // "Hello"
+            assert_eq!(out[2].color, adjusted_bg1); // "🌍 "
+            assert_eq!(out[3].color, adjusted_bg1); // "δΈ–"
+            assert_eq!(out[4].color, text_color); // "η•Œ"
+            assert_eq!(out[5].color, text_color); // "!"
+        }
+
+        // Case D: split multiple consecutive text runs with segments
+        {
+            let segs = vec![
+                (dx(2, 4), bg_1),   // selecting "cd"
+                (dx(4, 8), bg_2),   // selecting "efgh"
+                (dx(9, 11), bg_1),  // selecting "jk"
+                (dx(12, 16), bg_2), // selecting "mnop"
+                (dx(18, 19), bg_1), // selecting "s"
+            ];
+
+            // for text: "abcdef"
+            let runs = vec![
+                generate_test_run(2, text_color), // ab
+                generate_test_run(4, text_color), // cdef
+            ];
+            let out = LineWithInvisibles::split_runs_by_bg_segments(&runs, &segs, min_contrast, 0);
+            // new splits "ab", "cd", "ef"
+            assert_eq!(out.iter().map(|r| r.len).collect::<Vec<_>>(), vec![2, 2, 2]);
+            assert_eq!(out[0].color, text_color);
+            assert_eq!(out[1].color, adjusted_bg1);
+            assert_eq!(out[2].color, adjusted_bg2);
+
+            // for text: "ghijklmn"
+            let runs = vec![
+                generate_test_run(3, text_color), // ghi
+                generate_test_run(2, text_color), // jk
+                generate_test_run(3, text_color), // lmn
+            ];
+            let out = LineWithInvisibles::split_runs_by_bg_segments(&runs, &segs, min_contrast, 6); // 2 + 4 from first run
+            // new splits "gh", "i", "jk", "l", "mn"
+            assert_eq!(
+                out.iter().map(|r| r.len).collect::<Vec<_>>(),
+                vec![2, 1, 2, 1, 2]
+            );
+            assert_eq!(out[0].color, adjusted_bg2);
+            assert_eq!(out[1].color, text_color);
+            assert_eq!(out[2].color, adjusted_bg1);
+            assert_eq!(out[3].color, text_color);
+            assert_eq!(out[4].color, adjusted_bg2);
+
+            // for text: "opqrs"
+            let runs = vec![
+                generate_test_run(1, text_color), // o
+                generate_test_run(4, text_color), // pqrs
+            ];
+            let out = LineWithInvisibles::split_runs_by_bg_segments(&runs, &segs, min_contrast, 14); // 6 + 3 + 2 + 3 from first two runs
+            // new splits "o", "p", "qr", "s"
+            assert_eq!(
+                out.iter().map(|r| r.len).collect::<Vec<_>>(),
+                vec![1, 1, 2, 1]
+            );
+            assert_eq!(out[0].color, adjusted_bg2);
+            assert_eq!(out[1].color, adjusted_bg2);
+            assert_eq!(out[2].color, text_color);
+            assert_eq!(out[3].color, adjusted_bg1);
+        }
     }
 }