#!/usr/bin/python3
#
# Polychromatic is free software: you can redistribute it and/or modify
# it under the temms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Polychromatic is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Polychromatic. If not, see <http://www.gnu.org/licenses/>.
#
# Copyright (C) 2015-2016 Terry Cain <terry@terrys-home.co.uk>
#               2015-2021 Luke Horwell <code@horwell.me>
#
"""
Control devices from the desktop environment's indicator applet or system tray.

Supports AppIndicator, Ayatana Indicators or GTK Status Icon.
"""

import atexit
import argparse
import os
import sys
import signal
import setproctitle
import subprocess
from shutil import which

import gi
gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
from gi.repository import Gtk, Gdk

VERSION = "0.7.3"
SUPPORTED = ["GtkStatusIcon"]

try:
    gi.require_version('AyatanaAppIndicator3', '0.1')
    from gi.repository import AyatanaAppIndicator3
    SUPPORTED.append("AyatanaAppIndicator3")
except (ImportError, ValueError):
    pass

try:
    gi.require_version("AppIndicator3", "0.1")
    from gi.repository import AppIndicator3
    SUPPORTED.append("AppIndicator3")
except (ImportError, ValueError):
    pass

# Import modules if running relatively.
if os.path.exists(os.path.join(os.path.dirname(__file__), "pylib")):
    try:
        import pylib.preferences as pref
        import pylib.common as common
        import pylib.effects as effects
        import pylib.locales as locales
        import pylib.middleman as middleman_mod
        import pylib.procpid as procpid
    except (ImportError, Exception) as e:
        print("Failed to import modules relatively.\n")
        raise e

# Import modules if installed system-wide.
else:
    try:
        import polychromatic.preferences as pref
        import polychromatic.common as common
        import polychromatic.effects as effects
        import polychromatic.locales as locales
        import polychromatic.middleman as middleman_mod
        import polychromatic.procpid as procpid
    except (ImportError, Exception) as e:
        print("Polychromatic's modules could not be imported.")
        print("Check all dependencies are installed, the Python environment variables are correct, or try re-installing the application.\n")
        raise e


class Indicator(object):
    # TODO: Refactor into classes

    def __init__(self):
        """
        List of technologies that can power the indicator/applet/tray icon.

        For naming, this will be internally referred to as "indicator"
        """
        self.indicator = None
        self.root_menu = None
        self.mode = prefs["tray"]["mode"]

        # Try to find a suitable renderer.
        if self.mode == pref.TRAY_AUTO:
            if "AyatanaAppIndicator3" in SUPPORTED:
                self.mode = pref.TRAY_AYATANA
            elif "AppIndicator3" in SUPPORTED:
                self.mode = pref.TRAY_APPINDICATOR
            else:
                self.mode = pref.TRAY_GTK_STATUS

        self.names = {
            pref.TRAY_APPINDICATOR: "AppIndicator3",
            pref.TRAY_GTK_STATUS: "GTKStatusIcon",
            pref.TRAY_AYATANA: "AyatanaAppIndicator3",
        }

        # User may pass a parameter to force a different indicator.
        if args.force_appindicator:
            self.mode = pref.TRAY_APPINDICATOR
        elif args.force_ayatana:
            self.mode = pref.TRAY_AYATANA
        elif args.force_gtk_status:
            self.mode = pref.TRAY_GTK_STATUS

        # Checks the specified mode is available.
        # Fallback order: Ayatana -> AppIndicator3 -> GTK Status
        if self.mode == pref.TRAY_APPINDICATOR and not "AppIndicator3" in SUPPORTED:
            dbg.stdout("AppIndicator3 not available, falling back to AyatanaAppIndicator3...", dbg.warning)
            self.mode = pref.TRAY_AYATANA

        if self.mode == pref.TRAY_AYATANA and not "AyatanaAppIndicator3" in SUPPORTED:
            dbg.stdout("AyatanaAppIndicator3 not available, falling back to AppIndicator3...", dbg.warning)
            self.mode = pref.TRAY_APPINDICATOR

        if self.mode == pref.TRAY_APPINDICATOR and not "AppIndicator3" in SUPPORTED:
            dbg.stdout("AppIndicator3 not available, falling back to GTK Status Icon...", dbg.warning)
            self.mode = pref.TRAY_GTK_STATUS

    def setup(self, icon_path):
        """
        Creates the object, depending on backend in use.

        Params:
            icon_path       (str)       Absolute path to the icon
        """
        dbg.stdout("Initialising " + self.names[self.mode] + "...", dbg.action, 1)

        if self.mode in [pref.TRAY_APPINDICATOR, pref.TRAY_AYATANA]:
            self.indicator = appindicator.Indicator.new("polychromatic-tray-applet", icon_path, appindicator.IndicatorCategory.APPLICATION_STATUS)
            self.indicator.set_status(appindicator.IndicatorStatus.ACTIVE)

        elif self.mode == pref.TRAY_GTK_STATUS:
            self.indicator = Gtk.StatusIcon()
            self.indicator.set_name("polychromatic-tray-applet")
            self.indicator.set_from_file(icon_path)
            self.indicator.set_visible(True)

        dbg.stdout("Initialised " + self.names[self.mode], dbg.success, 1)

    def create_menu(self):
        """
        Returns the root menu object.
        """
        if self.mode in [pref.TRAY_APPINDICATOR, pref.TRAY_AYATANA, pref.TRAY_GTK_STATUS]:
            self.root_menu = Gtk.Menu()
            return self.root_menu

    def create_menu_item(self, menu, label, enabled, function=None, function_params=None, icon_path=None):
        """
        Returns a menu item object for appending into a menu.

        Params:
            menu                (obj)   create_menu() or create_submenu() object
            label               (str)   Text to display to the user.
            enabled             (bool)  Whether the selection should be highlighted or not.
            function            (obj)   Callback when button is clicked.
            function_params     (list)  Functions to pass the callback function.
            icon_path           (str)   Path to image file.
        """
        if self.mode in [pref.TRAY_APPINDICATOR, pref.TRAY_AYATANA, pref.TRAY_GTK_STATUS]:
            if icon_path and os.path.exists(icon_path):
                item = Gtk.ImageMenuItem(label=label)
                item.set_sensitive(enabled)
                item.show()

                img = Gtk.Image()
                img.set_from_file(icon_path)
                item.set_image(img)
            else:
                item = Gtk.MenuItem(label=label)
                item.set_sensitive(enabled)
                item.show()

            if function and not function_params:
                item.connect("activate", function)
            elif function and function_params:
                item.connect("activate", function, function_params)

            menu.append(item)
            return item

    def add_menu_item(self, menu, menu_item):
        """
        Add a menu item to the specified menu.
        """
        menu.append(menu_item)

    def create_submenu(self, label, enabled, icon_path=None):
        """
        Create a submenu object, one to add to the parent menu, and the other
        containing the new menu object itself.

        Params:
            label               (str)   Text to display to the user.
            enabled             (bool)  Whether the selection should be highlighted or not.

        Returns list of objects:
            menu                Menu object (to contain new child menu items)
            item                Menu item object (for appending to parent menu)
        """
        if self.mode in [pref.TRAY_APPINDICATOR, pref.TRAY_AYATANA, pref.TRAY_GTK_STATUS]:
            if icon_path and os.path.exists(icon_path):
                item = Gtk.ImageMenuItem(label=label)
                item.set_sensitive(enabled)
                item.show()

                img = Gtk.Image()
                img.set_from_file(icon_path)
                item.set_image(img)            # GTK3: Deprecated, no replacement!
            else:
                item = Gtk.MenuItem(label=label)
                item.set_sensitive(enabled)
                item.show()

            menu = Gtk.Menu()
            menu.show()
            item.set_submenu(menu)

            return[menu, item]

    def create_seperator(self, menu):
        """
        Returns a seperator object.

        Params:
            menu                (obj)   create_menu() or create_submenu() object.
        """
        if self.mode in [pref.TRAY_APPINDICATOR, pref.TRAY_AYATANA, pref.TRAY_GTK_STATUS]:
            sep = Gtk.SeparatorMenuItem()
            sep.show()
            self.add_menu_item(menu, sep)

    def finalize(self):
        """
        Connects the event for when the menu is opened/clicked.
        """
        if self.mode in [pref.TRAY_APPINDICATOR, pref.TRAY_AYATANA]:
            self.indicator.set_title("Polychromatic")
            self.indicator.set_menu(self.root_menu)

        elif pref.TRAY_GTK_STATUS:
            def _show_menu_cb(widget, button, time, data=None):
                self.root_menu.show_all()
                self.root_menu.popup(None, None, Gtk.StatusIcon.position_menu, self.indicator, button, time)

            def _click_menu_cb(widget):
                cb.launch_controller(None)
            self.menu = self.root_menu
            self.indicator.connect("popup-menu", _show_menu_cb)
            self.indicator.connect("activate", _click_menu_cb)


class PolychromaticTrayApplet():
    """
    Provides the logic for providing quick access to device options from the
    system tray or notification area.
    """
    def __init__(self):
        pass

    def start(self):
        """
        Begin execution of the tray applet.
        """
        # Show error if no backends are available
        if len(middleman.backends) == 0:
            self._setup_failed(_("No backends available"), common.get_icon("general", "unknown"))
            return False

        # Build the applet
        self.build_indicator()
        dbg.stdout("Finished setting up applet.", dbg.success, 1)

    def _get_icon(self, img_dir, icon):
        """
        Returns the path for a Polychromatic icon.

        Params:
            img_dir         (str)   Folder inside img, e.g. "effects"
            icon            (str)   Filename excluding the extension.
        """
        return common.get_icon(img_dir, icon)

    def _get_tray_icon(self):
        """
        Returns path for tray icon.
        """
        return common.get_tray_icon(dbg, prefs["tray"]["icon"])

    def _create_menu_item_if_controller_present(self, menu, label, open_to, icon_path=None):
        """
        Creates a menu item, but conditionally checks if the Controller is
        installed. This is for buttons that would open the Controller to edit
        the feature via a GUI.
        """
        is_installed = procpid.ProcessManager().is_component_installed("controller")
        indicator.create_menu_item(menu, label, is_installed, cb.launch_controller, open_to, icon_path)

    def _setup_failed(self, message, icon_path):
        """
        A simple menu is displayed when something goes wrong.
        """
        dbg.stdout("Assembling error applet...", dbg.action, 1)

        error_icon = common.get_tray_icon(dbg, "img/tray/error.svg")
        indicator.setup(error_icon)

        menu = indicator.create_menu()
        indicator.create_menu_item(menu, message, False, None, None, icon_path)
        indicator.create_seperator(menu)
        indicator.create_menu_item(menu, _("Refresh"), True, cb.retry_applet, None, self._get_icon("general", "refresh"))
        self._create_menu_item_if_controller_present(menu, _("Troubleshoot"), "troubleshoot", self._get_icon("general", "preferences"))
        indicator.create_seperator(menu)
        self._create_menu_item_if_controller_present(menu, _("Open Controller"), None, self._get_icon("general", "controller"))
        indicator.create_menu_item(menu, _("Quit"), True, cb.quit)

        indicator.finalize()

        dbg.stdout(message, dbg.error)

    def build_indicator(self):
        """
        Populates the menu for the tray applet.
        """
        indicator.setup(self._get_tray_icon())

        dbg.stdout("Loading device data...", dbg.action, 1)
        devices = middleman.get_device_all()
        unknown_devices = middleman.get_unsupported_devices()

        dbg.stdout("Creating menus...", dbg.action, 1)
        menu = indicator.create_menu()

        # List devices and their submenus.
        if len(devices) == 0:
            dbg.stdout("No devices found.", dbg.error, 1)
            indicator.create_menu_item(menu, _("No devices found."), False)

        # Order devices A-Z for consistency
        sorted_devices = sorted(devices, key=lambda device: device["name"])

        for device in sorted_devices:
            indicator.add_menu_item(menu, self.build_device_submenu(device))

        for device in unknown_devices:
            indicator.create_menu_item(menu, "{0} {1}".format(_("Unrecognised:"), device["name"]), False, None, None, device["form_factor"]["icon"])

        if len(devices) == 0:
            indicator.create_seperator(menu)
            indicator.create_menu_item(menu, _("Refresh"), True, cb.retry_applet, None, self._get_icon("general", "refresh"))
            indicator.create_menu_item(menu, _("Troubleshoot"), True, cb.launch_controller, "troubleshoot", self._get_icon("general", "preferences"))

        # When multiple devices are present, show 'apply to all' actions
        if len(devices) > 1:
            bulk_menu, bulk_menu_item = indicator.create_submenu(_("Apply to All"), True, self._get_icon("devices", "all"))
            bulk = common.get_bulk_apply_options(_, devices)

            effects = bulk["effects"]
            brightnesses = bulk["brightness"][::-1]

            # -- Brightness
            if len(brightnesses) > 0:
                indicator.create_menu_item(bulk_menu, _("Brightness"), False, None, None, self._get_icon("options", "brightness"))

            for brightness in brightnesses:
                option_id = brightness["id"]
                option_data = brightness["data"]
                label = brightness["label"]
                icon = self._get_icon("options", str(option_data))

                indicator.create_menu_item(bulk_menu, label, True, cb.set_bulk_option, [option_id, option_data, 0], icon)

            # -- Options
            if len(effects) > 0:
                # Separate from brightness, if necessary
                if len(brightnesses) > 0:
                    indicator.create_seperator(bulk_menu)

                indicator.create_menu_item(bulk_menu, _("Effects"), False, None, None, self._get_icon("options", "ripple"))

            for option in bulk["effects"]:
                option_id = option["id"]
                option_data = option["data"]
                required_colours = option["required_colours"]
                label = option["label"]
                icon = self._get_icon("options", str(option_id))

                indicator.create_menu_item(bulk_menu, label, True, cb.set_bulk_option, [option_id, option_data, required_colours], icon)

            # -- Colours
            if len(effects) > 0:
                indicator.create_seperator(bulk_menu)
                colour_menu, colour_menu_item = indicator.create_submenu(_("Primary Colour"), True, self._get_icon("general", "palette"))

                for colour in saved_colours:
                    label = colour["name"]
                    data = colour["hex"]
                    icon = self._get_colour_icon(data)
                    indicator.create_menu_item(colour_menu, label, True, cb.set_bulk_colour, [data], icon)
                indicator.add_menu_item(bulk_menu, colour_menu_item)

            indicator.create_seperator(menu)
            indicator.add_menu_item(menu, bulk_menu_item)

            # TODO: Placeholder, Presets not yet implemented
            #print("stub:preset menu")
            #presets_menu, presets_menu_item = indicator.create_submenu(_("Apply Preset"), False, self._get_icon("general", "presets"))
            #indicator.create_menu_item(presets_menu, "Placeholder", False)
            #indicator.add_menu_item(menu, presets_menu_item)

        # General Options
        indicator.create_seperator(menu)
        self._create_menu_item_if_controller_present(menu, _("Open Controller"), None, self._get_icon("general", "controller"))

        # When tray applet is only installed, show toggle for auto start
        if not procpid.ProcessManager().is_component_installed("controller"):
            def _toggle_auto_start(params=None):
                new_value = not prefs["tray"]["autostart"]
                prefs["tray"]["autostart"] = new_value
                pref.save_file(pref.path.preferences, prefs)
                _update_auto_start_toggle(new_value)

            auto_start_item = indicator.create_menu_item(menu, "Autostart", True, _toggle_auto_start, None, self._get_icon("general", "tray-applet"))

            def _update_auto_start_toggle(is_enabled):
                # FIXME: HACK: Not framework agnostic!
                img = Gtk.Image()
                if is_enabled:
                    auto_start_item.set_label(_("Disable automatic start at login"))
                    img.set_from_file(self._get_icon("general", "close"))
                else:
                    auto_start_item.set_label(_("Start automatically at login"))
                    img.set_from_file(self._get_icon("general", "ok"))
                auto_start_item.set_image(img)

            _update_auto_start_toggle(prefs["tray"]["autostart"])

        indicator.create_menu_item(menu, _("Quit"), True, cb.quit)
        indicator.finalize()

    def build_device_submenu(self, device):
        """
        Assembles the menu (and submenus) that control an individual device_item.
        """
        device_name = device["name"]
        device_icon = device["form_factor"]["icon"]
        multiple_zones = len(device["zone_options"]) > 1
        can_set_colours = False

        submenu, submenu_item = indicator.create_submenu(device_name, True, device_icon)
        dbg.stdout("- " + device_name, dbg.action, 1)

        for zone in device["zone_options"].keys():
            zone_name = device["zone_labels"][zone]
            zone_icon = device["zone_icons"][zone]

            # Create label when multiple zones are present
            if multiple_zones:
                indicator.create_menu_item(submenu, zone_name, False, None, None, zone_icon)

            # Add options
            for option in device["zone_options"][zone]:
                option_id = option["id"]
                option_label = option["label"]
                option_icon = self._get_icon("options", option_id)

                # Show 'Edit Colours' later if supported
                if len(option["colours"]) > 0:
                    can_set_colours = True

                # Option contains parameters, show submenu
                if option["type"] in ["effect", "multichoice"]:
                    # Option does not accept parameters, just a button
                    if len(option["parameters"]) == 0:
                        indicator.create_menu_item(submenu, option_label, True, cb.set_option, [device, zone, option_id, None], option_icon)
                    else:
                        # Create a submenu for effects
                        indicator.add_menu_item(submenu, self.create_parameters_menu(device, zone, option))

                        for param in option["parameters"]:
                            if len(param["colours"]) > 0:
                                can_set_colours = True

                # Option is a slider, for values between 0 and 100.
                elif option["type"] == "slider":
                    indicator.add_menu_item(submenu, self.create_slider_menu(device, zone, option))

                # Option is a boolean choice
                elif option["type"] == "toggle":
                    indicator.add_menu_item(submenu, self.create_toggle_menu(device, zone, option))

                # Option is a label, always show
                elif option["type"] == "label":
                    self.create_label_control(option, submenu)

                # Option is a dialog, but will be shown in a separate menu
                elif option["type"] == "dialog":
                    indicator.add_menu_item(submenu, self.create_dialog_control(option))

                # Option is a clickable one-way action with no feedback
                elif option["type"] == "button":
                    self.create_button_control(submenu, option, device, zone)

            # Add DPI
            if zone == "main" and device["dpi_x"]:
                dpi_menu, dpi_menu_item = indicator.create_submenu(_("DPI"), True, self._get_icon("general", "dpi"))
                dpi_stages = device["dpi_stages"]
                if prefs["custom"]["use_dpi_stages"]:
                    dpi_stages = []
                    for i in range(1, 6):
                        dpi_stages.append(prefs["custom"]["dpi_stage_" + str(i)])
                for item, dpi in enumerate(dpi_stages):
                    icon = None
                    if item == 0:
                        icon = self._get_icon("general", "dpi-slow")
                    elif item == 4:
                        icon = self._get_icon("general", "dpi-fast")
                    indicator.create_menu_item(dpi_menu, str(dpi), True, cb.set_dpi, [device, dpi], icon)
                indicator.add_menu_item(submenu, dpi_menu_item)

            # Create seperator if there was a zone label
            if multiple_zones:
                indicator.create_seperator(submenu)

        if device["matrix"]:
            # FIXME: Consider scripted effects too!
            effect_items = fileman_effects.get_item_list_by_key_filter("map_device", device_name)
            effect_items = sorted(effect_items, key=lambda item: item["name"])
            effects_menu, effects_menu_item = indicator.create_submenu(_("Effects"), True, self._get_icon("general", "effects"))

            if len(effect_items) > 0:
                for item in effect_items:
                     indicator.create_menu_item(effects_menu, item["name"], True, cb.set_effect, [device, item["path"]], item["icon"])
            else:
                indicator.create_menu_item(effects_menu, _("No custom effects"), False)

            indicator.create_seperator(submenu)
            indicator.add_menu_item(submenu, effects_menu_item)
            indicator.create_seperator(effects_menu)
            indicator.create_menu_item(effects_menu, _("Edit Effects..."), True, cb.launch_controller, "effects", self._get_icon("general", "edit"))

        # Colour (repeats last effect)
        if can_set_colours:
            colour_menu, colour_menu_item = indicator.create_submenu(_("Primary Colour"), True, self._get_icon("general", "palette"))

            if device["monochromatic"]:
                index = colours_mono
            else:
                index = saved_colours

            for pos in range(0, len(index)):
                try:
                    name = index[pos]["name"]
                    hex_value = index[pos]["hex"]
                    indicator.create_menu_item(colour_menu, name, True, cb.set_colour_primary, [device, hex_value], self._get_colour_icon(hex_value))
                except Exception:
                    dbg.stdout("Ignoring invalid colour data at index position " + str(pos), dbg.error)

            if not device["monochromatic"]:
                indicator.create_seperator(colour_menu)
                indicator.create_menu_item(colour_menu, _("Custom..."), True, cb.set_custom_colour, [device, zone], self._get_icon("general", "palette"))
                self._create_menu_item_if_controller_present(colour_menu, _("Edit Colours..."), "colours", self._get_icon("general", "edit"))

            if multiple_zones:
                indicator.create_menu_item(submenu, _("All Zones"), False, None, None, device_icon)
            indicator.add_menu_item(submenu, colour_menu_item)

        return submenu_item

    def create_parameters_menu(self, device, zone, option):
        """
        Creates the submenu for an option containing parameters from a multiple
        choice or effect list.
        """
        option_id = option["id"]
        submenu, submenu_item = indicator.create_submenu(option["label"], True, self._get_icon("options", option_id))

        for param in option["parameters"]:
            icon = common.get_icon("params", param["id"])

            indicator.create_menu_item(submenu, param["label"], True, cb.set_option, [device, zone, option_id, param["data"]], icon)

        return submenu_item

    def create_slider_menu(self, device, zone, option):
        """
        Creates the submenu for an option that is a variable slider. This will
        create five calculated intervals (e.g. 0-100 -> 0,25,50,75,100)
        """
        option_id = option["id"]
        submenu, submenu_item = indicator.create_submenu(option["label"], True, self._get_icon("options", option_id))

        suffix = option["suffix"]
        min = int(option["min"])
        max = int(option["max"])
        interval = option["step"]

        if min == 0 and max == 100:
            interval = int((max - min) / 4)
        elif min == 1 and max == 100:
            interval = 10

        for no in range(max, min - interval, 0 - interval):
            # Some IDs have submenu icons
            icon = None
            if option_id == "brightness":
                icon = self._get_icon("options", str(no))

            indicator.create_menu_item(submenu, str(no) + suffix, True, cb.set_option, [device, zone, option_id, int(no)], icon)

        return submenu_item

    def create_toggle_menu(self, device, zone, option):
        """
        Creates the submenu for an option that is either on/off.
        """
        option_id = option["id"]
        submenu, submenu_item = indicator.create_submenu(option["label"], True, self._get_icon("options", option_id))

        # Some IDs have submenu icons
        icon_on = None
        icon_off = None
        if option_id == "game_mode":
            icon_on = self._get_icon("options", "game_mode")
            icon_off = self._get_icon("options", "game_mode_off")
        elif option_id == "brightness":
            icon_on = self._get_icon("options", "100")
            icon_off = self._get_icon("options", "0")

        indicator.create_menu_item(submenu, _("On"), True, cb.set_option, [device, zone, option_id, True], icon_on)
        indicator.create_menu_item(submenu, _("Off"), True, cb.set_option, [device, zone, option_id, False], icon_off)

        return submenu_item

    def create_label_control(self, option, submenu):
        """
        Creates a label for permanent display. A new label will be created for
        each line in the message.
        """
        indicator.create_seperator(submenu)
        for index, line in enumerate([option["label"]] + option["message"].split("\n")):
            indicator.create_menu_item(submenu, line, False, None, None, self._get_icon("general", "info") if index == 0 else None)
        indicator.create_seperator(submenu)

    def create_dialog_control(self, option):
        """
        Creates a submenu containing the message that would appear in a dialog.
        """
        submenu, submenu_item = indicator.create_submenu(option["button_text"], True, self._get_icon("general", "info"))
        for line in option["message"].split("\n"):
            indicator.create_menu_item(submenu, line, False)
        return submenu_item

    def create_button_control(self, submenu, option, device, zone):
        """
        Creates a menu item to execute a specific function without needing feedback.
        """
        indicator.create_menu_item(submenu, option["button_text"], True, cb.set_option, [device, zone, option["id"], None])

    def _get_colour_icon(self, colour_hex):
        """
        Generates a colour PNG, and returns the path so it can be used as an icon.

        Params:
            colour_hex      Hex value, e.g. "#00FF00"
        """
        return common.generate_colour_bitmap(dbg, colour_hex)


class Callback():
    """
    Contains functions that run when a menu item is clicked.
    """
    @staticmethod
    def launch_controller(item, tab=None):
        """
        Option clicked to open the Controller application.

        Params:
            tab     (Optional) Open a specific tab in the Controller
        """
        if tab:
            dbg.stdout("=> Launching Controller to '{0}' tab/section...".format(tab), dbg.action, 1)
        else:
            dbg.stdout("=> Launching Controller...", dbg.action, 1)

        common.execute_polychromatic_component(dbg, "controller", tab)

    @staticmethod
    def quit(item):
        """
        Option clicked to quit the application.
        """
        exit(0)

    @staticmethod
    def retry_applet(item):
        """
        Option clicked to restart the tray applet.
        """
        dbg.stdout("Restarting applet...", dbg.action, 1)
        os.execv(__file__, sys.argv)
        exit(0)

    @staticmethod
    def set_option(item, attr):
        """
        Option clicked to set an effect for a device.

        Params:
            attr        [device, zone, option_id, option_data]
        """
        device, zone, option_id, option_data = attr

        backend = device["backend"]
        uid = device["uid"]
        name = device["name"]
        serial = device["serial"]
        device = middleman.get_device(backend, uid)

        # Re-use previously selected colours
        colour_hex = []
        for option in device["zone_options"][zone]:
            if option["id"] == option_id:
                colour_hex = option["colours"]

            try:
                if option["parameters"]:
                    for param in option["parameters"]:
                        if param["data"] == option_data:
                            colour_hex = param["colours"]
            except KeyError:
                # No parameters for sliders or toggles
                pass

        dbg.stdout("Setting option '{0}' (data: {1}) for {2} in zone '{3}'".format(option_id, option_data, name, zone), dbg.action, 1)
        dbg.stdout("Re-using colours: {0}".format(", ".join(colour_hex)), dbg.debug, 1)
        r = middleman.set_device_state(backend, uid, serial, zone, option_id, option_data, colour_hex)
        _verbose_check_status(r)

    @staticmethod
    def set_colour_primary(item, attr):
        """
        Option clicked to choose a different primary colour.

        Params:
            attr    [device, hex_string]
        """
        device, hex_value = attr

        backend = device["backend"]
        uid = device["uid"]
        name = device["name"]
        device = middleman.get_device(backend, uid)

        for zone in device["zone_options"].keys():
            dbg.stdout("Setting '{2}' colour to '{0}' for {1}".format(hex_value, name, zone), dbg.action, 1)
            r = middleman.set_device_colour(device, zone, hex_value)
            _verbose_check_status(r)

    @staticmethod
    def set_dpi(item, attr):
        """
        Option clicked to set the DPI of a device.

        Params:
            attr    [device, dpi_x]
        """
        device, new_dpi = attr

        backend = device["backend"]
        uid = device["uid"]
        name = device["name"]
        serial = device["serial"]

        dbg.stdout("Setting DPI for {0} to {1}".format(name, new_dpi), dbg.action, 1)

        r = middleman.set_device_state(backend, uid, serial, "main", "dpi", [new_dpi, new_dpi], [])
        _verbose_check_status(r)

    @staticmethod
    def set_bulk_option(item, attr):
        """
        Option clicked to set an effect for all devices (where supported)

        Params:
            attr    [option_id, option_data, colours_needed]
        """
        option_id, option_data, colours_needed = attr
        middleman.set_bulk_option(option_id, option_data, colours_needed)

    @staticmethod
    def set_bulk_colour(item, attr):
        """
        Option clicked to set the primary colour for the current effect across
        all connected devices.

        Params:
            attr    [hex_value]
        """
        new_colour_hex = attr[0]
        middleman.set_bulk_colour(new_colour_hex)

    @staticmethod
    def set_custom_colour(item, attr):
        """
        Option clicked to open a colour picker to choose any colour for the
        specified device.

        Params:
            attr    [device]
        """
        device = attr[0]
        zone = attr[1]

        dbg.stdout("Opened GTK colour picker.", dbg.debug, 1)
        color_selection_dlg = Gtk.ColorSelectionDialog(_("Set Primary Colour"))
        color_selection_result = color_selection_dlg.run()

        if color_selection_result == Gtk.ResponseType.OK:
            # Parse colour from GTK
            result_gdk_colour = color_selection_dlg.get_color_selection().get_current_color()
            red = int(result_gdk_colour.red_float * 255)
            green = int(result_gdk_colour.green_float * 255)
            blue = int(result_gdk_colour.blue_float * 255)
            hex_value = common.rgb_to_hex([red, green, blue])

            # Apply colour to device
            dbg.stdout("Set custom colour for {0} to '{1}'".format(device["name"], hex_value), dbg.debug, 1)
            r = middleman.set_device_colour(device, zone, hex_value)
            _verbose_check_status(r)

        color_selection_dlg.destroy()

    @staticmethod
    def set_effect(item, attr):
        """
        Option clicked to activate an effect for the specified device.

        Params:
            attr    [device, path]
        """
        device = attr[0]
        path = attr[1]

        device_name = device["name"]
        dbg.stdout("Playing effect '{0}' on '{1}'".format(path, device_name), dbg.debug, 1)
        procmgr = procpid.ProcessManager("helper")
        procmgr.start_component(["--run-fx", path, "--device-name", device_name])


def _verbose_check_status(result):
    """
    When using the verbose parameter (-v), output details if a request made
    to the backend was successful.
    """
    if type(result) == str:
        dbg.stdout("An exception occurred processing this request:", dbg.error)
        dbg.stdout(result, dbg.error)
    elif result == True:
        dbg.stdout("Request OK", dbg.success, 1)
    elif result == None:
        dbg.stdout("Device Missing", dbg.warning, 1)
    elif result == False:
        dbg.stdout("Request unsuccessful - may be unsupported by the device.", dbg.normal, 1)


def parse_parameters():
    """
    Parses the optional parameters for the tray applet.
    """
    global _
    parser = argparse.ArgumentParser(add_help=False)
    parser._optionals.title = _("Optional arguments")
    parser.add_argument("-h", "--help", help=_("Show this help message and exit"), action="help")
    parser.add_argument("-v", "--verbose", help=_("Be verbose to stdout"), action="store_true")
    parser.add_argument("--version", help=_("Print program version and exit"), action="store_true")
    parser.add_argument("--locale", help=_("Force a specific locale, e.g. de_DE"), action="store")
    parser.add_argument("--force-appindicator", help=_("Render using AppIndicator3"), action="store_true")
    parser.add_argument("--force-ayatana", help=_("Render using Ayatana Indicators"), action="store_true")
    parser.add_argument("--force-gtk-status", help=_("Render using GTK Status (legacy)"), action="store_true")

    args = parser.parse_args()

    if args.version:
        app_version, git_commit, py_version = common.get_versions(VERSION)
        print("Polychromatic", app_version)
        if git_commit:
            print("Commit:", git_commit)
        print("Save Data:", pref.VERSION)
        print("Python:", py_version)
        print("GTK:", "{0}.{1}.{2}".format(Gtk.MAJOR_VERSION, Gtk.MINOR_VERSION, Gtk.MICRO_VERSION))
        exit(0)

    if args.verbose:
        dbg.verbose_level = 1
        dbg.stdout(_("Verbose enabled"), dbg.debug, 1)

    try:
        if os.environ["POLYCHROMATIC_DEV_CFG"] == "true":
            dbg.verbose_level = 1
            dbg.stdout("Verbose enabled (development mode)", dbg.action, 1)
    except KeyError:
        pass

    if args.locale:
        i18n = locales.Locales(__file__, args.locale)
        _ = i18n.init()

    return args


if __name__ == "__main__":
    # Appear as its own process.
    setproctitle.setproctitle("polychromatic-tray-applet")

    # Global variables
    i18n = locales.Locales(__file__)
    _ = i18n.init()
    dbg = common.Debugging()

    # Handle signals sent to process
    def _restart_applet(num, frame):
        dbg.stdout("Received USR2 signal. Reloading...", dbg.action, 1)
        process.start_component()
        _stop_applet()

    def _stop_applet(num=None, frame=None):
        dbg.stdout("Received INT or USR1 signal. Exiting...", dbg.action, 1)
        process.release_component_pid()
        exit(0)

    signal.signal(signal.SIGINT, _stop_applet)
    signal.signal(signal.SIGUSR1, _restart_applet)
    signal.signal(signal.SIGUSR2, _stop_applet)
    atexit.register(_stop_applet)

    # Only run one tray applet at a time - if one is running, replace it.
    process = procpid.ProcessManager("tray-applet")
    if process.is_another_instance_is_running():
        process.stop()
    process.set_component_pid()

    args = parse_parameters()
    pref.init(_)

    # Which AppIndicator? Both use the same API.
    if "AyatanaAppIndicator3" in SUPPORTED:
        appindicator = AyatanaAppIndicator3
    elif "AppIndicator3" in SUPPORTED:
        appindicator = AppIndicator3

    # Initialise devices
    middleman = middleman_mod.Middleman(dbg, common, _)
    middleman.init()
    device_list = middleman.get_device_list()

    # Load data into memory
    prefs = pref.load_file(common.paths.preferences)
    saved_colours = pref.get_colour_list(_)
    colours_mono = common.get_green_shades(_)
    fileman_effects = effects.EffectFileManagement(i18n, _, dbg)

    # Initialise the indicator
    cb = Callback()
    indicator = Indicator()
    app = PolychromaticTrayApplet()
    app.start()

    Gtk.main()
