diff options
| author | srdusr <[email protected]> | 2026-08-13 16:51:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-08-13 16:51:00 +0200 |
| commit | f7426a86f2ccc40a18bc6b21d92fc3f073490de1 (patch) | |
| tree | 4aee9c493b0bbd6270eaba756b74ce8b9cdf9fc6 /crates/wayland/src/decoration | |
| parent | 38d899d19b5e5204062ecab4bb4105d51afddb6b (diff) | |
| download | srdwm-f7426a86f2ccc40a18bc6b21d92fc3f073490de1.tar.gz srdwm-f7426a86f2ccc40a18bc6b21d92fc3f073490de1.zip | |
Elide a long window title instead of cutting it mid-glyph
A title that did not fit was hard-cut at whatever character crossed the
button reservation. Nothing marked the cut, so a truncated name read as the
whole name - "annual-report-final-v7-reviewed-2026-with-appendix.ods -
LibreO" looks like a filename, not like a filename with its tail missing.
Titles now end in an ellipsis when they are shortened, which is what
Windows, GNOME and KDE all do, and it keeps the informative half: an
application or document title almost always begins distinctively and ends
in boilerplate. Whole characters are dropped until the ellipsis fits beside
what remains, so the result never overruns the buttons. A titlebar with no
room even for the ellipsis draws nothing, rather than a lone "..." that
says less than an empty titlebar does.
The mark itself is "…" where the system font has that glyph and "..." where
it does not. A missing glyph rasterizes to nothing at all, which would have
quietly reintroduced the invisible-truncation problem on any font without
it.
Also bounded the work: a client may set a title of any length - a browser
tab carrying a whole data URL - and every character cost a rasterization
before the layout could decide it did not fit. Measurement stops at 256
characters, well past anything legible in a titlebar, and a title that long
is elided many times over regardless.
Four tests: an over-long title fits its span and ends in the ellipsis mark;
a title that fits is left exactly alone; a titlebar too narrow for the
ellipsis draws nothing; a 20,000-character title never lays out more than
the cap. Checked on screen too, at 600px and at 220px.
Diffstat (limited to 'crates/wayland/src/decoration')
| -rw-r--r-- | crates/wayland/src/decoration/tests.rs | 48 | ||||
| -rw-r--r-- | crates/wayland/src/decoration/titlebar.rs | 77 |
2 files changed, 110 insertions, 15 deletions
diff --git a/crates/wayland/src/decoration/tests.rs b/crates/wayland/src/decoration/tests.rs index 0d6609f..0f1a9ab 100644 --- a/crates/wayland/src/decoration/tests.rs +++ b/crates/wayland/src/decoration/tests.rs @@ -967,3 +967,51 @@ fn snap_flyout_has_an_internal_grid_line_between_columns() { let idx = (30 * width as usize + cell_w as usize) * 4; assert_eq!(buf[idx + 3], 255, "column boundary must be drawn, not just the outer border"); } + +/// A long title used to be hard-cut mid-glyph, which leaves no sign that +/// anything was removed - a truncated name reads as the whole name. +#[test] +fn a_long_title_is_elided_rather_than_cut() { + let Some(font) = super::font::find_system_font() else { return }; + let long = "a-very-long-window-title-that-cannot-possibly-fit-in-a-narrow-titlebar.txt"; + let laid_out = super::titlebar::lay_out_title(&font, long, 120.0); + let width: f32 = laid_out.iter().map(|(m, _)| m.advance_width).sum(); + assert!(width <= 120.0, "elided title still overflows: {width}"); + let full: f32 = long.chars().map(|c| font.metrics(c, super::font::FONT_PIXELS).advance_width).sum(); + assert!(width < full, "nothing was elided at all"); + // The last glyph must be an ellipsis mark, not whatever character the + // cut happened to land on. + let ellipsis = if font.lookup_glyph_index('\u{2026}') != 0 { '\u{2026}' } else { '.' }; + let expected = font.rasterize(ellipsis, super::font::FONT_PIXELS); + let last = laid_out.last().expect("title elided to nothing"); + assert_eq!(last.1, expected.1, "a title was cut without an ellipsis"); +} + +/// A title that fits must be left exactly alone - no ellipsis, nothing +/// dropped. +#[test] +fn a_title_that_fits_is_not_touched() { + let Some(font) = super::font::find_system_font() else { return }; + let short = "Files"; + let laid_out = super::titlebar::lay_out_title(&font, short, 400.0); + assert_eq!(laid_out.len(), short.chars().count()); +} + +/// A titlebar too narrow even for the ellipsis draws nothing, rather than a +/// lone "..." that says less than an empty titlebar. +#[test] +fn a_titlebar_with_no_room_at_all_draws_no_title() { + let Some(font) = super::font::find_system_font() else { return }; + assert!(super::titlebar::lay_out_title(&font, "anything", 1.0).is_empty()); +} + +/// A client may set a title of any length; nothing should rasterize +/// thousands of glyphs to discover that. +#[test] +fn a_pathologically_long_title_is_bounded() { + let Some(font) = super::font::find_system_font() else { return }; + let huge: String = std::iter::repeat('x').take(20_000).collect(); + let laid_out = super::titlebar::lay_out_title(&font, &huge, 400.0); + assert!(laid_out.len() < super::titlebar::MAX_TITLE_CHARS); +} + diff --git a/crates/wayland/src/decoration/titlebar.rs b/crates/wayland/src/decoration/titlebar.rs index 846bd41..e03c5e9 100644 --- a/crates/wayland/src/decoration/titlebar.rs +++ b/crates/wayland/src/decoration/titlebar.rs @@ -36,6 +36,66 @@ use super::font::{blit_glyph, find_system_font, FONT_PIXELS, TEXT_LEFT_PADDING}; /// titlebar at all - but `0` is also the correct, harmless value if /// `round_corners` handling ever changes to allow it). #[allow(clippy::too_many_arguments)] +/// The most characters of a title that are ever measured. +/// +/// Nothing legible survives past this in any titlebar a person would use, +/// but a client is free to set a title of any length at all - a browser +/// tab carrying a whole data URL, say - and every character costs a +/// rasterization before the layout below can decide it does not fit. This +/// bounds that work without bounding what is drawn: a title this long is +/// elided many times over regardless. +pub(crate) const MAX_TITLE_CHARS: usize = 256; + +/// Lays a title out into `available` pixels, ending it with an ellipsis if +/// it does not fit. +/// +/// Truncating at the end is what Windows, GNOME and KDE all do, and it +/// keeps the informative half of a window title - an application or +/// document name almost always begins distinctively and ends in boilerplate +/// ("... - Mozilla Firefox"). The ellipsis matters on its own: hard-cutting +/// a title mid-glyph, which is what this did before, leaves no sign that +/// anything was removed, so a truncated name reads as the whole name. +/// +/// Prefers a real "…" and falls back to three periods when the system font +/// has no such glyph, since a missing glyph rasterizes to nothing at all +/// and would silently reintroduce the invisible-truncation problem. +pub(crate) fn lay_out_title(font: &fontdue::Font, title: &str, available: f32) -> Vec<(fontdue::Metrics, Vec<u8>)> { + let ellipsis: &[char] = if font.lookup_glyph_index('\u{2026}') != 0 { &['\u{2026}'] } else { &['.', '.', '.'] }; + let mut glyphs: Vec<(fontdue::Metrics, Vec<u8>)> = Vec::new(); + let mut width = 0.0f32; + let mut truncated = false; + for ch in title.chars().filter(|c| !c.is_control()).take(MAX_TITLE_CHARS) { + let (metrics, coverage) = font.rasterize(ch, FONT_PIXELS); + if width + metrics.advance_width > available { + truncated = true; + break; + } + width += metrics.advance_width; + glyphs.push((metrics, coverage)); + } + if title.chars().filter(|c| !c.is_control()).count() > MAX_TITLE_CHARS { + truncated = true; + } + if !truncated { + return glyphs; + } + let marks: Vec<(fontdue::Metrics, Vec<u8>)> = ellipsis.iter().map(|&c| font.rasterize(c, FONT_PIXELS)).collect(); + let marks_width: f32 = marks.iter().map(|(m, _)| m.advance_width).sum(); + // Drop whole characters off the end until the ellipsis fits beside what + // is left. A title with no room even for the ellipsis draws nothing + // rather than a lone "…", which says less than an empty titlebar does. + while !glyphs.is_empty() && width + marks_width > available { + if let Some((metrics, _)) = glyphs.pop() { + width -= metrics.advance_width; + } + } + if glyphs.is_empty() && marks_width > available { + return Vec::new(); + } + glyphs.extend(marks); + glyphs +} + pub fn render_titlebar( width: u32, height: u32, @@ -132,21 +192,8 @@ pub fn render_titlebar( // advance crosses `text_limit` still gets drawn (matches a real // window's furniture starting exactly at `text_limit`, not one // glyph-width short of it), only the *next* one is dropped. - let mut glyphs: Vec<(fontdue::Metrics, Vec<u8>)> = Vec::new(); - let mut total_width = 0.0f32; - for ch in title.chars() { - if ch.is_control() { - continue; - } - let (metrics, coverage) = font.rasterize(ch, FONT_PIXELS); - let advance = metrics.advance_width; - let already_past_limit = text_start + total_width >= text_limit; - if already_past_limit { - break; - } - total_width += advance; - glyphs.push((metrics, coverage)); - } + let glyphs = lay_out_title(&font, title, text_limit - text_start); + let total_width: f32 = glyphs.iter().map(|(m, _)| m.advance_width).sum(); // Centered on the *whole* titlebar width, not on the narrower // `text_start..text_limit` span left over after reserving the // button squares - matches real macOS, which ignores its own |