5 releases
0.2.0 | Mar 9, 2024 |
---|---|
0.1.3 | Feb 11, 2024 |
0.1.2 | Aug 15, 2023 |
0.1.1 | Aug 9, 2023 |
0.1.0 | Aug 8, 2023 |
#258 in GUI
48KB
899 lines
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 (although it appears to be pretty fast anyway).
- You can represent a full graph, including cycles
- Data has simple, lifetime-less types (
Prim<i32>
,List<i32>
, etc) which makes it simple to pass around and store in structures - Handles graph modifications during graph processing - new nodes will be processed after their dependencies
- Animation/easing property changes
This only handles synchronous graph processing at the moment. Asynchronous events are not handled.
Status: MVP
Usage
Basic usage
- Create an
EventGraph
eg
- Call
eg.event(|pc| { })
to do setup. - Within the event context, create values with
lunk::Prim::new()
andlunk::List::new()
. Create links to process input changes withlunk::link!()
. - When user input happens, call
eg.event
and modify data values using the associated methods.
An example:
fn main() {
let eg = lunk::EventGraph::new();
let (_input_a, _input_b, output, _link) = eg.event(|pc| {
let input_a = lunk::Prim::new(pc, 0i32);
let input_b = lunk::Prim::new(pc, 1f32);
let output = lunk::Prim::new(pc, 0f32);
let _link =
lunk::link!(
(ctx = pc),
(input_a = input_a.clone(), input_b = input_b.clone()),
(output = output.clone()),
() {
output.set(ctx, input_a.get() as f32 * input_b.get() + 5.);
}
);
input_a.set(pc, 46);
return (input_a, input_b, output, _link);
});
assert_eq!(output.get(), 51.);
}
When a
is modified, b
will be updated to have the a's value plus 5.
See link!
documentation for a detailed explanation. Links can be manually (without macros) defined but there's some boilerplate.
Ownership
Values have weak references to dependent links and no references to the links that write to them. This means that there are no rc-leaking cycles in the graph structure itself.
Links must be kept alive for the duration you want them to trigger. If a link is dropped, it will stop activating during events.
Animation
To animate primitive values
-
Create an
Animator
Since you'll probably want to pass it around, making it a
Rc<RefCell<Animator>>
is a good solution. -
Call
set_ease
on a primitive instead ofset
.set_ease
requires an easing function - ezing comes with a fairly complete set of easing functions:my_prim.set_ease(&mut animator, 44.3, 0.3, ezing::linear_inout);
set_ease
should be automatically implemented for anyPrim
where the value implementsMult<f32>
andAdd
andSub
for its own type. -
Call
update()
on theAnimator
regularly (at least until all animations finish).animator.update(&eg, delta_s);
This step the animation (
delta_s
is seconds since the last update) and returns true if there are still in-progress animations.
Aside from set_ease
, you can create your own custom animations by implementing PrimAnimation
and calling animator.start(MyPrimAnimation{...})
.
Idiomatic usage on the web
This was a bit tricky to work out, but I think the idiomatic way to use this in WASM currently with request_animation_frame
(via Gloo):
let eg = lunk::EventGraph::new();
let anim = Rc::new(RefCell::new(Animator::new()));
anim.as_ref().borrow_mut().set_start_cb({
let anim = anim.clone();
let eg = eg.clone();
let running = Rc::new(RefCell::new(None));
fn one_more_frame(
running: Rc<RefCell<Option<AnimationFrame>>>,
eg: EventGraph,
anim: Rc<RefCell<Animator>>,
) {
*running.borrow_mut() = Some(request_animation_frame({
let running = running.clone();
move |delta| {
if anim.as_ref().borrow_mut().update(&eg, delta) {
one_more_frame(running, eg, anim);
} else {
*running.borrow_mut() = None;
}
}
}));
}
move || {
if running.borrow().is_some() {
return;
}
one_more_frame(running.clone(), eg.clone(), anim.clone());
}
});
Basically request_animation_frame
needs to be called recursively to start the next frame, but the return value of each one needs to be stored until it's actually called.
To do this I made a shared Option
for holding the return, which is kept alive inside the callback which is owned by anim
.
Troubleshooting
Mismatched types; expected fn pointer
If you read down, it should say something like "closures can only be coerced to fn
types if they do not capture any variables". This isn't about wrong types, it's about implicit captures.
All captures in links created with link!
need to be in one of the ()
at the start.
In VS Code I need to click the link to see the full error before it shows which value is implicitly captured.
Unreachable statement
This occurs when you have an unconditional return
in your callback. To support short ciruiting Option
returns via ?
, link!
adds a return None
to the end of your function body. If you also return, this return None
becomes unreachable, hence the alerts.
My callback isn't firing
Possible causes
-
The callback link was dropped or an input/output value captured by weak reference was dropped, or something earlier in the path to this node in the graph was dropped.
Forward references are weak, so each dependent link object needs to be kept around as long as the callback is relevant.
-
You set the value but
PartialEq
determined it was equal to the current valueIf the value is the same, no updates will occur. This is to prevent unnecessary work when lots of changes are triggered by eliminating unmodified paths, but if you implemented
PartialEq
imprecisely it can prevent legitimate events from being handled. -
You have your capture groups mixed up, and inputs are interpreted as something else (outputs, other captures).
If you have the inputs in the wrong macro KV group they won't be acknowledged as a graph connection, so changes to dependencies won't trigger the callback.
My callback is firing (leaked)
Possible causes
- The callback captures the item that owns it. For example, you did
link!
and captured an html element that thelink!
modifies. You should change the capture to a weak reference.
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:)
- There may be some randomness in the benchmark, it's a browser after all
- Some of the difference may be due to non-event-graph things, like element creation (direct
rooting
manipulation vs Sycamore's JSX-like system)
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).