Reusable components

In this chapter, we will implement a simple alert dialog as a reusable component.

The alert example in the Relm4 repository implements a simple app for the reusable alert component we will write in this chapter. It's an other variant of a counter app, yet this time a dialog will be displayed if the counter does not match 42 when being closed. The main difference in the implementation is, that the dialog is implemented as component that can be reused in other applications.

App screenshot dark

This is how the dialog looks like in the alert example:

App screenshot dark

If you want to see an alert component, very similar to the one we will write in this chapter, used inside a Relm4 application have a look at the “alert” example. Run cargo run --example alert from the example directory if you want to see the code in action.

Reusable components don’t know their parent component at the time they are implemented. So if they want to interact with their parent component, they must assume that their parent model implements a trait as an interface for the component.

The parent traits

First, we’ll have a look at the traits the parent component, that will eventually use this component, has to implement.

Because we want our component to be flexible and able to display different messages, we first define a data type for configuring our component.

pub struct AlertSettings {
    /// Large text
    pub text: String,
    /// Optional secondary, smaller text
    pub secondary_text: Option<String>,
    /// Modal dialogs freeze other windows as long they are visible
    pub is_modal: bool,
    /// Sets color of the accept button to red if the theme supports it
    pub destructive_accept: bool,
    /// Text for confirm button
    pub confirm_label: String,
    /// Text for cancel button
    pub cancel_label: String,
    /// Text for third option button. If [`None`] the third button won't be created.
    pub option_label: Option<String>,
}

Next, we define a trait for our parent model that defines the messages our component will send to respond to the parent. The trait also defines a function that passes a new configuration to our component.

/// Interface for the parent model
pub trait AlertParent: Model
where
    Self::Widgets: AlertParentWidgets,
{
    /// Configuration for alert component.
    fn alert_config(&self) -> AlertSettings;

    /// Message sent to parent if user clicks confirm button
    fn confirm_msg() -> Self::Msg;

    /// Message sent to parent if user clicks cancel button
    fn cancel_msg() -> Self::Msg;

    /// Message sent to parent if user clicks third option button
    fn option_msg() -> Self::Msg;
}

Because you usually want to tell GTK to which window a dialog belongs to, we also add a trait that allows us to pass the parent window.

/// Get the parent window that allows setting the parent window of the dialog with
/// [`gtk::prelude::GtkWindowExt::set_transient_for`].
pub trait AlertParentWidgets {
    fn parent_window(&self) -> Option<gtk::Window>;
}

The model

Our model stores whether the component is visible and the configuration.

pub struct AlertModel {
    settings: AlertSettings,
    is_active: bool,
}

The message type only exposes the Show message to the parent component. The Response message is used internally for handling user interactions, so we hide it with #[doc(hidden)].

pub enum AlertMsg {
    /// Message sent by the parent to view the dialog
    Show,
    #[doc(hidden)]
    Response(gtk::ResponseType),
}

The ComponentUpdate trait would usually expect the parent component as a generic type. We don’t know the parent component yet, so we add trait bounds to a new generic type.

impl<ParentModel> ComponentUpdate<ParentModel> for AlertModel
where
    ParentModel: AlertParent,
    ParentModel::Widgets: AlertParentWidgets,
{

For initializing our model, we get the configuration from our parent component and set is_active to false.

    fn init_model(parent_model: &ParentModel) -> Self {
        AlertModel {
            settings: parent_model.alert_config(),
            is_active: false,
        }
    }

The update function handles the Show message from our parent component and the Response messages generated by user interactions. It also sends the appropriate messages to the parent.

    fn update(
        &mut self,
        msg: AlertMsg,
        _components: &(),
        _sender: Sender<AlertMsg>,
        parent_sender: Sender<ParentModel::Msg>,
    ) {
        match msg {
            AlertMsg::Show => {
                self.is_active = true;
            }
            AlertMsg::Response(ty) => {
                self.is_active = false;
                parent_sender
                    .send(match ty {
                        gtk::ResponseType::Accept => ParentModel::confirm_msg(),
                        gtk::ResponseType::Other(_) => ParentModel::option_msg(),
                        _ => ParentModel::cancel_msg(),
                    })
                    .unwrap();
            }
        }
    }

The widgets

The widgets have a generic type for the parent component with the expected trait bounds, too. Because they are part of a public interface, we also add the pub attribute to the widget macro. Apart from that, there is nothing special.

#[relm4_macros::widget(pub)]
impl<ParentModel> relm4::Widgets<AlertModel, ParentModel> for AlertWidgets
where
    ParentModel: AlertParent,
    ParentModel::Widgets: AlertParentWidgets,
{
    view! {
        dialog = gtk::MessageDialog {
            set_transient_for: parent_widgets.parent_window().as_ref(),
            set_message_type: gtk::MessageType::Question,
            set_visible: watch!(model.is_active),
            connect_response(sender) => move |_, response| {
                send!(sender, AlertMsg::Response(response));
            },

            // Apply configuration
            set_text: Some(&model.settings.text),
            set_secondary_text: model.settings.secondary_text.as_deref(),
            set_modal: model.settings.is_modal,
            add_button: args!(&model.settings.confirm_label, gtk::ResponseType::Accept),
            add_button: args!(&model.settings.cancel_label, gtk::ResponseType::Cancel),
        }
    }

    fn post_init() {
        if let Some(option_label) = &model.settings.option_label {
            dialog.add_button(option_label, gtk::ResponseType::Other(0));
        }
        if model.settings.destructive_accept {
            let accept_widget = dialog
                .widget_for_response(gtk::ResponseType::Accept)
                .expect("No button for accept response set");
            accept_widget.add_css_class("destructive-action");
        }
    }
}

Conclusion

We’re done! That’s your first reusable component.

You can find more examples of reusable components in the relm4-components crate here. You can also contribute your own reusable components to relm4-components :)

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::{DialogExt, GtkWindowExt, WidgetExt};
use relm4::{send, ComponentUpdate, Model, Sender};

pub struct AlertSettings {
    /// Large text
    pub text: String,
    /// Optional secondary, smaller text
    pub secondary_text: Option<String>,
    /// Modal dialogs freeze other windows as long they are visible
    pub is_modal: bool,
    /// Sets color of the accept button to red if the theme supports it
    pub destructive_accept: bool,
    /// Text for confirm button
    pub confirm_label: String,
    /// Text for cancel button
    pub cancel_label: String,
    /// Text for third option button. If [`None`] the third button won't be created.
    pub option_label: Option<String>,
}

pub struct AlertModel {
    settings: AlertSettings,
    is_active: bool,
}

pub enum AlertMsg {
    /// Message sent by the parent to view the dialog
    Show,
    #[doc(hidden)]
    Response(gtk::ResponseType),
}

impl Model for AlertModel {
    type Msg = AlertMsg;
    type Widgets = AlertWidgets;
    type Components = ();
}

/// Interface for the parent model
pub trait AlertParent: Model
where
    Self::Widgets: AlertParentWidgets,
{
    /// Configuration for alert component.
    fn alert_config(&self) -> AlertSettings;

    /// Message sent to parent if user clicks confirm button
    fn confirm_msg() -> Self::Msg;

    /// Message sent to parent if user clicks cancel button
    fn cancel_msg() -> Self::Msg;

    /// Message sent to parent if user clicks third option button
    fn option_msg() -> Self::Msg;
}

/// Get the parent window that allows setting the parent window of the dialog with
/// [`gtk::prelude::GtkWindowExt::set_transient_for`].
pub trait AlertParentWidgets {
    fn parent_window(&self) -> Option<gtk::Window>;
}

impl<ParentModel> ComponentUpdate<ParentModel> for AlertModel
where
    ParentModel: AlertParent,
    ParentModel::Widgets: AlertParentWidgets,
{
    fn init_model(parent_model: &ParentModel) -> Self {
        AlertModel {
            settings: parent_model.alert_config(),
            is_active: false,
        }
    }

    fn update(
        &mut self,
        msg: AlertMsg,
        _components: &(),
        _sender: Sender<AlertMsg>,
        parent_sender: Sender<ParentModel::Msg>,
    ) {
        match msg {
            AlertMsg::Show => {
                self.is_active = true;
            }
            AlertMsg::Response(ty) => {
                self.is_active = false;
                parent_sender
                    .send(match ty {
                        gtk::ResponseType::Accept => ParentModel::confirm_msg(),
                        gtk::ResponseType::Other(_) => ParentModel::option_msg(),
                        _ => ParentModel::cancel_msg(),
                    })
                    .unwrap();
            }
        }
    }
}

#[relm4_macros::widget(pub)]
impl<ParentModel> relm4::Widgets<AlertModel, ParentModel> for AlertWidgets
where
    ParentModel: AlertParent,
    ParentModel::Widgets: AlertParentWidgets,
{
    view! {
        dialog = gtk::MessageDialog {
            set_transient_for: parent_widgets.parent_window().as_ref(),
            set_message_type: gtk::MessageType::Question,
            set_visible: watch!(model.is_active),
            connect_response(sender) => move |_, response| {
                send!(sender, AlertMsg::Response(response));
            },

            // Apply configuration
            set_text: Some(&model.settings.text),
            set_secondary_text: model.settings.secondary_text.as_deref(),
            set_modal: model.settings.is_modal,
            add_button: args!(&model.settings.confirm_label, gtk::ResponseType::Accept),
            add_button: args!(&model.settings.cancel_label, gtk::ResponseType::Cancel),
        }
    }

    fn post_init() {
        if let Some(option_label) = &model.settings.option_label {
            dialog.add_button(option_label, gtk::ResponseType::Other(0));
        }
        if model.settings.destructive_accept {
            let accept_widget = dialog
                .widget_for_response(gtk::ResponseType::Accept)
                .expect("No button for accept response set");
            accept_widget.add_css_class("destructive-action");
        }
    }
}