oxide-mvu 0.3.0

A standalone MVU runtime for Rust with no_std support for embedded systems
Documentation
oxide-mvu-0.3.0 has been yanked.

oxide-mvu

CI Crates.io Documentation License Downloads

A lightweight Model-View-Update (MVU) runtime framework for Rust with no_std support.

Documentation | Crates.io | Repository | Examples

Overview

(Note that this framework is not yet ready for production, and APIs should not be considered stable)

oxide-mvu implements the MVU pattern for building predictable, testable applications in Rust. The framework is powerful enough to be a good choice for most applications, but will always evolve with support for no_std, low-memory, single-cpu, embedded systems as a baseline.

Oxide is intended to clarify the management of state in an application using clear separation of concerns between cleanly isolated view and application logic layers.

Features

  • Unidirectional data flow: State transitions occur in a single loop:
    • → event emission is triggered from an effect or user input
    • → update function receives event data and yields new state
    • → view function reduces state to renderable props
    • → renderer function receives props for display
  • Async/await support: Non-blocking event loop using async channels
  • Lock-free concurrency: Events can be emitted from any thread without mutex overhead
  • Type-safe event dispatch: Callbacks in Props maintain type safety
  • Effect system: Controlled, declarative side effects
  • no_std support: Works in embedded environments (requires alloc for heap allocation)

Installation

Add to your Cargo.toml:

[dependencies]
oxide-mvu = "0.3.0"

For no_std environments:

[dependencies]
oxide-mvu = { version = "0.3.0", features = ["no_std"] }

Usage

Define your types

use oxide_mvu::{Emitter, Effect, MvuLogic};

#[derive(Clone)]
enum Event {
    Increment,
    Decrement,
}

#[derive(Clone)]
struct Model {
    count: i32,
}

struct Props {
    count: i32,
    on_increment: Box<dyn Fn()>,
    on_decrement: Box<dyn Fn()>,
}

Implement the MvuLogic trait

struct Logic;

impl MvuLogic<Event, Model, Props> for Logic {
    fn init(&self, model: Model) -> (Model, Effect<Event>) {
        (model, Effect::none())
    }

    fn update(&self, event: Event, model: &Model) -> (Model, Effect<Event>) {
        let new_model = match event {
            Event::Increment => Model { count: model.count + 1 },
            Event::Decrement => Model { count: model.count - 1 },
        };
        (new_model, Effect::none())
    }

    fn view(&self, model: &Model, emitter: &Emitter<Event>) -> Props {
        let emitter_inc = emitter.clone();
        let emitter_dec = emitter.clone();
        Props {
            count: model.count,
            on_increment: Box::new(move || emitter_inc.emit(Event::Increment)),
            on_decrement: Box::new(move || emitter_dec.emit(Event::Decrement)),
        }
    }
}

Implement a Renderer

use oxide_mvu::Renderer;

struct MyRenderer;

impl Renderer<Props> for MyRenderer {
    fn render(&mut self, props: Props) {
        println!("Count: {}", props.count);
        // In a real app, you'd render UI here and wire up callbacks:
        // button.on_click = props.on_increment;
    }
}

Create a Spawner

The runtime needs a spawner to execute async effects. The spawner is framework-agnostic, allowing you to use any async runtime (tokio, async-std, smol, etc.). Any function or closure that implements the Spawner trait will work:

Using tokio:

let spawner = |fut| {
    tokio::spawn(fut);
};

Using async-std:

let spawner = |fut| {
    async_std::task::spawn(fut);
};

Run the application

The runtime uses async/await for non-blocking event processing. You can either await the runtime directly or spawn it:

use oxide_mvu::MvuRuntime;

#[tokio::main]
async fn main() {
    let model = Model { count: 0 };
    let logic = Logic;
    let renderer = MyRenderer;

    // Create a spawner for your chosen async runtime
    let spawner = |fut| {
        tokio::spawn(fut);
    };

    let runtime = MvuRuntime::new(model, logic, renderer, spawner);

    // Run the event loop (consumes the runtime)
    runtime.run().await;
}

Testing

oxide-mvu provides specialized testing utilities for integration testing your MVU applications.

Enabling Testing Utilities

To access the testing helpers in your project, enable the testing feature:

[dev-dependencies]
oxide-mvu = { version = "0.3.0", features = ["testing"] }

This gives you access to:

  • TestMvuRuntime - Runtime with manual event processing control
  • TestMvuDriver - Driver for manually processing events in tests
  • TestRenderer - Renderer that captures Props for assertions

Unit Testing

State transitions are pure functions, making them easy to unit test:

#[test]
fn test_increment() {
    let logic = Logic;
    let model = Model { count: 0 };
    let (new_model, _effect) = logic.update(Event::Increment, &model);
    assert_eq!(new_model.count, 1);
}

Integration Testing

Use TestMvuRuntime and TestRenderer to test the full MVU loop:

use oxide_mvu::{TestMvuRuntime, TestRenderer, create_test_spawner};

#[test]
fn test_full_mvu_loop() {
    // Create a TestRenderer to capture renders
    let renderer = TestRenderer::new();

    // Create runtime with test helpers
    let runtime = TestMvuRuntime::new(
        Model { count: 0 },
        Logic,
        renderer.clone(),
        create_test_spawner(),
    );

    // Run and get driver for manual event control
    let mut driver = runtime.run();

    // Verify initial render
    assert_eq!(renderer.count(), 1);
    renderer.with_renders(|renders| {
        assert_eq!(renders[0].count, 0);
    });

    // Trigger event via Props callback
    renderer.with_renders(|renders| {
        (renders[0].on_increment)();
    });

    // Manually process events
    driver.process_events();

    // Verify a second render occurred
    assert_eq!(renderer.count(), 2);
    renderer.with_renders(|renders| {
        assert_eq!(renders[1].count, 1);
    });
}

Key Testing Concepts

  • TestMvuRuntime: Unlike MvuRuntime, events are not automatically processed. You must call driver.process_events() to manually drive the event loop.
  • TestRenderer: Captures all rendered Props, allowing you to verify the runtime's output and trigger callbacks.
  • renderer.with_renders(): Access all captured Props for assertions or to trigger callbacks.
  • driver.process_events(): Process all queued events until the queue is empty.
  • create_test_spawner(): Creates a spawner that executes effects synchronously for deterministic testing.

See the tests directory for complete examples.

License

Licensed under the Apache License, Version 2.0 (LICENSE or http://www.apache.org/licenses/LICENSE-2.0)