delicious_adwaita/
lib.rs

1use crate::{color::ThemeColorVariant, theme::Theme};
2use adw::prelude::{AdwDialogExt, PreferencesRowExt};
3use gtk::{
4    gdk,
5    glib::{self, clone},
6    prelude::{BoxExt, ButtonExt, Cast, ObjectExt, WidgetExt},
7};
8use std::{cell::RefCell, fmt::Display, rc::Rc};
9
10pub mod color;
11pub mod named_colors;
12pub mod theme;
13
14#[derive(Debug, Clone)]
15pub struct ThemeEngine {
16    display: gdk::Display,
17    current_theme_provider: Rc<RefCell<Option<gtk::CssProvider>>>,
18    current_theme: Rc<RefCell<Theme>>,
19}
20
21#[derive(Debug, Clone, Copy)]
22pub enum ThemeEngineErr {
23    NoGdkDisplay,
24}
25
26impl Display for ThemeEngineErr {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        f.write_str(match self {
29            Self::NoGdkDisplay => "Failed to get default GDK Display",
30        })
31    }
32}
33
34pub type ThemeEngineResult<T> = Result<T, ThemeEngineErr>;
35
36impl ThemeEngine {
37    /// Instantiates a new [`ThemeEngine`] and sets the provided theme as the
38    /// current one. This is useful for restoring a user's theme preference
39    /// across sessions.
40    pub fn new_with_theme(theme: &Theme) -> ThemeEngineResult<Self> {
41        let this = Self::new()?;
42        this.apply(theme);
43        Ok(this)
44    }
45
46    pub fn new() -> ThemeEngineResult<Self> {
47        let this = Self {
48            display: gdk::Display::default().ok_or(ThemeEngineErr::NoGdkDisplay)?,
49            current_theme_provider: Rc::new(RefCell::new(None)),
50            current_theme: Rc::new(RefCell::new(Theme::default())),
51        };
52        // listen for light/dark change and reset the theme
53        adw::StyleManager::default().connect_local(
54            "notify::dark",
55            true,
56            clone!(
57                #[strong]
58                this,
59                move |_| {
60                    // this further delays the execution to make sure that the theme has
61                    // effectively changed
62                    glib::idle_add_local_once(clone!(
63                        #[strong]
64                        this,
65                        move || {
66                            this.reset_current_theme();
67                        }
68                    ));
69                    None
70                }
71            ),
72        );
73        Ok(this)
74    }
75
76    pub fn reset_current_theme(&self) {
77        self.apply(&self.current_theme.clone().take());
78    }
79
80    pub fn apply(&self, theme: &Theme) {
81        self.apply_no_set(theme);
82        self.current_theme.replace(theme.clone());
83    }
84
85    /// Apply the given theme without setting current_theme to it.
86    /// This is useful for previewing the theme and subsequently restoring the previously
87    /// selected one.
88    pub fn apply_no_set(&self, theme: &Theme) {
89        if let Some(current_theme_provider) = self.current_theme_provider.borrow().as_ref() {
90            gtk::style_context_remove_provider_for_display(&self.display, current_theme_provider);
91        }
92        if theme.is_system {
93            self.current_theme_provider.replace(None);
94        } else {
95            let provider = theme.get_provider(ThemeColorVariant::current(&self.display));
96            gtk::style_context_add_provider_for_display(
97                &self.display,
98                &provider,
99                gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
100            );
101            self.current_theme_provider.replace(Some(provider));
102        }
103    }
104
105    /// Retrieves the current theme name. This is useful for saving the theme
106    /// preference across sessions.
107    pub fn current_theme_name(&self) -> String {
108        self.current_theme.borrow().name.clone()
109    }
110
111    /// Creates a theme chooser widget consisting of a [`gtk::ListBox`] inside
112    /// a [`adw::Clamp`] inside a [`gtk::ScrolledWindow`]. The ListBox is inert
113    /// and requires connecting its signals to be functional, that's why it's
114    /// returned along with the parent.
115    ///
116    /// The return type is a tuple of the parent [`gtk::Box`], and the child
117    /// [`gtk::ListBox`] so that the consumer can connect to its signals and
118    /// read its state easily.
119    ///
120    /// Using this method is not recommended. It's preferable to use
121    /// [`Self::theme_chooser_dialog`] instead.
122    pub fn theme_chooser_widget(themes: &[Theme]) -> (gtk::Box, gtk::ListBox) {
123        let w = gtk::Box::builder()
124            .orientation(gtk::Orientation::Horizontal)
125            .spacing(12)
126            .hexpand(true)
127            .vexpand(true)
128            .build();
129        let lb = gtk::ListBox::builder()
130            .hexpand(true)
131            .vexpand(true)
132            .valign(gtk::Align::Start)
133            .css_classes(["boxed-list"])
134            .selection_mode(gtk::SelectionMode::Single)
135            .build();
136        lb.append(&Theme::default().action_row());
137        for theme in themes {
138            lb.append(&theme.action_row());
139        }
140        w.append(
141            &gtk::ScrolledWindow::builder()
142                .min_content_height(300)
143                .hscrollbar_policy(gtk::PolicyType::Never)
144                .hexpand(true)
145                .vexpand(true)
146                .child(
147                    &adw::Clamp::builder()
148                        .hexpand(true)
149                        .vexpand(true)
150                        .margin_top(24)
151                        .margin_bottom(24)
152                        .margin_start(12)
153                        .margin_end(12)
154                        .child(&lb)
155                        .build(),
156                )
157                .build(),
158        );
159        (w, lb)
160    }
161
162    /// Creates a ready to use [`adw::Dialog`] that allows to choose and set
163    /// a theme. It must be provided with a valid list of themes.
164    ///
165    /// It's important that the theme names are unique, otherwise if two themes
166    /// are homonymous only the first one will be used.
167    ///
168    /// There is no inherent mechanism to save the theme preference, which is
169    /// instead delegated to the consumer.
170    pub fn theme_chooser_dialog(&self, themes: &[Theme]) -> adw::Dialog {
171        let (theme_chooser_w, themes_lb) = Self::theme_chooser_widget(themes);
172        let apply_btn = gtk::Button::builder()
173            .label("Apply")
174            .css_classes(["suggested-action"])
175            .build();
176        let cancel_btn = gtk::Button::builder().label("Cancel").build();
177        let dialog = adw::Dialog::builder()
178            .child(&{
179                let tbv = adw::ToolbarView::builder()
180                    .content(&theme_chooser_w)
181                    .build();
182                tbv.add_top_bar(&{
183                    let hb = adw::HeaderBar::builder()
184                        .title_widget(&adw::WindowTitle::builder().title("Select a Theme").build())
185                        .show_start_title_buttons(false)
186                        .show_end_title_buttons(false)
187                        .build();
188                    hb.pack_start(&cancel_btn);
189                    hb.pack_end(&apply_btn);
190
191                    hb
192                });
193                tbv
194            })
195            .presentation_mode(adw::DialogPresentationMode::BottomSheet)
196            .build();
197
198        let themes_v: Vec<Theme> = themes.to_vec();
199        themes_lb.connect_row_selected(clone!(
200            #[strong]
201            apply_btn,
202            #[strong]
203            themes_v,
204            #[strong(rename_to = this)]
205            self,
206            move |_, row| {
207                apply_btn.set_sensitive(row.is_some());
208                if let Some(row) = row {
209                    // this unwrap is safe since the rows are created with
210                    // Self::theme_chooser_widget() and always ActionRows
211                    let target_name = row.clone().downcast::<adw::ActionRow>().unwrap().title();
212                    if let Some(theme) = themes_v.iter().find(|theme| theme.name == target_name) {
213                        this.apply_no_set(theme);
214                    } else {
215                        this.apply_no_set(&Theme::default())
216                    }
217                }
218            }
219        ));
220        apply_btn.connect_clicked(clone!(
221            #[strong]
222            themes_lb,
223            #[strong]
224            themes_v,
225            #[strong]
226            dialog,
227            #[strong(rename_to = this)]
228            self,
229            move |_| {
230                if let Some(row) = themes_lb.selected_row() {
231                    // this unwrap is safe since the rows are created with
232                    // Self::theme_chooser_widget() and are always ActionRows
233                    let target_name = row.downcast::<adw::ActionRow>().unwrap().title();
234                    if let Some(theme) = themes_v.iter().find(|theme| theme.name == target_name) {
235                        this.apply(theme);
236                    } else {
237                        this.apply(&Theme::default())
238                    }
239                    dialog.close();
240                }
241            }
242        ));
243        cancel_btn.connect_clicked(clone!(
244            #[strong]
245            dialog,
246            #[strong(rename_to = this)]
247            self,
248            move |_| {
249                this.reset_current_theme();
250                dialog.close();
251            }
252        ));
253
254        dialog
255    }
256}