sycamore icon indicating copy to clipboard operation
sycamore copied to clipboard

Typed Event Handlers

Open lukechu10 opened this issue 2 years ago • 2 comments

Event handlers should not take a base web_sys::Event but rather the appropriate type for the type of event. See Yew docs from more information on what this should look like: https://yew.rs/concepts/html/events#event-types

lukechu10 avatar Sep 14 '21 04:09 lukechu10

I need this feature, is there any preliminary design, I can do this binding.

By the way, even with Typed Event, there is still no auto-completion on my IDE: Code completion regression in web-sys

oovm avatar Dec 20 '21 03:12 oovm

So I have been thinking about this and toying around in a local branch about a solution.

NewType Events vs Event Name Trait

One decision is whether to have a describing trait to replace the event name or whether each event have their own event type and implement a SycamoreEvent trait. I think the latter after seeing the #176 issue which will require Sycamore having its own event types so this can be a baby step there. Also with a unique event type the builder API can become less verbose:

This:

input().on("keyup", |e: web_sys::Event| {
    let keyup_event: web_sys::KeyboardEvent = e.unchecked_into();
    let key = keyup_event.key();
    // do something with key
}) 

can become this:

use sycamore::web::event::KeyupEvent;

input().on(|e: KeyupEvent| {
    let key = e.key();
    // do something with key
})

If KeyupEvent implements the SycamoreEvent trait then it will also define the const EVENT_NAME: &'static str which we can use internally in the builder to get the event name without the user specifying it. Obviously if the user doesn't set the type for the event then they will have to add the event in the generics:

use sycamore::web::events::ClickEvent;

button().on::<ClickEvent, _>(|_| {})

Note: The second generic is required because of the Fn(E) + 'a generic defintion - this might be changed in the near future when rust allows for mixing explicit generics and impl trait defintions.

Now whether this is better than just setting |_: ClickEvent| is up to user preference, but either way we have saved them from typing the event name in a string.

In terms of the view macro because the on: directive is always used we can set the generic event type for the user using the alias, click for ClickEvent etc, this means that users can use inline anonomous closures in the view without having to set the event type in the closure.

The SycamoreEvent trait could be defined as such:

pub trait SycamoreEvent {
    const EVENT_NAME: &'static str;
}

Oh so simple... but not for long 😈 once we get through allowing Custom Events we need to become more paranoid 😞.

Custom Events

Currently Sycamore allows any Ident to be used after the on: directive and then converts that to a string to register the event handler which takes a G::EventType. This does not support all custom events only those with one word names so an event like MDCSnackbar:closing will not be parsed by the view macro.

If we relied on the fact that each event type was just a Newtype around an existing event, such as web_sys::Event, then we could easily add this without much change to the DomNode code and allow transmute to dissolve away the NewType which is sound to do. The problem is that with #176, Sycamore is likely going to want to keep some local fields in new events and for custom events we can't guarantee whether the actual type might be a Newtype so transmuting custom events become dangerous at runtime. This means that new SycamoreEvents must be created from the GenericNode::EventType which might lead to something like this:

pub trait SycamoreEvent {
    type BaseEvent;
    const EVENT_NAME: &'static str;
    
    fn from_base_event(e: Self::BaseEvent) -> Self;
}

This ties the SycamoreEvent implementation to a specific base event type so for the web this is web_sys::Event and this allows custom events to be constructed safely from the raw event (or as safely as the user chooses).

For web_sys events this is pretty easy to implement:

use std::ops::Deref;
use sycamore::generic_node::SycamoreEvent;
use wasm_bindgen::JsCast;

struct ClickEvent(web_sys::PointerEvent);

// need deref so we can access the underlying pointer event
impl Deref for ClickEvent {
    type Target = web_sys::PointerEvent;
    
    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

impl SycamoreEvent for ClickEvent {
    type BaseEvent = web_sys::Event;
    
    const EVENT_NAME: &'static str = "click";
    
    fn from_base_event(base_event: web_sys::Event) -> Self {
        Self(base_event.unchecked_into())
    }
}

So to see the above trait in action, the DomNode::event function might be implemented like:

fn event<'a, E, F>(&self, cx: Scope<'a>, mut handler: F)
where
    E: SycamoreEvent<BaseEvent = Self::EventType>,
    F: FnMut(E) + 'a,
{
    let boxed: Box<dyn FnMut(web_sys::Event)> =
        Box::new(move |e| handler(E::from_base_event(e)));
    // safety message as before
    let handler: Box<dyn FnMut(web_sys::Event) + 'static> =
        unsafe { std::mem::transmute(boxed) };
    let closure = create_ref(cx, Closure::wrap(handler));
    self.node
        .add_event_listener_with_callback(
            intern(E::EVENT_NAME),
            closure.as_ref().unchecked_ref(),
        )
        .unwrap_throw();   
}

current version of impl here for convenience: https://github.com/sycamore-rs/sycamore/blob/ea676489715c02c8e9cd57f41ed520b9691f124d/packages/sycamore-web/src/dom_node.rs#L326-L336

view Macro Support

The problem with typed events for the view macro is recognising the built in events and custom events well as the on: directive will now be parsing a path or ident associated with a type that implements SycamoreEvent. I can think of two ways to support this in the view macro without substantially changing the syntax (like adding a new custom directive or something like that which I'm not a fan of).

Parse Qualified Paths

One solution is that the on: directive should parse a Path instead of an Ident, it has to be a Path because (1) we probably don't want to litter the prelude namespace anymore; and (2) the input event conflicts with the input element. Now this might not be such an issue if we used the Rust type names like on:ClickEvent= but I'm not sure this will go down well.

So if Sycamore included an events module then it might look like this:

view! { cx,
    button(on:events::click=|_| {}) { "Click Me" }
}

More verbose... obviously users can decide to use sycamore::web::events::click then just use click as before but we can't just dump all of it into prelude.

This gives some auto-completion because it is just a type and the wrong type will cause compilation errors it is also easy to see how a custom event could be used:

struct MyCustomEvent(web_sys::CustomEvent);
#[allow(non_camel_case_types] // I think this is the right name :)
type custom = MyCustomEvent;

impl SycamoreEvent for MyCustomEvent {
    const EVENT_NAME: &'static str = "my:custom";
    //other impl
}

view! { cx,
    div(on:custom=|_| {})
    // can just use the Rust type name ofc 
    div(on:MyCustomEvent=|_| {})
}

I'm in two minds here.. the more verbose nature of paths is annoying and adds more typing but it far less magical and mirrors the state of how these types are used in the builder API.

List of known event names in Macro

This was how yew parsed event listeners, I'm not sure if they still do this tbh.

Another alternative to just accepting qualified paths is for the view macro to keep a list of known event names, similar to boolean attributes, and if the name is known then in the macro we append the qualifying path to the builtin definition otherwise we assume it is locally available and use the ident as is.

There is no auto-complete with this option because the view macro wouldn't know it was a builtin event until the name matched completely, of course it will still cause a compilation error if you typo so on:clik will be cause an error.

This does lead to a problem where the crate defining the events needs to stay in sync with the view macro and vice versa which from experience with yew is a pain. This would lend itself to needing a config file with all the events and using build scripts to create the list for view macro and another for creating the definitions for the events.

I'm not sure how much of a problem this really is but one thing with this approach is that users cannot override the default behaviour of builtin event names so even if they define their own ClickEvent with an alias of click this will still use Sycamores definition; however, they can use on:ClickEvent which will find their local definition so it just limits the list of names defined in the macro.

Conclusion

Sorry the comment is so long 😞, I hope it all makes sense! I do have some of this implemented in a local branch but there are quite a few decisions here so seemed better to just layout some of the ideas here and get some feedback and better yet if someone comes up with a better idea from this :)

mc1098 avatar Jun 26 '22 21:06 mc1098