linebender / druid

A data-first Rust-native UI design toolkit.
https://linebender.org/druid/
Apache License 2.0
9.53k stars 569 forks source link

Add Prism as a co-Lens #1135

Closed swfsql closed 1 month ago

swfsql commented 4 years ago

TL;DR This is an exploration for using enums with Prisms, similarly on how structs are used with Lenses.
Next step is try having them working comfortably with/as Widgets.

Widget, Data, and Lenses

A Lens is, as documented, "a datatype that gives access to a part of a larger data structure".

This is a useful concept/tool that often facilitates the handling and manipulation of data, of which reusable Widgets benefit from. A Widget may be reusable because it's defined to work with, let's say, a bool, and there are a lot of places and different libraries that could want to also use that Widget.

One detail of Widgets is that they need to have their instance defined inside some function, inside some scope - and note that such functions usually return -> impl Widget<MyWholeData> or something along these lines. Assume MyWholeData to be a struct that contains some stuff, including a bool, which we want our Widget to work it.
But we can't just use/return a Widget<bool> - a widget that works with / manipulates a bool - in a function that is expecting to return a Widget that work with / manipulate a MyWholeData. And we also cannot access the data by ourselves (to then hand it over to the widget), because let's say we might try having something like:

my_widget_that_works_with_bool()
    .actually_here_is_the_data_that_you_work_with(my_whole_data.my_bool)

But that is not possible, because here in our scope we are only defining (the instance of) the Widget, and only that. We don't actually have / hold an instance of the data (the my_whole_data, which would be an instance of MyWholeData) and therefore we can't really "access" the bool and "hand it over" to the Widget<bool>.

What we really do is to instruct (that instance of) the Widget<bool> on how to handle a MyWholeData data when given such data. And what it will do, is to access the inner bool contained in the MyWholeData by itself! So that's where we use lenses: it's that instruction itself.
(and then, after the correct lensing is applied, that Widget<bool> can now be treated as a Widget<MyWholeData>, which was the intention, which is the return type of the function)

So by using lenses, we can actually make Widgets reusable for other users, which are gonna have TheirOwnWholeData. This "lensing appliance" is actually a combination of Lenses and Widgets, where each combination keeps being a Widget - it's just now able to "work with" TheirOwnWholeData. That combination, in fact, simply applies the lensing properties on the data before calling the inner, reusable, widget.

It's worth noting that lenses (instruction about "accesses on data") compose with each other - think of it as combining multiple layers - and thus are able to provide a tree of "data access", of which each branch is used by one or more widgets, and all branches start at the root data, which is the "UltimateOuterWholeAppData".

Limitation

Currently, it's not possible to have "Lenses" into an enum's variant, although it's not clear if this should, actually, be avoided or not.

The Lensing concept on enums are called Prisms in some Haskell libraries, therefore I think they could show themselves useful for druid. Disclaimer: I myself don't know Haskell, and I'm new into the Lens/Prism concepts.

So now, assuming/given that it's possible to have "Lenses for enums", which are the Prisms, the next issue would be figuring if there is a way to comfortably use lenses and prisms with widgets.
Currently, for the linked PR, I think it is not, and so I don't think the Prism currently present a benefit, widget-wise.

As said, there is a drafty, initial PR for introducing Prisms, but again, their usage/interaction with/as Widgets is currently "bad".

Previous Work and Discussions

There are previous issues and discussions on this area:

Where druid-enums reaches an interesting possibility, by being able to create a Widget that is able to handle enums.
I tried comparing this approach and druid-enums, see https://github.com/linebender/druid/issues/1135#issuecomment-716332150.

luleyleo commented 4 years ago

Would be interesting to know what you prefer about that approach, compared to a derived matcher widget as offered by druid-enums (I've definitely to many repos for these...).

swfsql commented 4 years ago

yeah haha actually I did see that one as well (I'll be adding it now); --and thanks for those!

I didn't read that much about the code, but I did a cargo-expand on the example to see how the transformation occurred, and this enum transformation happened:

enum AppState {
    Login(LoginState),
    Main(MainState),
}

// generated
struct App {
    login: Option<Box<dyn ::druid::Widget<(LoginState)>>>,
    main: Option<Box<dyn ::druid::Widget<(MainState)>>>,
    default_: Option<Box<dyn ::druid::Widget<AppState>>>,
    discriminant_: Option<::std::mem::Discriminant<AppState>>,
}

So I understood that the enum had to be structurized (with a separated discriminant), but I had mixed feelings by seeing Box<dyn _>.. as it's not very common for me to use them - but I understand that probably there is no other way other than structuring them as such.

There is one other point that I found curious, which is executed at the App initialization:

self.login = Some(Box::new(widget)); // on login(..)
self.main = Some(Box::new(widget)); // on main(..)

And in this time again, I don't know if it would be possible avoiding this, but it's not common to see "in practice" ~all variants of the old enum instantiated at the same time. There is some disadvantage with memory size ofc, but it is something that I'd commonly not expect for an enum (the original enum).~

^edit: I removed something I misunderstood. The enum's data of each variant are not replicated at the same time. Only widgets that deal with each variant exist at the same time.

Then I did some reading on lenses for enums, and got into those pages talking about prisms.. so I thought about giving it a shot! (maybe it will "just work" or something lol)

^edit: no, they did not "just work".

luleyleo commented 4 years ago

I had mixed feelings by seeing Box<dyn _>.. as it's not very common for me to use them

Well, Box<dyn Widget<T>> simply is the way to go if you need to store a widget where you don't now the exact type. This is also what Flex does for example. https://github.com/linebender/druid/blob/0c63b5948c0bb36215ba915f2cf0aed48f812cab/druid/src/widget/flex.rs#L142-L153

it's not common to see "in practice" all variants of the old enum instantiated at the same time.

Yeah, I've though quite a bit about this and it could indeed be done differently by using constructor functions to initialize only the widget for the current variant. I'm also tempted to change it to work like that, but I doubt anyone will build a complex enough application, that utilizes enums so much that it would be possible for this to be a performance issue. And just passing in the widget you want to have there is a bit simpler.

That aside, how would this Prism help you with that? You would just end up with exactly the same "issue" right?

swfsql commented 4 years ago

Well, Box<dyn Widget<T>> simply is the way to go if you need to store a widget where you don't now the exact type. This is also what Flex does for example.

Oh, that I didn't know.. I mistakenly assumed it were some kind of workaround.. sorry about that

Yeah, I've though quite a bit about this and it could indeed be done differently by using constructor functions to initialize only the widget for the current variant.

Hum.. actually, I think it's really hard indeed, avoid having the widgets already initialized.. (now that I have a better grasp of the "data" vs "lenses" vs "widgets". And I agree with what you said!)

That aside, how would this Prism help you with that? You would just end up with exactly the same "issue" right?

Yes, I tried using them and still a separated widget is used for each variant, and all widgets must be initialized. I can't see a way out of that, in a lens-like usage.

I've been trying to combine prisms and lenses together, by trying to see prisms as some sort of lenses (following what's called a Prism -> Lens outside/2/3 method) - but I didn't get anywhere, and those haskell operations are crypt to me - but it would work by creating a Lens<Fn(Enum)->R),Fn(VariantField)->R> out of a Prism.
Doesn't really makes sense to me anyway.

I don't think there is any major advantage of using Prisms. Maybe it would just have a similar set of operations that Lenses have, and work in a similar way, which is that deriving Prism would only implement a few traits (actually, also create modules with concrete types that would actually have those traits implemented). Well, all in all, I see a similarity with Lenses as a positive.

but I doubt anyone will build a complex enough application, that utilizes enums so much that it would be possible for this to be a performance issue

I agree, but I also suspect that some patterns (which, exactly, I don't know..) could emerge if enums were more deeply usable for organizing the data, as structs are with the Lenses - but I could be wrong and it would just add code burden! idk

I personally gave up trying to make the Outside pattern (as I don't even really understand them), and now I'll try exploring the Traversals/2/3, to see what they are about.. they're supposed to work with both Lenses and Prisms underneath, or something like that..

But if I hit a wall in there too, then (at least for me), Lenses and Prisms wouldn't be mixable, so I'd give up this Prism thing.

swfsql commented 4 years ago

I think the combination of Lens and Prism, at least 'data'-wise (as in algebraic-data-wise), is working ok now.

There is a test called with_traversal, where it shows how they can be used together.

// .. structs and enums omitted ..

// given this instance
let mut bob = Person {
    name: "Bob".into(),
    addr: Addr::Extensive(ExtensiveAddr {
        street: "bob's street".into(),
        place: Place::Apartment(Apartment {
            building_number: 3,
            floor_number: -1,
            apartment_number: 4,
        }),
    }),
};

// and given this traversal
// traversal for A -> B -> C -> D -> E -> F
let person_apt_number = {
    use druid::optics::affine_traversal::Then;
    (Person::addr) // A -> B (lens)
        .then(Addr::extensive) // B -> C (then a prism)
        .then(ExtensiveAddr::place) // C -> D (then a lens)
        .then(Place::apartment) // D -> E (then a prism)
        .then(Apartment::apartment_number) // E -> F (then a lens)
};
// note: before different methods were used (then_lens and then_prism),
// but now we got the zero-cost Then::then method

// we can get bob's apt number with
assert_eq!(Some(4), person_apt_number.get(&bob));

// and we can change it with:
assert_eq!(Some(()), person_apt_number.with_mut(&mut bob, |n| *n = 7));
// note: Some(_) is the closure return; a None would mean the closure didn't run

Another (shorter) example would be:

type Outer = Result<Inner, f32>;
type Inner = (u32, bool);
let ok1 = prism!(Outer, Ok)
    .then(lens!(Inner, 1));
assert_eq!(Some(true), ok1.get(&Outer::Ok((3, true))));
assert_eq!(None, ok1.get(&Outer::Err(5.5)));

Even putting aside some re-naming of traits etc, I didn't really test how Widgets would interact with those Prism/Traversal. I mean, I did an implementation of Widget for them, but didn't really test it.

~Note: Currently (PR-wise) - I think - the Prism definition could be renamed to PartialPrism and/or AffineTraversal, as it is a subset of what full prisms are (which also have the replacement/upgrade functions, which are currently in a different trait).~
edit: The old Prism is now renamed into PartialPrism (which only contain the with/with_mut methods; the old Replace got renamed into Prism, as it's the PartialPrism + the replace method. I also left the AffineTraversal defined (actually it is just a re-export) for when the related implementation are not completely closed onto "only Prisms".
But PartialPrisms are AffineTraversals (as there's 0-or-1 item), and actually Lenses would be LinearTraversals (as there's always 1 item).

swfsql commented 4 years ago

@cmyr (from https://github.com/linebender/druid/pull/1136#issuecomment-682144434 ) yes, I wrote a clearer view of the problem (first post updated).
But to be honest, I still need to study the druid-enums as only now this direction of work is trying to start involving widgets. It's an unknown area for me, and it's also unknown to me if they will actually end up mixing well.
So that's why I was unable to clearly answer @Finnerale for those weeks (and I still am unable to answer you..).

So I'll try reading and actually understanding druid-enums, and then try to feedback!

But to make sure, I think that the lenses and prisms could be moved into a third party dependency for druid, as they are pratically independent of Widgets, and could be useful for other crates.

cmyr commented 4 years ago

It is possible that we could move lenses/prisms elsewhere, certainly, but it does increase the maintenance burden on us; we need to update another crate, be more careful about breaking changes, etcetera. My position would be to keep these things in druid until they are properly mature, and then we can consider moving them outside if it seems like there is a clear use-case.

swfsql commented 4 years ago

Just did a little update on the backstory, and did some ergonomics update for the drafty PR (now they all use then methods for linking); I also started studying druid-enums (and I'm impressed on how tidy it is).

swfsql commented 3 years ago

My try at explaining the issue intention, while adding info about the PR and some comparisons to druid-enums:

About

The linked PR is a possible implementation that tries to 1) extend the lensing concept and 2) create new and related widget wrappers.

Extension of Lenses

Lenses are instructions of, when given some outer/bigger type of data T1, accessing inner/smaller type of data T2. eg. access, from some struct T1, a given field T2.
As the accessed inner/smaller data is always one single (and existent) element, Lenses could be called something "Linear". eg. given a struct, it's fields are always present.

The extension adds a "type" of Lenses for enums. To access a given data T2 from a given variant of some enum T1, we must consider the possibility of that enum being in another variant, and thus T2 will be missing. So instead of being "Linear" (always 1 T2), this "type" of lenses could be called "Affine" (0-or-1 T2).
On some other lensing libraries (from Haskell, Scala, etc), that "type" of Lenses are called Prisms. They are Lenses' siblings.

Details

The usage of prisms is similar of lenses: deriving the correct trait for enums and then being able to combine prisms with each other (and also with lenses), and then attaching those combinations into widgets.
Outside of widgets, lenses can be used just to deal with data accesses, and the same is true for prisms. You can see those on the test files related to Lens/Prism trait derivation.

Accompanying Widgets

Lenses can be attached into Widgets and transform a Widget<T2> into a Widget<T1>, ie. transform a widget of some inner data into a widget of some outer data. More specifically, there is a Widget that wraps both some inner Widget<T2> and a Lens<T1, T2>, and it itself becomes a Widget<T1> by applying the lensing and then forwarding the calls into the inner widget.
There is a similar wrapper for Prisms, where a given Widget wraps both a Widget<T2> and a Prism<T1, T2> and becomes a Widget<T1>, same as for lens.

~Except that some event/lifecycle/layout still needs to be worked out, since the data may now "cease to exist" and "come back again", which is different from a "Linear" perspective.~
note: I myself am not intimate with how those concepts work, so this in itself already blocks this PR I think..
edit: afaik some issues related to that are now fixed.

Besides the direct wrapper for Widget<T2> + Prism<T1, T2>, there are second-layer wrappers, which were adapted from how Finnerale/druid-enums made his Widgets that ultimately, from a T1, may access-then-forward T2, or a T2', or a T2'', .., for it's inner widgets, as each inner widget may have their own kind of T2.

Comparison to Druid-Enums

I'll try laying a comparison of this implementation ("prism case") to "druid-enums", to the best of my knowledge.

Code Example

This shows a comparison on how the wrapping widget is used in druid-enums:

// druid-enums code

#[derive(Clone, Data, Matcher)]
enum AppState {
    Login(LoginState),
    Main(MainState),
}

fn ui() -> impl Widget<AppState> {
    AppStateMatcher::new()
        .login(login_ui())
        .main(main_ui())
        .controller(LoginController)
}

struct LoginController;
impl Controller<AppState, AppStateMatcher> for LoginController {
  // ..
}

And on the prism case:

// prism-case code

#[derive(Clone, Data, PartialPrism)]
enum AppState {
    Login(LoginState),
    Main(MainState),
}

fn ui() -> impl Widget<AppState> {
    druid::widget::overlay::Overlay2::new(
        login_ui().prism(AppState::login),
        main_ui().prism(AppState::main),
    )
    .controller(LoginController)
}

struct LoginController;
impl<W: Widget<AppState>> Controller<AppState, W> for LoginController {
  // ..
}

Differences

TL;DR: I think druid-enums is sufficient and efficient at making it possible to use enums within druid as Data and making them usable within widgets, and I see no advantage of having the prisms concepts when comparing it to druid-enums.

edit: the following has been fixed on druid-enums.

The conclusion is the TL;DR at the top, which is, druid-enums is a better way to go I think. Having said that, I personally find it interesting to see these prisms, but I wouldn't recommend it over druid-enums, unless there is some reason that I don't know of.

luleyleo commented 3 years ago

Great write-up / comparison! I hope to find some time today to take another look at the prism branch.

derekdreery commented 3 years ago

I'm excited about prisms too. Could they be made to work with runtime-defined length (for data in a Vec rather than an enum)? This might be a neat way to solve the problem of how best to view a dynamic-length list.

EDIT I think I'm mistaken here. I wonder what the haskell solution is for lists. Maybe just the applicable typeclass.

swfsql commented 3 years ago

@derekdreery I'm afraid they can't work outside of enums. Their intention is to take a tuple of different types (T2, T2', T2'', T2''', ..), each for a variant, and converge them into a single type T1 (the enum type itself).
When accessing through a prism, you're affinely-traversing, ie. you may get a single value (let's say, a value of type T2'') if your enum value (of type T1) currently is on the correct variant (for T2''); otherwise, you don't get any value out of it.
When accessing through a lens, you're linearly-traversing, ie. you can always get a field out of a struct. Note that you always get a single value (no more, no less).
Maybe you're referring to a "normal" "traversal" access. I don't know what kind of "layer" it is (eg. lens/prism/etc - I think it's just called "traversal"), but it's the idea that you can get 0, 1 or many values out of such access.

In that case, the return type for the trait would likely need to be impl Iterator<Item=X>, but this kind of return type for a function is currently not available for trait methods, so I guess this can't be done.. unless you want to use dyn around, I guess. For reference, such return value is Option<X> for prisms, and just X for lenses - and that is pretty much all of the difference between lenses and prisms.

But I'm not sure, and I haven't actually seen how they deal with that in haskell!

edit: I tried adding this to see how it would look like, although I'm certain I drew a bad trait. In this example, a traversal is combined with another one, and then used to change some numbers.

xarvic commented 3 years ago

I would like to share my thoughts on why i think prisms might be useful. I developed a few widgets (MultiCheckbox and MultiRadio) in the widget-nursery and after some time i noticed, what i need are prisms. For this reason i created a toy prism trait in the nursery to try out those ideas. I also tried to develop a formula-editor in druid. The data structure of the formula a bit simplified looks like this:

enum Term {
    Value(f64),
    Variable(String),
    Operator{left: Arc<Term>, operator: Operator, right: Arc<Term>},
    Exp{base: Arc<Term>, exponent: Arc<Term>},
    Root(Arc<Term>),
}

The problem is: this doesn't work since druid-enums requires me to create the widgets for all variants up front (correct me if i am wrong). To do this the switcher widget would have to create the widget when it is used. Nevertheless, having all variants present at the same time still useful in some other cases. Therefore i implemented an alternative to druid-enums (Switcher and LazySwitcher). I think it has some advantages but also a few drawbacks.

Comparison of (Lazy-)Switcher and druid-enums

Pro druid-enums

The main point for druid-enum i see at this point is usability. It doesn't need any further concepts like prisms, you just use #[derive(Matcher)] and get methods named like your variants to provide the corresponding widgets. That is great! another point is speed: druid-enums is probably always faster than an implementation using prisms, although the difference is not that much. Switcher and LazySwitcher also only do one check in update to test, if the variant is still the same (which is probably the case most of the time). Only if the variant changes, we have to check all other prisms to find the matching widget.

Pro Switcher

The main point here is flexibility: The most complex part of creating a switcher for enums is probably handling the enums and writing the derive-macro. When this done the rest is far easier to develop and to maintain when druid changes. For the same reason it is easier to develop other widgets with similar functionality like LazySwitcher for tree-like data, Switcher and LazySwitcher.

Example code druid-enums

#[derive(Clone, Data, Matcher)]
enum AppState {
    Login(LoginState),
    Main(MainState),
}

fn ui() -> impl Widget<AppState> {
    AppStateMatcher::new()
        .login(login_ui())
        .main(main_ui())
        .controller(LoginController)
}

Example code Switcher

#[derive(Clone, Data, Prism)]
enum AppState {
    Login(LoginState),
    Main(MainState),
}

fn ui() -> impl Widget<AppState> {
    Switcher::new()
        .with_variant(AppState::Login, login_ui())
        .with_variant(AppState::Main, main_ui())
        .controller(LoginController)
}

Example code LazySwitcher

#[derive(Clone, Data, Prism)]
enum AppState {
    Login(LoginState),
    Main(MainState),
}

fn ui() -> impl Widget<AppState> {
    LazySwitcher::new()
        .with_variant(AppState::Login, login_ui)
        .with_variant(AppState::Main, main_ui)
        .controller(LoginController)
}

My conclusion

druid-enums is really good at what it does, but it is limited in what it can do. One thing to keep in mind is: This comparison is maybe a bit biased. I am not sure how common/important these use-cases are.

cmyr commented 3 years ago

I do think that enums are an important case; my current feeling, though, is that we should hold off on making any big changes or additions until we have a clearer sense of where we are going, architecture-wise; if we end up moving to something more like lasagne then we won't have a Data param, and this problem disappears. :)

swfsql commented 1 month ago

Hi, I happened to revisit this and I'm opting to close it. Although I haven't used it, Xilem design appears to be easier and also very good.