Source: theme.js

/**
 * @namespace qui.theme
 */

import $      from '$qui/lib/jquery.module.js'
import Logger from '$qui/lib/logger.module.js'

import {gettext}         from '$qui/base/i18n.js'
import Signal            from '$qui/base/signal.js'
import Config            from '$qui/config.js'
import * as Colors       from '$qui/utils/colors.js'
import * as CSS          from '$qui/utils/css.js'
import {asap}            from '$qui/utils/misc.js'
import * as ObjectUtils  from '$qui/utils/object.js'
import * as PromiseUtils from '$qui/utils/promise.js'
import * as StringUtils  from '$qui/utils/string.js'
import * as Window       from '$qui/window.js'


const STORAGE_BACKGROUND_COLOR_KEY = 'theme.background-color'

const logger = Logger.get('qui.theme')

let currentTheme = null
let themeVars = null
let transitionDuration = null
let effectsDisabled = null


/**
 * Emitted whenever the theme is changed. Handlers are called with the following parameters:
 *  * `theme: String`, the new theme
 * @alias qui.theme.changeSignal
 */
export const changeSignal = new Signal()


/**
 * Tell the current theme.
 * @alias qui.theme.getCurrent
 * @returns {String}
 */
export function getCurrent() {
    return currentTheme
}

/**
 * Change the theme.
 * @alias qui.theme.setCurrent
 * @param {String} theme
 * @returns {Promise} a promise that resolves as soon as the theme has been set
 */
export function setCurrent(theme) {
    if (currentTheme === theme) {
        return Promise.resolve()
    }

    currentTheme = theme

    logger.debug(`setting theme to ${theme}`)

    if (Window.$body == null) {
        return Promise.resolve() /* Not initialized yet */
    }

    return updateCurrent()
}

function updateCurrent() {
    /* Fade out body */
    Window.$body.css('opacity', '')

    function isLoaded() {
        return CSS.findRules('^br.-theme-name$').some(function (rule) {
            let parts = rule.declaration.split(':', 2)
            if (parts.length < 2) {
                return
            }

            let value = parts[1].split(';')[0].trim()
            value = value.replace(/"/g, '') /* Remove quotation marks */

            return value === currentTheme
        })
    }

    function loadedOrLater() {
        if (isLoaded()) {
            return Promise.resolve()
        }
        else {
            return PromiseUtils.later(10).then(() => loadedOrLater())
        }
    }

    /* Allow 500ms for fading-out */
    return PromiseUtils.later(500).then(function () {

        /* Update disabled attribute of CSS link elements */
        $('link[theme]').each(function () {
            let $link = $(this)
            let linkTheme = $link.attr('theme')
            if (linkTheme === currentTheme) {
                $link.removeAttr('disabled')
            }
            else {
                $link.attr('disabled', '')
            }
        })

        return loadedOrLater().then(function () {

            logger.debug(`theme set to ${currentTheme}`)

            /* Invalidate theme vars */
            themeVars = null

            /* Finally, emit change signal */
            changeSignal.emit(currentTheme)

            /* Consider the theme updated, but allow another 500ms for section soft reload */
            setTimeout(function () {
                Window.$body.css('opacity', '1')
            }, 500)

            /* Set current background color in local storage, to be used at next app reload */
            window.localStorage.setItem(STORAGE_BACKGROUND_COLOR_KEY, getVar('background-color'))
        })

    })
}

/**
 * Return the available themes.
 * @alias qui.theme.getAvailable
 * @returns {Object<String,String>} a dictionary with theme names as keys and display names as values
 */
export function getAvailable() {
    return ObjectUtils.fromEntries(Config.themes.split(',').map(function (theme) {
        return [theme, gettext(StringUtils.title(theme))]
    }))
}

/**
 * Return the value of a theme variable.
 * @alias qui.theme.getVar
 * @param {String} name the variable name
 * @param {String} [def] a default value if the variable is not found or not set
 * @returns {String}
 */
export function getVar(name, def) {
    if (!themeVars) {
        themeVars = {}
        CSS.findRules('^br.-theme-').forEach(function (rule) {
            let name = rule.selector.substring(10)
            let parts = rule.declaration.split(':', 2)
            if (parts.length < 2) {
                return
            }

            themeVars[name] = parts[1].split(';')[0].trim()
        })
    }

    return themeVars[name] || def
}

/**
 * Resolve a color name and normalize it using {@link qui.utils.colors.normalize}.
 *
 * A color name can be an HTML color (e.g. `teal`) or a color theme variable name starting with an `@` (e.g.
 * `@background-color`).
 *
 * If a color is given, it will be normalized and returned right away. If the given color name cannot be resolved, the
 * `@foreground-color` is returned.
 *
 * @alias qui.theme.getColor
 * @param {String} color a color or a color name
 * @returns {String}
 */
export function getColor(color) {
    if (color.startsWith('@')) {
        color = getVar(color.substring(1))
    }

    if (!color) {
        color = getVar('foreground-color')
    }

    return Colors.normalize(color)
}

/**
 * Return the default transition duration, in milliseconds.
 * @alias qui.theme.getTransitionDuration
 * @returns {Number}
 */
export function getTransitionDuration() {
    if (transitionDuration == null) {
        transitionDuration = parseFloat(getVar('transition-duration')) * 1000
    }

    return transitionDuration
}

/**
 * Call a function after a timeout equal to a transition duration,
 * @alias qui.theme.afterTransition
 * @param {Function} func function to run
 * @param {?jQuery} [element] an optional HTML element whose visibility will be tested; if element is not currently
 * visible, `func` will be called asap; if supplied, will be used as `this` argument for `func`
 * @returns {Number} a timeout handle
 */
export function afterTransition(func, element = null) {
    let thisArg = element || window

    if (element && !element.is(':visible')) {
        return asap(function () {
            func.call(thisArg)
        })
    }

    return setTimeout(function () {
        func.call(thisArg)
    }, getTransitionDuration())
}

/**
 * Create a promise that resolves after a timeout equal to a transition duration,
 * @alias qui.theme.afterTransitionPromise
 * @param {?jQuery} [element] an optional HTML element whose visibility will be tested; if element is not currently
 * visible, promise is resolved asap
 * @returns {Promise}
 */
export function afterTransitionPromise(element = null) {
    return new Promise(function (resolve, reject) {

        if (element && !element.is(':visible')) {
            resolve()
        }
        else {
            afterTransition(function () {
                resolve()
            }, element)
        }
    })
}

/**
 * Enable transitions, animations, blur filters and other effects. Use this function to re-enable effects disabled by
 * {@link qui.theme.disableEffects}.
 * @alias qui.theme.enableEffects
 */
export function enableEffects() {
    if (!effectsDisabled) {
        return
    }
    effectsDisabled = false
    logger.debug('enabling effects')

    if (Window.$body != null) {
        Window.$body.removeClass('effects-disabled')
    }
}

/**
 * Disable transitions, animations, blur filters and other effects. Use {@link qui.theme.enableEffects} to re-enable
 * effects.
 * @alias qui.theme.disableEffects
 */
export function disableEffects() {
    if (effectsDisabled) {
        return
    }
    effectsDisabled = true
    logger.debug('disabling effects')

    if (Window.$body != null) {
        Window.$body.addClass('effects-disabled')
    }
}


/**
 * Initialize the theme subsystem.
 * @alias qui.theme.init
 * @returns {Promise} a promise that is resolved when theme subsystem has been initialized
 */
export function init() {
    if (effectsDisabled == null) {
        effectsDisabled = Config.defaultEffectsDisabled
    }
    if (currentTheme == null) {
        currentTheme = Config.defaultTheme
    }

    Window.$body.toggleClass('effects-disabled', effectsDisabled)

    return updateCurrent().then(function () {
        /* Normally updateCurrent() will take care of fading in body, adds extra 500ms for section soft reload.
         * This being the first call, we want the body visible as soon as theme is loaded */
        Window.$body.css('opacity', '1')
    })
}