Skip to main content

relm4_components/alert/
mod.rs

1//! Reusable and easily configurable alert component.
2//!
3//! **[Example implementation](https://github.com/AaronErhardt/relm4/blob/main/relm4-examples/examples/alert.rs)**
4
5use gtk::prelude::{BoxExt, ButtonExt, GtkWindowExt, OrientableExt, WidgetExt};
6use once_cell::sync::Lazy;
7use relm4::{Component, ComponentParts, ComponentSender, RelmWidgetExt, gtk};
8
9const LIBADWAITA_ENABLED: bool = cfg!(feature = "libadwaita");
10const COMPONENT_CSS: &str = include_str!("style.css");
11const MESSAGE_AREA_CSS: &str = "message-area";
12const RESPONSE_BUTTONS_CSS: &str = "response-buttons";
13
14/// The initializer for the CSS, ensuring it only happens once.
15static INITIALIZE_CSS: Lazy<()> = Lazy::new(|| {
16    relm4::set_global_css_with_priority(COMPONENT_CSS, gtk::STYLE_PROVIDER_PRIORITY_APPLICATION);
17});
18
19/// Configuration for the alert dialog component
20///
21/// The configuration object provides a [`Default`] implementation for any fields you don't want to manually specify, which is configured as such:
22///
23/// - `text` is set to "Alert".
24/// - `secondary_text` is set to [`None`].
25/// - `is_modal` is set to [`true`].
26/// - `destructive_accept` is set to [`false`].
27/// - `confirm_label` is set to [`None`].
28/// - `cancel_label` is set to [`None`].
29/// - `option_label` is set to [`None`].
30/// - `extra_child` is set to [`None`].
31#[derive(Debug)]
32pub struct AlertSettings {
33    /// Large text
34    pub text: Option<String>,
35    /// Optional secondary, smaller text
36    pub secondary_text: Option<String>,
37    /// Modal dialogs freeze other windows as long they are visible
38    pub is_modal: bool,
39    /// Sets color of the accept button to red if the theme supports it
40    pub destructive_accept: bool,
41    /// Text for confirm button. If [`None`] the button won't be shown.
42    pub confirm_label: Option<String>,
43    /// Text for cancel button. If [`None`] the button won't be shown.
44    pub cancel_label: Option<String>,
45    /// Text for third option button. If [`None`] the button won't be shown.
46    pub option_label: Option<String>,
47    /// An optional, extra widget to display below the secondary text.
48    pub extra_child: Option<gtk::Widget>,
49}
50
51impl Default for AlertSettings {
52    fn default() -> Self {
53        Self {
54            text: Some("Alert".into()),
55            secondary_text: None,
56            is_modal: true,
57            destructive_accept: false,
58            confirm_label: None,
59            cancel_label: None,
60            option_label: None,
61            extra_child: None,
62        }
63    }
64}
65
66/// Alert dialog component.
67#[derive(Debug)]
68pub struct Alert {
69    /// The settings used by the alert component.
70    pub settings: AlertSettings,
71    is_active: bool,
72    current_child: Option<gtk::Widget>,
73}
74
75/// Messages that can be sent to the alert dialog component
76#[derive(Debug)]
77pub enum AlertMsg {
78    /// Message sent by the parent to view the dialog
79    Show,
80
81    /// Message sent by the parent to hide the dialog
82    Hide,
83
84    #[doc(hidden)]
85    Response(AlertResponse),
86}
87
88/// User action performed on the alert dialog.
89#[derive(Debug)]
90pub enum AlertResponse {
91    /// User clicked confirm button.
92    Confirm,
93
94    /// User clicked cancel button.
95    Cancel,
96
97    /// User clicked user-supplied option.
98    Option,
99}
100
101/// Widgets of the alert dialog component.
102#[relm4::component(pub)]
103impl Component for Alert {
104    type Init = AlertSettings;
105    type Input = AlertMsg;
106    type Output = AlertResponse;
107    type CommandOutput = ();
108
109    view! {
110        gtk::Window {
111            #[watch]
112            set_visible: model.is_active,
113            set_modal: model.settings.is_modal,
114            add_css_class: "relm4-alert",
115
116            #[wrap(Some)]
117            set_titlebar = &gtk::Box {
118                set_visible: false,
119            },
120
121            gtk::Box {
122                set_orientation: gtk::Orientation::Vertical,
123
124                #[name(message_area)]
125                gtk::Box {
126                    set_orientation: gtk::Orientation::Vertical,
127                    set_spacing: 8,
128                    set_vexpand: true,
129                    add_css_class: MESSAGE_AREA_CSS,
130
131                    gtk::Label {
132                        #[watch]
133                        set_text: model.settings.text.as_deref().unwrap_or_default(),
134                        #[watch]
135                        set_visible: model.settings.text.is_some(),
136                        set_valign: gtk::Align::Start,
137                        set_justify: gtk::Justification::Center,
138                        add_css_class: relm4::css::TITLE_2,
139                        set_wrap: true,
140                        set_max_width_chars: 20,
141                    },
142
143                    gtk::Label {
144                        #[watch]
145                        set_text: model.settings.secondary_text.as_deref().unwrap_or_default(),
146                        set_vexpand: true,
147                        set_valign: gtk::Align::Fill,
148                        set_justify: gtk::Justification::Center,
149                        set_wrap: true,
150                        set_max_width_chars: 40,
151                    },
152                },
153
154                gtk::Box {
155                    add_css_class: RESPONSE_BUTTONS_CSS,
156                    set_orientation: gtk::Orientation::Vertical,
157                    set_vexpand_set: true,
158                    set_valign: gtk::Align::End,
159                    gtk::Separator {},
160
161                    gtk::Box {
162                        set_homogeneous: true,
163                        set_vexpand: true,
164                        set_valign: gtk::Align::End,
165
166                        // The confirm widget is a bit more complicated than the rest, since we have destructive coloring on it sometimes.
167                        //
168                        // - On GTK, we want the *background* of the button to be red.
169                        // - On Adwaita, we want the *text* of the button to be red.
170                        #[name(confirm_label)]
171                        gtk::Button {
172                            #[watch]
173                            set_visible: model.settings.confirm_label.is_some(),
174                            #[watch]
175                            set_class_active: (relm4::css::DESTRUCTIVE_ACTION, !LIBADWAITA_ENABLED && model.settings.destructive_accept),
176                            #[watch]
177                            set_class_active: (relm4::css::FLAT, LIBADWAITA_ENABLED || !model.settings.destructive_accept),
178                            set_hexpand: true,
179                            connect_clicked => AlertMsg::Response(AlertResponse::Confirm),
180
181                            gtk::Label {
182                                #[watch]
183                                set_label: model.settings.confirm_label.as_deref().unwrap_or_default(),
184                                #[watch]
185                                set_class_active: (relm4::css::ERROR, LIBADWAITA_ENABLED && model.settings.destructive_accept),
186                            }
187                        },
188
189                        gtk::Box {
190                            #[watch]
191                            set_visible: model.settings.cancel_label.is_some(),
192
193                            gtk::Separator {},
194
195                            #[name(cancel_label)]
196                            gtk::Button {
197                                #[watch]
198                                set_label: model.settings.cancel_label.as_deref().unwrap_or_default(),
199                                add_css_class: relm4::css::FLAT,
200                                set_hexpand: true,
201                                connect_clicked => AlertMsg::Response(AlertResponse::Cancel)
202                            }
203                        },
204
205                        gtk::Box {
206                            #[watch]
207                            set_visible: model.settings.option_label.is_some(),
208
209                            gtk::Separator {},
210
211                            #[name(option_label)]
212                            gtk::Button {
213                                #[watch]
214                                set_label: model.settings.option_label.as_deref().unwrap_or_default(),
215                                add_css_class: relm4::css::FLAT,
216                                set_hexpand: true,
217                                connect_clicked => AlertMsg::Response(AlertResponse::Option)
218                            }
219                        }
220                    }
221                }
222            }
223        }
224    }
225
226    fn init(
227        settings: AlertSettings,
228        root: Self::Root,
229        sender: ComponentSender<Self>,
230    ) -> ComponentParts<Self> {
231        // Initialize the CSS.
232        #[allow(clippy::no_effect)] // Fixes a false positive in Rust < 1.78
233        *INITIALIZE_CSS;
234
235        let current_child = settings.extra_child.clone();
236
237        let model = Alert {
238            settings,
239            is_active: false,
240            current_child,
241        };
242
243        let widgets = view_output!();
244
245        ComponentParts { model, widgets }
246    }
247
248    fn update_with_view(
249        &mut self,
250        widgets: &mut Self::Widgets,
251        input: AlertMsg,
252        sender: ComponentSender<Self>,
253        _root: &Self::Root,
254    ) {
255        // Update the view to contain the extra component, by removing whatever's present in the UI and then adding what the caller's current widget is.
256        if let Some(widget) = self.current_child.take() {
257            widgets.message_area.remove(&widget);
258        }
259
260        if let Some(extra_child) = self.settings.extra_child.clone() {
261            widgets.message_area.append(&extra_child);
262            self.current_child = Some(extra_child);
263        }
264
265        match input {
266            AlertMsg::Show => self.is_active = true,
267            AlertMsg::Hide => self.is_active = false,
268            AlertMsg::Response(resp) => {
269                self.is_active = false;
270                sender.output(resp).unwrap();
271            }
272        }
273
274        self.update_view(widgets, sender);
275    }
276}