前言

系統托盤圖示 (system tray icon) 通常用來在桌面應用程式最小化後 , 不希望在任務列 (task bar) 出現時 , 提供一個簡單跟使用者互動的介面 。 透過這樣的用戶介面 , 應用程式可以在有重要事件發生時 , 即時通知用戶 。 因此 , 系統托盤圖示常被諸如 「 郵件檢查 」、「 股票報價 」 等不需要複雜介面 的桌面應用所使用 。 本文山姆鍋說明 Python 如何使用 PySide 來實現一個跨平台 (cross-platform) 的系統托盤圖示應用程式 。

目前三種主要的桌面作業系統 , 也就是 Windows, Mac OSX, 以及 Linux, 都有支援托盤圖示介面 , 但是名稱跟支援程度稍有不同 。 PySide (QT) 提供一個跨平台的方案 , 對大部份的 Python 桌面應用來說 , 這是適合的方案 。 如果真的不需要其他介面元件 , 可以針對各個平台分別來實現系統托盤程式 , 對於這樣的情況 , 山姆鍋建議使用下列組合 :

注意 : 其中 PyGObject 只適合 GTK3 桌面環境 。

執行環境

山姆鍋假設以下的執行環境 :

  • Python 2.7.x
  • PySide 1.2.2
  • QT 4

其中 ,Python 建議使用 Anaconda Scientific Python Distribution

範例程式

本文使用 PySide 完成一個單純的桌面程式 AvaShell, 可以做到下列功能 :

  1. 在系統托盤顯示一個圖示 。
  2. 用戶點選圖示後 , 會彈跳 (pop-up) 一個選單 。
  3. 用戶可以從選單選擇離開程式 。

是的 , 目前就只能完成上述功能 。

為了封裝程式碼 , 先定義一個 Shell 抽象類別作為後續實作的基礎 , 這樣以後可以依照不同環境選用不同實作 :

# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function, unicode_literals

from abc import abstractmethod


class ShellBase(object):

    @abstractmethod
    def run(self):
        """ Starts up the shell.
        """
        pass

抽象方法 (method) run 必須由繼承的子類別來實作 。 底下是採用 PySide 的實作類別 :

# -*- coding: utf-8 -*-
from __future__ import absolute_import, print_function, unicode_literals

import sys
import logging

from PySide.QtGui import *


from avashell.shell_base import ShellBase
from avashell.utils import resource_path

_logger = logging.getLogger(__name__)


class MainWnd(QMainWindow):
    def __init__(self, shell, icon):
        super(MainWnd, self).__init__()
        self._shell = shell
        self.icon = icon
        self.context_menu = None
        self.tray_icon = None

        if not QSystemTrayIcon.isSystemTrayAvailable():
            msg = "I couldn't detect any system tray on this system."
            _logger.error(msg)
            QMessageBox.critical(None, "AvaShell", msg)
            sys.exit(1)

        self.init_ui()

    def init_ui(self):
        self.setWindowIcon(self.icon)
        self.setWindowTitle('AvaShell')

        self.create_tray_icon(self.icon)
        self.tray_icon.show()

    def on_tray_activated(self, reason=None):
        _logger.debug("Tray icon activated.")

    def on_quit(self):
        self._shell.quit_app()

    def create_context_menu(self):
        self.quit_action = QAction("&Quit AvaShell", self, triggered=self.on_quit)

        menu = QMenu(self)
        menu.addAction(self.quit_action)
        return menu

    def create_tray_icon(self, icon):
        self.context_menu = self.create_context_menu()
        self.tray_icon = QSystemTrayIcon(self)
        self.tray_icon.setContextMenu(self.context_menu)
        self.tray_icon.setIcon(icon)
        self.tray_icon.activated.connect(self.on_tray_activated)


class Shell(ShellBase):
    """ Shell implementation using PySide
    """
    def __init__(self):
        super(Shell, self).__init__()
        self.app = QApplication(sys.argv)
        self.app.setQuitOnLastWindowClosed(False)  # 1
        self.icon = QIcon(resource_path('res/icon.png'))
        self.menu = None
        self.wnd = MainWnd(self, self.icon)

    def quit_app(self):
        self.app.quit()

    def run(self):
        _logger.info("Shell is running...")
        self.app.exec_()


if __name__ == '__main__':
    shell = Shell()
    shell.run()

ShellShellBase 的子類別 , 負責建構 QApplication 以及主視窗 (MainWnd); 主視窗負責建立 QSystemTrayIcon 這個代表系統托盤的物件 , 以及它使用的彈出式選單 , 同時註冊相關事件處理器 。 由於主視窗並不顯示 , 且不希望主視窗被關閉的時候 , 程式自動離開 , 標號 1 的敘述就是通知 PySide 不要自動離開程式 。

為了找到圖檔資源 (resource), 另外定義了一個公用函式 resource_path, 以便之後在使用 PyInstaller 打包後能正常運作 。 此公用函式定義在 utils.py 這個檔案中 。

# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function, unicode_literals

import os
import sys

def resource_path(relative):
    """ Gets the resource's absolute path.

    :param relative: the relative path to the resource file.
    :return: the absolute path to the resource file.
    """
    if hasattr(sys, "_MEIPASS"):                             # 1
        return os.path.join(sys._MEIPASS, relative)

    abspath = os.path.abspath(os.path.join(__file__, ".."))  # 2
    abspath = os.path.dirname(abspath)
    return os.path.join(abspath, relative)

標號 1 的敘述判斷是否在經過 PyInstaller 打包好的環境執行 , 如果是則直接使用 sys._MEIPASS 這個特殊屬性值作為資源路徑 ; 如果不是 , resource_path 則假設資源檔案放在它的上層目錄 。 因此 , 標號 2 的敘述需要根據 utils.py 與資源的相對位置作調整 。

結語

使用 PySide 或者 PyQT 可以很方便地實現跨平台的圖形介面 , 對於大部份的桌面應用來說 , 這是好事 。 但是對於只需要一個簡單的系統托盤圖示的應用來說 , 使用 PySide 意味著額外需要十幾 MB 的空間來散佈所開發的應用程式 。 即使如此 , 相對於針對各個平台開發所需的時間來說 , 使用 PySide 通常是比較合理的作法 。