Skip to content

Commit 1546af4

Browse files
committed
text: add line-height property
Expose a font-size-relative line height on Text, TextInput, and StyledText. Apply it consistently in Parley, testing, and software-renderer layout, including measurement, elision, and cursor positioning. Document the property and cover the layout and hit-testing behavior. ChangeLog: Add line-height to Text, TextInput, and StyledText.
1 parent 814caf5 commit 1546af4

17 files changed

Lines changed: 462 additions & 90 deletions

File tree

docs/development/text-layout.md

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Slint's text layout system handles the complex process of converting text string
1414
- **Script-aware boundaries**: Splitting text by Unicode script for font selection
1515
- **Line breaking**: Unicode-compliant line break algorithm
1616
- **Text wrapping**: Word wrap, character wrap, and no wrap modes
17+
- **Line height**: Natural font metrics or an explicit line height
1718
- **Text overflow**: Clipping and elision (ellipsis)
1819
- **Styled text**: Markdown parsing with formatting spans
1920

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

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

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

142+
### TextLayout
143+
144+
`TextLayout` combines a font with paragraph-wide spacing options:
145+
146+
```rust
147+
pub struct TextLayout<'a, Font: AbstractFont> {
148+
pub font: &'a Font,
149+
pub letter_spacing: Option<<Font as TextShaper>::Length>,
150+
pub line_height: Option<<Font as TextShaper>::Length>,
151+
}
152+
```
153+
154+
Both spacing values use the font's `Length` unit.
155+
`None` leaves letter spacing unchanged and uses `FontMetrics::height()` for line height.
156+
The `.slint` `line-height` multiplier is converted to an absolute value before this stage.
157+
141158
## Script Boundary Detection
142159

143160
The `ShapeBoundaries` iterator splits text by Unicode script for optimal font selection:
@@ -273,6 +290,7 @@ pub struct TextParagraphLayout<'a, Font: AbstractFont> {
273290
pub wrap: TextWrap,
274291
pub overflow: TextOverflow,
275292
pub single_line: bool,
293+
pub max_lines: Option<usize>,
276294
}
277295
```
278296

@@ -432,11 +450,12 @@ impl StyledText {
432450
### Measuring Text
433451

434452
```rust
435-
let layout = TextLayout { font: &font, letter_spacing: None };
453+
let layout = TextLayout { font: &font, letter_spacing: None, line_height: None };
436454
let (width, height) = layout.text_size(
437455
"Hello World",
438456
Some(max_width), // None for unconstrained
439457
TextWrap::WordWrap,
458+
None, // No line limit
440459
);
441460
```
442461

@@ -445,14 +464,15 @@ let (width, height) = layout.text_size(
445464
```rust
446465
let paragraph = TextParagraphLayout {
447466
string: text,
448-
layout: TextLayout { font: &font, letter_spacing: None },
467+
layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
449468
max_width: 200.0,
450469
max_height: 100.0,
451470
horizontal_alignment: TextHorizontalAlignment::Left,
452471
vertical_alignment: TextVerticalAlignment::Top,
453472
wrap: TextWrap::WordWrap,
454473
overflow: TextOverflow::Elide,
455474
single_line: false,
475+
max_lines: None,
456476
};
457477

458478
paragraph.layout_lines::<()>(
@@ -501,8 +521,8 @@ impl TextShaper for MyFont {
501521
// ... build glyph
502522
}
503523

504-
fn max_lines(&self, max_height: f32) -> usize {
505-
(max_height / self.height()).floor() as usize
524+
fn max_lines(&self, max_height: f32, line_height: f32) -> usize {
525+
(max_height / line_height).floor() as usize
506526
}
507527
}
508528
```

internal/backends/testing/testing_backend.rs

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
33

44
use i_slint_core::api::PhysicalSize;
5-
use i_slint_core::graphics::euclid::{Point2D, Size2D};
5+
use i_slint_core::graphics::{
6+
FontRequest,
7+
euclid::{Point2D, Size2D},
8+
};
69
use i_slint_core::item_rendering::HasFont;
710
use i_slint_core::lengths::{LogicalLength, LogicalPoint, LogicalRect, LogicalSize};
811
use i_slint_core::platform::PlatformError;
@@ -140,6 +143,10 @@ fn is_fixed_test_font(family: &Option<SharedString>) -> bool {
140143
family.as_ref().is_some_and(|f| f == FIXED_TEST_FONT)
141144
}
142145

146+
fn fixed_test_font_line_height(font_request: &FontRequest, pixel_size: f32) -> f32 {
147+
font_request.line_height_for_font_size(pixel_size).unwrap_or(pixel_size)
148+
}
149+
143150
#[derive(Default)]
144151
pub struct TestingBackendOptions {
145152
pub mock_time: bool,
@@ -523,7 +530,8 @@ impl RendererSealed for TestingWindow {
523530
.take(max_lines)
524531
.fold((0, 0), |(len, count), line| (len.max(line.len()), count + 1));
525532
let width = max_line_len as f32 * pixel_size;
526-
let height = num_lines.max(1) as f32 * pixel_size;
533+
let height =
534+
num_lines.max(1) as f32 * fixed_test_font_line_height(&font_request, pixel_size);
527535
LogicalSize::new(width, height)
528536
} else {
529537
sharedparley::text_size(self, text_item, item_rc, max_width, text_wrap, None)
@@ -568,7 +576,7 @@ impl RendererSealed for TestingWindow {
568576
let font_request = text_item.font_request(item_rc);
569577
if is_fixed_test_font(&font_request.family) {
570578
let pixel_size = font_request.pixel_size.map_or(10., |s| s.get());
571-
LogicalSize::new(pixel_size, pixel_size)
579+
LogicalSize::new(pixel_size, fixed_test_font_line_height(&font_request, pixel_size))
572580
} else {
573581
let Some(ctx) = self.slint_context() else {
574582
return LogicalSize::default();
@@ -608,16 +616,13 @@ impl RendererSealed for TestingWindow {
608616
let font_request = text_input.font_request(item_rc);
609617
if is_fixed_test_font(&font_request.family) {
610618
let pixel_size = font_request.pixel_size.map_or(10., |s| s.get());
619+
let line_height = fixed_test_font_line_height(&font_request, pixel_size);
611620
let text = text_input.text();
612621
if pos.y < 0. {
613622
return 0;
614623
}
615-
let line = (pos.y / pixel_size) as usize;
616-
let offset = if line >= 1 {
617-
text.split('\n').take(line - 1).map(|l| l.len() + 1).sum()
618-
} else {
619-
0
620-
};
624+
let line = (pos.y / line_height) as usize;
625+
let offset: usize = text.split('\n').take(line).map(|l| l.len() + 1).sum();
621626
let Some(line) = text.split('\n').nth(line) else {
622627
return text.len();
623628
};
@@ -637,12 +642,13 @@ impl RendererSealed for TestingWindow {
637642
let font_request = text_input.font_request(item_rc);
638643
if is_fixed_test_font(&font_request.family) {
639644
let pixel_size = font_request.pixel_size.map_or(10., |s| s.get());
645+
let line_height = fixed_test_font_line_height(&font_request, pixel_size);
640646
let text = text_input.text();
641647
let line = text[..byte_offset].chars().filter(|c| *c == '\n').count();
642648
let column = text[..byte_offset].split('\n').nth(line).unwrap_or("").len();
643649
LogicalRect::new(
644-
Point2D::new(column as f32 * pixel_size, line as f32 * pixel_size),
645-
Size2D::new(1., pixel_size),
650+
Point2D::new(column as f32 * pixel_size, line as f32 * line_height),
651+
Size2D::new(1., line_height),
646652
)
647653
} else {
648654
sharedparley::text_input_cursor_rect_for_byte_offset(

internal/compiler/builtins.slint

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,20 @@ component ComplexText inherits SimpleText {
658658
/// }
659659
/// ```
660660
in property <length> letter-spacing;
661+
/// The line height as a unitless multiple of the font size or as a percentage.
662+
/// Values less than or equal to zero use the natural line height.
663+
/// The CSS `normal` keyword and length values aren't supported.
664+
///
665+
/// ```slint "line-height: 1.5;" imageAlt="text with increased line height" width="200" height="200" needsBackground
666+
/// Text {
667+
/// text: "Two lines\nof text";
668+
/// color: black;
669+
/// font-size: 30pt;
670+
/// line-height: 1.5;
671+
/// }
672+
/// ```
673+
/// \default 0
674+
in property <float> line-height;
661675
/// The brush used for the text outline.
662676
/// ```slint "stroke: darkblue;" imageAlt="text stroke" width="300" height="200" needsBackground
663677
/// Text {
@@ -747,6 +761,11 @@ component StyledTextItem inherits Empty {
747761
in property <string> default-font-family;
748762
/// 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`.
749763
in property <length> default-font-size;
764+
/// The line height as a unitless multiple of the font size or as a percentage.
765+
/// Values less than or equal to zero use the natural line height.
766+
/// The CSS `normal` keyword and length values aren't supported.
767+
/// \default 0
768+
in property <float> line-height;
750769
/// The horizontal alignment of the text.
751770
in property <TextHorizontalAlignment> horizontal-alignment;
752771
/// The color used for rendering links in the text.
@@ -1737,6 +1756,11 @@ export component TextInput {
17371756
/// The letter spacing allows changing the spacing between the glyphs. A positive value increases the spacing and a negative value decreases the distance.
17381757
/// \default 0
17391758
in property <length> letter-spacing;
1759+
/// The line height as a unitless multiple of the font size or as a percentage.
1760+
/// Values less than or equal to zero use the natural line height.
1761+
/// The CSS `normal` keyword and length values aren't supported.
1762+
/// \default 0
1763+
in property <float> line-height;
17401764
in property <length> width;
17411765
in property <length> height;
17421766
/// The height of the page used to compute how much to scroll when the user presses page up or page down.

internal/compiler/passes/binding_analysis.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -953,6 +953,7 @@ fn visit_implicit_layout_info_dependencies(
953953
vis(&NamedReference::new(item, SmolStr::new_static("font-size")).into(), N);
954954
vis(&NamedReference::new(item, SmolStr::new_static("font-weight")).into(), N);
955955
vis(&NamedReference::new(item, SmolStr::new_static("letter-spacing")).into(), N);
956+
vis(&NamedReference::new(item, SmolStr::new_static("line-height")).into(), N);
956957
vis(&NamedReference::new(item, SmolStr::new_static("wrap")).into(), N);
957958
let wrap_set = item.borrow().is_binding_set("wrap", false)
958959
|| item

internal/core/graphics.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,21 @@ pub struct FontRequest {
9999
/// The additional spacing (or shrinking if negative) between glyphs. This is usually not submitted to
100100
/// the font-subsystem but collected here for API convenience
101101
pub letter_spacing: Option<LogicalLength>,
102+
/// The line height as a multiple of the font size.
103+
/// `None` uses the font's natural line height.
104+
pub line_height: Option<f32>,
102105
/// Whether to select an italic face of the font family.
103106
pub italic: bool,
104107
}
105108

109+
impl FontRequest {
110+
/// Returns the configured line height in the same unit as `font_size`,
111+
/// or `None` when the font's natural line height applies.
112+
pub fn line_height_for_font_size(&self, font_size: f32) -> Option<f32> {
113+
self.line_height.map(|line_height| font_size * line_height)
114+
}
115+
}
116+
106117
#[cfg(feature = "shared-fontique")]
107118
impl FontRequest {
108119
/// Attempts to query the fontique font collection for a matching font.

internal/core/item_rendering.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,7 @@ impl HasFont for (SharedString, Brush) {
394394
0,
395395
LogicalLength::default(),
396396
LogicalLength::default(),
397+
0.0,
397398
false,
398399
)
399400
}

internal/core/items.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1453,6 +1453,7 @@ impl WindowItem {
14531453
local_font_weight: i32,
14541454
local_font_size: LogicalLength,
14551455
local_letter_spacing: LogicalLength,
1456+
local_line_height: f32,
14561457
local_italic: bool,
14571458
) -> FontRequest {
14581459
let Some(window_item_rc) = next_window_item(self_rc) else {
@@ -1492,6 +1493,8 @@ impl WindowItem {
14921493
}
14931494
},
14941495
letter_spacing: Some(local_letter_spacing),
1496+
line_height: (local_line_height.is_finite() && local_line_height > 0.0)
1497+
.then_some(local_line_height),
14951498
italic: local_italic,
14961499
}
14971500
}

internal/core/items/text.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ pub struct ComplexText {
5959
pub wrap: Property<TextWrap>,
6060
pub overflow: Property<TextOverflow>,
6161
pub letter_spacing: Property<LogicalLength>,
62+
pub line_height: Property<f32>,
6263
pub stroke: Property<Brush>,
6364
pub stroke_width: Property<LogicalLength>,
6465
pub stroke_style: Property<TextStrokeStyle>,
@@ -173,6 +174,7 @@ impl HasFont for ComplexText {
173174
self.font_weight(),
174175
self.font_size(),
175176
self.letter_spacing(),
177+
self.line_height(),
176178
self.font_italic(),
177179
)
178180
}
@@ -246,6 +248,7 @@ pub struct StyledTextItem {
246248
pub default_color: Property<Brush>,
247249
pub default_font_size: Property<LogicalLength>,
248250
pub default_font_family: Property<SharedString>,
251+
pub line_height: Property<f32>,
249252
pub horizontal_alignment: Property<TextHorizontalAlignment>,
250253
pub vertical_alignment: Property<TextVerticalAlignment>,
251254
pub max_lines: Property<i32>,
@@ -401,6 +404,7 @@ impl HasFont for StyledTextItem {
401404
Default::default(),
402405
self.default_font_size(),
403406
Default::default(),
407+
self.line_height(),
404408
Default::default(),
405409
)
406410
}
@@ -589,6 +593,7 @@ impl HasFont for SimpleText {
589593
self.font_weight(),
590594
self.font_size(),
591595
LogicalLength::default(),
596+
0.0,
592597
false,
593598
)
594599
}
@@ -772,6 +777,7 @@ pub struct TextInput {
772777
pub input_type: Property<InputType>,
773778
pub input_method_hints: Property<InputMethodHints>,
774779
pub letter_spacing: Property<LogicalLength>,
780+
pub line_height: Property<f32>,
775781
pub width: Property<LogicalLength>,
776782
pub height: Property<LogicalLength>,
777783
pub cursor_position_byte_offset: Property<i32>,
@@ -1322,6 +1328,7 @@ impl HasFont for TextInput {
13221328
self.font_weight(),
13231329
self.font_size(),
13241330
self.letter_spacing(),
1331+
self.line_height(),
13251332
self.font_italic(),
13261333
)
13271334
}

0 commit comments

Comments
 (0)