zrho / purescript-optic-ui

PureScript UI framework based on lenses.
122 stars 10 forks source link

Question: different approach to TraversalP, LensP etc. #4

Open paf31 opened 9 years ago

paf31 commented 9 years ago

First of all, thank you for writing this library, it is really excellent.

I was experimenting with zoomOne and zoomAll, and was trying to come up with a slightly different, more uniform approach. The idea is that if we can express the UI type as s -> f s for some Applicativef`, then you get lifting over all optics "for free". I came up with this:

newtype Markup a = Markup (Array (HTML a))

instance functorMarkup :: Functor Markup where ...

instance alternativeMarkup :: Alternative Markup where ...

newtype UI state = UI (state -> NonEmpty Markup state)

Then the core combinators become trivial:

lens :: forall a b. LensP a b -> UI b -> UI a
lens l (UI ui) = UI (l ui)

traverse :: forall a b. TraversalP a b -> UI b -> UI a
traverse t (UI ui) = UI (t ui)

The key is the NonEmpty applicative functor which captures something like Halogen's mergeWith combinator, combining components in parallel, keeping the latest state from each:

data NonEmpty f state = NonEmpty state (f state)

instance functorNonEmpty :: (Functor f) => Functor (NonEmpty f) where
  map f (NonEmpty state fa) = NonEmpty (f state) (map f fa)

instance applyNonEmpty :: (Alt f) => Apply (NonEmpty f) where
  apply (NonEmpty f fs) (NonEmpty x xs) = NonEmpty (f x) (map ($ x) fs <|> map f xs)

instance applicativeNonEmpty :: (Alternative f) => Applicative (NonEmpty f) where
  pure a = NonEmpty a empty

I think this could generalize nicely in a few ways:

Right now, I think this library has the best story for component composition I've seen in a PureScript library, but it would be really great to be able to handle third-party components and things like AJAX too.

I know this is a fairly big change, but would you take a PR?

Thanks

zrho commented 9 years ago

That sounds quite interesting indeed. As soon as I come back from vacation (September 2nd) I will have a more thorough look at it. If it works out, I will be happy to accept the PR.

Btw, lifting Eff into the UI is already possible, though there is not yet a function for lifting. Am 29.08.2015 18:25 schrieb "Phil Freeman" notifications@github.com:

First of all, thank you for writing this library, it is really excellent.

I was experimenting with zoomOne and zoomAll, and was trying to come up with a slightly different, more uniform approach. The idea is that if we can express the UI type as s -> f s for some Applicativef`, then you get lifting over all optics "for free". I came up with this:

newtype Markup a = Markup (Array (HTML a)) instance functorMarkup :: Functor Markup where ... instance alternativeMarkup :: Alternative Markup where ... newtype UI state = UI (state -> NonEmpty Markup state)

Then the core combinators become trivial:

lens :: forall a b. LensP a b -> UI b -> UI a lens l (UI ui) = UI (l ui) traverse :: forall a b. TraversalP a b -> UI b -> UI a traverse t (UI ui) = UI (t ui)

The key is the NonEmpty applicative functor which captures something like Halogen's mergeWith combinator, combining components in parallel, keeping the latest state from each:

data NonEmpty f state = NonEmpty state (f state) instance functorNonEmpty :: (Functor f) => Functor (NonEmpty f) where map f (NonEmpty state fa) = NonEmpty (f state) (map f fa) instance applyNonEmpty :: (Alt f) => Apply (NonEmpty f) where apply (NonEmpty f fs) (NonEmpty x xs) = NonEmpty (f x) (map ($ x) fs <|> map f xs) instance applicativeNonEmpty :: (Alternative f) => Applicative (NonEmpty f) where pure a = NonEmpty a empty

I think this could generalize nicely in a few ways:

  • We can use other Applicatives instead of just Markup, including Aff, Producer, etc.
  • We can use Eff inside UI to support third-party widgets.

Right now, I think this library has the best story for component composition I've seen in a PureScript library, but it would be really great to be able to handle third-party components and things like AJAX too.

I know this is a fairly big change, but would you take a PR?

Thanks

— Reply to this email directly or view it on GitHub https://github.com/zrho/purescript-optic-ui/issues/4.

paf31 commented 9 years ago

One other small observation: if we generalize s -> f s to s -> f t, then the lens operations will still work, but we can separate the state and "action" types. Then a function s -> t -> s (is that a Setter?) can be composed on the right to get back to this original formulation.

paf31 commented 9 years ago

I spent some time on this, but ran into some issues:

I'll keep looking into whether or not this approach is feasible.

paf31 commented 9 years ago

I did a bit more work on this, but using profunctor lenses. I worked in a separate repo, because code got very different, but it might be worth having a look at anyway.

https://github.com/paf31/purescript-sym/blob/master/src/SYM.purs

paf31 commented 9 years ago

I feel like I'm getting close enough to feature parity that it would make sense to start thinking about making a PR again. I've learned the following things so far:

I still need to show that things like AJAX are possible, and demonstrate integration with 3rd party components, but it's not far off.

Also, sym uses React, since it was easier to prototype that way, but I think it would be nice to support both React and virtual-dom backends.

What do you think?

zrho commented 9 years ago

I think this is a great direction for the library to go.

The monad instance of UI, as we have it now, doesn't really seem to be particularily useful if you have views that admit a monoid instance; hence all the WriterT stuff currently. In order to allow for multiple backends, we could parameterize the UI profunctor over an arbitrary view type and give it a monoid instance if the view type has one. In addition to that, a censor- or withHTML-like function for nested elements or non-monoid views and we have recovered the view part. There is also the choice of effects or monad for integrating third-party components or AJAX. With something like

newtype UI eff v s t = UI (s -> (t -> Eff eff Unit) -> ExceptT (Eff eff Unit) (Eff eff) v)

we should be able to recover all the current functionality, with a much cleaner interface.

zrho commented 9 years ago

I uploaded my experiments at 9912b7e4c34ae4d4d1225f45c62966b0155e7429, with a bit of integration. The todo example already works, the ajax example does not due to the lack of lifecycle features currently. What do you think?

paf31 commented 9 years ago

Nice! I'm glad it fit so well together :smile:

Some comments from a quick look over the code:

My opinion is that while you've lost AJAX, this is a net improvement overall, and perhaps a pleasant API for AJAX could be constructed using Handler?

To me, the four main pain points in a UI library seem to be (after working on Halogen 0.4):

So, the first is solved (as I've said before, I think this library has the most elegant composition story that I've seen), and I think optics give a very nice solution for the second as well, but I think this puts OpticUI in a nice position to solve the remaining two.

paf31 commented 9 years ago

For AJAX, one simple approach might be a primitive like:

withCallback :: forall eff a. Handler eff a -> ((a -> Eff eff Unit) -> Eff eff Unit) -> Handler eff Unit

used like

onClick $ handler `withCallback` \k -> makeRequest "http://purescript.org" k

Edit: one more thing - I think keeping Handler abstract, and hiding the constructor, might be a good thing.

FrigoEU commented 9 years ago

The profunctor way is amazing! It took me some time to get how it works, but it's amazingly elegant.

I have some time available and I'd really like to contribute to this library. Looking at paf31's post above, I feel "integrating with 3rd part components" is a place where I could get started. From my own experience, integrating with them mostly comes down to adding some hooks that are run when initializing, finalizing, and sometimes updating components.

Do you see this as the way to go for Optic UI as well? If yes, I'll take a look around at how Halogen and Thermite implement it and take a stab. Something along these lines:

initialize :: forall eff. (HTMLElement -> Eff eff Unit) -> Props
finalize :: forall eff. (HTMLElement -> Eff eff Unit) -> Props

AJAX also seems interesting, although I don't know immediately how to start implementing that one.

paf31 commented 9 years ago

I really like the idea of parameterizing UI over some monoidal view type, which should allow us to support various backends, like React/Thermite, virtual-dom/Halogen and now possibly incremental-dom as well. The abstraction is very general and could probably stretch as far as something like a command line UI or React Native.

One other thing I really like about OpticUI is how (aside from the lens dependency), it is very low-level and unopinionated, using simple things like Eff for handlers, and not imposing any choice of abstraction on the user.

Hopefully, extracting a minimal core library would allow things like initializers and finalizers to be implemented in other libraries for suitable backends.

FrigoEU commented 9 years ago

Optic UI with Reactive Native would be totally awesome!

I thought initializers and finalizers would be easy, but it's more challenging than I thought. I have a simple implementation:

exports.onInitializedP = function (f) {
  var Hook = function () {};
  Hook.prototype.hook = function (node) {
     f(node)();
   };
  return new Hook(f);
};

exports.onFinalizedP = function (f) {
  var Hook = function () {};
  Hook.prototype.unhook = function (node) {
    f(node)();
  };
  return new Hook(f);
};

onInitialized :: forall eff. (HTMLElement -> Eff eff Unit) -> Prop
onInitialized f = prop "opticui-initialize" $ onInitializedP f

foreign import onInitializedP :: forall eff. (HTMLElement -> Eff eff Unit) -> Prop

onFinalized :: forall eff. (HTMLElement -> Eff eff Unit) -> Prop
onFinalized f = prop "opticui-finalize" $ onFinalizedP f

foreign import onFinalizedP :: forall eff. (HTMLElement -> Eff eff Unit) -> Prop

The problem with this is that you can only use this like this:

init = H.onInitialized (\e -> setClassName "init" (htmlElementToElement e))
fin = H.onFinalized (\e -> setClassName "fin" (htmlElementToElement e))
H.button [ init, fin ] (text "X")

And not inline like this:

H.button [ H.onInitialized (\e -> setClassName "init" (htmlElementToElement e)) ] (text "X")

Reason is that we're making a new function on each pass, virtual dom compares these functions with ===, decides they're not the same, and rerenders the whole node.

Halogen solves this by keeping some kind of data structure around that keeps the previous initializers/finalizers. I haven't looked at the solution there in detail though. Is that the only way to do it? Anyone has another idea?

zrho commented 9 years ago

I do not know horribly much about the inner workings of virtual-dom, but I think there is a way to bypass automatic checking for updates and use some hash code like thing that is compared instead. Maybe that would be interesting for some third-party components, especially if they manage their DOM on their own (e.g. when injecting React components). In some other cases, where you do want virtual-dom to manage the view, but on the same time want to use initializers and finalizers, that is not really an option.

With regards to storing the previous initializers and finalizers, this leads to a problem I experienced with async computations, delay components and etc.: while it is easy to store "technical" data like a reference to a handler, previous initializers, timestamps in animations etc. in the managed state of a UI component, routing that state becomes annoying quite quickly and the type signature of the component leaks the "technical" state in addition to merely the "logical" - read visible - state of the UI.

Thus it might be useful to think about options to store the technical state somewhere different, but I haven't found a way yet to do that without encountering the same kind of problems that caused me to work on optic UI instead with react, e.g. components suddenly forgetting or magically remembering state that doesn't belong to them.

And, yes, React Native support would be incredible. I would be glad to split out the virtual-dom and HTML generating parts into an extra library to leave a minimal core after we found a solution to the state problem, since there might be some interesting interactions with the backend required to solve it.

paf31 commented 9 years ago

In Halogen, something like Hide p a b = Hide exists s. p (Tuple a s) (Tuple b s) worked quite nicely, but didn't have the combinators necessary to make composition simple.

Given that Hide p is a Profunctor etc., maybe the same composition operators still apply?

Edit: I guess the issue there is that you need an initial s as well, since OpticUI's UI profunctor doesn't hold the initial state.

zrho commented 9 years ago

There is another issue as well: When we incorporate a second state that is hidden under an existential quantifier, we would need to make sure that the second state is focused the same way as the first one. For example, with two components storing their state in a tuple, we would need a tuple for the hidden state of the components and would have to focus appropriately. I do not know whether that is possible.

paf31 commented 9 years ago

Possibly, but isn't that plumbing baked into the instances for Hide?

The way I imagine using it is via a combinator:

hide :: forall a b. UI (Tuple a b) (Tuple a b) -> UI a a
zrho commented 9 years ago

There is no way for the profunctor to distinguish between _1 and _2: the only real difference between those is the functions supplied to dimap, which are opaque to the profunctor. So if I use

foo = _1 (hide bar) <> _2 (hide baz)

how will the hidden state be routed correctly?

paf31 commented 9 years ago

Oh, I see. Well, the way I imagined it, it wouldn't. Hidden state is not shared between components here. To share, you would have to use something like hide (_1 bar <> _2 baz). Unless I'm still missing something.

In any case, I don't think it's possible to implement hide since the initial state is not part of the UI.

FrigoEU commented 9 years ago

I've made a PR with my implementation of initializers & finalizers, the Halogen way. More info in PR https://github.com/zrho/purescript-optic-ui/pull/8

Please let me know if anyone has a better idea, or has comments on the ideas/coding etc!

paf31 commented 9 years ago

Regarding composition, I'm starting to see the appeal of separating "internal composition" from composition with other web components. In theory, it should be possible to make the two use the same notion of composition, but in practice, users will want to write components in different languages/paradigms (React, Halogen, etc.), and drop in an OpticUI component, using their own out-of-band communication mechanism between components (EventEmitter, Observable, Coroutine, whatever).

So, I think using optics for internal composition, and just adding the necessary hooks for external composition, is enough. As long as things are kept low-level, users can send messages to other components with Eff, or update their state based on some external message). This is the benefit of a low-level API for me, and also one of the nice results of not assuming a single global abstraction (Signal or whatever).

FrigoEU commented 9 years ago

I have been thinking about defining the Handler type a bit differently. To nicely allow Async handlers and better 3rd party integration, I think this might be an improvement:

newtype Handler eff s t = Handler ((s -> t) -> Eff eff Unit)

So instead of passing the new state to the handler, which is pretty much always derived from the state that was closed over (and can get outdated in Async/External updates), you pass a function that gets the up-to-date state and updates that into the new state. I've done some work on this, partially to get some practice in implementing these advanced type classes. I compiled my work into this gist:

https://gist.github.com/FrigoEU/131d0d36350a869da712

I'm having issues with two things however:

  1. I have no idea if the choice instance for UI is in anyway correct.
  2. I have no idea how I would go about defining forEach. I tried to read the current definition, but it's very difficult for me, and will talk me quite a bit more time to really get it. That's why I'm asking help from anyone, maybe that might speed up the process...

Apart from the implementation, conceptually I do think it might be annoying that when you're defining a handler inline, you actually have access to two states, one being the state that is being closed over, the other the state that will get passed into the handler function itself. In my opinion that is not such a big problem though, and doesn't outweigh the benefits.

zrho commented 9 years ago

I have played around a bit and found that it is possible to give UI a Wander instance if UI eff v s t components keep track of a function s -> t. I have implemented this in a gist (https://gist.github.com/zrho/14a4d9256ff5d43e97a2) first to get your opinion on it since there is a problem with it: this way UI eff v s s is not a monoid anymore.

When combining two components, each carrying a function s -> s, there are three possible choices: take the left function, take the right function or take the identity. For the unit, a function of type s -> s must be produced and by parametricity this must be id. Regardless of what choice one takes for the composition, composition with the unit is not the identity in some pathological cases such as:

mainA = animate ["a", "b"] $ traversed $ lmap (<> "!") (textField [])
mainB = animate ["a", "b"] $ traversed $ lmap (<> "!") (textField []) <> mempty
mainC = animate ["a", "b"] $ traversed $ mempty <> lmap (<> "!") (textField [])

Depending on the choice only two of these will agree. mainA always appends a ! to the text of the other text field that was edited. mainB or mainC have the desired behaviour where both text fields are independent.

On the one hand this is problematic: mainA does some entirely unexpected thing and components aren't a proper monoid anymore. Yikes! On the other hand, this problem should not occur when using proper optics rather than an orphan lmap since the state of the other components is rmaped back to its original value then. The Profunctor instance is part of the public API however (by neccessity), so maybe it is bad to rely on proper usage.

What do you think? Does the convenience of being able to use traversals directly on UI components outweigh these problems?

@FrigoEU This maybe looks a bit like what you have done with the Handler type. I haven't quite understood the possible use cases of that change though. Would you care to elaborate?

FrigoEU commented 9 years ago

Well I went 180° since my previous post :D. When I first saw your way of making async handlers possible (with the async and onResult function) I really disliked it. So I started thinking about other ways to handle the problem of closed-over data going "out of date" when doing asynchroneous handlers. After using your way for a bit though, it's actually very easy to use once you understand it. I have a small data type like this:

data AsyncModel eff a  = Initial
                       | Busy (Async eff a)
                       | Errored Error
                       | Done a

I'm using this now to describe models that get asynchroneously populated in a CRUD app I'm writing for a friend, and it's actually super elegant. I love how clear it is to describe your UI specifically for these 4 states.

Secondly, my idea of passing in a function from state -> state to the handler may solve the actual state from going out of date, but doesn't solve other variables (like indexes in fromEach) from going out of date, so I consider it a dead end pretty much.

About your new proposal: I feel losing Monoid is a heavy price to pay, plus I think traversals are really easy to use already, I'm not immediately seeing what kind of code it would allow or be able to make nicer.

Tbh as I'm writing this app with optic-ui, I have very little, if any, points that I wish to be different... and I have issues with every single UI library :D! I love how super small and non-invasive the core of the library is, and how you can make use of whatever abstractions you want. Defining lenses by hand is a bit of an annoyance though. I'm about to set up a decent gulpfile soon and will use paf31's derive lenses plugin, so we'll see how that goes.

I strongly believe in the concept of optic-ui, the only thing I worry about is not being able to make performance tunings when it's needed in the future, but I'm not even close to reaching that problem for now. Stuff like implementing shouldComponentUpdate in a future React (Native) implementation, I don't see how that's possible in this library. Not that I mind... at least not at this point.