Skip to content

core/textlayout: LineBreakIterator drops a trailing separator's own mandatory break, so a text ending with a newline is laid out one line short and a caret cannot reach that line (software renderer) #13297

Closed

Description

@ikskoder

Bug Description

"Hello\n" is laid out as one line, where "Hello\n\nWorld" is four. Every empty line at the end of a text is dropped, not only the last one: "a\n\n" comes out one line tall rather than three.

A multi-line TextInput therefore cannot show a caret that has just moved onto the last line. cursor_rect_for_byte_offset resolves it to the end of the line above and draws it there, and a box sized from preferred-height does not grow until the next character arrives. Pressing Return reads as Return doing nothing, and the line appears one keystroke late.

That is #3590 word for word — "sometimes you need to press enter twice to insert a new line; once you type in the next character, the extra line break appears" — which was fixed for Skia in #3593 by taking the last line's metrics. This is the same symptom on the TextParagraphLayout path.

The crate does not agree with itself. sharedparley lays the same strings out correctly, and test_paragraph_ranges already asserts that "hello\n" splits into ["hello", ""]. linebreak_simple.rs, the iterator used without the unicode-linebreak feature, reports a trailing separator's break too. Only the unicode-linebreak iterator plus TextLineBreaker lose it.

Expected: a text ending with a line separator occupies the empty line the separator opens, and a caret can be placed on it.

Actual: the line is not laid out, and the caret is reported and drawn at the end of the line before it.

Measured. The stand below sets a TextInput's text and reads input.preferred-height, on 1.17.1, with no windowing system:

$ SLINT_SOFTWARE_RENDERER_PARLEY_DISABLED=1 cargo run
text      height  lines  expected
"a"           16      1         1
"a\n"         16      1         2 <
"a\nb"        32      2         2
"a\nb\n"      32      2         3 <
"a\n\nb"      48      3         3
"a\n\n"       16      1         3 <

$ cargo run            # the parley path, for comparison
text      height  lines  expected
"a"           17      1         1
"a\n"         33      2         2
"a\nb"        33      2         2
"a\nb\n"      49      3         3
"a\n\nb"      49      3         3
"a\n\n"       49      3         3

Where it comes from. Three places, in the order they bite.

textlayout/linebreak_unicode.rs, LineBreakIterator::new:

let iterator = unicode_linebreak::linebreaks(text).filter(|(offset, opportunity)| {
    // unicode-linebreaks emits a mandatory break at the end of the text. We're not interested
    // in that.
    *offset != text.len() || !matches!(opportunity, BreakOpportunity::Mandatory)
});

Dropping the artificial end-of-text break of UAX #14 rule LB3 is right. A text that ends with a separator has its own mandatory break at that same offset, and the two cannot be told apart by position, so that one is dropped with it. The last TextFragment then carries trailing_mandatory_break: false and the separator is gone before anything can count it.

textlayout/linebreaker.rs, at the end of TextLineBreaker::next:

// Emit at least one single line
if next_line.is_none()
    && (!self.current_line.byte_range.is_empty() || self.num_emitted_lines == 0)
{
    next_line = Some(core::mem::take(&mut self.current_line));
}

Even with the break reported there is no line after it. TextFragmentIterator folds a trailing separator into the fragment before it, and makes a fragment of its own only for a separator that directly follows another — which is why "Hello\n\nWorld" is four lines and "Hello\n" is one.

Restoring the break exposes a third, in the width-overflow branch of the same function:

let next_line = core::mem::take(&mut self.current_line);
self.mandatory_line_break_on_next_iteration = fragment.trailing_mandatory_break;

if self.text_wrap != TextWrap::CharWrap
    && !fragments.break_anywhere
    && fragment.width < available_width
{
    self.current_line.add_fragment(&fragment);
    self.fragments = fragments;
}

The flag is set from a fragment this branch may put back rather than take. Read again on the next call, that fragment opens an empty line before itself: "Hello World\n" wrapped at exactly the width of "Hello" comes out ["Hello", "", "World", ""].

Which builds have it. TextParagraphLayout is reached from one crate, i-slint-renderer-software; nothing else in the tree names it or TextLineBreaker. That renderer takes it when the systemfonts feature is off, when the matched font is a PixelFont, or when SLINT_SOFTWARE_RENDERER_PARLEY_DISABLED is set, and delegates to sharedparley otherwise. FemtoVG and Skia call sharedparley::text_size and sharedparley::text_input_cursor_rect_for_byte_offset unconditionally, so a GPU build does not have this.

That makes an MCU build the widest case rather than the narrowest: without systemfonts there is no branch to take, so any embedded application with a multi-line TextInput is on the affected path.

Confirmed by running one application against unpatched 1.17.1 both ways: on FemtoVG the caret lands on the line Return opens, and on the software renderer it stays at the end of the line above. Same source, two renderers, opposite answers.

A pull request with the three changes, three regression tests and one updated assertion follows.

Reproducible Code (if applicable)

Four files. No display needed: the software renderer is driven through `MinimalSoftwareWindow`, the minimal platform an MCU build uses.

`Cargo.toml`:


[package]
name = "trailing-line-repro"
version = "0.0.0"
edition = "2021"
build = "build.rs"

[[bin]]
name = "trailing-line-repro"
path = "main.rs"

[dependencies]
slint = { version = "=1.17.1", default-features = false, features = [
    "compat-1-2", "std", "backend-winit", "renderer-software",
] }

[build-dependencies]
slint-build = "=1.17.1"


`build.rs`:


fn main() {
    slint_build::compile("repro.slint").unwrap();
}


`repro.slint`:


export component Repro inherits Window {
    width: 400px;
    height: 200px;

    in-out property <string> value: "a";
    out property <length> measured: input.preferred-height;

    input := TextInput {
        width: 300px;
        single-line: false;
        wrap: word-wrap;
        font-size: 14px;
        text: root.value;
    }
}


`main.rs`:


use std::rc::Rc;

use slint::platform::software_renderer::{MinimalSoftwareWindow, RepaintBufferType};
use slint::platform::{Platform, WindowAdapter};

slint::include_modules!();

struct Headless {
    window: Rc<MinimalSoftwareWindow>,
}

impl Platform for Headless {
    fn create_window_adapter(&self) -> Result<Rc<dyn WindowAdapter>, slint::PlatformError> {
        Ok(self.window.clone())
    }
}

fn main() -> Result<(), slint::PlatformError> {
    let window = MinimalSoftwareWindow::new(RepaintBufferType::NewBuffer);
    slint::platform::set_platform(Box::new(Headless { window: window.clone() }))
        .expect("a platform is set once");
    window.set_size(slint::PhysicalSize::new(400, 200));

    let w = Repro::new()?;
    w.set_value("a".into());
    let one_line = w.get_measured();

    println!("{:<8}  {:>6}  {:>5}  {}", "text", "height", "lines", "expected");
    for (text, expected) in
        [("a", 1), ("a\n", 2), ("a\nb", 2), ("a\nb\n", 3), ("a\n\nb", 3), ("a\n\n", 3)]
    {
        w.set_value(text.into());
        let h = w.get_measured();
        let lines = (h / one_line).round() as i32;
        let mark = if lines == expected { " " } else { "<" };
        println!("{:<8}  {h:>6}  {lines:>5}  {expected:>8} {mark}", format!("{text:?}"));
    }
    Ok(())
}


Then:


SLINT_SOFTWARE_RENDERER_PARLEY_DISABLED=1 cargo run   # the path in question
cargo run                                             # parley, for comparison


The same three rows are also visible at the unit level, with no application at all:


#[test]
fn trailing_separator_opens_a_line() {
    let font = FixedTestFont;
    let text = "Hello\nWorld\n";
    let shape_buffer = ShapeBuffer::new(
        &TextLayout { font: &font, letter_spacing: None, line_height: None },
        text,
    );
    let lines =
        TextLineBreaker::<FixedTestFont>::new(text, &shape_buffer, None, None, TextWrap::WordWrap)
            .collect::<std::vec::Vec<_>>();
    assert_eq!(lines.len(), 3); // fails with left: 2
}

Environment Details

  • Slint Version: 1.17.1. The three sites are unchanged on master as of 2026-09-08. Whether older releases differ has not been checked, so this is not reported as a regression.
  • Platform/OS: Ubuntu 26.04 in a Docker container, with no display server. The stand drives the software renderer through MinimalSoftwareWindow, so it needs no windowing system. It does need a font, and picks one through fontconfig — DejaVu Sans here. The evidence is the line counts rather than the pixel heights: the heights move with the font and with which of the two engines measured them, and the counts do not.
  • Programming Language: Rust, rustc 1.100.0-nightly (67854e511 2026-08-15).
  • Backend/Renderer: renderer-software through slint::platform::set_platform, with and without SLINT_SOFTWARE_RENDERER_PARLEY_DISABLED. The FemtoVG comparison was run separately, under winit on Wayland with a software GL stack (llvmpipe), since the container has no /dev/dri.

Product Impact

A desktop Rust application with one field where a value is a list, one entry per line, and Return puts in a line rather than meaning done. Return there did nothing visible until the next keystroke, which reads as the key being ignored, and the caret sat on the line above the one the next character would land on.

The application is on the TextParagraphLayout path because it sets SLINT_SOFTWARE_RENDERER_PARLEY_DISABLED=1 to keep a TextInput's selection rectangles inside their box; on the parley path they are filled without intersecting the item's clip. Dropping that variable trades one fault for the other.

Every build of the software renderer without systemfonts is on this path with no variable and no choice, so any MCU application with a multi-line TextInput has it too.

The height can be worked around in the application, by measuring the missing line with a pair of hidden probes one character apart. The caret cannot: nothing in a .slint can move where the toolkit draws it.

Activity

  1. added theissue type on Sep 8, 2026
  2. added
    need triagingIssue that the owner of the area still need to triage
    on Sep 8, 2026
  3. added
    a:textText rendering, fonts, Text input (mS,bF)
    on Sep 9, 2026
  4. removed
    need triagingIssue that the owner of the area still need to triage
    on Oct 13, 2026
  5. tronical commented on Oct 13, 2026

    @tronical
    Member

    I agree that this is a bug. "Hello\n" should render as two lines. Generally speaking, text laid out in the software renderer should look the same (layout wise) whether to use parley or not.

  6. added a commit that references this issue on Oct 14, 2026
    aa62bfd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    a:textText rendering, fonts, Text input (mS,bF)

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions