I’m working on a tool, scriptable in Roto, where I wish to provide a function to the runtime, lets call it score.update():
mod score {
fn update(category: String, tag: String, score: f32) -> f32;
}
I also want to allow the Roto script to have a score_update function, with the same prototype. The idea is that if score_update exists, then score.update() would call that, otherwise it’d use a default implementation.
The approach I ended up with involves compiling the Roto code twice. I made a type like this:
pub type ScoreUpdateCallback =
TypedFunc<roto::Ctx<Ctx>, fn(RotoString, RotoString, f32) -> f32>;
#[derive(Debug, Clone)]
pub struct ScoreUpdate {
pub ctx: Ctx,
pub func: Option<ScoreUpdateCallback>,
}
// impl Default for ScoreUpdate { ... }
// impl PartialEq for ScoreUpdate { ... }
impl ScoreUpdate {
pub fn new(func: Option<ScoreUpdateCallback>) -> Self {
Self {
ctx: Ctx::new(),
func,
}
}
pub fn library() -> Library {
library! {
#[clone] type ScoreUpdate = Val<ScoreUpdate>;
impl Val<ScoreUpdate> {
fn update(
mut self,
category: RotoString,
tag: RotoString,
score: f32,
) -> f32 {
self
.0
.func
.as_ref()
.map_or(
score,
|func| func.call(&mut self.0.ctx, category, tag, score)
)
}
}
}
}
}
Then, when creating the first Runtime, I do:
let lib = library! {
include!(ScoreUpdate::library());
const score: Val<ScoreUpdate> = Val(ScoreUpdate::default());
};
Then compile the script, and pick out the score_update function, and shove it into a ScoreUpdate. Then, I with the second runtime, I can create a similar library, but this time, using the extracted function:
let lib = library! {
include!(ScoreUpdate::library());
const score: Val<ScoreUpdate> = Val(score_update);
};
This gets the job done, but has two side effects: score.update() runs with its own context, which is no biggie, it shouldn’t need one. The only reason it has a context in the first place is to let the same script compile for the first pass. The second side effect is the double compilation itself, which also means that any const my script creates, will be created twice. Not a big deal in this case, but a bit wasteful.
So, while this whole thing works, it feels like there should be a better way to achieve the same thing, to provide a default implementation, which the Roto side can override. That, or to have way to create a Rust function that can call a Roto function if that function exists, and provide a default behaviour if it does not.
First class functions would solve this, and I wouldn’t need to compile twice. If I could implement a function on the Rust side that can refer to the runtime context, that’d also work, because then I could extract the function if it exists, and pass the TypedFunc to the context. For example, if I could do something like:
let lib = library! {
mod score {
fn update<Ctx>(mut ctx: Ctx, score: f32) -> f32 {
if let Some(ref func) = ctx.score_update {
func.call(&mut ctx, score)
} else {
score
}
}
}
};
…that’d be huge. Or, well, any other way I could access the context on the Rust side, that’d be neat.
I also considered using channels, but that made my head hurt, and it would have been even messier than the double compilation I ended up with. The same problem could also be solved by requiring the script to declare a const mapping table, and then score.update() would be implementable entirely on the Roto side. I could also make it require the mapping table as an optional param (Option[Map]), but… a declarative mapping table is slightly less powerful. I can’t do score * 2.0 - I can only override. Or encode the operation in the mapping table too, and… lets not go there.
…and that’s about all the ideas I had in my head about this topic. Apologies for the wall of text, and the somewhat rambly post.