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

Replace per-node measure functions with a single measure function + per-node context #490

Merged
merged 27 commits into from
Oct 9, 2023

Conversation

nicoburns
Copy link
Collaborator

@nicoburns nicoburns commented May 31, 2023

Objective

  • Make it possible for measure functions to borrow external state rather than being required to own all of the state they need.
  • Make the Send+Sync bounds on measure functions opt-in

Changes made

A new parameter (of user-specifiable generic type) has been added to:

  • The Taffy::compute_layout function
  • The measure function of the Measurable trait (measure functions)
  • The Send + Sync bound has been removed from the Measurable trait
  • A new SyncMeasureFunc type with Send+Sync bounds has been added

The parameter passed to the Taffy::compute_layout function by the user is passed through when Taffy calls into measure functions.

To make this work, an associated type Context has been added to the Measurable trait, and a generic type parameter Measure: Measurable has been added to the Taffy struct. This generic parameter also allows users of Taffy to choose between the Send+Sync SyncMeasureFunc and the regular MeasureFunc which doesn't have those bounds, or to implement their own custom Measurable type (allowing for things like using an enum instead of a boxed closure).

Context

  • Will allow https://github.com/trimental/inlyne to remove unnecessary Mutex's from their Taffy integration
  • Will allow WASM bindings to use Taffy without a sketchy unsafe Send+Sync implementation
  • Will allow Bevy to create a custom Measureable type and avoid boxed closures

Feedback wanted

  • Does this seem like a good direction?
  • Context is currently always passed by mutable reference. Can we do better?
  • How should we handle constructors? Taffy having a generic parameter is mostly painless, but it does make constructing a Taffy more awkward.

@nicoburns nicoburns force-pushed the measure-func-context branch from 47a5a3f to 9865cd1 Compare May 31, 2023 14:33
@nicoburns nicoburns added enhancement New feature or request breaking-change A change that breaks our public interface labels May 31, 2023
@nicoburns nicoburns added this to the 0.4 milestone May 31, 2023
@nicoburns nicoburns force-pushed the measure-func-context branch from a49d152 to 2e037d9 Compare June 1, 2023 21:11
@nicoburns nicoburns marked this pull request as ready for review June 1, 2023 23:57
@nicoburns nicoburns mentioned this pull request Jun 1, 2023
@nicoburns nicoburns added the controversial This work requires a heightened standard of review due to implementation or design complexity label Jun 2, 2023
@nicoburns nicoburns requested a review from jkelleyrtp June 2, 2023 01:40
@nicoburns
Copy link
Collaborator Author

@twop I would be interested on your opinions on this design given your work in #198

Copy link
Collaborator

@TimJentzsch TimJentzsch left a comment

Choose a reason for hiding this comment

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

At first glance, I don't mind this implementation and it seems to unlock a fair bit of use cases.

Because you have to commit to one Context for everything I was first concerned that it might be harder to create reuseable functionality for this system. But then I'm not sure if reuse across multiple taffy client would even make sense or how that could be implemented differently... so it probably doesn't matter.

I left some minor comments about typos and documentation.

README.md Outdated
@@ -20,7 +20,7 @@ Right now, it powers:
use taffy::prelude::*;

// First create an instance of Taffy
let mut taffy = Taffy::new();
let mut taffy: Taffy<MeasureFunc<()>> = Taffy::new();
Copy link
Collaborator

Choose a reason for hiding this comment

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

Since the Context defaults to (), is it possible to just write Taffy<MeasureFunc> here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I can try it, but I'm guessing not on the basis that Measure defaults to MeasureFunc<()> so if that were possible then it ought to be possible to just write Taffy (but I tried that and it didn't work). I'm thinking maybe an alias like type DefaultTree = Taffy<MeasureFunc<()>>?

See also: #449 (comment)

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah if we can't get type inference working let's do a type alias.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

My latest thinking on this is that we ought to rename the generic struct to TaffyTree, and then have a type Taffy = TaffyTree<MeasureFunc<()>> for backwards compatibility, but otherwise encourage users of Taffy to define their own alias, and documenting a bunch of helpful examples on the TaffyTree struct.

src/tree/measure_func.rs Outdated Show resolved Hide resolved
src/tree/measure_func.rs Outdated Show resolved Hide resolved
src/tree/measure_func.rs Outdated Show resolved Hide resolved
Comment on lines 430 to 471
/// Updates the stored layout of the provided `node` and its children
pub fn compute_layout_with_context(
&mut self,
node: NodeId,
available_space: Size<AvailableSpace>,
context: Measure::Context,
) -> Result<(), TaffyError> {
self.context = Some(context);
let result = compute_layout(self, node, available_space);
self.context = None;
result
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I feel like this function could cause unintended side-effects because the self.context is overridden.
Maybe it could make sense to save the old self.context in a variable and then restore that instead of overwriting with None?

In any case, I think the behavior with self.context should be documented in the comment.

Copy link
Collaborator Author

@nicoburns nicoburns Jun 19, 2023

Choose a reason for hiding this comment

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

It can't, because this is the only place that self.context is overridden. It's really just a hack to avoid passing the context through all the compute functions. It shouldn't really work like this though, and your comment has made me realise a different issue with this setup: it's going to require Context to be Send/Sync in order for Taffy to be Send/Sync.

I think the proper fix for this might be another type which wraps Taffy and Context and implements LayoutTree. Last time I tried this kind of things it ended up being a perf regression, but I think I might try it again for this because it would be a lot cleaner.

Copy link

Choose a reason for hiding this comment

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

That is the same conclusion I had when I experimented with this idea, so that's why I was super excited when your approach allowed to preserve the same API surface area (more or less).

But I think fundamentally there needs to be another implementation of LayoutTree for more a "advanced" use case.

@twop
Copy link

twop commented Jun 2, 2023

@twop I would be interested on your opinions on this design given your work in #198

@nicoburns I love the idea! I think it solves some paint points of the sketch I made in #198, but it is spiritually the same what I had in mind. I think your implementation is more elegant :)

I'm not sure about the exact API ergonomics, for example

 pub fn compute_layout_with_context(
        &mut self,
        node: NodeId,
        available_space: Size<AvailableSpace>,
        context: Measure::Context,
    ) -> Result<(), TaffyError> {
        self.context = Some(context);
        let result = compute_layout(self, node, available_space);
        self.context = None;
        result
    }

feels intuitively a bit off, specifically that pattern.

   self.context = Some(context);
   self.context = None;

But I don't know on top of my head how it can be structured a bit better.

Overall, I think that direction is a big win in my head for the library (as a lib user). Nice job 👍!!

@twop
Copy link

twop commented Jun 2, 2023

One other interesting detail/observation: Context needs to be mut for measure call (for example for caching, thus changing internal state), so compute_layout_with_context takes ownership of Context to potentially mutate it internally. I wonder if it is worth giving a shot passing &mut Context instead, it can be potentially more ergonomic for implementers.

Maybe I'm missing something, but it seems that using the API properly might involve some Rc<RefCell> voodoo, at least in some cases, thus &mut Context with potentially some lifetime tracking might provide better dev ergonomics

@inobelar
Copy link
Contributor

inobelar commented Jun 2, 2023

@nicoburns I'm sorry that I'm "climbing into someone else's monastery with my charter" (I'm a C++ programmer, and I only "read" Rust, and don't "write"), but doesn't instantiating a Taffy template/generic with different types generate twice as much code? For example:

let mut taffy1: Taffy<MeasureFunc<TextLayoutData>> = Taffy::new(); // Instantiation for one type
let mut taffy2: Taffy<MeasureFunc<SomeOtherData>> = Taffy::new();  // Instantiation for another type

Two different instances produces 2 unique Taffy specializations & double code generation (if in Rust it works same as in C++).
I know, that its unexpected/unusal code example (2 separated intances of nodes tree), but even if we have only single instance for application, and we need extra data during measuring widget size - can we avoid the template parameter for the whole Taffy tree structure (since it may be various types in different cases)?

For example, in C++, not too-safe, but good-old common-pattern for passing any context - using non-owning void pointer (type erasurement from known type), and then back - casting into known type when it's needed. For example (C++-like pseudocode):

class SomeSpeciallyMeasuredWidget
{
    taffy::NodeId _id;
    taffy::Style _style;
    
    SomeUsefulDataForMeasurement _meas_data;
    
    SomeSpeciallyMeasuredWidget (Widget* parent)
         : Widget(parent)
    {
         taffy::Taffy* taffy_tree = this->getRootTree();
         // `this` is mutable pointer to self, here is it passed as `void*`
         _id = taffy_tree->new_leaf_with_measure_and_context(_style, this, 
             [](const Size<Option<float>>& known_dimensions, const Size<taffy::AvailableSpace>& available_space, void* context) -> taffy::Size<float>
             {
                 SomeSpeciallyMeasuredWidget* self = static_cast<SomeSpeciallyMeasuredWidget*>(context); // from void* into known type
                 return my_measurement_computation(known_dimensions, available_space, self->_meas_data);
             }
         );
    }
};

Maybe somehow we can achieve the same in Rust?

@twop
Copy link

twop commented Jun 2, 2023

@inobelar I think your intuition is correct, but I'm not sure what is the impact of the issue in practice.

E.g. How many different Measure implementations any given project uses? Judging from personal subjective experience, I don't see a common usage pattern where that is the case 🤷‍♀️

@nicoburns
Copy link
Collaborator Author

@inobelar You're definitely correct that this will generate two copies of everything. And this definitely seems non-ideal given that it's only a very small part of the code that would actually need to change.

The Rust equivalent of void * in this case would probably be &mut dyn Any: that can be downcast to &mut dyn T if you know the correct T. I'm a little reluctant to make that change though, as it would require all measure function implementations (written by the user) to manually downcast the value.

The other way to avoid duplicating everything would be to make the algorithms take &mut dyn LayoutTree rather than &mut impl LayoutTree. But I'm also reluctant to do that as I suspect that might cause performance issues.

The expected way of using Taffy would be:

struct TextLayoutData { ... }
struct SomeOtherData { ... }

enum CombinedData {
     Text(TextLayoutData),
     Other(SomeOtherData),
}

let mut taffy: Taffy<MeasureFunc<CombinedData>> = Taffy::new();

which also avoids this problem. And I agree with @twop that it would be uncommon to need multiple Taffy instances with different Context parameters.

@nicoburns nicoburns force-pushed the measure-func-context branch 2 times, most recently from f1e12b5 to 22d9fbc Compare June 3, 2023 11:58
@nicoburns nicoburns force-pushed the measure-func-context branch 2 times, most recently from 20f3fd9 to 6e408a6 Compare August 14, 2023 23:04
@lucasmerlin
Copy link

I'm building a integration to use taffy for egui for more complex layouts. I based my work on this PR since I hoped it would solve the lifetime issue I was facing, but in the end I had to use some unsafe code to make things work.

My api basically looks like this:

    let mut taffy = TaffyPass::new(
        ui,
        Id::new("flexible"),
        Style {
            display: Display::Flex,
            ..Default::default()
        },
    );

    taffy.add(
        Id::new("child_2"),
        Style {
            flex_grow: 1.0,
            ..Default::default()
        },
        Layout::centered_and_justified(Direction::TopDown),
        |ui| {
            let _ = ui.button("Button 1");
        },
    );

    taffy.add(
        Id::new("child_3"),
        Style {
            flex_grow: 1.0,
            ..Default::default()
        },
        Layout::centered_and_justified(Direction::TopDown),
        |ui| {
            let _ = ui.button("Button 2");
        },
    );
    taffy.show();

Since egui is immediate mode and I'm creating this as a library to be used in combination with egui's layout capabilities, my use of taffy is probably a bit different than most others. But I think what I'm trying to do should be possible with safe rust.

My problem is: The last parameter to the add function is a FnMut closure. I want to pass this as a &mut reference to the measure function and then after the layout was calculated, I want to call it again to create the actual ui. But since the compute_layout_with_context requires the context to live for the length of the Taffy struct, this is not possible.

Here is a simple example that fails due to a similar lifetime problem:

Details

use taffy::prelude::{Size, Style};
use taffy::tree::MeasureFunc;
use taffy::Taffy;

struct TaffyState<'f> {
    taffy: Taffy<MeasureFunc<&'f mut str>>,
}

fn main() {
    let mut state = TaffyState {
        taffy: Taffy::new(),
    };

    let node = state
        .taffy
        .new_with_children(Style { ..Style::default() }, &[])
        .unwrap();

    {
        let mut str = "hello".to_string();

        let result = state
            .taffy
            .compute_layout_with_context(node, Size::length(100.0), &mut str)
            .unwrap();
    }
}

For reference, here is the unsafe code I had to write to make things work. I think what I'm doing there should be sound, but I'm not very experienced with rust safety.

Even if this won't be possible for some reason, your PR makes what I'm trying to do much easier, so I really appreciate it!

@nicoburns
Copy link
Collaborator Author

Hmm… the lifetime being required to outlive the Taffy struct doesn’t sound right! I suspect this is a result of the hack (temporarily storing the context in the Taffy struct for the duration of the layout) that is currently being used to avoid having to pass the context around.

I suspect I’m going to have to update this PR to do things properly, which will likely mean creating a TaffyView struct or similar which can act as an “iterator” over the Taffy tree without actually being the tree. We should be able to store the context in that struct without causing this lifetime issue.

I’m quite busy over the next month, so I’m not sure when I’ll next have time to work on Taffy. But this PR is top of my list, and the change discussed above is the main thing blocking this PR from being merged, so hopefully it shouldn’t be too long :)

@inobelar
Copy link
Contributor

@nicoburns , again I apologize for the offtopic, but adding of this new 'generic' parameter also will affect the 'C bindings' #404 and 'Wasm bindings' #394 PRs, or any 'bindings to other languages' in general.

Since, we cannot expose generics through 'C ABI' - we need some 'specialization' for that case. Is it will be: Taffy<MeasureFunc<()>>, or something different?

@nicoburns
Copy link
Collaborator Author

C and WASM bindings are actually one of the motivations for this PR. It allows them to define their own type to use as the measure function. For C, I am imagining this being something like:

struct CMeasureFunc {
    context: *mut (),
    measure: fn(
        known_dimensions: Size<Option<f32>>,
        available_space: Size<AvailableSpace>,
        context: *mut ()
     ) -> Size<f32>,
}

where *mut () is a Rust representation of a C void * pointer. (there will probably need to be some tweaks to the types of the known_dimensions and available_space parameters too, in order to avoid using data-containing enums).

For WASM, there would be some type that wraps a handle to a JavaScript function. Notably such functions are !Send and therefore won't work well with the regular MeaureFunction.

@nicoburns nicoburns force-pushed the measure-func-context branch from 6e408a6 to b20cda8 Compare August 23, 2023 23:30
@nicoburns nicoburns force-pushed the measure-func-context branch from e691451 to dde4431 Compare October 9, 2023 20:13
@nicoburns
Copy link
Collaborator Author

@alice-i-cecile Release notes added :)

@alice-i-cecile alice-i-cecile merged commit be627e7 into DioxusLabs:main Oct 9, 2023
17 checks passed
@nicoburns nicoburns mentioned this pull request Oct 9, 2023
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
breaking-change A change that breaks our public interface controversial This work requires a heightened standard of review due to implementation or design complexity enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants