1# Copyright (C) 2011 Chris Dekter
2# Copyright (C) 2018 Thomas Hess
3#
4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation, either version 3 of the License, or
7# (at your option) any later version.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License
15# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
17import logging
18from typing import Optional, Callable, TYPE_CHECKING
19
20from PyQt5.QtGui import QIcon
21from PyQt5.QtWidgets import QSystemTrayIcon, QAction, QMenu
22
23from autokey.qtui import popupmenu
24import autokey.qtui.common as ui_common
25from autokey import configmanager as cm
26
27if TYPE_CHECKING:
28    from autokey.qtapp import Application
29
30TOOLTIP_RUNNING = "AutoKey - running"
31TOOLTIP_PAUSED = "AutoKey - paused"
32
33logger = ui_common.logger.getChild("System-tray-notifier")  # type: logging.Logger
34
35
36class Notifier(QSystemTrayIcon):
37
38    def __init__(self, app):
39        logger.debug("Creating system tray icon notifier.")
40        icon = self._load_default_icon()
41        super(Notifier, self).__init__(icon, app)
42        # Actions
43        self.action_view_script_error = None  # type: QAction
44        self.action_hide_icon = None  # type: QAction
45        self.action_show_config_window = None  # type: QAction
46        self.action_quit = None  # type: QAction
47        self.action_enable_monitoring = None  # type: QAction
48
49        self.app = app  # type: Application
50        self.config_manager = self.app.configManager
51        self.activated.connect(self.on_activate)
52
53        self._create_static_actions()
54        self.create_assign_context_menu()
55        self.update_tool_tip(cm.ConfigManager.SETTINGS[cm.SERVICE_RUNNING])
56        self.app.monitoring_disabled.connect(self.update_tool_tip)
57        if cm.ConfigManager.SETTINGS[cm.SHOW_TRAY_ICON]:
58            logger.debug("About to show the tray icon.")
59            self.show()
60        logger.info("System tray icon notifier created.")
61
62    def create_assign_context_menu(self):
63        """
64        Create a context menu, then set the created QMenu as the context menu.
65        This builds the menu with all required actions and signal-slot connections.
66        """
67        menu = QMenu("AutoKey")
68        self._build_menu(menu)
69        self.setContextMenu(menu)
70
71    def update_tool_tip(self, service_running: bool):
72        """Slot function that updates the tooltip when the user activates or deactivates the expansion service."""
73        if service_running:
74            self.setToolTip(TOOLTIP_RUNNING)
75        else:
76            self.setToolTip(TOOLTIP_PAUSED)
77
78    @staticmethod
79    def _load_default_icon() -> QIcon:
80        return QIcon.fromTheme(
81            cm.ConfigManager.SETTINGS[cm.NOTIFICATION_ICON],
82            ui_common.load_icon(ui_common.AutoKeyIcon.SYSTEM_TRAY)
83        )
84
85    @staticmethod
86    def _load_error_state_icon() -> QIcon:
87        return QIcon.fromTheme(
88            "autokey-status-error",
89            ui_common.load_icon(ui_common.AutoKeyIcon.SYSTEM_TRAY_ERROR)
90        )
91
92    def _create_action(
93            self,
94            icon_name: Optional[str],
95            title: str,
96            slot_function: Callable[[None], None],
97            tool_tip: Optional[str]=None)-> QAction:
98        """
99        QAction factory. All items created belong to the calling instance, i.e. created QAction parent is self.
100        """
101        action = QAction(title, self)
102        if icon_name:
103            action.setIcon(QIcon.fromTheme(icon_name))
104        action.triggered.connect(slot_function)
105        if tool_tip:
106            action.setToolTip(tool_tip)
107        return action
108
109    def _create_static_actions(self):
110        """
111        Create all static menu actions. The created actions will be placed in the tray icon context menu.
112        """
113        logger.info("Creating static context menu actions.")
114        self.action_view_script_error = self._create_action(
115            None, "&View script error", self.reset_tray_icon,
116            "View the last script error."
117        )
118        self.action_view_script_error.triggered.connect(self.app.show_script_error)
119        # The action should disable itself
120        self.action_view_script_error.setDisabled(True)
121        self.action_view_script_error.triggered.connect(self.action_view_script_error.setEnabled)
122        self.action_hide_icon = self._create_action(
123            "edit-clear", "Temporarily &Hide Icon", self.hide,
124            "Temporarily hide the system tray icon.\nUse the settings to hide it permanently."
125        )
126        self.action_show_config_window = self._create_action(
127            "configure", "&Show Main Window", self.app.show_configure,
128            "Show the main AutoKey window. This does the same as left clicking the tray icon."
129        )
130        self.action_quit = self._create_action("application-exit", "Exit AutoKey", self.app.shutdown)
131        # TODO: maybe import this from configwindow.py ? The exact same Action is defined in the main window.
132        self.action_enable_monitoring = self._create_action(
133            None, "&Enable Monitoring", self.app.toggle_service,
134            "Pause the phrase expansion and script execution, both by abbreviations and hotkeys.\n"
135            "The global hotkeys to show the main window and to toggle this setting, as defined in the AutoKey "
136            "settings, are not affected and will work regardless."
137        )
138        self.action_enable_monitoring.setCheckable(True)
139        self.action_enable_monitoring.setChecked(self.app.service.is_running())
140        self.action_enable_monitoring.setDisabled(self.app.serviceDisabled)
141        # Sync action state with internal service state
142        self.app.monitoring_disabled.connect(self.action_enable_monitoring.setChecked)
143
144    def _fill_context_menu_with_model_item_actions(self, context_menu: QMenu):
145        """
146        Find all model items that should be available in the context menu and create QActions for each, by
147        using the available logic in popupmenu.PopupMenu.
148        """
149        # Get phrase folders to add to main menu
150        logger.info("Rebuilding model item actions, adding all items marked for access through the tray icon.")
151        folders = [folder for folder in self.config_manager.allFolders if folder.show_in_tray_menu]
152        items = [item for item in self.config_manager.allItems if item.show_in_tray_menu]
153        # Only extract the QActions, but discard the PopupMenu instance.
154        # This is done, because the PopupMenu class is not directly usable as a context menu here.
155        menu = popupmenu.PopupMenu(self.app.service, folders, items, False, "AutoKey")
156        new_item_actions = menu.actions()
157        context_menu.addActions(new_item_actions)
158        for action in new_item_actions:  # type: QAction
159            # QMenu does not take the ownership when adding QActions, so manually re-parent all actions.
160            # This causes the QActions to be destroyed when the context menu is cleared or re-created.
161            action.setParent(context_menu)
162
163        if not context_menu.isEmpty():
164            # Avoid a stray separator line, if no items are marked for display in the context menu.
165            context_menu.addSeparator()
166
167    def _build_menu(self, context_menu: QMenu):
168        """Build the context menu."""
169        logger.debug("Show tray icon enabled in settings: {}".format(cm.ConfigManager.SETTINGS[cm.SHOW_TRAY_ICON]))
170        # Items selected for display are shown on top
171        self._fill_context_menu_with_model_item_actions(context_menu)
172        # The static actions are added at the bottom
173        context_menu.addAction(self.action_view_script_error)
174        context_menu.addAction(self.action_enable_monitoring)
175        context_menu.addAction(self.action_hide_icon)
176        context_menu.addAction(self.action_show_config_window)
177        context_menu.addAction(self.action_quit)
178
179    def update_visible_status(self):
180        visible = cm.ConfigManager.SETTINGS[cm.SHOW_TRAY_ICON]
181        if visible:
182            self.create_assign_context_menu()
183        self.setVisible(visible)
184        logger.info("Updated tray icon visibility. Is icon shown: {}".format(visible))
185
186    def notify_error(self, message: str):
187        self.setIcon(self._load_error_state_icon())
188        self.action_view_script_error.setEnabled(True)
189        self.showMessage("AutoKey Error", message)
190
191    def reset_tray_icon(self):
192        """
193        Slot function that resets the icon to the default, as configured in the settings.
194        Used when the user switches the icon theme in the settings and when a script error condition is cleared.
195        """
196        self.setIcon(self._load_default_icon())
197
198    def on_activate(self, reason: QSystemTrayIcon.ActivationReason):
199        logger.debug("Triggered system tray icon with reason: {}".format(reason))
200        if reason == QSystemTrayIcon.ActivationReason(QSystemTrayIcon.Trigger):
201            self.app.show_configure()
202