From beada67860aac56ed6f0b31567115edecfb5cfee Mon Sep 17 00:00:00 2001 From: Vincent Bernardoff Date: Sat, 17 Oct 2026 15:42:35 +0200 Subject: [PATCH] fix: keep sidebar previews within their row Collapse embedded line breaks and tabs for sidebar display so multiline messages and drafts cannot paint over neighboring Inbox rows. Clip the title and preview slots and size them with the text step. Preserve full message and draft formatting outside the sidebar. Cover multiline preview and draft inputs with a regression test and update the Inbox design specification. All 40 sidebar tests pass. Co-Authored-By: GPT-6 --- crates/tinyclient/src/gui/sidebar/chat.rs | 51 ++++++++++++++----- .../tinyclient/src/gui/sidebar/inbox_view.rs | 10 ++-- docs/design/polish/04-unified.md | 6 +++ 3 files changed, 49 insertions(+), 18 deletions(-) diff --git a/crates/tinyclient/src/gui/sidebar/chat.rs b/crates/tinyclient/src/gui/sidebar/chat.rs index 911041e..44b7644 100644 --- a/crates/tinyclient/src/gui/sidebar/chat.rs +++ b/crates/tinyclient/src/gui/sidebar/chat.rs @@ -38,6 +38,15 @@ pub fn row_time(now: i64, at: i64) -> String { time::stamp(now, at) } +/// Sidebar text occupies one visual line, even when a message or draft has paragraphs. +pub(super) fn one_line(text: &str) -> std::borrow::Cow<'_, str> { + if text.chars().any(|c| c.is_whitespace() && c != ' ') { + std::borrow::Cow::Owned(text.split_whitespace().collect::>().join(" ")) + } else { + std::borrow::Cow::Borrowed(text) + } +} + /// A preview that starts by naming me says it to me, so the name is dropped (C12). pub fn strip_me<'a>(preview: &'a str, me: &str) -> &'a str { if me.is_empty() { @@ -217,18 +226,18 @@ impl Shell { /// Line two, first match wins: not sent, typing, a draft when nothing is unread, then the /// preview with its sender and my ticks. pub(super) fn line_two(&self, c: &Conversation, look: Look, color: Rgba) -> Div { - let line = div().flex_1().min_w_0().h(px(16.)).flex().items_center().gap(px(4.)).type_small(); + let line = div().flex_1().min_w_0().h(px(16. + theme::text_step() as f32)).overflow_hidden().flex().items_center().gap(px(4.)).type_small(); // A4 off: no words of any message, for screen sharing. Typing and Not sent still say so. let previews = crate::gui::app::prefs::choices().previews; if self.send_failed_in(&c.key) { return line .child(glyph("icons/circle-alert.svg", 12., theme::danger())) .child(div().flex_none().text_color(theme::danger()).child("Not sent")) - .when(previews, |line| line.child(div().min_w_0().truncate().text_color(theme::text_faint()).child(format!("· {}", c.preview)))); + .when(previews, |line| line.child(div().min_w_0().truncate().text_color(theme::text_faint()).child(format!("· {}", one_line(&c.preview))))); } let typing = self.slots.typing.and_then(|typing| typing(self, &c.key)); if let Some(words) = typing { - return line.child(div().min_w_0().truncate().text_color(theme::accent_text()).child(words)); + return line.child(div().min_w_0().truncate().text_color(theme::accent_text()).child(one_line(&words).into_owned())); } if !previews { return line; @@ -243,7 +252,7 @@ impl Shell { return line .child(glyph("icons/pencil.svg", 12., quiet(theme::text_faint()))) .child(div().flex_none().text_color(quiet(theme::text())).font_weight(if c.muted { theme::REGULAR } else { theme::MEDIUM }).child("Draft:")) - .child(div().min_w_0().truncate().text_color(quiet(theme::text_soft())).child(draft.to_owned())); + .child(div().min_w_0().truncate().text_color(quiet(theme::text_soft())).child(one_line(draft).into_owned())); } let names = self.my_names(c); let preview = names.iter().map(|me| strip_me(&c.preview, me)).find(|stripped| stripped.len() != c.preview.len()).unwrap_or(&c.preview); @@ -255,19 +264,19 @@ impl Shell { .children(ticks) .children(who) .children(attached) - .child(div().min_w_0().truncate().child(preview.to_owned())) + .child(div().min_w_0().truncate().child(one_line(preview).into_owned())) } /// What the other side of a DM is doing, for a home that shows it over the last message: /// in a call, then an activity, then their status. `muted` is the one color of a muted row. fn doing(&self, c: &Conversation, muted: Option) -> Option
{ let peer = Key { id: c.peer.clone()?, ..c.key.clone() }; - let line = |color: Rgba| div().flex_1().min_w_0().h(px(16.)).flex().items_center().gap(px(4.)).type_small().text_color(muted.unwrap_or(color)); + let line = |color: Rgba| div().flex_1().min_w_0().h(px(16. + theme::text_step() as f32)).overflow_hidden().flex().items_center().gap(px(4.)).type_small().text_color(muted.unwrap_or(color)); let glyph = |icon: &'static str, size: f32, color: Rgba| glyph(icon, size, muted.unwrap_or(color)); let person = self.people_state.hub().person(&peer); let voice = person.as_ref().and_then(|p| p.activity.as_ref()).filter(|a| a.kind == protocol::ActivityKind::InVoice && !a.name.is_empty()); if let Some(voice) = voice { - return Some(line(theme::accent_text()).child(glyph("icons/volume-2.svg", 12., theme::accent_text())).child(div().min_w_0().truncate().child(format!("In {}", voice.name)))); + return Some(line(theme::accent_text()).child(glyph("icons/volume-2.svg", 12., theme::accent_text())).child(div().min_w_0().truncate().child(format!("In {}", one_line(&voice.name))))); } if self.in_call(&peer) { return Some(line(theme::accent_text()).child(glyph("icons/phone.svg", 12., theme::accent_text())).child("In a call")); @@ -287,12 +296,12 @@ impl Shell { Presenting => "Presenting", }; let words = if activity.name.is_empty() { verb.to_owned() } else { format!("{verb} {}", activity.name) }; - return Some(line(theme::text_soft()).child(div().min_w_0().truncate().child(words))); + return Some(line(theme::text_soft()).child(div().min_w_0().truncate().child(one_line(&words).into_owned()))); } let status = self.people_state.hub().status_of(&peer); let status = if status.is_empty() { person.status.clone() } else { status }; (!status.is_empty() || !person.emoji.is_empty()).then(|| { - line(theme::text_soft()).child(div().min_w_0().truncate().child(presence::status_words(&person.emoji, &status))) + line(theme::text_soft()).child(div().min_w_0().truncate().child(one_line(&presence::status_words(&person.emoji, &status)).into_owned())) }) } @@ -322,7 +331,9 @@ impl Shell { // Six letters and the ellipsis, or nothing. let context = context.filter(|_| room > 6. * 7. + 14.); let line_one = div() - .h(px(18.)) + .h(px(18. + theme::text_step() as f32)) + .flex_none() + .overflow_hidden() .flex() .items_center() .gap(px(6.)) @@ -335,11 +346,11 @@ impl Shell { .type_ui() .font_weight(tone.weight) .text_color(tone.title) - .child(title), + .child(one_line(&title).into_owned()), ) .children(name_mark(c)) .map(|line| match context { - Some(context) => line.child(div().flex_1().min_w_0().truncate().type_ui().text_color(faint).child(context)), + Some(context) => line.child(div().flex_1().min_w_0().truncate().type_ui().text_color(faint).child(one_line(&context).into_owned())), None => line.child(div().flex_1()), }) .when(!stamp.is_empty(), |line| line.child(div().flex_none().type_micro().text_color(faint).child(stamp))); @@ -354,7 +365,7 @@ impl Shell { .flex() .flex_col() .child(line_one) - .child(div().h(px(16.)).flex().items_center().gap(px(6.)).child(self.line_two(c, look, tone.preview)).children(badge)), + .child(div().h(px(16. + theme::text_step() as f32)).flex_none().overflow_hidden().flex().items_center().gap(px(6.)).child(self.line_two(c, look, tone.preview)).children(badge)), ); let (picked, menu_row) = (c.clone(), c.clone()); list_row(row_id(id, &c.key), theme::row_height(theme::ROW_TWO), selected, focused, content) @@ -391,6 +402,20 @@ impl Shell { mod tests { use super::*; + #[test] + fn multiline_previews_and_drafts_stay_on_one_sidebar_line() { + for (text, expected) in [ + ("@everyone\n\nWe will be performing network upgrade…", "@everyone We will be performing network upgrade…"), + ("UI Updates:\r\n+ RFQ builder now…", "UI Updates: + RFQ builder now…"), + ("first\u{2028}second\u{2029}third", "first second third"), + ("draft\titem\nnext", "draft item next"), + ] { + assert_eq!(one_line(text), expected); + } + assert!(matches!(one_line("You: Nice."), std::borrow::Cow::Borrowed(_))); + assert_eq!(one_line("two spaces"), "two spaces"); + } + #[test] fn a_preview_drops_my_name_and_names_its_attachment() { assert_eq!(strip_me("@Robin Vale need a yes or no", "Robin Vale"), "need a yes or no"); diff --git a/crates/tinyclient/src/gui/sidebar/inbox_view.rs b/crates/tinyclient/src/gui/sidebar/inbox_view.rs index 6f44e1d..909411b 100644 --- a/crates/tinyclient/src/gui/sidebar/inbox_view.rs +++ b/crates/tinyclient/src/gui/sidebar/inbox_view.rs @@ -153,7 +153,7 @@ impl Shell { .flex_col() .child( div() - .h(px(18.)) + .h(px(18. + theme::text_step() as f32)).flex_none().overflow_hidden() .flex() .items_center() .gap(px(4.)) @@ -164,11 +164,11 @@ impl Shell { ) .child( div() - .h(px(16.)) + .h(px(16. + theme::text_step() as f32)).flex_none().overflow_hidden() .flex() .items_center() .gap(px(6.)) - .child(div().flex_1().min_w_0().truncate().type_small().text_color(if unread { theme::text() } else { theme::text_soft() }).child(apps.names())) + .child(div().flex_1().min_w_0().truncate().type_small().text_color(if unread { theme::text() } else { theme::text_soft() }).child(super::chat::one_line(&apps.names()).into_owned())) .children(badge), ), ); @@ -186,9 +186,9 @@ impl Shell { .gap(px(8.)) .child(Avatar::new(&app.key, &app.title, 20.).shape(Shape::Place).image_at(picture).build()) .children(mark.then(|| super::chat::mark(app.key.network, 12., faint))) - .child(div().flex_none().max_w(px(110.)).truncate().type_ui().font_weight(if unread { theme::SEMIBOLD } else { theme::REGULAR }).text_color(theme::text()).child(app.title.clone())) + .child(div().flex_none().max_w(px(110.)).truncate().type_ui().font_weight(if unread { theme::SEMIBOLD } else { theme::REGULAR }).text_color(theme::text()).child(super::chat::one_line(&app.title).into_owned())) // A4 off: the name and time only, as every other row. - .child(div().flex_1().min_w_0().truncate().type_small().text_color(faint).when(crate::gui::app::prefs::choices().previews, |line| line.child(app.preview.clone()))) + .child(div().flex_1().min_w_0().truncate().type_small().text_color(faint).when(crate::gui::app::prefs::choices().previews, |line| line.child(super::chat::one_line(&app.preview).into_owned()))) .child(div().flex_none().type_micro().text_color(faint).child(row_time(now(), app.updated))) .children(super::super::components::row_badge(app)); let picked = app.clone(); diff --git a/docs/design/polish/04-unified.md b/docs/design/polish/04-unified.md index c82b3dd..12c5fe3 100644 --- a/docs/design/polish/04-unified.md +++ b/docs/design/polish/04-unified.md @@ -32,6 +32,12 @@ Requirements: `docs/product/polish/05-unified-views.md`, settled by `00-rulings. | Line 2 | First match: **Not sent** (alert 12 + `danger` "Not sent" + faint ` · text`) · **typing** (three 4 pt dots + "Mara is typing", `accent_text`, the `06` timer) · **draft** when nothing is unread (pencil 12, "Draft:" `text`/500, then the text) · **missed call** (phone-missed 12 + `danger` "Missed call") · **preview**. `You: ` for mine, `Sender: ` in groups and channels (first name). Attachments: glyph 12 `text_faint` + word ("Photo", "2 photos", "Voice message", "Sticker", file name). Custom emoji `:name:`. A leading mention of me is stripped. Colour: `text_soft`, or `text` when unread. | | Badge | 16 high, end of line 2. **Mention:** `accent` fill, white numeral, no "@". **Unread count:** no fill, 1 pt `border_strong` inset ring, `text` numeral. **Starred channel, plain unread:** 8 `text` dot. **Muted:** 6 `text_faint` dot; a mention still shows its filled pill. | +Sidebar text stays on one visual line per slot. Embedded line breaks and tabs in +previews, drafts, activity/status text, and names are collapsed to spaces for +sidebar display; stored messages and drafts keep their formatting. Each title +and preview slot clips overflow and follows the current text-size step, so +resizing the sidebar cannot let a preview paint into a neighboring row. + | State | Drawn as | |---|---| | rest / hover / pressed | none / `hover` / `pressed` fill |