Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Calculate minimum and maximum content width #259

Merged
merged 9 commits into from
Feb 17, 2025
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ This release has an [MSRV] of 1.82.
#### Fontique

- `FontStretch`, `FontStyle`, and `FontWeight` get helper functions `from_fontconfig` ([#212] by [@waywardmonkeys][])
- `Layout` methods to calculate minimum and maximum content widths. ([#259][] by [@wfdewith][])

#### Parley

Expand All @@ -38,6 +39,7 @@ This release has an [MSRV] of 1.82.
- Breaking change: `PlainEditor`'s semantics are no longer transactional ([#192][] by [@DJMcNab][])
- Breaking change: `Alignment::Start` and `Alignment::End` now depend on text base direction.
`Alignment::Left` and `Alignment::Right` are introduced for text direction-independent alignment. ([#250][] by [@tomcur][])
- Breaking change: `Layout` is no longer `Sync`. ([#259][] by [@wfdewith][])

### Fixed

Expand Down Expand Up @@ -103,6 +105,7 @@ This release has an [MSRV] of 1.70.
[@nicoburns]: https://github.com/nicoburns
[@tomcur]: https://github.com/tomcur
[@waywardmonkeys]: https://github.com/waywardmonkeys
[@wfdewith]: https://github.com/wfdewith
[@xorgy]: https://github.com/xorgy

[#54]: https://github.com/linebender/parley/pull/54
Expand All @@ -124,6 +127,7 @@ This release has an [MSRV] of 1.70.
[#223]: https://github.com/linebender/parley/pull/223
[#224]: https://github.com/linebender/parley/pull/224
[#250]: https://github.com/linebender/parley/pull/250
[#259]: https://github.com/linebender/parley/pull/259
[#268]: https://github.com/linebender/parley/pull/268

[Unreleased]: https://github.com/linebender/parley/compare/v0.2.0...HEAD
Expand Down
77 changes: 76 additions & 1 deletion parley/src/layout/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,22 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT

use crate::inline_box::InlineBox;
use crate::layout::{Alignment, Glyph, LineMetrics, RunMetrics, Style};
use crate::layout::{Alignment, ContentWidths, Glyph, LineMetrics, RunMetrics, Style};
use crate::style::Brush;
use crate::util::nearly_zero;
use crate::Font;
use core::cell::OnceCell;
use core::ops::Range;
use swash::shape::Shaper;
use swash::text::cluster::{Boundary, ClusterInfo};
use swash::Synthesis;

use alloc::vec::Vec;

#[cfg(feature = "libm")]
#[allow(unused_imports)]
use core_maths::CoreFloat;

#[derive(Copy, Clone)]
pub(crate) struct ClusterData {
pub(crate) info: ClusterInfo,
Expand Down Expand Up @@ -199,6 +204,9 @@ pub(crate) struct LayoutData<B: Brush> {
pub(crate) fonts: Vec<Font>,
pub(crate) coords: Vec<i16>,

// Lazily calculated values
content_widths: OnceCell<ContentWidths>,

// Input (/ output of style resolution)
pub(crate) styles: Vec<Style<B>>,
pub(crate) inline_boxes: Vec<InlineBox>,
Expand All @@ -223,6 +231,7 @@ impl<B: Brush> Default for LayoutData<B> {
text_len: 0,
width: 0.,
full_width: 0.,
content_widths: OnceCell::new(),
height: 0.,
fonts: Vec::new(),
coords: Vec::new(),
Expand All @@ -246,6 +255,7 @@ impl<B: Brush> LayoutData<B> {
self.text_len = 0;
self.width = 0.;
self.full_width = 0.;
self.content_widths.take();
self.height = 0.;
self.fonts.clear();
self.coords.clear();
Expand Down Expand Up @@ -470,4 +480,69 @@ impl<B: Brush> LayoutData<B> {
}
}
}

pub(crate) fn content_widths(&self) -> ContentWidths {
*self
.content_widths
.get_or_init(|| self.calculate_content_widths())
}

// TODO: this method does not handle mixed direction text at all.
fn calculate_content_widths(&self) -> ContentWidths {
fn whitespace_advance(cluster: Option<&ClusterData>) -> f32 {
cluster
.filter(|cluster| cluster.info.whitespace().is_space_or_nbsp())
.map_or(0.0, |cluster| cluster.advance)
}

let mut min_width = 0.0_f32;
let mut max_width = 0.0_f32;

let mut running_max_width = 0.0;
let mut prev_cluster: Option<&ClusterData> = None;
let is_rtl = self.base_level & 1 == 1;
for item in &self.items {
match item.kind {
LayoutItemKind::TextRun => {
let run = &self.runs[item.index];
let mut running_min_width = 0.0;
let clusters = &self.clusters[run.cluster_range.clone()];
if is_rtl {
prev_cluster = clusters.first();
}
for cluster in clusters {
let boundary = cluster.info.boundary();
if matches!(boundary, Boundary::Line | Boundary::Mandatory) {
let trailing_whitespace = whitespace_advance(prev_cluster);
min_width = min_width.max(running_min_width - trailing_whitespace);
running_min_width = 0.0;
if boundary == Boundary::Mandatory {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if greedy.rs can be rewritten to use Boundary::Mandatory rather than checking for newline white space.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe @dfrg can shine some light on this, but from my quick look at Swash, I assume that Boundary::Mandatory corresponds the mandatory break in the Unicode standard here (see also Table 1), which does seem to be the correct choice for line breaking as well.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if greedy.rs can be rewritten to use Boundary::Mandatory rather than checking for newline white space.

It used to! This was changed in the recent refactor of that code to fix selection/cursors. I can't remember why it was changed, but I believe it was a matter of convenience as part of a wider refactor and that it ought to be possible to change it back to using Boundary::Mandatory.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swash follows the Unicode LBA and attaches Boundary::Mandatory to the cluster after the newline sequence which means we never encounter that state when the source text ends with a newline. This is arguably a swash bug but I “fixed” it in parley by just checking for the newline white space flag and that actually simplified a lot of code.

running_max_width = 0.0;
}
}
running_min_width += cluster.advance;
running_max_width += cluster.advance;
if !is_rtl {
prev_cluster = Some(cluster);
}
}
let trailing_whitespace = whitespace_advance(prev_cluster);
min_width = min_width.max(running_min_width - trailing_whitespace);
}
LayoutItemKind::InlineBox => {
let ibox = &self.inline_boxes[item.index];
min_width = min_width.max(ibox.width);
running_max_width += ibox.width;
prev_cluster = None;
}
}
let trailing_whitespace = whitespace_advance(prev_cluster);
max_width = max_width.max(running_max_width - trailing_whitespace);
}

ContentWidths {
min: min_width,
max: max_width,
}
}
}
28 changes: 28 additions & 0 deletions parley/src/layout/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,23 @@ impl<B: Brush> Layout<B> {
self.data.full_width
}

/// Returns the lower and upper bounds on the width of the layout.
pub fn content_widths(&self) -> ContentWidths {
self.data.content_widths()
}

/// Returns the minimum content width of the layout. This is the width of the layout if _all_
/// soft line-breaking opportunities are taken.
pub fn min_content_width(&self) -> f32 {
self.data.content_widths().min
}

/// Returns the maximum content width of the layout. This is the width of the layout if _no_
/// soft line-breaking opportunities are taken.
pub fn max_content_width(&self) -> f32 {
self.data.content_widths().max
}

/// Returns the height of the layout.
pub fn height(&self) -> f32 {
self.data.height
Expand Down Expand Up @@ -451,3 +468,14 @@ impl LayoutAccessibility {
}
}
}

/// Lower and upper bounds on layout width based on its contents.
#[derive(Copy, Clone, Debug)]
pub struct ContentWidths {
/// The minimum content width. This is the width of the layout if _all_ soft line-breaking
/// opportunities are taken.
pub min: f32,
/// The maximum content width. This is the width of the layout if _no_ soft line-breaking
/// opportunities are taken.
pub max: f32,
}
83 changes: 83 additions & 0 deletions parley/src/tests/test_basic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,86 @@ fn overflow_alignment_rtl() {
env.rendering_config().size = Some(Size::new(10., layout.height().into()));
env.check_layout_snapshot(&layout);
}

#[test]
fn content_widths() {
let mut env = testenv!();

let text = "Hello world!\nLonger line with a looooooooong word.";
let mut builder = env.ranged_builder(text);

let mut layout = builder.build(text);

layout.break_all_lines(Some(layout.min_content_width()));
layout.align(None, Alignment::Start, false);
env.with_name("min").check_layout_snapshot(&layout);

layout.break_all_lines(Some(layout.max_content_width()));
layout.align(None, Alignment::Start, false);
env.with_name("max").check_layout_snapshot(&layout);
}

#[test]
fn content_widths_rtl() {
let mut env = testenv!();

let text = "بببب ااااا";
let mut builder = env.ranged_builder(text);

let mut layout = builder.build(text);

layout.break_all_lines(Some(layout.min_content_width()));
layout.align(None, Alignment::Start, false);
env.with_name("min").check_layout_snapshot(&layout);

layout.break_all_lines(Some(layout.max_content_width()));
layout.align(None, Alignment::Start, false);
assert!(
layout.width() <= layout.max_content_width(),
"Layout should never be wider than the max content width"
);
env.with_name("max").check_layout_snapshot(&layout);
}

#[test]
fn inbox_content_width() {
let mut env = testenv!();

{
let text = "Hello world!";
let mut builder = env.ranged_builder(text);
builder.push_inline_box(InlineBox {
id: 0,
index: 3,
width: 100.0,
height: 10.0,
});
let mut layout = builder.build(text);
layout.break_all_lines(Some(layout.min_content_width()));
layout.align(None, Alignment::Start, false);

env.with_name("full_width").check_layout_snapshot(&layout);
}

{
let text = "A ";
let mut builder = env.ranged_builder(text);
builder.push_inline_box(InlineBox {
id: 0,
index: 2,
width: 10.0,
height: 10.0,
});
let mut layout = builder.build(text);
layout.break_all_lines(Some(layout.max_content_width()));
layout.align(None, Alignment::Start, false);

assert!(
layout.width() <= layout.max_content_width(),
"Layout should never be wider than the max content width"
);

env.with_name("trailing_whitespace")
.check_layout_snapshot(&layout);
}
}
3 changes: 3 additions & 0 deletions parley/tests/snapshots/content_widths-max.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions parley/tests/snapshots/content_widths-min.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions parley/tests/snapshots/content_widths_rtl-max.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions parley/tests/snapshots/content_widths_rtl-min.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions parley/tests/snapshots/inbox_content_width-full_width.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading