Skip to content

Latest commit

 

History

History
243 lines (188 loc) · 8.75 KB

File metadata and controls

243 lines (188 loc) · 8.75 KB

3.6. Getting Started with VMx — Rust

This tutorial walks you through building viewmodels with the VMx Rust crate. You will build a ComponentVm<Model>, a RelayCommand, and a CompositeVm<T> with child selection — all in a plain Cargo binary.

The Rust flavor is a source-tree flavor at the v0.29.0 source line: it declares MIN_SPEC_VERSION = "3.23.0" and carries behavioral tests for all 403 library conformance IDs. The vmx-rs crate is not yet published to crates.io; consume it as a path or git dependency (below). See the Rust flavor page for current status and convergence evidence.

For the contracts behind each type, see the component family, command families, and composite family.


3.6.1. Install

The crate is named vmx-rs and exposes the module namespace vmx. It requires Rust edition 2021 and a 1.88 toolchain floor. Because it is not yet on crates.io, depend on it by path from a checkout:

[dependencies]
vmx-rs = { path = "langs/rust" }

Or by git:

[dependencies]
vmx-rs = { git = "https://github.com/thekaveh/VMx.git" }

The crate declares only serde and thiserror as runtime dependencies; the reactive primitives are VMx-owned hot-stream facades, so no third-party reactive runtime is pulled in.

3.6.2. Wire up MessageHub and a Dispatcher

Every viewmodel needs two services: a hub that carries messages between viewmodels and a dispatcher that routes scheduled work. NullDispatcher runs foreground and background work inline on the calling thread — the right choice for tests and synchronous programs. It is Copy, so it can be handed to several viewmodels without cloning.

DefaultDispatcher owns dedicated serial foreground and background workers. ManualDispatcher provides separate foreground and background queues for deterministic tests. NullDispatcher and ImmediateDispatcher deliberately run both channels inline.

use vmx::{MessageHub, NullDispatcher};

fn main() {
    let hub = MessageHub::new();
    let dispatcher = NullDispatcher::new();
    assert!(hub.history().is_empty());
    let _ = dispatcher;
}

Enable the paired-channel lifecycle path with ComponentVm::builder().background(true) (or the read-only component builder). Terminal state and publication are committed on foreground. Subscribe to the hot background_errors() stream for hook failures after the fire-and-forget call returns; the stream completes on disposal.

3.6.3. Build a ComponentVm<Model>

ComponentVm<M> is the primary leaf viewmodel. It holds a typed model, publishes a property message on the hub when the model changes, and participates in the lifecycle state machine. with_model constructs one directly; the model type must be Clone + PartialEq.

use vmx::{ComponentVm, MessageHub, NullDispatcher, VmxResult};

#[derive(Clone, PartialEq)]
struct UserModel {
    name: String,
    email: String,
}

fn main() -> VmxResult<()> {
    let hub = MessageHub::new();
    let dispatcher = NullDispatcher::new();

    let user = ComponentVm::with_model(
        "user-card",
        UserModel { name: "Alice".into(), email: "alice@example.com".into() },
        hub.clone(),
        dispatcher,
    )
    .with_model_hint(|model| Some(model.name.clone()));

    user.construct()?;
    assert!(user.is_constructed());

    user.set_model(UserModel { name: "Alice Smith".into(), email: "asmith@example.com".into() });
    assert_eq!(user.model().name, "Alice Smith");
    assert_eq!(user.modeled_hint(), Some("Alice Smith".to_string()));
    user.dispose()
}

See the component family for the full contract.

3.6.4. Build a RelayCommand

RelayCommand wraps an action closure, an optional can_execute predicate, and zero or more trigger hubs that announce when eligibility may have changed. Use RelayCommand::new for the action-only form, or the builder to add a predicate.

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use vmx::{Command, RelayCommand};

fn main() {
    let is_dirty = Arc::new(AtomicBool::new(false));
    let dirty_for_predicate = is_dirty.clone();

    let save = RelayCommand::builder()
        .action(|| println!("Saving..."))
        .can_execute(move || dirty_for_predicate.load(Ordering::Acquire))
        .build();

    assert!(!save.can_execute());
    is_dirty.store(true, Ordering::Release);
    assert!(save.can_execute());
    save.execute(); // prints "Saving..."

    save.dispose();
}

See command families for the full contract. Command is the object-safe trait exposing can_execute() and execute().

3.6.5. Build a CompositeVm<T> with selection

CompositeVm<T> owns an ordered child collection and a current selection slot. Children come from a factory evaluated on the first construct(); an optional current selector seeds the initial selection.

use vmx::{
    ComponentVm, CompositeVm, FilteredCompositeVm, MessageHub, NullDispatcher, VmxResult,
};

fn build_tabs() -> VmxResult<()> {
    let hub = MessageHub::new();
    let dispatcher = NullDispatcher::new();

    let home = ComponentVm::with_model("home-tab", "Home", hub.clone(), dispatcher);
    let settings = ComponentVm::with_model("settings-tab", "Settings", hub.clone(), dispatcher);

    let tabs = CompositeVm::builder()
        .name("tab-bar")
        .services(hub, dispatcher)
        .children({
            let children = vec![home.clone(), settings.clone()];
            move || children.clone()
        })
        .current(|items| items.first().cloned())
        .build()?;

    tabs.construct()?;
    assert_eq!(tabs.len(), 2);
    assert_eq!(tabs.current().map(|c| c.name()), Some("home-tab".to_string()));

    tabs.select_component(&settings)?;
    assert_eq!(tabs.current().map(|c| c.name()), Some("settings-tab".to_string()));

    let matches = FilteredCompositeVm::new(tabs.clone(), |tab| tab.model().contains("Home"));
    assert_eq!(matches.visible_count(), 1);

    tabs.dispose()
}

fn main() -> VmxResult<()> {
    build_tabs()
}

The example also projects a live filtered view with FilteredCompositeVm. See the composite family for the full contract.

3.6.6. Lifecycle and cleanup

Every viewmodel follows a five-state lifecycle — Destructed -> Constructing -> Constructed -> Destructing -> Destructed — plus the terminal Disposed. The mutating transitions return VmxResult<()>: an illegal transition (for example constructing a disposed viewmodel) is a catchable Err, not a panic, under the v3 lifecycle convergence (ADR-0053).

use vmx::{ComponentVm, ConstructionStatus, VmxResult};

fn main() -> VmxResult<()> {
    let user = ComponentVm::new("user");
    user.construct()?;
    assert_eq!(user.status(), ConstructionStatus::Constructed);

    user.reconstruct()?; // destruct + construct in one call; round-trips to Constructed
    assert_eq!(user.status(), ConstructionStatus::Constructed);

    user.destruct()?;
    assert_eq!(user.status(), ConstructionStatus::Destructed);

    user.dispose()?; // idempotent and terminal
    assert_eq!(user.status(), ConstructionStatus::Disposed);
    Ok(())
}

Selecting a non-child returns Err(VmxError::NonChild) rather than trapping, so callers can branch on the result. Builders return Err from build() when a required field is missing.

See Lifecycle & Messaging for the lifecycle contract (LIFE-001..015), including transition and disposal coordination.

3.6.7. Where to go next

Resource Documentation page
Specification status Specification & Conformance
Lifecycle contract Lifecycle & Messaging
Commands Command Families
Component contract Component Family
Composite contract Composite Family
Architecture Architecture Map
Rust status Rust Flavor
Rust examples Rust TUI Notes Showcase