-
Notifications
You must be signed in to change notification settings - Fork 29
Add new, cleaner Trigger syntax #177
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
Conversation
This will ensure we don't accidentally change the API surface by changing the functions' implementations in a way that causes TypeScript to infer a different return type.
I also tried enabling the no-unnecessary-condition rule but there are a bunch of false positives relating to the "yield until something sets this variable to false" pattern.
These don't exactly match which properties are and are not underscored-- that's gonna have to be a future project
No integration yet but the basic TriggerCreator type is created and used here, supporting deprecated GREEN_FLAG etc format.
trigger: Trigger["trigger"], | ||
options: Trigger["options"] | undefined, | ||
target: Sprite | Stage | ||
// TODO: Rework to not accept a symbol. Just compare to TriggerCreator. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if we should split the implementation -- one matches()
method that accepts a symbol and we can mark the entire method as deprecated (keep the same method name so as to not break old projects), and then make a new matchesCreator()
method that does not have the deprecation warning. That way we can smoothly warn old projects about how to upgrade, and provides a path for smooth migration?
Not super confident about this suggestion, so please take it with a grain of salt. :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Woe betide the interfaces of yore :)
I think that's a good idea in general, but in our case it's OK to not stress too much about providing a new name matchesCreator
. In web specifications it's quite crucial to not change the behavior of an existing name, because you never know how outdated a web browser will be consuming your site/code; old clients expect functions to behave a certain way and will error if it doesn't. New names also provide a convenient way to ostensibly detect support (just check if 'doABarrelRoll' in window
!). But those aren't concerns here because semantic versioning inherently declares that a version 2.0.0 update does change or remove existing interfaces, in which case updating the dependency (leopard
) isn't to be done lightly.
What we can do is warn, here, if a symbol is provided. This should only happen if a user is using outdated sb-edit, is unfamiliar with the new pattern, or is working on an existing project. The latter is the most likely case we want to help smoothen migration for. And we don't need to introduce a new name to detect and inform about the new syntax — checking for a passed symbol
would be enough to trigger the notice only when appropriate.
Self-assigning for symbol deprecation notice, I'm going to only give notice when the triggers are created (i.e. sprite/stage is constructed) so we don't constantly spam the console during project interpretation. That's detectable too (trigger constructor already differentiates symbol/function) and should cover everything except custom uses of |
I made these changes yesterday but forgot to push.
I think there might be a way to better integrate this with TypeScript--I'll have a go at a prototype implementation soon |
I've been putting this off for a while because the new syntax still doesn't feel right to me, and I think I'm slowly working towards why? Fundamentally, triggers are like the JS concept of event listeners (putting aside edge-activated triggers for a moment)--when a certain event occurs, like the user clicking the green flag or pressing a certain key, the sprite wants to call its own function in response to that. In JS-land, there are two main ways to add an event listener. The first is using
This lets you add as many event listeners to one target as you want. Notice how the event name is passed in as the first argument--this is somewhat similar to the current The second way is using event handler properties, which only let you attach one listener per event type, as setting the property will overwrite the previous listener:
Notice how both these examples have the same word order: target, event, handler. This is probably something that Leopard should should attempt to do as well. Right now (before this PR), triggers look like this:
This has the event and the handler, but not the target. In this case, the target is the project. If we translated this into DOM API language, using the same "button click" example from earlier, this would look something like:
This is similar to the My concern is that this PR's syntax would align even less with event listener syntax. Here's what it would look like with the "button clicked" example:
This seems a bit backwards when you look at it from the perspective of event listeners. You'd need to hold all event types as static properties on a single class, which discards information about which types of objects can fire which events. This might be fine for Leopard since all events are fired from the project, but would break the principle of encapsulation for most other APIs--one class's "click" event might mean something very different from another class's "click" event, and the two could have very different signatures. Ideally, the existing triggers would be made into event listeners on I don't really have a good answer as to what the best syntax for triggers is, but I do worry that this isn't it. I'd be interested in hearing others' thoughts. |
Brainstorming here... How would you feel about some kind of "addEventListener" syntax? It would be a little magical because it would accept generator functions as input (rather than a plain callback), but otherwise could be pretty similar to the DOM apis. class MySprite extends Sprite {
constructor() {
// ...
this.addEventListener("click", this.whenThisSpriteClicked);
this.stage.addEventListener("costume", this.whenBackdropSwitches, { name: "backdrop2" });
const listener = this.addEventListener("broadcast", this.whenIReceive, { message: "message1" });
// Maybe also allow something like this...
this.removeEventListener("broadcast", this.whenIReceive, { message: "message1" });
// ...or this...
this.removeEventListener(listener);
}
} This would also allow attaching and removing listeners while the project is running in a more JS-y way. |
Thanks for your remarks, both! @adroitwhiz, I agree. Triggers are at their core an acceptably JavaScript-y concept — defining handler functions which get called later, potentially multiple times, according to certain events/conditions — but the way we use them in Leopard is currently at best an approximation of standard JS form, and this PR goes further away still. I agree that's something to address!
Something to note is that JavaScript is... kind of awkward here? Because event listeners are a long-standing system which predate the more extensible patterns found in classes and web standards introduced post-ES6, they're incapsulated insofar as each class (usually In other words, there's a benefit to modelling based on JavaScript's system (in that we teach new, but important, and soon familiar syntax), but we also should be conscious of how we implement it so that we're teaching and exposing useful capabilities to advanced users while not bogging new users down in minute semantics. I don't think anyone was proposing to just pull JS's event listener system identically anyway — just putting the note out there of why we might not want to!
I'd like to think more about Leopard events individually, but in general yes, I think there's a benefit to exposing
@PullJosh Like I mentioned above, I think addEventListener is a syntax worth using — it's certainly more commonplace than the other standard JS way, /* A */ this.addEventListener("click", this.whenThisSpriteClicked);
/* B */ this.stage.addEventListener("costume", this.whenBackdropSwitches, { name: "backdrop2" });
/* C */ const listener = this.addEventListener("broadcast", this.whenIReceive, { message: "message1" });
/* D */ this.removeEventListener("broadcast", this.whenIReceive, { message: "message1" });
/* E */ this.removeEventListener(listener); ...Fair warning: I'm going to use your example as a starting point for discussing the syntax specifics, which means digging into this code a lot 📦 While I mentioned that JavaScript has a lot of nuanced complexities for event handling that we may not want to reflect fully here, I do think that if we're going to implement a version of In JavaScript, this.addEventListener("click", this.whenThisSpriteClicked);
this.removeEventListener("click", this.whenThisSpriteClicked); There is no other syntax for removing event listeners. There are also only two (related) ways to add an event listener: name then function, or name then function then options. This is basically reflected in your lines B and C above... but not really, because the scope for the options argument is completely different. In JavaScript, the options argument covers I believe that's an arbitrary code pattern which we would do more inconvenience than good to teach new JavaScript learners. It's just not reflective of the standard. This goes for the Of course, the trouble is that it's nice, and the alternative just isn't going to be as nice. For example, consider the "costume" event for "when costume switches to..." / "when backdrop switches to...", your line B. In JavaScript the way to implement it is with an early return (or other conditional clause) in the handler function: constructor() {
...
// If something else about this code seems wrong, I'll get to it shortly 👻
this.stage.addEventListener("costume", this.whenBackdropSwitches);
}
whenBackdropSwitches() {
if (this.stage.costume !== "backdrop2") {
return;
}
// do something
} I think there are node libraries which implement their own custom filtering behavior, but it's non-standard. The only filter option provided in standard JavaScript is the event name itself. All other filtering must be done by the listener function. Another awkward note, particularly when adding a listener to another object, is the definition of class Foo {
getThis() {
return {myself: this};
}
getGetThis() {
return this.getThis;
}
}
foo = new Foo();
foo.getThis(); // myself === foo
foo.getGetThis()(); // myself === undefined It's technically fine for events assigned to the self (e.g. this.stage.addEventListener("click", () => this.whenStageClicked);
this.stage.addEventListener("click", this.whenStageClicked.bind(this)); And now you run into the hilarious issue of function identity for removal, because... class Foo {
excitability = 10;
handlePartyInvitation() { console.log("Yay" + "!".repeat(this.excitability)); }
}
foo = new Foo();
bar = new EventTarget();
bar.addEventListener("invite", foo.handlePartyInvitation.bind(foo));
bar.dispatchEvent(new Event("invite")); // Yay!!!!!!!!!!
bar.removeEventListener("invite", foo.handlePartyInvitation.bind(foo));
bar.dispatchEvent(new Event("invite")); // Yay!!!!!!!!!! This obviously goes for arrow functions as well, where I don't know if there is a standard way to handle this. It's absolutely one of the messinesses of JS's event handling system (and/or its I generally do this: class Foo {
constructor() {
this.bindEventHandlers();
this.addEventListeners();
}
teardown() {
// Called e.g. when a clone is deleted
this.removeEventListeners();
}
bindEventHandlers() {
// Get ready for boilerplate...
this.handlePartyInvitation = this.handlePartyInvitation.bind(this);
this.handleStageClicked = this.handleStageClicked.bind(this); // etc
}
addEventListeners() {
// This function MUST be called AFTER bindEventHandlers.
something.addEventListener('invite', this.handlePartyInvitation);
stage.addEventListener('click', this.handleStageClicked);
}
removeEventListeners() {
// This references the same bound functions defined before being added as listeners,
// so the functions are identical and are removed correctly here.
something.removeEventListener('invite', this.handlePartyInvitation);
stage.removeEventListener('click', this.handleStageClicked);
}
} The current system cheats by understanding who Something to note is that some form of a teardown function would be strictly required because if you can add event handlers to other objects, they're no longer encapsulated on the one object, so when that object gets disabled (i.e. a clone is deleted), there is no way to trace listeners it created at any point in its lifetime back to the object (and then remove those listeners). A general-purpose workaround would be non-standard (such as a custom Unfortunately, all this adds a fair amount of overhead via boilerplate to JavaScript code — in all but the most simple use cases (self-listeners avoid the issue... but sprites adding listening for events on the stage or project already brings it up, you don't even need complex behavior like dynamically adding and removing listeners). I don't believe it's possible to avoid the majority of it due to the way JavaScript fundamentally behaves (this kind of boilerplate necessarily plagues any library built on native EventTargets), but am interested in alternate perspectives on this fact of the matter. Is this boilerplate OK? Since this is just how JavaScript is, do we really want to shroud it or look far away? Or is it all too much for new users working with their generated Leopard code, introducing an unfortunately involved system of "event handlers" too quickly and bringing in excruciatingly awkward nuances like |
@towerofnix Thank you for all the thoughts. There is definitely room for disagreement here, but I am leaning towards feeling like we would be better off trying to hide the ugly To that end... We could automatically bind all of a target's methods automatically as soon as it is created. (Essentially performing nix's The following code makes this auto-binding completely transparent to the user: // Within the Leopard source code
class Target {
constructor() {
// ...
this.autoBindAllMethods();
}
getAllMethods() {
return Object.getOwnPropertyNames(this)
.filter(property => typeof this[property] === "function");
}
autoBindAllMethods() {
const methods = this.constructor.prototype.getAllMethods();
for (const name of methods) {
this[name] = this[name].bind(this);
}
}
}
// Now this works transparently for Leopard users
class MySprite extends Sprite {
constructor() {
super();
this.stage.addEventListener("click", this.whenStageClicked);
}
whenStageClicked() {
console.log("I am", this); // this === instance of MySprite
}
} I have a complete, working proof of concept here. I am not completely convinced that this is the right move, but I'm also not necessarily convinced that it isn't. 😜 With the auto-binding done, I'm pretty sure the following works as expected in all cases, right? this.stage.addEventListener("click", this.whenStageClicked);
this.stage.removeEventListener("click", this.whenStageClicked); |
@PullJosh Yes, auto-binding gets most of the way there! Thanks for the discussion. Another hurdle to consider is removing events from other objects when a clone is deleted. We can implement "magic" behavior to handle this, in a couple of different ways:
@adroitwhiz This definitely isn't something we've made a final decision on! But it seems like if we go with If this is a syntax direction you're interested in exploring, I'd be interested to hear your thoughts on the implementation or specifics of that "magic" — you're generally more experienced with data structures and the ways objects get tied to each other than me so I'd like to hear your thoughts, so we can have code that is both practically performant and not so complex as to befuddle us or anyone working on it in the future lol. |
Development
aTrigger.match(TriggerCreator)
andTrigger.match(TriggerCreator, TriggerCreator)
are internals-agnostic.adroitwhiz/typescript
)aTrigger.trigger
asTriggerCreator
(function
) instead ofsymbol
(which has.message
). The existing methodaTrigger.matches(triggerCreator)
and new methodTrigger.matches(triggerCreator1, triggerCreator2)
are compatible with both new and old syntax. Users can update their deprecated syntax at any time before its support is removed and won't need to make any further changes then.Description
Example new output:
Testing
Tested manually with this project: https://scratch.mit.edu/projects/817687184/