Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions docs/development/text-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Slint's text layout system handles the complex process of converting text string
- **Script-aware boundaries**: Splitting text by Unicode script for font selection
- **Line breaking**: Unicode-compliant line break algorithm
- **Text wrapping**: Word wrap, character wrap, and no wrap modes
- **Line height**: Natural font metrics or an explicit line height
- **Text overflow**: Clipping and elision (ellipsis)
- **Styled text**: Markdown parsing with formatting spans

Expand Down Expand Up @@ -111,8 +112,8 @@ pub trait TextShaper {
/// Get glyph for a single character (e.g., ellipsis)
fn glyph_for_char(&self, ch: char) -> Option<Glyph<Self::Length>>;

/// Calculate max lines that fit in height
fn max_lines(&self, max_height: Self::Length) -> usize;
/// Returns how many lines of `line_height` fit in `max_height`.
fn max_lines(&self, max_height: Self::Length, line_height: Self::Length) -> usize;
}
```

Expand All @@ -138,6 +139,22 @@ Combined trait for fonts:
pub trait AbstractFont: TextShaper + FontMetrics<<Self as TextShaper>::Length> {}
```

### TextLayout

`TextLayout` combines a font with paragraph-wide spacing options:

```rust
pub struct TextLayout<'a, Font: AbstractFont> {
pub font: &'a Font,
pub letter_spacing: Option<<Font as TextShaper>::Length>,
pub line_height: Option<<Font as TextShaper>::Length>,
}
```

Both spacing values use the font's `Length` unit.
`None` leaves letter spacing unchanged and uses `FontMetrics::height()` for line height.
The `.slint` `line-height` multiplier is converted to an absolute value before this stage.

## Script Boundary Detection

The `ShapeBoundaries` iterator splits text by Unicode script for optimal font selection:
Expand Down Expand Up @@ -273,6 +290,7 @@ pub struct TextParagraphLayout<'a, Font: AbstractFont> {
pub wrap: TextWrap,
pub overflow: TextOverflow,
pub single_line: bool,
pub max_lines: Option<usize>,
}
```

Expand Down Expand Up @@ -432,11 +450,12 @@ impl StyledText {
### Measuring Text

```rust
let layout = TextLayout { font: &font, letter_spacing: None };
let layout = TextLayout { font: &font, letter_spacing: None, line_height: None };
let (width, height) = layout.text_size(
"Hello World",
Some(max_width), // None for unconstrained
TextWrap::WordWrap,
None, // No line limit
);
```

Expand All @@ -445,14 +464,15 @@ let (width, height) = layout.text_size(
```rust
let paragraph = TextParagraphLayout {
string: text,
layout: TextLayout { font: &font, letter_spacing: None },
layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
max_width: 200.0,
max_height: 100.0,
horizontal_alignment: TextHorizontalAlignment::Left,
vertical_alignment: TextVerticalAlignment::Top,
wrap: TextWrap::WordWrap,
overflow: TextOverflow::Elide,
single_line: false,
max_lines: None,
};

paragraph.layout_lines::<()>(
Expand Down Expand Up @@ -501,8 +521,8 @@ impl TextShaper for MyFont {
// ... build glyph
}

fn max_lines(&self, max_height: f32) -> usize {
(max_height / self.height()).floor() as usize
fn max_lines(&self, max_height: f32, line_height: f32) -> usize {
(max_height / line_height).floor() as usize
}
}
```
Expand Down
28 changes: 17 additions & 11 deletions internal/backends/testing/testing_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0

use i_slint_core::api::PhysicalSize;
use i_slint_core::graphics::euclid::{Point2D, Size2D};
use i_slint_core::graphics::{
FontRequest,
euclid::{Point2D, Size2D},
};
use i_slint_core::item_rendering::HasFont;
use i_slint_core::lengths::{LogicalLength, LogicalPoint, LogicalRect, LogicalSize};
use i_slint_core::platform::PlatformError;
Expand Down Expand Up @@ -140,6 +143,10 @@ fn is_fixed_test_font(family: &Option<SharedString>) -> bool {
family.as_ref().is_some_and(|f| f == FIXED_TEST_FONT)
}

fn fixed_test_font_line_height(font_request: &FontRequest, pixel_size: f32) -> f32 {
font_request.line_height_for_font_size(pixel_size).unwrap_or(pixel_size)
}

#[derive(Default)]
pub struct TestingBackendOptions {
pub mock_time: bool,
Expand Down Expand Up @@ -523,7 +530,8 @@ impl RendererSealed for TestingWindow {
.take(max_lines)
.fold((0, 0), |(len, count), line| (len.max(line.len()), count + 1));
let width = max_line_len as f32 * pixel_size;
let height = num_lines.max(1) as f32 * pixel_size;
let height =
num_lines.max(1) as f32 * fixed_test_font_line_height(&font_request, pixel_size);
LogicalSize::new(width, height)
} else {
sharedparley::text_size(self, text_item, item_rc, max_width, text_wrap, None)
Expand Down Expand Up @@ -568,7 +576,7 @@ impl RendererSealed for TestingWindow {
let font_request = text_item.font_request(item_rc);
if is_fixed_test_font(&font_request.family) {
let pixel_size = font_request.pixel_size.map_or(10., |s| s.get());
LogicalSize::new(pixel_size, pixel_size)
LogicalSize::new(pixel_size, fixed_test_font_line_height(&font_request, pixel_size))
} else {
let Some(ctx) = self.slint_context() else {
return LogicalSize::default();
Expand Down Expand Up @@ -608,16 +616,13 @@ impl RendererSealed for TestingWindow {
let font_request = text_input.font_request(item_rc);
if is_fixed_test_font(&font_request.family) {
let pixel_size = font_request.pixel_size.map_or(10., |s| s.get());
let line_height = fixed_test_font_line_height(&font_request, pixel_size);
let text = text_input.text();
if pos.y < 0. {
return 0;
}
let line = (pos.y / pixel_size) as usize;
let offset = if line >= 1 {
text.split('\n').take(line - 1).map(|l| l.len() + 1).sum()
} else {
0
};
let line = (pos.y / line_height) as usize;
let offset: usize = text.split('\n').take(line).map(|l| l.len() + 1).sum();
let Some(line) = text.split('\n').nth(line) else {
return text.len();
};
Expand All @@ -637,12 +642,13 @@ impl RendererSealed for TestingWindow {
let font_request = text_input.font_request(item_rc);
if is_fixed_test_font(&font_request.family) {
let pixel_size = font_request.pixel_size.map_or(10., |s| s.get());
let line_height = fixed_test_font_line_height(&font_request, pixel_size);
let text = text_input.text();
let line = text[..byte_offset].chars().filter(|c| *c == '\n').count();
let column = text[..byte_offset].split('\n').nth(line).unwrap_or("").len();
LogicalRect::new(
Point2D::new(column as f32 * pixel_size, line as f32 * pixel_size),
Size2D::new(1., pixel_size),
Point2D::new(column as f32 * pixel_size, line as f32 * line_height),
Size2D::new(1., line_height),
)
} else {
sharedparley::text_input_cursor_rect_for_byte_offset(
Expand Down
24 changes: 24 additions & 0 deletions internal/compiler/builtins.slint
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,20 @@ component ComplexText inherits SimpleText {
/// }
/// ```
in property <length> letter-spacing;
/// The line height as a unitless multiple of the font size or as a percentage.
/// Values less than or equal to zero use the natural line height.
/// The CSS `normal` keyword and length values aren't supported.
///
/// ```slint "line-height: 1.5;" imageAlt="text with increased line height" width="200" height="200" needsBackground
/// Text {
/// text: "Two lines\nof text";
/// color: black;
/// font-size: 30pt;
/// line-height: 1.5;
/// }
/// ```
/// \default 0
in property <float> line-height;
/// The brush used for the text outline.
/// ```slint "stroke: darkblue;" imageAlt="text stroke" width="300" height="200" needsBackground
/// Text {
Expand Down Expand Up @@ -747,6 +761,11 @@ component StyledTextItem inherits Empty {
in property <string> default-font-family;
/// The default font size used to render the text, when no size is specified via markup. If unset (or zero), the value falls back to the enclosing `Window`'s `default-font-size`.
in property <length> default-font-size;
/// The line height as a unitless multiple of the font size or as a percentage.
/// Values less than or equal to zero use the natural line height.
/// The CSS `normal` keyword and length values aren't supported.
/// \default 0
in property <float> line-height;
/// The horizontal alignment of the text.
in property <TextHorizontalAlignment> horizontal-alignment;
/// The color used for rendering links in the text.
Expand Down Expand Up @@ -1741,6 +1760,11 @@ export component TextInput {
/// The letter spacing allows changing the spacing between the glyphs. A positive value increases the spacing and a negative value decreases the distance.
/// \default 0
in property <length> letter-spacing;
/// The line height as a unitless multiple of the font size or as a percentage.
/// Values less than or equal to zero use the natural line height.
/// The CSS `normal` keyword and length values aren't supported.
/// \default 0
in property <float> line-height;
in property <length> width;
in property <length> height;
/// The height of the page used to compute how much to scroll when the user presses page up or page down.
Expand Down
1 change: 1 addition & 0 deletions internal/compiler/passes/binding_analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,7 @@ fn visit_implicit_layout_info_dependencies(
vis(&NamedReference::new(item, SmolStr::new_static("font-size")).into(), N);
vis(&NamedReference::new(item, SmolStr::new_static("font-weight")).into(), N);
vis(&NamedReference::new(item, SmolStr::new_static("letter-spacing")).into(), N);
vis(&NamedReference::new(item, SmolStr::new_static("line-height")).into(), N);
vis(&NamedReference::new(item, SmolStr::new_static("wrap")).into(), N);
let wrap_set = item.borrow().is_binding_set("wrap", false)
|| item
Expand Down
11 changes: 11 additions & 0 deletions internal/core/graphics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,21 @@ pub struct FontRequest {
/// The additional spacing (or shrinking if negative) between glyphs. This is usually not submitted to
/// the font-subsystem but collected here for API convenience
pub letter_spacing: Option<LogicalLength>,
/// The line height as a multiple of the font size.
/// `None` uses the font's natural line height.
pub line_height: Option<f32>,
/// Whether to select an italic face of the font family.
pub italic: bool,
}

impl FontRequest {
/// Returns the configured line height in the same unit as `font_size`,
/// or `None` when the font's natural line height applies.
pub fn line_height_for_font_size(&self, font_size: f32) -> Option<f32> {
self.line_height.map(|line_height| font_size * line_height)
}
}

#[cfg(feature = "shared-fontique")]
impl FontRequest {
/// Attempts to query the fontique font collection for a matching font.
Expand Down
1 change: 1 addition & 0 deletions internal/core/item_rendering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ impl HasFont for (SharedString, Brush) {
0,
LogicalLength::default(),
LogicalLength::default(),
0.0,
false,
)
}
Expand Down
3 changes: 3 additions & 0 deletions internal/core/items.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1453,6 +1453,7 @@ impl WindowItem {
local_font_weight: i32,
local_font_size: LogicalLength,
local_letter_spacing: LogicalLength,
local_line_height: f32,
local_italic: bool,
) -> FontRequest {
let Some(window_item_rc) = next_window_item(self_rc) else {
Expand Down Expand Up @@ -1492,6 +1493,8 @@ impl WindowItem {
}
},
letter_spacing: Some(local_letter_spacing),
line_height: (local_line_height.is_finite() && local_line_height > 0.0)
.then_some(local_line_height),
italic: local_italic,
}
}
Expand Down
7 changes: 7 additions & 0 deletions internal/core/items/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ pub struct ComplexText {
pub wrap: Property<TextWrap>,
pub overflow: Property<TextOverflow>,
pub letter_spacing: Property<LogicalLength>,
pub line_height: Property<f32>,
pub stroke: Property<Brush>,
pub stroke_width: Property<LogicalLength>,
pub stroke_style: Property<TextStrokeStyle>,
Expand Down Expand Up @@ -173,6 +174,7 @@ impl HasFont for ComplexText {
self.font_weight(),
self.font_size(),
self.letter_spacing(),
self.line_height(),
self.font_italic(),
)
}
Expand Down Expand Up @@ -246,6 +248,7 @@ pub struct StyledTextItem {
pub default_color: Property<Brush>,
pub default_font_size: Property<LogicalLength>,
pub default_font_family: Property<SharedString>,
pub line_height: Property<f32>,
pub horizontal_alignment: Property<TextHorizontalAlignment>,
pub vertical_alignment: Property<TextVerticalAlignment>,
pub max_lines: Property<i32>,
Expand Down Expand Up @@ -401,6 +404,7 @@ impl HasFont for StyledTextItem {
Default::default(),
self.default_font_size(),
Default::default(),
self.line_height(),
Default::default(),
)
}
Expand Down Expand Up @@ -589,6 +593,7 @@ impl HasFont for SimpleText {
self.font_weight(),
self.font_size(),
LogicalLength::default(),
0.0,
false,
)
}
Expand Down Expand Up @@ -772,6 +777,7 @@ pub struct TextInput {
pub input_type: Property<InputType>,
pub input_method_hints: Property<InputMethodHints>,
pub letter_spacing: Property<LogicalLength>,
pub line_height: Property<f32>,
pub width: Property<LogicalLength>,
pub height: Property<LogicalLength>,
pub cursor_position_byte_offset: Property<i32>,
Expand Down Expand Up @@ -1322,6 +1328,7 @@ impl HasFont for TextInput {
self.font_weight(),
self.font_size(),
self.letter_spacing(),
self.line_height(),
self.font_italic(),
)
}
Expand Down
Loading
Loading