Skip to content

Change the "sticky" attribute to take a direction#6020

Closed
efeyakinci wants to merge 5 commits intotypst:mainfrom
efeyakinci:sticky-direction
Closed

Change the "sticky" attribute to take a direction#6020
efeyakinci wants to merge 5 commits intotypst:mainfrom
efeyakinci:sticky-direction

Conversation

@efeyakinci
Copy link

This change closes #5259

It is useful to be able to create blocks that stick to the element above them. The most notable use case is for lists and enums to prevent pagebreaks in documents like

//...
The following list shows the funding sources that sponsored this article:
// A pagebreak is undesirable here!
- Acme Inc.
- Typst GmbH
- Company C

Other use cases include code blocks that follow text, or footnotes such as table footnotes that refer to content above them

Currently, there is no good way to achieve this. This change modifies the sticky parameter to take one of "below", "above", "both", or none.

This change also includes a change to the behavior of sticky when interacting with breakable blocks that spill that becomes more visible with such use cases. For an example like the following:

#set page(height:120pt, margin: (y: 0pt))
#set block(spacing: 0pt, width: 20pt)

#block(height: 60pt, fill: red)[A]
#block(height: 70pt, fill: green, breakable: true, sticky: true)[A]
#block(height: 10pt, fill: purple)[C]

The behavior is that the middle block will cause a pagebreak so that it can be with the following block in unbroken form. However, this is likely not the least-surprising behavior. For example, consider a table that covers 90% of the height of a page. If a user added such a table where 20% of the vertical space is occupied and made the table sticky to add footnotes after the table, the expected behavior is likely not that the first page is left 90% blank, and the second page contains the table and footnotes in their entirety.

This PR adds a check to cause a pagebreak on a sticky elements that ends the page. In this case, we break if and only if there is no spill. Examining the cases, we see

  1. There is no spill: Then, we should break, so that the element can stay with the element following it, unless the element is the first element on the page.
  2. There is some spill. Clearly (given that one agrees with the general behavior), we not should break if the spillage can be next to the element on the next page. However, this change does make it possible where we avoid breaking, even though breaking would cause spillage onto a third page whose size can accommodate the successor after it.

The second (rare) edge case does allow for a sticky element to be on a different page than its successor, although the behavior with the change is still (I believe) less surprising than the current behavior.

This is non-trivial to fix, as knowing when to break would require calculating the amount of spillage on the final page of spillage, knowing the size of that page, and calculating whether the items will fit. As such, this is not done in this PR.

Copy link
Contributor

@PgBiel PgBiel left a comment

Choose a reason for hiding this comment

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

Thanks for the proposal. Regarding the feature itself, I can see there being value in being able to take control over "widow prevention", similar to how the existing sticky attribute allows control over "orphan prevention".

However, I have a few concerns; I'll list some below to start, but in general, one important concern is that a single test won't take care of this change with potential for regressions. I feel like we need to clearly study, document and test the behavior of each possible flow child when affected by sticky: "above", and perhaps add more information about edge cases - I mention some below.

Comment on lines +332 to +347
let has_sticky_successor = self
.composer
.work
.children
.iter()
.skip(1)
.find_map(|child| match child {
Child::Single(single) => Some(
single.sticky.as_ref().map(|s| s.is_sticky_above()).unwrap_or(false),
),
Child::Multi(multi) => Some(
multi.sticky.as_ref().map(|s| s.is_sticky_above()).unwrap_or(false),
),
_ => None,
})
.unwrap_or(false);
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 it could be more elegant to do this in the compositor, attaching additional sticky information to the previous frame (the latest frame's index is kept during collection) after we find each one. I'd want to try to avoid this potentially "unbounded" search on each frame, in principle.

In principle, it'd be doable to add a "followed by sticky" bool to e.g. Single and Multi children. However, a paragraph is split into multiple Line children for example, so we'd have to track those as well...

Which leads me to a separate question: how would this work with lines? Currently, it seems that all frames between two Single / Multi will become sticky with a sticky: "above", which would mean, essentially, all paragraphs? This seems inappropriate, not to mention that the same search would be repeated for each line, wasting precious CPU power.

So we need to decide whether this should pull the entire last paragraph, or just a few lines at the end, for example. This behavior should be specified and tested. And the repeated search should also be tackled if possible.

stick_to_successor();
}
_ => {
// Only clear the snapshot if this frame isn't empty
Copy link
Contributor

Choose a reason for hiding this comment

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

This is a bit self-evident, let's keep (or adapt) the original comment instead.

Comment on lines +475 to +481
// Only restore snapshot if there's no spill
// If a sticky breakable element can be spilled to the following page,
// it's fine to put some of it on this page, since the next page will still
// have the next element on the same page as *some* of the spilled content.
if self.composer.work.spill.is_none() {
self.restore(snapshot)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you add a test where this changes the behavior of existing sticky: "below" blocks so we can see what this is actually changing? Want to avoid regressions here.

/// )
/// #block(sticky: "above")[The above table shows that...]
/// ```
#[default(false)]
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's add some compatibility behavior to temporarily accept bools with a warning. (You can leave this for last, after all other reviews have been addressed.)

})
.unwrap_or(false);

let mut stick_to_successor = || {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd love some tests showing how a sequence of sticky: "above" which don't fit in a single page behave. There is one already for sticky: "below", not too sure if it was block-sticky-many, but might have been

@laurmaedje laurmaedje added interface PRs that add to or change Typst's user-facing interface as opposed to internals or docs changes. layout Related to the layout category, which is about composing, positioning, etc. waiting-on-author Pull request waits on author labels Mar 24, 2025
@PgBiel PgBiel self-assigned this Aug 1, 2025
@laurmaedje
Copy link
Member

@efeyakinci Do you still plan to work on this? In principle, it would not be exceedingly hard to implement this feature, but it would need some changes from the current implementation. As it is very central code, it would also need to be well-tested. If you'd prefer not to proceed, that's totally fine, too. Then, I'd close the PR, to "unlock the mutex" on the issue, so to speak.

Regarding API design, I posted some thoughts in the issue: #5259 (comment)

Implementation-wise, my gut feeling is that it would be easier to move the pre-processing of this to the collection phase (collect.rs) as it should be possible to make this transformation without knowledge of the concrete layout regions. The LineChild would then also need a sticky bool that could be set by a following Single/Multi child with sticky: top. (Take all of this with a graint of salt, as I've not actually experimented with it.)

@laurmaedje
Copy link
Member

For now, I'll close this due to inactivity. Thanks still!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

interface PRs that add to or change Typst's user-facing interface as opposed to internals or docs changes. layout Related to the layout category, which is about composing, positioning, etc.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

block's sticky should have a direction parameter.

3 participants