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

Implement inline box layout #67

Merged
merged 12 commits into from
Jul 20, 2024
Merged

Conversation

nicoburns
Copy link
Contributor

@nicoburns nicoburns commented May 24, 2024

Objective

Changes made

The concept of a "layout item" has been introduced. This is either a run of text or an inline box.

Copy link
Member

@DJMcNab DJMcNab left a comment

Choose a reason for hiding this comment

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

A scanning code-style review. Some points here probably aren't related to your PR, but happen to be in your diff due to new indentation - feel free to ignore

examples/swash_render/src/main.rs Outdated Show resolved Hide resolved
@@ -135,7 +144,8 @@ impl<'a, B: Brush, T: TextSource> RangedBuilder<'a, B, T> {
char_index += 1;
}
}
use super::layout::{Decoration, Style};

// Define a function that converts `ResolvedDecoration` into `Decoration` (used just below)
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure how useful this comment is - maybe it would be better to rename the function?

Copy link
Member

Choose a reason for hiding this comment

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

Might be better as an impl on Decoration<B> since it is unrelated to LayoutContext and doesn't access self at all.
And just as a matter of style, the words ‘define a function that...’ don't belong in a comment right above a fn definition.

parley/src/context.rs Outdated Show resolved Hide resolved
/// User-specified identifier for the box, which can be used by the user to determine which box in
/// parley's output corresponds to which box in it's input.
pub id: u64,
/// The character index into the underlying text string at which the box should be placed.
Copy link
Member

Choose a reason for hiding this comment

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

I'm presuming this is a byte index

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes. Docs updated to reflect this.

/// User-specified identifier for the box, which can be used by the user to determine which box in
/// parley's output corresponds to which box in it's input.
pub id: u64,
/// The character index into the underlying text string at which the box should be placed.
Copy link
Member

Choose a reason for hiding this comment

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

We should document the bidi decision here (i.e. that the boxes follow the bidi of the logically previous character)

parley/src/layout/data.rs Outdated Show resolved Hide resolved
}
LayoutItemKind::InlineBox => {
// TODO: account for vertical alignment (e.g. baseline alignment)
layout.inline_boxes[self.index].height
Copy link
Member

Choose a reason for hiding this comment

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

So we don't implement flowing around the inline boxes; that makes sense - it seems like it would be a nightmare to do so.

But we should probably make sure that's documented somewhere

parley/src/layout/line/greedy.rs Show resolved Hide resolved
parley/src/layout/line/greedy.rs Show resolved Hide resolved
self.state.cluster_idx += 1;
}
if try_commit_line!(BreakReason::Emergency) {
self.state.cluster_idx += 1;
Copy link
Member

Choose a reason for hiding this comment

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

I've not tested it, but it seems like this would always skip a cluster? This doesn't look the same as other code above (but be aware that I'm only doing a single-pass review on unfamiliar code)

Copy link
Collaborator

Choose a reason for hiding this comment

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

Good question. This appears to match the original code which seemed to handle this correctly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I would also like to understand how/why/if this works correctly (and I don't). But as Chad says, this code is unchanged from the existing code so I don't think it should be blocking.

Copy link
Collaborator

@dfrg dfrg left a comment

Choose a reason for hiding this comment

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

Thanks Nico! It's really exciting to see this actually working even if it's not quite complete.

I left a lot of notes, some of which are suggestions and some are questions about how the changes here might play with future work.

Hopefully we can resolve some of the open questions and move this out of draft soon.

Comment on lines +119 to +121
pub fn push_inline_box(&mut self, inline_box: InlineBox) {
self.lcx.inline_boxes.push(inline_box);
}
Copy link
Collaborator

@dfrg dfrg Jun 16, 2024

Choose a reason for hiding this comment

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

There's a trade off here in how we split the complexity. I assume this requires sorting the boxes later and then carefully keeping track of them in the shaping stage.

Thinking ahead to generalizing to tree based layout, I'd probably prefer to track/split item ranges here during building. Basically, context should probably keep a Vec<Element> where Element is something like:

enum Element {
    Text(Range<usize>),
    // (byte offset, box properties)
    InlineBox(usize, InlineBox),
    // maybe others for more complicated layout: StartSpan, EndSpan, Break, etc.
}

My thinking is that we initialize the vec with Element::Text containing the whole range. When pushing an object, we binary search for the appropriate insertion point and then split any overlapping text range. This introduces some additional complexity here while reducing it in later stages. It also maps more closely to what we might generate when building from a tree and lets us deal with object locations that might not land on character boundaries.

If this seems like a reasonable approach to you, I think it makes sense to push it to a future PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Seems reasonable. Although it might potentially make the bidi resolution stage a little more tricky?

Copy link
Collaborator

Choose a reason for hiding this comment

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

My guess is that bidi resolution is going to become more complicated anyway if we want to properly handle inline objects, borders, margin, padding, etc.

parley/src/layout/line/greedy.rs Outdated Show resolved Hide resolved
parley/src/layout/line/greedy.rs Outdated Show resolved Hide resolved
parley/src/layout/line/greedy.rs Show resolved Hide resolved
Comment on lines +267 to +270
// We only skip a mandatory break immediately after another mandatory break so if we
// have got this far then we should disable mandatory break skipping
self.state.line.skip_mandatory_break = false;
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think the actual reasoning here is that we return early from the function when processing a mandatory break that we actually handle (skip_mandatory_break == false).

I'll try to do a state table to capture the logic when boundary == Mandatory:

skip_mandatory_break action
false Commit line without consuming newline cluster, set skip_mandatory_break to true, return. Next pass will process this cluster again.
true Pretend like this isn't a break and append the newline cluster to the current line (this will always be the beginning of the line).

The key point is that mandatory break clusters are processed twice.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm... I see. Could we change this so that we eagerly append the cluster to the newline instead? (can of course be in a followup PR)

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes, we can definitely do better here. Incidentally, I tried changing the code so the shaper forces a break before every newline cluster and found that it already does so after one, so the code appears to be working at competing purposes here. Let’s address this later.

parley/src/layout/line/greedy.rs Show resolved Hide resolved
parley/src/layout/line/mod.rs Outdated Show resolved Hide resolved
parley/src/layout/line/mod.rs Outdated Show resolved Hide resolved
parley/src/layout/line/mod.rs Outdated Show resolved Hide resolved
Comment on lines 190 to 198
while let Some(box_idx) = deferred_boxes.pop() {
// Push the box to the list of items
layout.data.push_inline_box(box_idx);
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this will reverse the order of inline boxes with the same index.

The deferred_boxes vec could be replaced by an Option<Range<usize>> since they're guaranteed to be contiguous.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch! Using a range is also a good idea.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok. I've updated this to:

  • Use .drain() to fix the ordering issue
  • Hoist the Vec allocation out of the loop so it's now only 1 Vec alloc per call to shape_text

I'm hoping the range based version can be tackled in a followup.

@nicoburns nicoburns force-pushed the inline-boxes branch 12 times, most recently from 72f9d0e to 9976cf1 Compare July 20, 2024 08:55
@nicoburns nicoburns changed the title WIP: Implement inline box layout Implement inline box layout Jul 20, 2024
@nicoburns nicoburns marked this pull request as ready for review July 20, 2024 09:02
@nicoburns
Copy link
Contributor Author

@dfrg This is now ready for re-review. I've cleaned up the history and backported all the fixes to line breaking that were previously only included in #76

If you're open to merging and then improving in followups then that would be my preferred approach as there are quite a few further improvements to parley in general that I'd like to make, but I'm currently a little wary that the stack of PRs is getting quite long and unwieldy.

xorgy
xorgy previously requested changes Jul 20, 2024
Copy link
Member

@xorgy xorgy left a comment

Choose a reason for hiding this comment

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

On this pass it looks mostly good. I may do one more detailed review.

@@ -135,7 +144,8 @@ impl<'a, B: Brush, T: TextSource> RangedBuilder<'a, B, T> {
char_index += 1;
}
}
use super::layout::{Decoration, Style};

// Define a function that converts `ResolvedDecoration` into `Decoration` (used just below)
Copy link
Member

Choose a reason for hiding this comment

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

Might be better as an impl on Decoration<B> since it is unrelated to LayoutContext and doesn't access self at all.
And just as a matter of style, the words ‘define a function that...’ don't belong in a comment right above a fn definition.


// Sort the inline boxes as subsequent code assumes that they are in text index order.
// Note: It's important that this is a stable sort to allow users to control the order of contiguous inline boxes
lcx.inline_boxes.sort_by_key(|b| b.index);
Copy link
Member

Choose a reason for hiding this comment

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

Should we use an inherently sorted structure instead of Vec if the code assumes they're sorted? Memory is a good reason not to, but just a thought.

Copy link
Collaborator

Choose a reason for hiding this comment

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

For better or worse, parley was intentionally designed to avoid transient heap allocation and a non-trivial chunk of the complexity is a result of that. So I’d like to avoid letting “allocation per item” collections creep in and break that property. There are already a couple cases of that in this PR (including the stable sort here) but I believe they will all be corrected in future work.

Copy link
Member

Choose a reason for hiding this comment

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

I mean, it doesn't have to be BTreeMap, but I guess that's one more dependency.

pub advance: f32,

// Fields that only apply to text runs (Ignored for boxes)
// TODO: factor this out?
Copy link
Member

Choose a reason for hiding this comment

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

Maybe instead of kind this could be factored into an enum that contains this stuff.. though arguably text_range still applies (it's just a caret range) and ditto cluster_range and is_whitespace.

Copy link
Collaborator

Choose a reason for hiding this comment

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

+1 to rethinking some of these data structures but that’s likely to affect a lot of code so I’d like to see it done as a follow up.

Copy link
Collaborator

@dfrg dfrg left a comment

Choose a reason for hiding this comment

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

While there are still some rough edges, this a great step forward and I’m inclined to agree with merging this now to unblock future progress. Thanks Nico!

@nicoburns nicoburns dismissed xorgy’s stale review July 20, 2024 22:18

Suggestions either addressed or Chad has suggested we do later

@nicoburns nicoburns added this pull request to the merge queue Jul 20, 2024
Merged via the queue into linebender:main with commit caabcf1 Jul 20, 2024
19 checks passed
@nicoburns nicoburns deleted the inline-boxes branch July 20, 2024 22:23
This pull request was closed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support inline box layout
4 participants