oxide-mvu 0.4.0

A standalone MVU runtime for Rust with no_std support for embedded systems
Documentation
oxide-mvu-0.4.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.4.0"

For no_std environments:

[dependencies]
oxide-mvu = { version = "0.4.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.try_emit(Event::Increment); }),
            on_decrement: Box::new(move || { emitter_dec.try_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::builder(model, logic, renderer, spawner).build();

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

Configuring Event Buffer Capacity

The runtime uses a bounded channel to queue events. The default capacity (DEFAULT_EVENT_CAPACITY = 32) is sized for embedded systems with limited heap. You can customize this via the builder:

use oxide_mvu::MvuRuntime;

// For memory-constrained embedded systems
let runtime = MvuRuntime::builder(model, logic, renderer, spawner)
    .capacity(8)
    .build();

// For high-throughput applications with event bursts
let runtime = MvuRuntime::builder(model, logic, renderer, spawner)
    .capacity(1024)
    .build();

When the buffer is full, Emitter::try_emit() returns false and the event is dropped. Use Emitter::emit().await if you need backpressure (waits until space is available).

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.4.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::builder(
        Model { count: 0 },
        Logic,
        renderer.clone(),
        create_test_spawner(),
    ).build();

    // 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);
    });
}

See the tests directory for more examples.

License

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