delicious-adwaita 0.3.2

Color scheme manager for gtk4 libadwaita applications
Documentation
use crate::{color::ThemeColorVariant, theme::Theme};
use adw::prelude::{AdwDialogExt, PreferencesRowExt};
use gtk::{
    gdk,
    glib::{self, clone},
    prelude::{BoxExt, ButtonExt, Cast, ObjectExt, WidgetExt},
};
use std::{cell::RefCell, fmt::Display, rc::Rc};

pub mod color;
pub mod named_colors;
pub mod theme;

#[derive(Debug, Clone)]
pub struct ThemeEngine {
    display: gdk::Display,
    current_theme_provider: Rc<RefCell<Option<gtk::CssProvider>>>,
    current_theme: Rc<RefCell<Theme>>,
}

#[derive(Debug, Clone, Copy)]
pub enum ThemeEngineErr {
    NoGdkDisplay,
}

impl Display for ThemeEngineErr {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            Self::NoGdkDisplay => "Failed to get default GDK Display",
        })
    }
}

pub type ThemeEngineResult<T> = Result<T, ThemeEngineErr>;

impl ThemeEngine {
    /// Instantiates a new [`ThemeEngine`] and sets the provided theme as the
    /// current one. This is useful for restoring a user's theme preference
    /// across sessions.
    pub fn new_with_theme(theme: &Theme) -> ThemeEngineResult<Self> {
        let this = Self::new()?;
        this.apply(theme);
        Ok(this)
    }

    pub fn new() -> ThemeEngineResult<Self> {
        let this = Self {
            display: gdk::Display::default().ok_or(ThemeEngineErr::NoGdkDisplay)?,
            current_theme_provider: Rc::new(RefCell::new(None)),
            current_theme: Rc::new(RefCell::new(Theme::default())),
        };
        // listen for light/dark change and reset the theme
        adw::StyleManager::default().connect_local(
            "notify::dark",
            true,
            clone!(
                #[strong]
                this,
                move |_| {
                    // this further delays the execution to make sure that the theme has
                    // effectively changed
                    glib::idle_add_local_once(clone!(
                        #[strong]
                        this,
                        move || {
                            this.reset_current_theme();
                        }
                    ));
                    None
                }
            ),
        );
        Ok(this)
    }

    pub fn reset_current_theme(&self) {
        self.apply(&self.current_theme.clone().take());
    }

    pub fn apply(&self, theme: &Theme) {
        self.apply_no_set(theme);
        self.current_theme.replace(theme.clone());
    }

    /// Apply the given theme without setting current_theme to it.
    /// This is useful for previewing the theme and subsequently restoring the previously
    /// selected one.
    pub fn apply_no_set(&self, theme: &Theme) {
        if let Some(current_theme_provider) = self.current_theme_provider.borrow().as_ref() {
            gtk::style_context_remove_provider_for_display(&self.display, current_theme_provider);
        }
        if theme.is_system {
            self.current_theme_provider.replace(None);
        } else {
            let provider = theme.get_provider(ThemeColorVariant::current(&self.display));
            gtk::style_context_add_provider_for_display(
                &self.display,
                &provider,
                gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
            );
            self.current_theme_provider.replace(Some(provider));
        }
    }

    /// Retrieves the current theme name. This is useful for saving the theme
    /// preference across sessions.
    pub fn current_theme_name(&self) -> String {
        self.current_theme.borrow().name.clone()
    }

    /// Creates a theme chooser widget consisting of a [`gtk::ListBox`] inside
    /// a [`adw::Clamp`] inside a [`gtk::ScrolledWindow`]. The ListBox is inert
    /// and requires connecting its signals to be functional, that's why it's
    /// returned along with the parent.
    ///
    /// The return type is a tuple of the parent [`gtk::Box`], and the child
    /// [`gtk::ListBox`] so that the consumer can connect to its signals and
    /// read its state easily.
    ///
    /// Using this method is not recommended. It's preferable to use
    /// [`Self::theme_chooser_dialog`] instead.
    pub fn theme_chooser_widget(themes: &[Theme]) -> (gtk::Box, gtk::ListBox) {
        let w = gtk::Box::builder()
            .orientation(gtk::Orientation::Horizontal)
            .spacing(12)
            .hexpand(true)
            .vexpand(true)
            .build();
        let lb = gtk::ListBox::builder()
            .hexpand(true)
            .vexpand(true)
            .valign(gtk::Align::Start)
            .css_classes(["boxed-list"])
            .selection_mode(gtk::SelectionMode::Single)
            .build();
        lb.append(&Theme::default().action_row());
        for theme in themes {
            lb.append(&theme.action_row());
        }
        w.append(
            &gtk::ScrolledWindow::builder()
                .min_content_height(300)
                .hscrollbar_policy(gtk::PolicyType::Never)
                .hexpand(true)
                .vexpand(true)
                .child(
                    &adw::Clamp::builder()
                        .hexpand(true)
                        .vexpand(true)
                        .margin_top(24)
                        .margin_bottom(24)
                        .margin_start(12)
                        .margin_end(12)
                        .child(&lb)
                        .build(),
                )
                .build(),
        );
        (w, lb)
    }

    /// Creates a ready to use [`adw::Dialog`] that allows to choose and set
    /// a theme. It must be provided with a valid list of themes.
    ///
    /// It's important that the theme names are unique, otherwise if two themes
    /// are homonymous only the first one will be used.
    ///
    /// There is no inherent mechanism to save the theme preference, which is
    /// instead delegated to the consumer.
    pub fn theme_chooser_dialog(&self, themes: &[Theme]) -> adw::Dialog {
        let (theme_chooser_w, themes_lb) = Self::theme_chooser_widget(themes);
        let apply_btn = gtk::Button::builder()
            .label("Apply")
            .css_classes(["suggested-action"])
            .build();
        let cancel_btn = gtk::Button::builder().label("Cancel").build();
        let dialog = adw::Dialog::builder()
            .child(&{
                let tbv = adw::ToolbarView::builder()
                    .content(&theme_chooser_w)
                    .build();
                tbv.add_top_bar(&{
                    let hb = adw::HeaderBar::builder()
                        .title_widget(&adw::WindowTitle::builder().title("Select a Theme").build())
                        .show_start_title_buttons(false)
                        .show_end_title_buttons(false)
                        .build();
                    hb.pack_start(&cancel_btn);
                    hb.pack_end(&apply_btn);

                    hb
                });
                tbv
            })
            .presentation_mode(adw::DialogPresentationMode::BottomSheet)
            .build();

        let themes_v: Vec<Theme> = themes.to_vec();
        themes_lb.connect_row_selected(clone!(
            #[strong]
            apply_btn,
            #[strong]
            themes_v,
            #[strong(rename_to = this)]
            self,
            move |_, row| {
                apply_btn.set_sensitive(row.is_some());
                if let Some(row) = row {
                    // this unwrap is safe since the rows are created with
                    // Self::theme_chooser_widget() and always ActionRows
                    let target_name = row.clone().downcast::<adw::ActionRow>().unwrap().title();
                    if let Some(theme) = themes_v.iter().find(|theme| theme.name == target_name) {
                        this.apply_no_set(theme);
                    } else {
                        this.apply_no_set(&Theme::default())
                    }
                }
            }
        ));
        apply_btn.connect_clicked(clone!(
            #[strong]
            themes_lb,
            #[strong]
            themes_v,
            #[strong]
            dialog,
            #[strong(rename_to = this)]
            self,
            move |_| {
                if let Some(row) = themes_lb.selected_row() {
                    // this unwrap is safe since the rows are created with
                    // Self::theme_chooser_widget() and are always ActionRows
                    let target_name = row.downcast::<adw::ActionRow>().unwrap().title();
                    if let Some(theme) = themes_v.iter().find(|theme| theme.name == target_name) {
                        this.apply(theme);
                    } else {
                        this.apply(&Theme::default())
                    }
                    dialog.close();
                }
            }
        ));
        cancel_btn.connect_clicked(clone!(
            #[strong]
            dialog,
            #[strong(rename_to = this)]
            self,
            move |_| {
                this.reset_current_theme();
                dialog.close();
            }
        ));

        dialog
    }
}