Lunk is an event graph processing library.

The expected use case is user interfaces, where user events (clicks, typing) can set off cascades of other changes and computation. Specifically, WASM/web user interfaces (since it's single threaded) but this could also reasonably be used with other single-threaded UI libraries like Gtk.

Most web UI frameworks include their own event graph processing tools: Sycamore has signals, Dioxus and others have similar. This is a standalone tool, for use without a framework (i.e. with Gloo and other a la carte tools).

Compared to other tools, this library focuses on ease of use, flexibility, and composition with common language structures rather than on performance.

This only handles synchronous graph processing at the moment. Asynchronous events are not handled.

Status: MVP

Usage

Basic usage

  1. Create an EventGraph eg
  2. Call eg.event(|pc| { }). All events and program initialization should be done within eg.event. eg.event waits until the callback ends and then processes the dirty graph. Creating new links triggers event processing, which is why even initialization should be done in this context.
  3. Within the event context, create values with lunk::Prim::new() and lunk::Vec::new(). Create links with lunk::link!().
  4. Modify data values using the associated methods to trigger cascading updates. Note that any newly created links will also always be run during the event processing in the event they were created.

An example helps:

rust fn graph_stuff() { let ec = EventGraph::new(); let (_a, b, _link) = ec.event(|ctx| { let a = lunk::Prim::new(ctx, 0i32); let b = lunk::Vec::new(ctx, 0i32); let _link = lunk::link!(( ctx = ctx, output: lunk::Prim<i32> = b; a = a.weak(); ) { let a = a.upgrade()?; output.set(ctx, a.borrow().get() + 5); }); a.set(ctx, 46); return (a, b, _link); }); assert_eq!(*b.borrow().get(), 51); }

Linking things

The basic way to link things is to implement LinkCb on a struct and then instantiate the link with new_link.

There's a macro to automate this: link!, which I'd recommend. I'm not a fan of macros, but I think this macro is fairly un-surprising and saves a bit of boilerplate.

The link! macro looks kind of like a function signature, but where the arguments are separated into three segments with ;.

rust let _link = link!((ctx1 = ctx2, output: DTYPE = output1; a=a1, ...; b=b1, ...) { let a = a.upgrade()?; let b = b.upgrade()?; output.set(a.get() + b.get()); })

The first argument segment has fixed elements:

The second segment takes any number of values:

The third section again takes any number of values:

The body is a function implementation. It returns an Option<()> if you want to abort processing here, for example if a weak reference to an input is invalid. The macro adds return None to the end.

Ownership

The graph is heterogenous, with data referring to dependent links, and links referring to both input and output data.

Links store a strong reference to their output data. Depending on how you invoke the link! macro, link inputs can be kept with either strong or weak references. Data keeps weak references to dependent links.

This means that in general cycles won't lead to memory leaks, but if a link gets dropped accidentally may unexpectedly stop.

I recommend storing links and data scoped to their associated view components, so that when those components are removed the corresponding links and data values also get dropped.

Animation

To animate primitive values just create an Animator and call set_ease on the primitive instead of set. set_ease requires an easing function - the crate ezing looks complete and should be easy to use I think.

my_prim.set_ease(animator.borrow_mut(), 44.3, 0.3, ezing::linear_inout);

Then regularly call

animator.borrow_mut().update(eg, delta_s);

to step the animation (delta_s is seconds since the last update).

Any value that implements Mult Add and Sub can be eased like this.

You can create your own custom animations by implementing PrimAnimation and calling animator.start(MyPrimAnimation{...}).

Troubleshooting

My callback isn't firing

Possible causes

Why flexibility over performance

The main gains from these libraries come from helping you avoid costly work. The more flexible, the more work it'll help you avoid.

For work it doesn't avoid, it's still fast: it's written in Rust, the time spent doing graph processing is miniscule compared to FFI calls, styling, layout, rendering in a web environment.

Despite performance not being a focus, it's actually very fast! I tried integrating it into https://github.com/krausest/js-framework-benchmark and got good performance: 746ms vs 796ms for Sycamore (!)

(I don't quite believe this is faster than Sycamore:)

Design decisions

Separate links and data values

You typically pass around data so other systems can attach their own listeners. If a value is an output of a graph computation, it needs to include references to all the inputs the computation needs, which in general means you need a new type for each computation. By keeping the data separate from the link, the data types can be simple while the complexity is kept in the links.

This also supports many configurations with only a few functions/macros: value that's manually triggered not computed, a value that's computed from other values, and a computation that doesn't output a value.

Requiring the user to specify the output type in the macro

The macro lacks the ability to detect the output type where it's needed.

I could have used template magic to infer the type, but this would have made manual (macro-less) link implementations need more boilerplate, so I decided against it. The types are fairly simple so I don't think it's a huge downside.

Animations as a separate structure

In true a la carte philosophy, I figured some people might not want animations and it wasn't hard to make entirely separate.

I think bundling the animator with the processing context shouldn't be too hard.

In case there are other similar extensions, having a solution that allows external extension is important (maybe this won't happen though, then I may go ahead and integrate it).