From cb91525cfd9da7ac15ea1b21b962145c77bb9922 Mon Sep 17 00:00:00 2001 From: Codex-ytr83r Date: Fri, 11 Sep 2026 13:48:21 -0700 Subject: [PATCH] Preserve grapheme clusters when shortening desktop labels --- Cargo.lock | 1 + crates/oa-desktop/Cargo.toml | 1 + crates/oa-desktop/src/views.rs | 61 ++++++++++++++++++++++++++++++++-- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b3bda78b..deb7652d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2176,6 +2176,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "unicode-segmentation", ] [[package]] diff --git a/crates/oa-desktop/Cargo.toml b/crates/oa-desktop/Cargo.toml index 2eed9a7c..13f5f911 100644 --- a/crates/oa-desktop/Cargo.toml +++ b/crates/oa-desktop/Cargo.toml @@ -18,6 +18,7 @@ serde_json.workspace = true reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } tokio = { version = "1", features = ["rt", "time"] } tempfile = "3" +unicode-segmentation = "1.13.3" [dev-dependencies] iced_runtime = "0.14" diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index 54608744..d0f472b1 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -12,6 +12,7 @@ use iced::{ }, }; use oa_core::*; +use unicode_segmentation::UnicodeSegmentation; pub fn label<'a>(s: impl Into) -> widget::Text<'a> { text(s.into()).size(11).color(style::MUTED) @@ -24,10 +25,12 @@ pub fn action<'a>(s: &str, m: Message) -> widget::Button<'a, Message> { } pub fn short(s: &str, limit: usize) -> String { let s = s.split_whitespace().collect::>().join(" "); - if s.chars().count() > limit { + if s.graphemes(true).count() > limit { format!( "{}…", - s.chars().take(limit.saturating_sub(1)).collect::() + s.graphemes(true) + .take(limit.saturating_sub(1)) + .collect::() ) } else { s @@ -1290,3 +1293,57 @@ fn checks(app: &App) -> View<'_, Message> { } scrollable(out.padding(24)).into() } + +#[cfg(test)] +mod tests { + use super::short; + + #[test] + fn short_preserves_native_lifeline_clusters_at_cutoff() { + let oa = "abcdefghijklmnope\u{0301}尾"; + assert_eq!(short(oa, 17), "abcdefghijklmnop…"); + assert_eq!(short(oa, 18), oa); + assert_eq!(short(oa, 19), oa); + + let sa = "abcdefghijklmnop👩\u{200d}🔬 tail"; + assert_eq!(short(sa, 17), "abcdefghijklmnop…"); + assert_eq!(short(sa, 18), "abcdefghijklmnop👩\u{200d}🔬…"); + assert_eq!(short(sa, 19), "abcdefghijklmnop👩\u{200d}🔬 …"); + assert_eq!(short(sa, 22), sa); + } + + #[test] + fn short_never_splits_retained_extended_graphemes() { + for cluster in ["e\u{0301}", "👩\u{200d}🔬", "👍🏽", "🇫🇷"] { + let value = format!("A{cluster}BC"); + assert_eq!(short(&value, 2), "A…"); + assert_eq!(short(&value, 3), format!("A{cluster}…")); + assert_eq!(short(&value, 4), value); + } + } + + #[test] + fn short_preserves_ascii_cjk_and_whitespace_policy() { + assert_eq!(short("abcd", 3), "ab…"); + assert_eq!(short("abcd", 4), "abcd"); + assert_eq!(short("abcd", 5), "abcd"); + assert_eq!(short("甲乙丙丁", 3), "甲乙…"); + assert_eq!(short("甲乙丙丁", 4), "甲乙丙丁"); + assert_eq!(short(" \tA\r\n e\u{0301}\u{2003}B ", 5), "A e\u{0301} B"); + assert_eq!(short(" \tA\r\n e\u{0301}\u{2003}B ", 4), "A e\u{0301}…"); + } + + #[test] + fn short_preserves_empty_and_small_limit_policy() { + for limit in [0, 1, 2] { + assert_eq!(short("", limit), ""); + assert_eq!(short(" \t\r\n", limit), ""); + } + assert_eq!(short("A", 0), "…"); + assert_eq!(short("A", 1), "A"); + assert_eq!(short("AB", 1), "…"); + assert_eq!(short("e\u{0301}", 0), "…"); + assert_eq!(short("e\u{0301}", 1), "e\u{0301}"); + assert_eq!(short("e\u{0301}B", 1), "…"); + } +}