git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit42b12b169238651afd9b5d1f2f7cf95db2d327e0
parent1bbb4d92ed
authorLucas Galante <[email protected]>
date2026-07-21 08:33
fix: char-boundary-safe truncation in the preview pane

Three byte-slice truncation sites (name tail, detail-value tails, symlink
target) panicked on multi-byte UTF-8 at the slice boundary. Add
truncate_tail/truncate_head helpers (char-based, unit-tested, exported via
widget::display and widget) and route all four truncation sites in
PreviewState through them.

Phase 0 of the preview-pane refactor: the fix lands here so it travels
with the widget when it moves into cce-files.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/widget/display/mod.rs        |  2 +-
 src/widget/display/preview.rs    | 26 ++-----------
 src/widget/display/text_sizer.rs | 81 ++++++++++++++++++++++++++++++++++++++++
 src/widget/mod.rs                |  3 +-
 4 files changed, 88 insertions(+), 24 deletions(-)

diff --git a/src/widget/display/mod.rs b/src/widget/display/mod.rs
index ba7bba2..e218a5b 100644
--- a/src/widget/display/mod.rs
+++ b/src/widget/display/mod.rs
@@ -33,5 +33,5 @@ pub use self::usage_bar::UsageBar;
 pub use self::info_box::InfoBox;
 pub use self::status_dot::{DotStatus, StatusDot};
 pub use self::preview::{PreviewState, ImagePreviewData};
-pub use self::text_sizer::{measure_text_width, measure_text};
+pub use self::text_sizer::{measure_text_width, measure_text, truncate_head, truncate_tail};
 
diff --git a/src/widget/display/preview.rs b/src/widget/display/preview.rs
index 6bc0485..0825ba1 100644
--- a/src/widget/display/preview.rs
+++ b/src/widget/display/preview.rs
@@ -294,13 +294,7 @@ impl PreviewState {
                     break;
                 }
                 let limit = (((cw - 40.0) / 6.8).floor() as usize).max(20);
-                let line_truncated = if line.chars().count() > limit {
-                    let mut s: String = line.chars().take(limit - 3).collect();
-                    s.push_str("...");
-                    s
-                } else {
-                    line.to_string()
-                };
+                let line_truncated = crate::widget::display::truncate_tail(line, limit);
                 canvas.text_with_font(&line_truncated, cx + 20.0, text_y, 11.0, text_fg, "monospace");
                 text_y += 15.0;
             }
@@ -333,22 +327,14 @@ impl PreviewState {
         let header_y = details_content_start_y + 6.0;
         canvas.text(icon, cx + 12.0, header_y, 20.0, text_fg);
 
-        let name_truncated = if self.name.len() > 30 {
-            format!("{}...", &self.name[..27])
-        } else {
-            self.name.clone()
-        };
+        let name_truncated = crate::widget::display::truncate_tail(&self.name, 30);
         canvas.text(&name_truncated, cx + 42.0, header_y + 4.0, 16.0, text_fg);
 
         let mut y = details_content_start_y + 36.0;
         for (label, val) in &details {
             canvas.text(label, cx + 12.0, y, 12.0, label_fg);
 
-            let val_str = if val.len() > 40 {
-                format!("...{}", &val[val.len() - 37..])
-            } else {
-                val.to_string()
-            };
+            let val_str = crate::widget::display::truncate_head(val, 40);
             canvas.text(&val_str, cx + 112.0, y, 12.0, text_dim);
             y += 20.0;
         }
@@ -357,11 +343,7 @@ impl PreviewState {
             y += 8.0;
             canvas.text("Target", cx + 12.0, y, 12.0, label_fg);
 
-            let target_str = if self.target.len() > 40 {
-                format!("...{}", &self.target[self.target.len() - 37..])
-            } else {
-                self.target.clone()
-            };
+            let target_str = crate::widget::display::truncate_head(&self.target, 40);
             canvas.text(&target_str, cx + 112.0, y, 12.0, text_dim);
         }
 
diff --git a/src/widget/display/text_sizer.rs b/src/widget/display/text_sizer.rs
index 99c366d..23ce8e2 100644
--- a/src/widget/display/text_sizer.rs
+++ b/src/widget/display/text_sizer.rs
@@ -44,6 +44,31 @@ pub fn measure_text(text: &str, font_size: f32) -> f32 {
     measure_text_width(text, &font_family, font_size)
 }
 
+/// Truncate to at most `max_chars` characters, replacing the tail with "..."
+/// (for names/titles where the head identifies the item). Char-boundary safe —
+/// byte-slicing a multi-byte string panics; this never does.
+pub fn truncate_tail(s: &str, max_chars: usize) -> String {
+    if s.chars().count() <= max_chars {
+        return s.to_string();
+    }
+    let keep = max_chars.saturating_sub(3);
+    let mut out: String = s.chars().take(keep).collect();
+    out.push_str("...");
+    out
+}
+
+/// Truncate to at most `max_chars` characters, replacing the head with "..."
+/// (for paths/targets where the tail identifies the item). Char-boundary safe.
+pub fn truncate_head(s: &str, max_chars: usize) -> String {
+    let count = s.chars().count();
+    if count <= max_chars {
+        return s.to_string();
+    }
+    let keep = max_chars.saturating_sub(3);
+    let tail: String = s.chars().skip(count - keep).collect();
+    format!("...{tail}")
+}
+
 fn perform_svg_measurement(text: &str, font_family: &str, font_size: f32, scale: f32) -> f32 {
     if text.is_empty() {
         return 0.0;
@@ -101,4 +126,60 @@ fn perform_svg_measurement(text: &str, font_family: &str, font_size: f32, scale:
     TextLabel::estimate_width(text, font_size)
 }
 
+#[cfg(test)]
+mod tests {
+    use super::{truncate_head, truncate_tail};
+
+    #[test]
+    fn short_strings_pass_through() {
+        assert_eq!(truncate_tail("abc", 30), "abc");
+        assert_eq!(truncate_head("abc", 30), "abc");
+        assert_eq!(truncate_tail("", 5), "");
+        assert_eq!(truncate_head("", 5), "");
+    }
+
+    #[test]
+    fn exact_length_passes_through() {
+        let s = "a".repeat(30);
+        assert_eq!(truncate_tail(&s, 30), s);
+        assert_eq!(truncate_head(&s, 30), s);
+    }
+
+    #[test]
+    fn tail_truncates_to_max() {
+        let s = "abcdefghij";
+        assert_eq!(truncate_tail(s, 8), "abcde...");
+        assert_eq!(truncate_tail(s, 8).chars().count(), 8);
+    }
+
+    #[test]
+    fn head_truncates_keeping_tail() {
+        let s = "/very/long/path/to/file";
+        // "..." + 7 tail chars = 10 visible chars budgeted
+        assert_eq!(truncate_head(s, 10), "...to/file");
+        assert_eq!(truncate_head(s, 10).chars().count(), 10);
+    }
+
+    #[test]
+    fn multibyte_at_the_old_panic_boundary() {
+        // 30+ two-byte chars: the old `&name[..27]` byte-slice panicked when
+        // byte 27 fell inside a code point. Char-based truncation must not.
+        let s = "é".repeat(35);
+        let t = truncate_tail(&s, 30);
+        assert_eq!(t.chars().count(), 30);
+        assert!(t.ends_with("..."));
+        let h = truncate_head(&s, 40);
+        assert_eq!(h, s); // 35 chars <= 40: untouched despite 70 bytes
+        let h2 = truncate_head(&s, 30);
+        assert!(h2.starts_with("..."));
+        assert_eq!(h2.chars().count(), 30);
+    }
+
+    #[test]
+    fn tiny_budget_degrades_gracefully() {
+        assert_eq!(truncate_tail("abcdef", 3), "...");
+        assert_eq!(truncate_head("abcdef", 2), "...");
+    }
+}
+
 
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 74fcf71..952d58c 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -532,7 +532,8 @@ pub use self::display::{
     TextLabel, Label, StyledLabel, LabelPrim, TextItem, UsageBar,
     InfoBox, StatusDot, InteractiveListItem,
     GraphNode, Graph, Float3, ProgressBar, StatusBar, Splitter, Node, Separator,
-    DotStatus, Panel, PreviewState, ImagePreviewData, serialize_widgets
+    DotStatus, Panel, PreviewState, ImagePreviewData, serialize_widgets,
+    truncate_head, truncate_tail,
 };
 
 pub trait PageSelector {