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

Add examples of text layouting #216

Closed
Tracked by #345
bestouff opened this issue Aug 19, 2022 · 20 comments · Fixed by #586
Closed
Tracked by #345

Add examples of text layouting #216

bestouff opened this issue Aug 19, 2022 · 20 comments · Fixed by #586
Labels
enhancement New feature or request
Milestone

Comments

@bestouff
Copy link

What problem does this solve or what need does it fill?

Newcomers to taffy may have a hard time understanding how to do text layouting:

  • wrapping at word boundary
  • word truncation if needed

What solution would you like?

I'd like a few examples for text layout (basic, wrapping, truncation)

What alternative(s) have you considered?

Maybe try to understand taffy innards from the source code in my copious spare time ?

@bestouff bestouff added the enhancement New feature or request label Aug 19, 2022
@nicoburns
Copy link
Collaborator

I've been thinking about this a bit recently, and I totally agree. I was thinking that perhaps a fully-working example, but with a monospace font (which would avoid complication around text shaping) might be a good way to approach this.

FWIW, the quick high level answer is:

  • Taffy doesn't provide any specific support for text layout (and it's probably out of scope, although it could perhaps be developed as a separate crate designed to work with Taffy).
  • Taffy does provide a hook with which you can integrate your own text layout. You can do this by providing a MeasureFunc (a function or closure that conforms Fn(Size<Option<f32>>) -> Size<f32>) for the taffy node that contains text. You can set a measurement function on a mode by using https://docs.rs/taffy/latest/taffy/node/struct.Taffy.html#method.set_measure

If you're looking for code that does actually implement full-on text layout (including for variable-width fonts) in Rust, then you may want to look at https://github.com/dfrg/parley or https://github.com/dfrg/swash_demo. But be aware that this not simple to do at all, and the code is therefore rather complicated.

@bestouff
Copy link
Author

I understood that I had to provide a MeasureFunc but it would be nice to have some description of what it should do.
Is it kind of e.g. "here I give you a container with width=100 and height=unspecified; now give me back the width and height you need for layout" ?
And then what should happen if things can't fit ? Could I signal I have a preferred size but I can do some other min/max size ?

@nicoburns
Copy link
Collaborator

Could I signal I have a preferred size but I can do some other min/max size ?

Not currently, but I'm going to be adding this as part of my work to support CSS Grid (it should already have it for Flexbox, but it doesn't). The way it will work (at least the way I'm currently planning for it to work) is instead of Undefined the a "constraint" (e.g. MinSize or MaxSize, possibly also PreferredSize)) representing the dimensions the algorithm wants you to determine, and you should measure the content (e.g text) under that constraint (the measure function could potentially be called multiple times if we the size under multiple constraints).

Hopefully we can make the docs clearer once we've made this change!

And then what should happen if things can't fit ?

I think in most cases if you're given width=100 and height=unspecified, then you'll want to give back width=100 no matter what and make do with as sensible a height as possible. You'll have to choose whether you want to force the text to wrap (increasing the height), or just cut the text off at the edge of the box. Note, that it wouldn't be unheard to get width=unspecified height=unspecified (it is possible to pass this is in to taffy as the dimensions of the root node). This would make sense as the dimensions for a container that can scroll in both directions for example.

I'm not sure what would happen if you returned a larger width in this case. I don't think it'd break, but I also think it's probably not what you'd actually want in most cases.

@bestouff
Copy link
Author

(trying very hard not to sound like a dick) Do you have an idea when your work to support CSS Grid will be "ready" ?

@alice-i-cecile
Copy link
Collaborator

Not currently, but I'm going to be adding this as part of my work to support CSS Grid (it should already have it for Flexbox, but it doesn't). The way it will work (at least the way I'm currently planning for it to work) is instead of Undefined the a "constraint" (e.g. MinSize or MaxSize, possibly also PreferredSize)) representing the dimensions the algorithm wants you to determine, and you should measure the content (e.g text) under that constraint (the measure function could potentially be called multiple times if we the size under multiple constraints).

@bestouff Feel free to split out this change and I can merge it more quickly!

@nicoburns
Copy link
Collaborator

@bestouff Probably at least 2 months before it's in a releasable state. Good progress has been made, but the trickiest part (track sizing) hasn't yet been implemented. And beyond that there will need to be a good bit of testing (and no-doubt fixing) before it's ready.

Having said that, it would be possible to pull this change out separately. I guess that would involve:

@alice-i-cecile My current design for this is:

enum AvailableSpace {
     Definite(f32),
     MinContent,
     MaxContent,
}

(There is no IdealSize here as the CSS spec seems to indicate that is equivalent to the max-content size (see "max-content size" and "min-content size" here: https://www.w3.org/TR/css-sizing-3/#auto-box-sizes))

with the signature of the MeasureFunc becoming:

Fn(Size<AvailableSpace>) -> Size<f32>

A couple of things I'm not 100% on yet:

  • Whether we might need an additional AvailableSpace::Infinite/AvailableSpace::Undefined variant to represent an undefined length with no sizing constraint on it. My instinct at the moment is that the returned size in this case would never differ from MaxContent (but perhaps @bestouff has an example where it would?).
  • Whether we need to indicate which axis we are actively sizing (as both axis may be an indefinite length). My instinct is that the algorithm of the node being sized will determine the sizing regardless of which axis we're interested in (e.g. horizontal vs. vertical text, or flex-direction: row vs flex-direction: column), and therefore this probably isn't necessary.

@bestouff
Copy link
Author

I like your AvailableSpace sketch. I don't see a use right now for an Undefined variant, and Infinite doesn't look right to me (what if you want to distribute free space between nodes in an infinite space ?).

I'll wait for your Grid implementation anyway - my use-case is a terminal markdown viewer in which I want to get rid of my custom layout code, so I'll need tables-like features. Also I need integer-only operations but I digress.

@alice-i-cecile
Copy link
Collaborator

I like your AvailableSpace sketch. I don't see a use right now for an Undefined variant, and Infinite doesn't look right to me (what if you want to distribute free space between nodes in an infinite space ?).

This aligns with my thinking too. I'm in favor of splitting this work out; it seems useful otherwise and makes the PR review more feasible.

@nicoburns
Copy link
Collaborator

@alice-i-cecile

I has a stab at implementing this, but I've realised that I had missed something in my initial analysis. The existing Option<f32> parameter to layout is potentially used for two purposes:

  1. To define available space constraints and act as a "max" dimension
  2. To communicate known dimensions and ask the layout to calculate in the other direction

I had originally assumed that the only purpose was (1). Now I can see it's definitely used for (2). I think it is likely used for (1) as well, although I'm not 100% sure about this: I suppose that it would be possible to just not pass max-size constraints to the MeasureFunc at all and clamp the output after it has run.

I believe will necessitate either adding an additional variant to the enum, or otherwise communicating the known sizes separately from the available space constraint. Something like:

enum Constraint {
    KnownSize(f32),
    MaxSize(f32),
    MinContent,
    MaxContent,
}

If the MeasureFunc is passed KnownSize in a given dimension then it ought to return that known size directly as the output in that dimension (and it's output in that dimension probably ought to be ignored). This could potentially be split up into multiple functions, but I'm not yet convinced that's a good idea (seems like it might be hard to generalise across different algorithms)

@alice-i-cecile
Copy link
Collaborator

Yes please; that seems dramatically clearer.

@geom3trik
Copy link
Collaborator

Maybe I'm missing something here but if you had a measure function for a particular axis, perhaps determined by the flex direction, could you not replace the Constraint with just the known size if you call the measure function after computing the size in the main axis?

For example, let's say I have a node with a flexible width and an undetermined height. The width is not determined by the height so can be computed independently, including any min/max constraints. This width can then be passed to a measure function which delegates the height calculation to the user so the height can be determined by the wrapped text.

One thing that confuses me is the idea of have a min/max content size for the input to the measure function. Surely it's impossible to determine this if the content is text, which then depends on the measure function. Can nodes contain both text and other nodes and have layout depend on them both?

@nicoburns
Copy link
Collaborator

For example, let's say I have a node with a flexible width and an undetermined height. The width is not determined by the height so can be computed independently, including any min/max constraints. This width can then be passed to a measure function which delegates the height calculation to the user so the height can be determined by the wrapped text.

I believe that is roughly what happens when there is a defined width, but in general it is possible for the width to depend on content size too (I believe the flex-basis of a flex item frequently does). If you play around with min-content and max-content widths for nodes containing text in browsers, you'll see that max-content will render everything on a single line and take up the full width of all the text rendered on that single line while min-content will line-break at every space and other "line break opportunity" and only take up the width of the widest unbroken word/character sequence.

The measure function will get called twice in these cases. The second time with a known size in (at least) one dimension.

Can nodes contain both text and other nodes and have layout depend on them both?

Currently if a layout contains other nodes then it is treated a flexbox node and the measure function is ignored. Hopefully we can make that behaviour a bit more explicit in future (perhaps with a "Leaf" or "Custom" layout mode, but I think this basic principle that a Taffy node is EITHER a taffy layout type XOR has a custom measure function (which would include text nodes) will remain.

However, it's worth noting the measure function doesn't always directly determine the final size of the node. It's more like a desired size and may still for example be clamped by the min-size and max-size or at behest of the parent node's layout.

One thing that confuses me is the idea of have a min/max content size for the input to the measure function. Surely it's impossible to determine this if the content is text, which then depends on the measure function. Can nodes contain both text and other nodes and have layout depend on them both?

Are you talking about the MinContent and MaxContent variants above? If so, then see my first answer. If you mean the MaxSize variant, then this roughly represents the size of the parent node, if that helps?

@geom3trik
Copy link
Collaborator

If you play around with min-content and max-content widths for nodes containing text in browsers, you'll see that max-content will render everything on a single line and take up the full width of all the text rendered on that single line while min-content will line-break at every space and other "line break opportunity" and only take up the width of the widest unbroken word/character sequence.

Ah okay, I was not aware of this behaviour. I suppose that makes sense. Does this really need two passes of the measure function though? Since the min/max content size in that case is independent on the height could the user not just set the min/max size constraint of the node itself based on the text? In my mind the measure function only needs to come in to play when one dimension depends on the other, like for wrapping of text. But maybe I'm thinking about it wrong?

I think this basic principle that a Taffy node is EITHER a taffy layout type XOR has a custom measure function (which would include text nodes) will remain.

Yep I think that makes a lot of sense and simplifies things, particularly for understanding.

However, it's worth noting the measure function doesn't always directly determine the final size of the node. It's more like a desired size and may still for example be clamped by the min-size and max-size or at behest of the parent node's layout.

Yes I thought that would be the case, though this does not sound like an easy constraints problem to solve. Particularly if you have flexible nodes along the same axis as the undetermined size of a node with a measure function.

Are you talking about the MinContent and MaxContent variants above? If so, then see my first answer. If you mean the MaxSize variant, then this roughly represents the size of the parent node, if that helps?

I was talking about MinContent and MaxContent and you did indeed answer my question in your first part, thank you 😊

@nicoburns
Copy link
Collaborator

Ah okay, I was not aware of this behaviour. I suppose that makes sense. Does this really need two passes of the measure function though? Since the min/max content size in that case is independent on the height could the user not just set the min/max size constraint of the node itself based on the text? In my mind the measure function only needs to come in to play when one dimension depends on the other, like for wrapping of text. But maybe I'm thinking about it wrong?

If only min/max constraints are set, then the measure function will still be called to determine where in that range the the size should be set (it's probably possible to optimise the min=max case in some places, but I don't think that's been implemented). If the actual size property is set, then that will indeed prevent the measure function from being called in many cases.

However, I think the model is that the min/max/exact size properties are the end-user to set, and that the measure function would be how a text-layouting library would communicate it's text layout based dimensions to taffy. Taffy does do some caching if the measure function is called with the exact same inputs (and mark_dirty clears those caches), but if that text-layouting module wants to do further caching to avoid relayouting then it should do that internally within the measure function.

Yes I thought that would be the case, though this does not sound like an easy constraints problem to solve. Particularly if you have flexible nodes along the same axis as the undetermined size of a node with a measure function.

It's not, but luckily the flexbox spec authors have done all the hard work for us here. The general principle is that the measure function is used to independently determine the flex-basis for all children (if a node has an explicit flex-basis or width/height then the measure func won't be called), and growing and shrinking is then done in proportion to those flex-bases and in accordance with the flex-grow and flex-shrink properties.

@bestouff
Copy link
Author

If the MeasureFunc is passed KnownSize in a given dimension then it ought to return that known size directly as the output in that dimension (and it's output in that dimension probably ought to be ignored). This could potentially be split up into multiple functions, but I'm not yet convinced that's a good idea (seems like it might be hard to generalise across different algorithms)

So if I read this correctly some of this enum variants are input/output, some are input-only ? That better be well described in the documentation.

@nicoburns
Copy link
Collaborator

So if I read this correctly some of this enum variants are input/output, some are input-only ? That better be well described in the documentation.

One would return the f32 contained in the KnownSize variant rather than the variant itself. The key thing to note is that if you receive KnownSize then that dimension should be treated as fixed and you should only compute the other dimension. If you return a different value for a dimension that had a KnownSize passed in then that size will be ignored.

@bestouff
Copy link
Author

Ah yes. Looks good.

@nicoburns
Copy link
Collaborator

We can probably use the new https://github.com/pop-os/cosmic-text for a text layouting example.

@alice-i-cecile
Copy link
Collaborator

Yes please, I'm happy to demonstrate cosmic-text integration.

@nicoburns
Copy link
Collaborator

I'm hoping together a more end-to-end example of this soon, but for anyone wanting to take the DIY approach:

  1. Xilem also has an example using Parley: https://github.com/linebender/xilem/blob/main/src/widget/button.rs#L82. I think both Parley and cosmic_text are usable for this purpose.

  2. I have a very basic example that I'm using in our generated tests. This example assumes that text contains only H and \u{200B} (zero-width space) characters, and that it is rendered using a font/font-size such that an H is exactly 10px wide by 10px high (which allows us to calculate layout sizes without bringing in any kind of library). Obviously this isn't very realistic, but it may help people to understand how to integrate with Taffy:

Creating a node with a measure_func that captures some text:

use taffy::prelude::*;

// The text in the node
let TEXT = String::new("Paragraph of text node goes here");

// MeasureFunc to pass to node. Moves text into the closure.
let measure_func = MeasureFunc::Boxed(Box::new(move |known_dimensions, available_space| {
    measure_standard_text(known_dimensions, available_space, TEXT)
}))

// Create node with created measure_func
let text_node = taffy.new_leaf_with_measure(Style::DEFAULT, measure_func);

A rudimentary text layouting function that is called by the measure_func:

use taffy::prelude::*;

// Naive function to layout text based based on known dimensions and available space.
// We assume:
//   - Text contains only H and zero-width space characters
//   - An H measures 10 units wide by 10 units high
//   - A zero-width space measure 0 units wide by 0 zero units high and provides a line-breaking opportunity
//   - Text is laid out in a horizontal left-to-right direction and height depends on width
fn measure_standard_text(
    known_dimensions: taffy::geometry::Size<Option<f32>>,
    available_space: taffy::geometry::Size<taffy::layout::AvailableSpace>,
    text_content: &str,
) -> taffy::geometry::Size<f32> {
    const ZERO_WITH_SPACE: char = '\u{200B}';
    const H_WIDTH: f32 = 10.0;
    const H_HEIGHT: f32 = 10.0;

    // If both dimensions are known then simply return them (this shouldn't ever really happen,
    // but best to cover this case).
    if let Size { width: Some(width), height: Some(height) } = known_dimensions {
        return Size { width, height };
    }

    // Split text into lines at all possible line break opportunities. Here this only zero-width spaces,
    // but in a more realistic scenario this would likely include all whitespace as well as things like
    // hyphens. Your text layouting library will probably handle this for you.
    let lines: Vec<&str> = text_content.split(ZERO_WITH_SPACE).collect();

    // Return a zero size in both dimensions if there is no non-whitespace text. This makes sense in our case because our 
    // whitespace is zero-sized. In general you would want to account for space taken up by whitespace.
    if lines.len() == 0 {
        return Size::ZERO;
    }

    // Find the number of characters in:
    //   - The longest single line
    //   - All lines added toghether
    let min_line_length: usize = lines.iter().map(|line| line.len()).max().unwrap_or(0);
    let max_line_length: usize = lines.iter().map(|line| line.len()).sum();


    // Calculate the width:
    //  - If the width is passed as a "known_dimension", then we simply return that.
    //  - Otherwise our width depends on the available_space in that dimension:
    //     - MinContent: width is the width of the longest single line
    //     - MaxContent: width is the width of all lines added together
    //     - Definite (a specific width in points): width is that width, clamped by the max-content and min-content width
    let width = known_dimensions.width.unwrap_or_else(|| match available_space.width {
        AvailableSpace::MinContent => min_line_length as f32 * H_WIDTH,
        AvailableSpace::MaxContent => max_line_length as f32 * H_WIDTH,
        AvailableSpace::Definite(width) => {
            width.min(max_line_length as f32 * H_WIDTH).max(min_line_length as f32 * H_WIDTH)
        }
    });

    // Calculate the height (based on the width):
    //   - If the height is passed as a "known_dimension", then we simply return that.
    //   - Otherwise:
    //       - We pack lines of text into the width starting a new line when a line doesn't 
    //         fit into the current line.
    //       - This allow us to determine how many lines are required to fit our text
    //       - We multiply the number of lines by the line height to get the height
    let height = known_dimensions.height.unwrap_or_else(|| {
        let width_line_length = (width / H_WIDTH).floor() as usize;
        let mut line_count = 1;
        let mut current_line_length = 0;
        for line in &lines {
            if current_line_length + line.len() > width_line_length {
                line_count += 1;
                current_line_length = line.len();
            } else {
                current_line_length += line.len();
            };
        }
        (line_count as f32) * H_HEIGHT
    });

    // Return computed width and height
    Size { width, height }
}

@nicoburns nicoburns mentioned this issue Jan 30, 2023
37 tasks
@nicoburns nicoburns added this to the 0.4 milestone Nov 26, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants