oxide-mvu 0.2.0

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

oxide-mvu

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

Overview

(Note that this framework is not yet ready for production, and APIs should not be considered stable. Many of the current interfaces are an assault on the eyes as of v0.2.0)

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
  • 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.2.0"

For no_std environments:

[dependencies]
oxide-mvu = { version = "0.2.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 function to execute async effects. The spawner is framework-agnostic, allowing you to use any async runtime (tokio, async-std, smol, etc.):

Using tokio:

use oxide_mvu::Spawner;

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

Using async-std:

use oxide_mvu::Spawner;

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

Run the application

use oxide_mvu::{MvuRuntime, Spawner};

fn main() {
    let model = Model { count: 0 };
    let logic = Box::new(Logic);
    let renderer = Box::new(MyRenderer);

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

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

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.2.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, TestMvuDriver, 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 },
        Box::new(Logic),
        renderer.boxed(),
        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 updated render
    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::boxed(): Returns a boxed renderer while keeping a handle for assertions.
  • 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)