linebender / xilem

An experimental Rust native UI framework
Apache License 2.0
3.44k stars 107 forks source link

Proposal for layout / child widget sizing in Xilem #37

Open nicoburns opened 1 year ago

nicoburns commented 1 year ago

The proposals here came from me looking into what it would take to integrate Taffy layout into Xilem. But nothing proposed here is really specific to the CSS style layout modes (Flexbox and CSS Grid) that Taffy implements. Nor would they commit Xilem to CSS style layout. Rather, I believe they would enable Taffy layout modes to implemented in Xilem as widgets (which could live in an external crate), in much the same way that the existing Flex widget is implemented in Druid.

I suspect that we could make a much more streamlined system if support for associating arbitrary data (e.g. "styles") with elements such that a parent widget could access them on a child widget the chidl widget having to add support for them was implemented (ala https://github.com/linebender/druid/issues/2207). But that's a much more significant change, which I think can wait.

I have also written a prototype integration of Taffy with Iced (Iced also uses a similar layout mechanism to Druid and Xilem). And despite having to work around some limitation of Iced's system (like no measure method, and layout taking &self rather than &mut self), the integration actually ended up being relatively straightforward (you can see the implementation of Iced's layout method here (calling into Taffy from Iced), and the implementation of Taffy's perform_child_layout method here (calling back into Iced from Taffy)).

Review of Existing Systems

Here I lay out the state of things as they are in Xilem, Druid, and Taffy.

Prerequisite Type Defintions

A Size<T> in Taffy is defined as:

struct Size<T> {
    width: T,
    height: T,
}

A Size in Druid/Xilem (kurbo) is a Size<f64> using the above definition. For the remainder of this post I will translate this to Size<f64> in the function signatures below for clarity.

A BoxConstraints in Druid is defined as:

struct BoxConstraints {
    min: Size<f64>,
    max: Size<f64>,
}

An AvailableSpace in Taffy is defined as:

enum AvailableSpace {
    MinContent,
    MaxContent,
    Definite(Size<f32>),
}

A SizingMode in Taffy is defined as:

enum SizingMode {
    ContentSize,  // Size ignoring explicit styles
    InherentSize, // Size including explicit styles
}

Xilem's Existing Layout System

/// Compute intrinsic sizes.
/// The returned sizes are (min_size, max_size)
fn measure(&mut self, cx: &mut LayoutCx) -> (Size<f64>, Size<f64>);

/// Compute size given proposed size.
fn layout(&mut self, cx: &mut LayoutCx, proposed_size: Size<f64>) -> Size<f64>;

Druid's Layout System

/// Max intrinsic/preferred dimension is the dimension the widget could take, provided infinite constraint on that axis.
/// Intrinsic is a *could-be* value. It's the value a widget *could* have given infinite constraints. This does not mean the value returned by layout() would be the same.
/// This method **must** return a finite value.
fn compute_max_intrinsic(
    &mut self,
    ctx: &mut LayoutCtx,
    axis: Axis,
    bc: &BoxConstraints,
    data: &T,
    env: &Env,
) -> f64

/// For efficiency, a container should only invoke layout of a child widget
/// once, though there is nothing enforcing this.
fn layout(
    &mut self,
    ctx: &mut LayoutCtx,
    bc: &BoxConstraints,
    data: &T,
    env: &Env
) -> Size;

Taffy's Layout System

fn measure_size(
    tree: &mut impl LayoutTree,
    node: Node,
    known_dimensions: Size<Option<f32>>,
    parent_size: Size<Option<f32>>,
    available_space: Size<AvailableSpace>,
    sizing_mode: SizingMode,
) -> Size<f32>

fn perform_layout(
    tree: &mut impl LayoutTree,
    node: Node,
    known_dimensions: Size<Option<f32>>,
    parent_size: Size<Option<f32>>,
    available_space: Size<AvailableSpace>,
    sizing_mode: SizingMode,
) -> Size<f32>

Analysis

Trivial Differences

There are a few difference which look like they might be important, but I suspect that they are actually not:

Extra data parameters

Comparison of functions

I would suggest that the concept of a "min content size" is important and should definitely be included. I would also suggest that the function should not compute both the min and max sizes at once as Xilem currently does, as this could be expensive (e.g. for a text node) and at least for CSS layout it's relatively common that only one of the sizes is required.

~Whether both axis are computed together or seperately I don't have too strong an opinion about. Taffy layout modes would probably compute both either way and cache the other one the other one for future queries.~ Update: I now believe that the single-axis-at-a-time model is superior.

Comparision of function parameters

Constraint paramters

Constaint parameters have a direct relationship with the returned size and must be respected by nodes' measurement/layout functions (and/or the sizes returned will be ignored/clamped if they are not).

Hint parameters

Hint parameters provide extra information that nodes may use to help choose their size. These are merely hints and may be ignored in some cases. But will likely be very helpful to allow the parent and child node to cooperatively choose a good size.

Proposal for Xilem

The following type definitions are used in the propsoal below:

struct Size<T> {
    width: T,
    height: T,
}

struct BoxConstraints {
    min: Size<f32>,
    max: Size<f32>,
}

enum RequestedSize {
    MinContent,
    MaxContent,
    FitAvailableSpace,
}

I propose that the Xilem widget trait has the following two methods for layout, replacing the existing layout and measure methods:

fn measure(
    &mut self,
    box_constraints: BoxConstraints,
    parent_size: Size<f32>,
    available_space: Size<f32>,
    requested_size: Size<RequestedSize>,
    axis: Axis,
) -> Size<f32>;

fn layout(
    &mut self,
    box_constraints: BoxConstraints,
    parent_size: Size<f32>,
    available_space: Size<f32>,
    requested_size: Size<RequestedSize>,
) -> Size<f32>;

I believe this would provide a strong framework within which lots of powerful layout paradigms could be implemented. But I'm sure I haven't thought of everything and feedback and discussion is of course enouraged!

raphlinus commented 1 year ago

I haven't had a chance to review in detail, but I can answer the f64 question. For a very large scrolling area, the scroll offset would lose precision (ie not even be able to represent an integer scroll offset) around 2^24. On CPU, the speed of doing f64 arithmetic is generally the same as f32.

I should also point out that what's referred to as "Xilem" above is an experimental prototype based on SwiftUI, and doesn't represent the current proposal, which is the same as Druid.

alice-i-cecile commented 1 year ago

Taffy maintainer here: I'm happy to make upstream changes that you need. Adding f64 support is feasible, if that's what you end up needing.

Speykious commented 1 year ago

For a very large scrolling area, the scroll offset would lose precision (ie not even be able to represent an integer scroll offset) around 2^24.

Isn't there a way to alleviate this issue regardless of the precision of float numbers? As in, making the widgets move into the view of the scrolling area, rather than the view moving inside the scrolling area, which would eliminate any problems for widgets displayed on screen and have any float precision issue invisible outside.

Edit: hmmm maybe that fixes the view position precision but not the widget position precision?

alice-i-cecile commented 1 year ago

In game dev, that strategy is called a "floating origin". I think there's a good chance it would work well here.

raphlinus commented 1 year ago

Yes, and we may well end up wanting to do that, partly because transforms are going to be f32 on the GPU. I'm explaining the reasoning why it was f64 in Druid, and it's still open to discussion.