Wrapped text breaks a word wider than the line instead of letting it overflow
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

`wrap_lines` broke lines only on whitespace, so a word wider than `max_width` (a long path, URL or file name) was laid out whole on its own line and painted past the right edge of its widget. In the notifier it ran out of the notification card. Such a word is now broken between characters into pieces that fit the line, each piece continuing on the next line. The last piece stays the current line, so the following word can still share it. Breaking on `char` boundaries keeps multi-byte UTF-8 sequences intact. `preferred_size` already reported the height of every resulting line, so the widget grows by as many lines as the break produces.
The wrap width also leaves room for the left inset. `draw` indents each wrapped line by `4.0_f32.min( slack )`, which drops to zero for a line that fills the full width, so such a line started a few pixels left of its neighbours. Breaking long words makes full-width lines common. `preferred_size` and `draw` now wrap to the width minus the new `WRAP_PAD` constant (4 px), and `draw` takes the inset from the same constant, so every line keeps it. As a consequence, every wrapped `text` has 4 px less usable width per line.
The new test `wrap_breaks_words_wider_than_the_line` checks that a word wider than the line is split over several lines, that no line is wider than the wrap width, and that the lines together hold the whole text.
This commit is contained in:
2026-10-08 12:48:29 +02:00
parent 17e649dd32
commit 4a6ec797a2
2 changed files with 40 additions and 12 deletions
+25 -12
View File
@@ -212,7 +212,7 @@ impl Text
if self.wrap
{
let lines = wrap_lines( &self.content, size, max_width, canvas, font.as_ref() );
let lines = wrap_lines( &self.content, size, max_width - WRAP_PAD, canvas, font.as_ref() );
let h = line_h * lines.len().max( 1 ) as f32;
( max_width, h )
}
@@ -236,12 +236,12 @@ impl Text
if self.wrap
{
let lines = wrap_lines( &self.content, size, rect.width, canvas, font.as_ref() );
let lines = wrap_lines( &self.content, size, rect.width - WRAP_PAD, canvas, font.as_ref() );
for ( i, line ) in lines.iter().enumerate()
{
let line_w = self.measure( line, canvas, font.as_ref() );
let slack = ( rect.width - line_w ).max( 0.0 );
let pad = 4.0_f32.min( slack );
let pad = WRAP_PAD.min( slack );
let tx = match self.align
{
TextAlign::Left => rect.x + pad,
@@ -297,10 +297,12 @@ impl Text
}
}
const WRAP_PAD: f32 = 4.0;
/// Greedy word-wrap: split `text` on whitespace and pack words into
/// lines whose total width stays under `max_width`. A word longer
/// than `max_width` overflows on its own line rather than being
/// hyphenated. Routes through the font override when one is set so
/// than `max_width` is broken between characters and continues on
/// the next line. Routes through the font override when one is set so
/// measurement and rendering agree on advance widths.
fn wrap_lines( text: &str, size: f32, max_width: f32, canvas: &Canvas, font: Option<&Arc<FontFace>> ) -> Vec<String>
{
@@ -323,23 +325,34 @@ fn wrap_lines( text: &str, size: f32, max_width: f32, canvas: &Canvas, font: Opt
for word in text.split_whitespace()
{
let word_w = measure( word );
if current.is_empty()
{
current.push_str( word );
current_w = word_w;
}
else if current_w + space_w + word_w <= max_width
if !current.is_empty() && current_w + space_w + word_w <= max_width
{
current.push( ' ' );
current.push_str( word );
current_w += space_w + word_w;
continue;
}
else
if !current.is_empty()
{
lines.push( std::mem::take( &mut current ) );
}
if word_w <= max_width
{
current.push_str( word );
current_w = word_w;
continue;
}
let mut start = 0;
for ( i, ch ) in word.char_indices().skip( 1 )
{
if measure( &word[ start..i + ch.len_utf8() ] ) > max_width
{
lines.push( word[ start..i ].to_string() );
start = i;
}
}
current.push_str( &word[ start.. ] );
current_w = measure( &current );
}
if !current.is_empty() { lines.push( current ); }
if lines.is_empty() { lines.push( String::new() ); }