Fix line truncate crash on Windows (#17271)

张小白 created

Closes #17267

We should update the `len` of `runs` when truncating. cc @huacnlee 

Release Notes:

- N/A

Change summary

crates/gpui/src/elements/text.rs            |   4 
crates/gpui/src/text_system/line_wrapper.rs | 197 ++++++++++++++++++++--
2 files changed, 174 insertions(+), 27 deletions(-)

Detailed changes

crates/gpui/src/elements/text.rs đź”—

@@ -263,7 +263,7 @@ impl TextLayout {
             .line_height
             .to_pixels(font_size.into(), cx.rem_size());
 
-        let runs = if let Some(runs) = runs {
+        let mut runs = if let Some(runs) = runs {
             runs
         } else {
             vec![text_style.to_run(text.len())]
@@ -306,7 +306,7 @@ impl TextLayout {
 
                 let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size);
                 let text = if let Some(truncate_width) = truncate_width {
-                    line_wrapper.truncate_line(text.clone(), truncate_width, ellipsis)
+                    line_wrapper.truncate_line(text.clone(), truncate_width, ellipsis, &mut runs)
                 } else {
                     text.clone()
                 };

crates/gpui/src/text_system/line_wrapper.rs đź”—

@@ -1,4 +1,4 @@
-use crate::{px, FontId, FontRun, Pixels, PlatformTextSystem, SharedString};
+use crate::{px, FontId, FontRun, Pixels, PlatformTextSystem, SharedString, TextRun};
 use collections::HashMap;
 use std::{iter, sync::Arc};
 
@@ -104,6 +104,7 @@ impl LineWrapper {
         line: SharedString,
         truncate_width: Pixels,
         ellipsis: Option<&str>,
+        runs: &mut Vec<TextRun>,
     ) -> SharedString {
         let mut width = px(0.);
         let mut ellipsis_width = px(0.);
@@ -124,15 +125,15 @@ impl LineWrapper {
             width += char_width;
 
             if width.floor() > truncate_width {
-                return SharedString::from(format!(
-                    "{}{}",
-                    &line[..truncate_ix],
-                    ellipsis.unwrap_or("")
-                ));
+                let ellipsis = ellipsis.unwrap_or("");
+                let result = SharedString::from(format!("{}{}", &line[..truncate_ix], ellipsis));
+                update_runs_after_truncation(&result, ellipsis, runs);
+
+                return result;
             }
         }
 
-        line.clone()
+        line
     }
 
     pub(crate) fn is_word_char(c: char) -> bool {
@@ -195,6 +196,23 @@ impl LineWrapper {
     }
 }
 
+fn update_runs_after_truncation(result: &str, ellipsis: &str, runs: &mut Vec<TextRun>) {
+    let mut truncate_at = result.len() - ellipsis.len();
+    let mut run_end = None;
+    for (run_index, run) in runs.iter_mut().enumerate() {
+        if run.len <= truncate_at {
+            truncate_at -= run.len;
+        } else {
+            run.len = truncate_at + ellipsis.len();
+            run_end = Some(run_index + 1);
+            break;
+        }
+    }
+    if let Some(run_end) = run_end {
+        runs.truncate(run_end);
+    }
+}
+
 /// A boundary between two lines of text.
 #[derive(Copy, Clone, Debug, PartialEq, Eq)]
 pub struct Boundary {
@@ -213,7 +231,9 @@ impl Boundary {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::{font, TestAppContext, TestDispatcher};
+    use crate::{
+        font, Font, FontFeatures, FontStyle, FontWeight, Hsla, TestAppContext, TestDispatcher,
+    };
     #[cfg(target_os = "macos")]
     use crate::{TextRun, WindowTextSystem, WrapBoundary};
     use rand::prelude::*;
@@ -232,6 +252,26 @@ mod tests {
         LineWrapper::new(id, px(16.), cx.text_system().platform_text_system.clone())
     }
 
+    fn generate_test_runs(input_run_len: &[usize]) -> Vec<TextRun> {
+        input_run_len
+            .iter()
+            .map(|run_len| TextRun {
+                len: *run_len,
+                font: Font {
+                    family: "Dummy".into(),
+                    features: FontFeatures::default(),
+                    fallbacks: None,
+                    weight: FontWeight::default(),
+                    style: FontStyle::Normal,
+                },
+                color: Hsla::default(),
+                background_color: None,
+                underline: None,
+                strikethrough: None,
+            })
+            .collect()
+    }
+
     #[test]
     fn test_wrap_line() {
         let mut wrapper = build_wrapper();
@@ -293,28 +333,135 @@ mod tests {
     fn test_truncate_line() {
         let mut wrapper = build_wrapper();
 
-        assert_eq!(
-            wrapper.truncate_line("aa bbb cccc ddddd eeee ffff gggg".into(), px(220.), None),
-            "aa bbb cccc ddddd eeee"
+        fn perform_test(
+            wrapper: &mut LineWrapper,
+            text: &'static str,
+            result: &'static str,
+            ellipsis: Option<&str>,
+        ) {
+            let dummy_run_lens = vec![text.len()];
+            let mut dummy_runs = generate_test_runs(&dummy_run_lens);
+            assert_eq!(
+                wrapper.truncate_line(text.into(), px(220.), ellipsis, &mut dummy_runs),
+                result
+            );
+            assert_eq!(dummy_runs.first().unwrap().len, result.len());
+        }
+
+        perform_test(
+            &mut wrapper,
+            "aa bbb cccc ddddd eeee ffff gggg",
+            "aa bbb cccc ddddd eeee",
+            None,
         );
-        assert_eq!(
-            wrapper.truncate_line(
-                "aa bbb cccc ddddd eeee ffff gggg".into(),
-                px(220.),
-                Some("…")
-            ),
-            "aa bbb cccc ddddd eee…"
+        perform_test(
+            &mut wrapper,
+            "aa bbb cccc ddddd eeee ffff gggg",
+            "aa bbb cccc ddddd eee…",
+            Some("…"),
         );
-        assert_eq!(
-            wrapper.truncate_line(
-                "aa bbb cccc ddddd eeee ffff gggg".into(),
-                px(220.),
-                Some("......")
-            ),
-            "aa bbb cccc dddd......"
+        perform_test(
+            &mut wrapper,
+            "aa bbb cccc ddddd eeee ffff gggg",
+            "aa bbb cccc dddd......",
+            Some("......"),
         );
     }
 
+    #[test]
+    fn test_truncate_multiple_runs() {
+        let mut wrapper = build_wrapper();
+
+        fn perform_test(
+            wrapper: &mut LineWrapper,
+            text: &'static str,
+            result: &str,
+            run_lens: &[usize],
+            result_run_len: &[usize],
+            line_width: Pixels,
+        ) {
+            let mut dummy_runs = generate_test_runs(run_lens);
+            assert_eq!(
+                wrapper.truncate_line(text.into(), line_width, Some("…"), &mut dummy_runs),
+                result
+            );
+            for (run, result_len) in dummy_runs.iter().zip(result_run_len) {
+                assert_eq!(run.len, *result_len);
+            }
+        }
+        // Case 0: Normal
+        // Text: abcdefghijkl
+        // Runs: Run0 { len: 12, ... }
+        //
+        // Truncate res: abcd… (truncate_at = 4)
+        // Run res: Run0 { string: abcd…, len: 7, ... }
+        perform_test(&mut wrapper, "abcdefghijkl", "abcd…", &[12], &[7], px(50.));
+        // Case 1: Drop some runs
+        // Text: abcdefghijkl
+        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
+        //
+        // Truncate res: abcdef… (truncate_at = 6)
+        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: ef…, len:
+        // 5, ... }
+        perform_test(
+            &mut wrapper,
+            "abcdefghijkl",
+            "abcdef…",
+            &[4, 4, 4],
+            &[4, 5],
+            px(70.),
+        );
+        // Case 2: Truncate at start of some run
+        // Text: abcdefghijkl
+        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
+        //
+        // Truncate res: abcdefgh… (truncate_at = 8)
+        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: efgh, len:
+        // 4, ... }, Run2 { string: …, len: 3, ... }
+        perform_test(
+            &mut wrapper,
+            "abcdefghijkl",
+            "abcdefgh…",
+            &[4, 4, 4],
+            &[4, 4, 3],
+            px(90.),
+        );
+    }
+
+    #[test]
+    fn test_update_run_after_truncation() {
+        fn perform_test(result: &str, run_lens: &[usize], result_run_lens: &[usize]) {
+            let mut dummy_runs = generate_test_runs(run_lens);
+            update_runs_after_truncation(result, "…", &mut dummy_runs);
+            for (run, result_len) in dummy_runs.iter().zip(result_run_lens) {
+                assert_eq!(run.len, *result_len);
+            }
+        }
+        // Case 0: Normal
+        // Text: abcdefghijkl
+        // Runs: Run0 { len: 12, ... }
+        //
+        // Truncate res: abcd… (truncate_at = 4)
+        // Run res: Run0 { string: abcd…, len: 7, ... }
+        perform_test("abcd…", &[12], &[7]);
+        // Case 1: Drop some runs
+        // Text: abcdefghijkl
+        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
+        //
+        // Truncate res: abcdef… (truncate_at = 6)
+        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: ef…, len:
+        // 5, ... }
+        perform_test("abcdef…", &[4, 4, 4], &[4, 5]);
+        // Case 2: Truncate at start of some run
+        // Text: abcdefghijkl
+        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
+        //
+        // Truncate res: abcdefgh… (truncate_at = 8)
+        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: efgh, len:
+        // 4, ... }, Run2 { string: …, len: 3, ... }
+        perform_test("abcdefgh…", &[4, 4, 4], &[4, 4, 3]);
+    }
+
     #[test]
     fn test_is_word_char() {
         #[track_caller]