mail client (IMAP/SMTP)
git clone https://git.lucas.co/cce-mail.git
fix: char-safe truncation (UTF-8 panic) + word-wrapped detail body
- clean_body cut at &cleaned[..1200] and the list labels sliced from/
subject/snippet at fixed byte offsets — any multi-byte char straddling
the cut panics. clean_body now floors to a char boundary; the labels
go through a char-counting ellipsize().
- The detail pane body is one boxed text prim (pc.text_boxed, wrap_width
= pane width, clipped to the pane) instead of the detail_body TextBox
walk, which drew each logical line as a single run and truncated long
paragraphs at the pane edge. The TextBox no longer receives events —
focusing it only let you invisibly edit the display copy.
- Unread badge anchored to the Inbox tab's lower edge (it drifted onto
the Sent tab after the y=60 paginator offset).
Live-verified with an injected mail: 49-char UTF-8 sender ellipsized at
the exact offsets that used to panic, long paragraph soft-wraps, 700 'é'
chars flow through the pane, badge renders on the Inbox tab.
Co-Authored-By: Claude Fable 5 <[email protected]>
src/main.rs | 64 +++++++++++++++++++++++++++++++++++++++++++++++--------------
1 file changed, 50 insertions(+), 14 deletions(-)
diff --git a/src/main.rs b/src/main.rs
index 6f716b0..d0a6ea2 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -257,6 +257,15 @@ fn process_header(line: &str, from: &mut String, subject: &mut String, date: &mu
}
}
+/// Char-boundary-safe ellipsized truncation. Byte slicing (`&s[..n]`) panics
+/// mid-UTF-8, and real-world mail headers/bodies are full of multi-byte chars.
+fn ellipsize(s: &str, max_chars: usize) -> String {
+ match s.char_indices().nth(max_chars) {
+ Some((idx, _)) => format!("{}...", &s[..idx]),
+ None => s.to_string(),
+ }
+}
+
fn clean_body(body: &str) -> String {
let mut cleaned = String::new();
let mut in_headers = false;
@@ -276,7 +285,12 @@ fn clean_body(body: &str) -> String {
}
}
if cleaned.len() > 1200 {
- cleaned = format!("{}...", &cleaned[..1200]);
+ let mut cut = 1200;
+ while !cleaned.is_char_boundary(cut) {
+ cut -= 1;
+ }
+ cleaned.truncate(cut);
+ cleaned.push_str("...");
}
cleaned
}
@@ -876,7 +890,7 @@ impl ClearEmailApp {
labels.push(TextLabel {
text: badge_text,
x: margin_x + (tab_w - est_w) / 2.0,
- y: 144.0, // tracks the paginator's y=60 tab offset
+ y: 106.0, // centers in the badge pill (by + 12 + ~2)
font_size: 10.0,
color: [0xff, 0xff, 0xff],
});
@@ -934,7 +948,7 @@ impl ClearEmailApp {
if let Some(draw_y) = self.email_list.get_item_draw_y(idx, 0.0) {
// Sender name
labels.push(TextLabel {
- text: if email.from.len() > 24 { format!("{}...", &email.from[..21]) } else { email.from.clone() },
+ text: ellipsize(&email.from, 21),
x: list_x + 20.0,
y: draw_y + 6.0,
font_size: 11.0,
@@ -952,7 +966,7 @@ impl ClearEmailApp {
// Subject
labels.push(TextLabel {
- text: if email.subject.len() > 32 { format!("{}...", &email.subject[..29]) } else { email.subject.clone() },
+ text: ellipsize(&email.subject, 29),
x: list_x + 20.0,
y: draw_y + 20.0,
font_size: 10.0,
@@ -961,7 +975,7 @@ impl ClearEmailApp {
// Snippet
let snippet_raw = email.body.replace('\n', " ");
- let snippet = if snippet_raw.len() > 40 { format!("{}...", &snippet_raw[..37]) } else { snippet_raw };
+ let snippet = ellipsize(&snippet_raw, 37);
labels.push(TextLabel {
text: snippet,
x: list_x + 20.0,
@@ -1834,7 +1848,7 @@ impl Application for ClearEmailApp {
let inbox_unread = self.emails.iter().filter(|e| e.folder == "inbox" && !e.read).count();
if inbox_unread > 0 {
let bx = margin_x;
- let by = 130.0; // tracks the paginator's y=60 tab offset
+ let by = 92.0; // lower edge of the Inbox tab (tabs start at the y=60 paginator offset)
quads.push((bx + (tab_w - 22.0) / 2.0, by + 12.0, 22.0, 16.0, [0.20, 0.45, 0.85, 0.8]));
}
@@ -1895,9 +1909,32 @@ impl Application for ClearEmailApp {
cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.btn_delete, &mut *quads.pc);
cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.btn_unread, &mut *quads.pc);
- // Detail body textbox: chrome-less, but the walk emits its text
- self.detail_body.prepare_text(&mut self.font_system);
- cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.detail_body, &mut *quads.pc);
+ // Body as one boxed text prim: word-wrapped at the pane width and
+ // clipped to the pane (the TextBox walk drew each logical line as a
+ // single run, so long paragraphs truncated at the pane edge). Skipped
+ // while a modal is up — boxed text still renders above the panel.
+ if !self.compose_open && !self.account_dialog_open {
+ if let Some(email) = self.emails.iter().find(|e| e.id == selected_id) {
+ let body_w = (w_f32 - (detail_x + 15.0)).max(100.0);
+ let body_h = (h_f32 - 190.0).max(100.0);
+ quads.pc.text_boxed(
+ email.body.clone(),
+ detail_x,
+ 170.0,
+ 12.0,
+ [0xc8, 0xc8, 0xd0],
+ Some("sans-serif".to_string()),
+ Some([detail_x, 170.0, detail_x + body_w, 170.0 + body_h]),
+ cce_ui::scene::paint::TextAttrs::default(),
+ cce_ui::scene::paint::TextLayout {
+ wrap_width: Some(body_w),
+ box_height: body_h,
+ align_h: cce_ui::scene::paint::AlignH::Left,
+ align_v: cce_ui::scene::paint::AlignV::Top,
+ },
+ );
+ }
+ }
}
}
@@ -2022,7 +2059,7 @@ impl Application for ClearEmailApp {
if ctx.propagate_event(&mv, self.btn_reply.id()) { changed = true; }
if ctx.propagate_event(&mv, self.btn_delete.id()) { changed = true; }
if ctx.propagate_event(&mv, self.btn_unread.id()) { changed = true; }
- if ctx.propagate_event(&mv, self.detail_body.id()) { changed = true; }
+ // detail_body: read-only boxed-text pane, no event routing
}
}
@@ -2286,10 +2323,9 @@ impl Application for ClearEmailApp {
msg_out = Some(AppMessage::ToggleUnread);
}
}
- if ctx.propagate_event(&ev, self.detail_body.id()) {
- changed = true;
- if state == ElementState::Pressed { ctx.set_focused(&mut self.detail_body); }
- }
+ // detail_body deliberately gets no events: the pane is a read-only
+ // boxed-text render now, and focusing the TextBox only let you
+ // invisibly edit the display copy.
}
}