Components

Technically, we already used components in the previous chapters. So far, we've only used one component per application, but in this chapter, we're going to use multiple components to structure our app.

Components are independent parts of your application that can communicate with each other. They are used in a parent-child model: The main app component can have several components and each component can have child components and so on. This means that each component has a parent, except for the main app component which is at the top of this tree structure.

To showcase this, we will create a small application which opens a dialog when the user tries to close it. The header bar and the dialog will be implemented as standalone components.

App screenshot dark

App screenshot dark

When to use components

Components are very useful for separating parts of the UI into smaller, more manageable parts. They are not necessary but for larger applications, they can be very helpful.

Message handling

Components store their child components inside the model as a Controller<ChildModel> and handle output messages in the init function by calling the forward method.

        let header: Controller<HeaderModel> =
            HeaderModel::builder()
                .launch(())
                .forward(sender.input_sender(), |msg| match msg {
                    HeaderOutput::View => AppMsg::SetMode(AppMode::View),
                    HeaderOutput::Edit => AppMsg::SetMode(AppMode::Edit),
                    HeaderOutput::Export => AppMsg::SetMode(AppMode::Export),
                });

The forward method will redirect the output messages from the child component and transform them into the parent's input messages.

Components are independent from each another so a component can be used easily with several different parent components. Therefore, the child component doesn't know which type its parent component will have. Thus, the forward method allows the parent component to transform the output messages of child components to a message type it can handle properly.

In this example, HeaderOutput messages are translated into AppMsg.

Example application

Let's write a small example app to see how components can be used in action. For this example, we write parts of an app that can edit images.

The app we will write in this chapter is also available here. Run cargo run --example components from the example directory if you want to see the code in action.

The header bar

Our first component will be a header bar. There are not a lot of advantages for writing this as component except for reducing the complexity in other parts of our UI.

The header bar will have three buttons for three modes that our application can have:

  • View: View the image.
  • Edit: Edit the image.
  • Export: Export the image in different formats.

We will not implement the actual functionality, but instead use placeholders to keep things simple.

The model

Usually you want to store everything that affects only your component in the state of the component. However, in this case, there is no state that can be stored in the component, but only state that affects the root component (app). Therefore, we leave the model empty and only send messages to the root component.

struct HeaderModel;

The message type allows us to switch between the modes.

#[derive(Debug)]
enum HeaderOutput {
    View,
    Edit,
    Export,
}

Our component needs no update method, because the view can emit the component's output messages as part of its click signal handlers, as we will see in the next section.

The widgets

There's nothing special about widgets of a child component. The only difference to the main app component is that the root widget doesn't need to be a gtk::Window. Instead, we use a gtk::HeaderBar here, but theoretically the root widget doesn't even need to be a widget at all (which can be useful in special cases).

    view! {
        #[root]
        gtk::HeaderBar {
            #[wrap(Some)]
            set_title_widget = &gtk::Box {
                add_css_class: "linked",
                #[name = "group"]
                gtk::ToggleButton {
                    set_label: "View",
                    set_active: true,
                    connect_toggled[sender] => move |btn| {
                        if btn.is_active() {
                            sender.output(HeaderOutput::View).unwrap()
                        }
                    },
                },
                gtk::ToggleButton {
                    set_label: "Edit",
                    set_group: Some(&group),
                    connect_toggled[sender] => move |btn| {
                        if btn.is_active() {
                            sender.output(HeaderOutput::Edit).unwrap()
                        }
                    },
                },
                gtk::ToggleButton {
                    set_label: "Export",
                    set_group: Some(&group),
                    connect_toggled[sender] => move |btn| {
                        if btn.is_active() {
                            sender.output(HeaderOutput::Export).unwrap()
                        }
                    },
                },
            }
        }
    }

The close alert

As with a normal application used to edit files, we want to notify the user before they accidentally close the application and discard all progress. For this — you might have guessed it already — we will use another component.

The model

The state of the dialog only needs to store whether or not it's hidden.

struct DialogModel {
    hidden: bool,
}

The message contains three options:

  • Show is used by the parent to display the dialog.
  • Accept is used internally to indicate that the user agreed to close the application.
  • Cancel is used internally to indicate that the user changes his mind and doesn't want to close the application.
#[derive(Debug)]
enum DialogInput {
    Show,
    Accept,
    Cancel,
}

#[derive(Debug)]
enum DialogOutput {
    Close,
}

The widgets

Unlike the last component, the DialogModel component doesn't send its output messages from a signal handler. Instead, the response signal handler sends input messages to itself, handles them in update, and then sends output messages if necessary. This is a common pattern for more complex components.

If your component accepts non-internal inputs as well, you may want to mark the internal variants as #[doc(hidden)] so that users of your component know they're only intended for internal use.

    view! {
        gtk::MessageDialog {
            set_modal: true,
            #[watch]
            set_visible: !model.hidden,
            set_text: Some("Do you want to close before saving?"),
            set_secondary_text: Some("All unsaved changes will be lost"),
            add_button: ("Close", gtk::ResponseType::Accept),
            add_button: ("Cancel", gtk::ResponseType::Cancel),
            connect_response[sender] => move |_, resp| {
                sender.input(if resp == gtk::ResponseType::Accept {
                    DialogInput::Accept
                } else {
                    DialogInput::Cancel
                })
            }
        }
    }

In the update implementation, we match the input messages and emit an output if needed.

    fn update(&mut self, msg: Self::Input, sender: ComponentSender<Self>) {
        match msg {
            DialogInput::Show => self.hidden = false,
            DialogInput::Accept => {
                self.hidden = true;
                sender.output(DialogOutput::Close).unwrap()
            }
            DialogInput::Cancel => self.hidden = true,
        }
    }

The main app

Now all parts come together to form a single app.

The model

First, let's define the model of the main app and its messages.

#[derive(Debug)]
enum AppMode {
    View,
    Edit,
    Export,
}

#[derive(Debug)]
enum AppMsg {
    SetMode(AppMode),
    CloseRequest,
    Close,
}

struct AppModel {
    mode: AppMode,
    header: Controller<HeaderModel>,
    dialog: Controller<DialogModel>,
}

The AppMode struct stores the modes the application can be in. The SetMode message is transformed from the output of our header bar component to update the state of the main application when someone presses a button in the header bar. The Close message is transformed from the output of the dialog component to indicate that the window should be closed.

In the model, we store the current AppMode as well as a Controller for each of our child components.

The update function of the model is pretty straightforward.

    fn update(&mut self, msg: Self::Input, _sender: ComponentSender<Self>) {
        match msg {
            AppMsg::SetMode(mode) => {
                self.mode = mode;
            }
            AppMsg::CloseRequest => {
                self.dialog.sender().send(DialogInput::Show).unwrap();
            }
            AppMsg::Close => {
                relm4::main_application().quit();
            }
        }
    }

We can retrieve a sender for the child component by calling the sender() method on the associated Controller, and then send messages of the associated Input type through it.

Controllers

When initializing the app component, we construct the child components by passing the appropriate Init and forwarding any desired inputs and outputs. This is done through a builder provided by Component implementations. We pass the initial parameters via the launch method, and then retrieve the final Controller by calling the forward method. In addition to starting the component, the forward method allows us to take the outputs of the component, transform them with a mapping function, and then pass the result as an input message to another sender (in this case, the input sender of the app component). If you don't need to forward any outputs, you can start the component with the detach method instead.

    fn init(
        params: Self::Init,
        root: Self::Root,
        sender: ComponentSender<Self>,
    ) -> ComponentParts<Self> {
        let header: Controller<HeaderModel> =
            HeaderModel::builder()
                .launch(())
                .forward(sender.input_sender(), |msg| match msg {
                    HeaderOutput::View => AppMsg::SetMode(AppMode::View),
                    HeaderOutput::Edit => AppMsg::SetMode(AppMode::Edit),
                    HeaderOutput::Export => AppMsg::SetMode(AppMode::Export),
                });

        let dialog = DialogModel::builder()
            .transient_for(&root)
            .launch(true)
            .forward(sender.input_sender(), |msg| match msg {
                DialogOutput::Close => AppMsg::Close,
            });

        let model = AppModel {
            mode: params,
            header,
            dialog,
        };

        let widgets = view_output!();
        ComponentParts { model, widgets }
    }

Also, we set the set_transient_for property, which actually uses the main window. The dialog should set his parent window so that GTK can handle the dialog better. The GTK docs state: "[set_transient_for] allows window managers to e.g. keep the dialog on top of the main window, or center the dialog over the main window".

#[derive(Debug)]
enum AppMode {
    View,
    Edit,
    Export,
}

#[derive(Debug)]
enum AppMsg {
    SetMode(AppMode),
    CloseRequest,
    Close,
}

struct AppModel {
    mode: AppMode,
    header: Controller<HeaderModel>,
    dialog: Controller<DialogModel>,
}

The widgets

We're almost done! Lastly, let's take a look at the app widgets.

    view! {
        main_window = gtk::Window {
            set_default_width: 500,
            set_default_height: 250,
            set_titlebar: Some(model.header.widget()),

            gtk::Label {
                #[watch]
                set_label: &format!("Placeholder for {:?}", model.mode),
            },
            connect_close_request[sender] => move |_| {
                sender.input(AppMsg::CloseRequest);
                gtk::glib::Propagation::Stop
            }
        }
    }

Most notably, we retrieve the root widget of our header component through the widget() method on the associated Controller to set it as a child of the main window.

Conclusion

You now know most of the secrets that Relm4 offers. Components can be powerful and if they are implemented correctly, they are even reusable across different apps. The relm4-components crate offers several reusable components you can use in your applications. In the following chapters, we'll look at an even simpler component type called worker, how to implement reusable components yourself and how to use components with async code and multiple threads.

The complete code

Let's review our code in one piece one more time to see how all these parts work together:

use gtk::prelude::{
    ApplicationExt, ButtonExt, DialogExt, GtkWindowExt, ToggleButtonExt, WidgetExt,
};
use relm4::*;

struct HeaderModel;

#[derive(Debug)]
enum HeaderOutput {
    View,
    Edit,
    Export,
}


#[relm4::component]
impl SimpleComponent for HeaderModel {
    type Init = ();
    type Input = ();
    type Output = HeaderOutput;

    view! {
        #[root]
        gtk::HeaderBar {
            #[wrap(Some)]
            set_title_widget = &gtk::Box {
                add_css_class: "linked",
                #[name = "group"]
                gtk::ToggleButton {
                    set_label: "View",
                    set_active: true,
                    connect_toggled[sender] => move |btn| {
                        if btn.is_active() {
                            sender.output(HeaderOutput::View).unwrap()
                        }
                    },
                },
                gtk::ToggleButton {
                    set_label: "Edit",
                    set_group: Some(&group),
                    connect_toggled[sender] => move |btn| {
                        if btn.is_active() {
                            sender.output(HeaderOutput::Edit).unwrap()
                        }
                    },
                },
                gtk::ToggleButton {
                    set_label: "Export",
                    set_group: Some(&group),
                    connect_toggled[sender] => move |btn| {
                        if btn.is_active() {
                            sender.output(HeaderOutput::Export).unwrap()
                        }
                    },
                },
            }
        }
    }

    fn init(
        _params: Self::Init,
        root: Self::Root,
        sender: ComponentSender<Self>,
    ) -> ComponentParts<Self> {
        let model = HeaderModel;
        let widgets = view_output!();
        ComponentParts { model, widgets }
    }
}


struct DialogModel {
    hidden: bool,
}

#[derive(Debug)]
enum DialogInput {
    Show,
    Accept,
    Cancel,
}

#[derive(Debug)]
enum DialogOutput {
    Close,
}

#[relm4::component]
impl SimpleComponent for DialogModel {
    type Init = bool;
    type Input = DialogInput;
    type Output = DialogOutput;

    view! {
        gtk::MessageDialog {
            set_modal: true,
            #[watch]
            set_visible: !model.hidden,
            set_text: Some("Do you want to close before saving?"),
            set_secondary_text: Some("All unsaved changes will be lost"),
            add_button: ("Close", gtk::ResponseType::Accept),
            add_button: ("Cancel", gtk::ResponseType::Cancel),
            connect_response[sender] => move |_, resp| {
                sender.input(if resp == gtk::ResponseType::Accept {
                    DialogInput::Accept
                } else {
                    DialogInput::Cancel
                })
            }
        }
    }

    fn init(
        params: Self::Init,
        root: Self::Root,
        sender: ComponentSender<Self>,
    ) -> ComponentParts<Self> {
        let model = DialogModel { hidden: params };
        let widgets = view_output!();
        ComponentParts { model, widgets }
    }

    fn update(&mut self, msg: Self::Input, sender: ComponentSender<Self>) {
        match msg {
            DialogInput::Show => self.hidden = false,
            DialogInput::Accept => {
                self.hidden = true;
                sender.output(DialogOutput::Close).unwrap()
            }
            DialogInput::Cancel => self.hidden = true,
        }
    }
}

#[derive(Debug)]
enum AppMode {
    View,
    Edit,
    Export,
}

#[derive(Debug)]
enum AppMsg {
    SetMode(AppMode),
    CloseRequest,
    Close,
}

struct AppModel {
    mode: AppMode,
    header: Controller<HeaderModel>,
    dialog: Controller<DialogModel>,
}

#[relm4::component]
impl SimpleComponent for AppModel {
    type Init = AppMode;
    type Input = AppMsg;
    type Output = ();

    view! {
        main_window = gtk::Window {
            set_default_width: 500,
            set_default_height: 250,
            set_titlebar: Some(model.header.widget()),

            gtk::Label {
                #[watch]
                set_label: &format!("Placeholder for {:?}", model.mode),
            },
            connect_close_request[sender] => move |_| {
                sender.input(AppMsg::CloseRequest);
                gtk::glib::Propagation::Stop
            }
        }
    }

    fn init(
        params: Self::Init,
        root: Self::Root,
        sender: ComponentSender<Self>,
    ) -> ComponentParts<Self> {
        let header: Controller<HeaderModel> =
            HeaderModel::builder()
                .launch(())
                .forward(sender.input_sender(), |msg| match msg {
                    HeaderOutput::View => AppMsg::SetMode(AppMode::View),
                    HeaderOutput::Edit => AppMsg::SetMode(AppMode::Edit),
                    HeaderOutput::Export => AppMsg::SetMode(AppMode::Export),
                });

        let dialog = DialogModel::builder()
            .transient_for(&root)
            .launch(true)
            .forward(sender.input_sender(), |msg| match msg {
                DialogOutput::Close => AppMsg::Close,
            });

        let model = AppModel {
            mode: params,
            header,
            dialog,
        };

        let widgets = view_output!();
        ComponentParts { model, widgets }
    }

    fn update(&mut self, msg: Self::Input, _sender: ComponentSender<Self>) {
        match msg {
            AppMsg::SetMode(mode) => {
                self.mode = mode;
            }
            AppMsg::CloseRequest => {
                self.dialog.sender().send(DialogInput::Show).unwrap();
            }
            AppMsg::Close => {
                relm4::main_application().quit();
            }
        }
    }
}

fn main() {
    let relm = RelmApp::new("ewlm4.test.components");
    relm.run::<AppModel>(AppMode::Edit);
}