GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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 {