Introduction

Maycoon is a framework for creating interactive user interfaces using Rust.

It is built on top of winit and optionally vello for window management and rendering.

This makes it compatible with many platforms and easy to use with different graphics backends.

The framework also includes a reactive signal system for managing internal application state, as well as a pluggable task runner interface.

Quick Start

Installation

If you haven't already, you can install the Rust Programming Language using rustup.

To create a new cargo project, run cargo new --bin <project-name>.

After creating the project, you can cd into the folder and then run cargo add maycoon to add maycoon as a dependency.

Feature Gates

Maycoon has feature gates that toggle special mechanics. You can configure them via the features key in the dependency descriptor:

[package]
name = "my_project"
description = "My Awesome App"
# other stuff...

[dependencies]
# '*' uses any compatible version (latest)
maycoon = { version = "*", features = ["canvas"] }

Following maycoon features are available:

  • vello-vg - Enables the vello rendering backend (enabled by default).
  • macros - Enables useful macros for easier development (enabled by default).
  • canvas - Enables the Canvas widget.
  • dummy-runner - Enables the dummy task runner.
  • tokio-runner - Enables the tokio task runner (enabled by default).
  • svg - Enables drawing Scaled-Vector-Graphics and the Icon widget.

NOTE: Maycoon requires that at least one -runner feature is enabled (e.g. tokio-runner).


Congratulations! You can now proceed to your first app.

A Basic Counter App

The easiest way to learn about Maycoon is to start studying some examples. In this Tutorial we will find out how to create a basic counter App with increase and decrease buttons, as well as a text widget to display the counter.

Setup

For creating a new project, see the Installation Guide.

The Struct

Every Maycoon App starts with an implementation of Application. We will create an empty struct to represent our app.

struct MyApp;

impl Application for MyApp {
    // implementation goes here...
}

The Types

Your application requires some fundamental types to be defined.

// ...

impl Application for MyApp {
    type Theme = CelesteTheme;
    type Graphics = DefaultGraphics;
    type State = ();
}

The Theme type specifies the Theme we want to use. A theme object is responsible for styling the whole UI and must be specified at compile time.

The Graphics type specifies which graphics backend to use. We'll stick with the default one for now.

The State type allows us to specify a global application state. You could use an integer as state to represent the counter, but we will handle the counter function later in another way, so we'll just use an empty tuple.

The UI

Since our application is supposed to do something, we need to add some UI logic.

impl Application for MyApp {
    // ...

    fn build(context: AppContext, _: Self::State) -> impl Widget {
        Container::new(vec![
            Box::new(Button::new(Text::new("Increase".to_string()))),
            Box::new(Button::new(Text::new("Decrease".to_string()))),
            Box::new(Text::new(/* counter? */)),
        ]).with_layout_style(LayoutStyle {
            size: Vector2::<Dimension>::new(Dimension::percent(1.0), Dimension::percent(1.0)),
            flex_direction: FlexDirection::Column,
            align_items: Some(AlignItems::Center),
            ..Default::default()
        })
    }
}

You can see, that we implemented a method called build that returns a Widget. This is our entry point to the actual application lifecycle. We want three widgets:

  • An increase button
  • A decrease button
  • A text widget to display the counter

We use a Container to contain all of these widgets. You can think of this Container as a <div> in HTML.

Inside the Container, we define a vector of boxed widgets:

  • A Button widget, containing a Text widget which displays the text "Increase".
  • A Button widget, containing a Text widget which displays the text "Decrease".
  • A Text widget to display the counter.

We also define a LayoutStyle for the Container to make it fill the whole screen and center its children.

The logic

So far, our UI doesn't contain any actual logic. To fix this, we add a signal to our build method:

// ...
fn build(context: AppContext, _: Self::State) -> impl Widget {
    let counter = context.use_signal(StateSignal::new(0));

    Container::new(vec![
        {
            let counter = counter.clone();

            Box::new(
                Button::new(Text::new("Increase".to_string())).with_on_pressed(
                    EvalSignal::new(move || {
                        counter.mutate(|c| *c += 1);

                        Update::DRAW
                    })
                        .hook(&context)
                        .maybe(),
                ),
            )
        },
        {
            let counter = counter.clone();

            Box::new(
                Button::new(Text::new("Decrease".to_string())).with_on_pressed(
                    EvalSignal::new(move || {
                        counter.mutate(|c| *c -= 1);

                        Update::DRAW
                    })
                        .hook(&context)
                        .maybe(),
                ),
            )
        },
        Box::new(Text::new(counter.map(|i| Ref::Owned(i.to_string())))),
    ])
        .with_layout_style(LayoutStyle {
            size: Vector2::<Dimension>::new(Dimension::percent(1.0), Dimension::percent(1.0)),
            flex_direction: FlexDirection::Column,
            align_items: Some(AlignItems::Center),
            ..Default::default()
        })
}
// ...

Wow. A lot to unpack here.

Let's start with the counter variable. It creates a state signal (simplest form of a signal) with an initial value of 0. We could just create one with StateSignal::new(...), but we would need to hook it into the application lifecycle which context.use_state(...) already does.

Then we create an EvalSignal which is just a fancy closure that gets called and returns data every time it's pressed. We mutate the counter widget and return Update::DRAW to trigger a redraw (since we just need to redraw the UI and not re-layout it).

We use the counter signal and map it to a string (in order to use it in our Text).

Note that we clone the counter multiple times. This is required, as of right now, to move ownership of the signal into the closures.

The Config

After the UI logic, we need to define a config for our application.

// ...
impl Application for MyApp {
    // ...
    fn config(&self) -> MayConfig<Self::Theme, Self::Graphics> {
        MayConfig::default()
    }
    // ...
}

We use the default config for now. See the Configuration section for more information.

The final touch

We can now run the application in our main function.

fn main() {
    MyApp.run(());
}

The ´run´ method requires a state of type Self::State as an argument. We use an empty tuple, as said before.

The Result

Congratulations! This is your first application.

The final code should look like this:

// Of course, we need to import all of the required items
use maycoon::core::app::Application;
use maycoon::core::app::context::AppContext;
use maycoon::core::app::update::Update;
use maycoon::core::config::MayConfig;
use maycoon::core::layout::{AlignItems, Dimension, FlexDirection, LayoutStyle};
use maycoon::core::reference::Ref;
use maycoon::core::signal::Signal;
use maycoon::core::signal::eval::EvalSignal;
use maycoon::core::signal::state::StateSignal;
use maycoon::core::vgi::DefaultGraphics;
use maycoon::core::widget::{Widget, WidgetLayoutExt};
use maycoon::math::Vector2;
use maycoon::theme::theme::celeste::CelesteTheme;
use maycoon::widgets::button::Button;
use maycoon::widgets::container::Container;
use maycoon::widgets::text::Text;

struct MyApp;

impl Application for MyApp {
    type Theme = CelesteTheme;
    type Graphics = DefaultGraphics;
    type State = ();

    fn build(context: AppContext, _: Self::State) -> impl Widget {
        let counter = context.use_signal(StateSignal::new(0));

        Container::new(vec![
            {
                let counter = counter.clone();

                Box::new(
                    Button::new(Text::new("Increase".to_string())).with_on_pressed(
                        EvalSignal::new(move || {
                            counter.mutate(|c| *c += 1);

                            Update::DRAW
                        })
                            .hook(&context)
                            .maybe(),
                    ),
                )
            },
            {
                let counter = counter.clone();

                Box::new(
                    Button::new(Text::new("Decrease".to_string())).with_on_pressed(
                        EvalSignal::new(move || {
                            counter.mutate(|c| *c -= 1);

                            Update::DRAW
                        })
                            .hook(&context)
                            .maybe(),
                    ),
                )
            },
            Box::new(Text::new(counter.map(|i| Ref::Owned(i.to_string())))),
        ])
            .with_layout_style(LayoutStyle {
                size: Vector2::<Dimension>::new(Dimension::percent(1.0), Dimension::percent(1.0)),
                flex_direction: FlexDirection::Column,
                align_items: Some(AlignItems::Center),
                ..Default::default()
            })
    }

    fn config(&self) -> MayConfig<Self::Theme, Self::Graphics> {
        MayConfig::default()
    }
}

fn main() {
    MyApp.run(())
}

You can now proceed to the State Management section.

State Management

Global State Management

The global state is specified by the State type of the application.

It's simple and straightforward.

There's currently no real global state management solution (yet).

Signals

So far, we've seen "magic" signals do most of the work for us in our application.

Signals are a way to store values that can be observed and updated within the UI.

They are similar to flutter hooks or react signals (if you are familiar with those).

Every signal must implement the Signal trait. It contains functions, such as...

  • get() -> Ref<'_, T> to get a reference to the inner value.
  • set(value: T) to set the value of the signal.
  • listen(listener: Listener) to listen to changes to the signal value.

There are more methods, but these are the ones we will focus on for now.

The get method simply returns a reference to the inner value. It can only return a reference, as the inner value cannot be safely mutated while the signal is being used.

The set method sets the inner value of the signal in a safe manner and notifies the signal listeners of changes.

The listen method allows you to listen to changes to the signal value.

Worth mentioning is also the hook method, which hooks the signal to the application lifecycle. Using AppContext::use_signal() will hook the signal automatically to the application lifecycle.

Behind the scenes

Basic signals like StateSignal (which just contains a mutable value) are based on Rc and RefCell from the standard Rust library. This is because we need to be able to mutate the inner value without it being in use. See the std::cell docs from the standard library for more.

The hook method will add a listener to the signal that will trigger a re-evaluation of the UI, once the signal value changes.

Some signals also don't make use of listeners and state change at all, because they contain any state that can be mutated. EvalSignal for example produces a value by calling a closure and doesn't need to notify listeners of changes, because it doesn't contain any inner state (except for the closure).

Basic Signals

Here are some basic built-in signals:

SignalShortcutFunctionExample Usage
StateSignal<T>context.use_state(...)Stores mutable data that can be set or directly mutatedCounter context.use_state(1)
FixedSignal<T>context.use_fixed(...)Contains a fixed/immutable value.Fixed values context.use_fixed("Fixed String".to_string())
EvalSignal<T>context.use_eval(...)Produces its inner value by calling a closureButton-Press-Actions context.use_eval(\|\| Update::DRAW)
MemoizedSignal<T>context.use_memoized(...)Caches its inner value once via a closure and returns the cached value if requested againUsing complex and expensive immutable values context.use_memoized(\|\| Database::new())
MapSignal<T, U>signal.map(...)Maps a signal value of type T to a value of type UMapping signal types signal.map(\|int\| int.to_string())

MaybeSignal<T>

Most of the time, you want to use cheap fixed and immutable data (like a simple constant text), but sometimes you want reactive data (using a signal).

These two types of data can be combined using the MaybeSignal which may be a signal, but can also be a fixed value.

MaybeSignal is an enum composed of two different variants:

  • MaybeSignal::Signal(BoxedSignal<T>) - A generic signal
  • MaybeSignal::Fixed(T) - A fixed value

Signals automatically implement Into<MaybeSignal<T>> and have the maybe() -> MaybeSignal<T> method. Widgets will take impl Into<MaybeSignal<T>>> parameters, so you don't necessarily need to use MaybeSignal, but it's useful to know about.

Configuration

You can configure your app using the MayConfig struct.

It has many different fields to customize your application to your liking:

window: WindowConfig

Configures the application window.

title: String

The title of the window in desktop environments.

size: Vector2<f64>

The initial size of the window in desktop environments.

min_size: Vector2<f64>

The minimum size the window can have in desktop environments.

max_size: Vector2<f64>

The maximum size the window can have in desktop environments.

resizable: bool

Whether the window can be resized in desktop environments.

maximized: bool

Whether the window is maximized in desktop environments.

mode: WindowMode

The mode of the window in desktop environments. Can be WindowMode::Windowed, WindowMode::Borderless or WindowMode::Fullscreen.

level: WindowLevel

The level of the window in desktop environments. Can be WindowLevel::Normal, WindowLevel::AlwaysOnTop or WindowLevel::AlwaysOnBottom.

visible: bool

If the window should be visible on startup in desktop environments.

blur: bool

If the window background should be blurred in desktop environments.

transparent: bool

If the window background should be transparent in desktop environments. May not be compatible with all desktop environments.

position: Option<Point2<f64>>

The initial position of the window in desktop environments. Uses the default positioning if None.

active: bool

If the window should be active/focused on startup in desktop environments.

buttons: WindowButtons

The window buttons to enable in desktop environments.

decorations: bool

If the window should have decorations (borders) in desktop environments.

resize_increments: Option<Vector2<f64>>

The resize increments of the window in desktop environments. May not be compatible with all desktop environments.

content_protected: bool

Prevents window capturing by some apps (not all though).

icon: Option<WindowIcon>

The window icon in desktop environments.

cursor: Cursor

The window cursor in desktop environments.

close_on_request: bool

If the window should exit on close request (pressing the close window button) in desktop environments.

theme: Theme

The application theme.

graphics: VectorGraphicsInterface::Config

The configuration for the selected vector graphics interface.

Built-in Themes

WIP

Task Runner

Maycoon provides a task runner architecture to spawn asynchronous tasks in the background.

You are required to specify a task runner during compile time (using the feature array in your Cargo.toml).

By default, the tokio-runner feature is enabled, which uses the tokio runtime.

There's also a dummy-runner feature which will panic on any method call, but can be used to compile maycoon without specifying a task runner (e.g. if you want to create a library).