Alfred Workflow Liked App#

Alfred Workflow 是 Mac 上一款超强的效率工具. 它的核心原理是在文本框里输入字符, 然后用 drop down menu 的方式展示输出, 并选择 item 之后用快捷键与之互动, 打开文件, 打开网页, 运行命令等.

../../_images/alfredworkflow-liked-app.png

我希望用 QT 来做一个类似的跨平台的小工具. 请阅读下面的源码来学习一个极简的 Alfred Workflow App 是怎么被做出来的.

main.py
  1# -*- coding: utf-8 -*-
  2
  3# 任何面向最终用户的 app 基本上都是从命令行接入的, 所以 sys 模块是必须的
  4import sys
  5
  6# QtCore, QtWidgets, QtGui 是 PySide 的三个主要模块, 一般每一个项目都会用到这三个模块.
  7# 所以我一般在开始就会 Import 它们, 不管用不用得到.
  8# 全部的模块列表: https://doc.qt.io/qtforpython-6/modules.html#
  9from PySide6 import QtCore, QtWidgets, QtGui
 10import json
 11from pathlib import Path
 12
 13
 14class MainWindow(QtWidgets.QMainWindow):
 15    """
 16    一般任何一个 App 都有一个主窗口. 在这个主窗口里我们可以塞下各种各样的 Widget.
 17    """
 18
 19    def __init__(self, widget: QtWidgets.QWidget):
 20        QtWidgets.QMainWindow.__init__(self)
 21        self.setWindowTitle("Alfred Liked App")
 22        self.add_menu()
 23        self.setCentralWidget(widget)
 24
 25    def add_menu(self):
 26        # Create the tray
 27        self.tray = QtWidgets.QSystemTrayIcon()
 28        self.tray.setIcon(QtGui.QIcon("icon.png"))
 29        self.tray.setVisible(True)
 30
 31        # Create the menu
 32        self.menu = QtWidgets.QMenu()
 33        self.file_menu = self.menu.addMenu("File")
 34
 35        settings_action = QtGui.QAction("settings", self)
 36        settings_action.triggered.connect(self.update_settings)
 37
 38        self.file_menu.addAction(settings_action)
 39        self.tray.setContextMenu(self.menu)
 40
 41    @QtCore.Slot()
 42    def update_settings(self, checked):
 43        print("update settings")
 44
 45
 46class TabDialog(QtWidgets.QDialog):
 47    """
 48    这是我们主要的 Widget, 它包含了多个 Tab.
 49
 50    每个 Tab 本身是一个子 Widget.
 51    """
 52
 53    def __init__(self, parent: QtWidgets.QWidget = None):
 54        super().__init__(parent)
 55        self.tab_widget = QtWidgets.QTabWidget()
 56
 57        # 一共有两个 Tab, 一个用于搜索, 一个用于设置
 58        self.tab_widget.addTab(SearchWidget(self), "Search")
 59        self.tab_widget.addTab(SettingsWidget(self), "Settings")
 60
 61        # 这个 Widget 只有一个 Layout, 用于放置 Tab
 62        main_layout = QtWidgets.QVBoxLayout()
 63        main_layout.addWidget(self.tab_widget)
 64        self.setLayout(main_layout)
 65
 66
 67class SearchWidget(QtWidgets.QWidget):
 68    """
 69    Alfred 的 Search Input box Widget, 包含了一个输入框和一个下拉框.
 70    """
 71
 72    def __init__(self, parent: QtWidgets.QWidget):
 73        super().__init__(parent)
 74
 75        self.add_dropdown_items()
 76        self.add_input_box()
 77        self.set_layout()
 78
 79    def add_dropdown_items(self):
 80        # 创建一个 ListWidget 作为 dropdown items 的容器
 81        self.item_list = QtWidgets.QListWidget()
 82        # 将 item list 设置为单选模式, 它一共有以下几种模式可供选择
 83        #
 84        # - NoSelection
 85        # - SingleSelection
 86        # - MultiSelection
 87        # - ExtendedSelection
 88        # - ContiguousSelection
 89        self.item_list.setSelectionMode(
 90            QtWidgets.QAbstractItemView.SelectionMode.SingleSelection
 91        )
 92        # 当用户双击某个 item 的时候, 会触发 itemDoubleClicked 事件
 93        # 我们将其与一个函数绑定, 用来处理这个事件
 94        self.item_list.itemDoubleClicked.connect(
 95            self.item_list_item_double_clicked_event_handler
 96        )
 97        self.set_n_items(3)
 98
 99    def set_n_items(self, n: int):
100        self.item_list.clear()
101        for i in range(1, 1 + n):
102            item = QtWidgets.QListWidgetItem(f"Item {i}")
103            item.setTextAlignment(QtCore.Qt.AlignLeft)
104            self.item_list.addItem(item)
105
106    def item_list_item_double_clicked_event_handler(self):
107        """
108        当用户双击 dropdown items 中的某个 item 的时候会触发这个函数. 也就是 Alfred 中
109        的 action. 在 Alfred 中你可以点击, 也可以用 Enter, Ctrl + C 等各种快捷键.
110        在这个极简的例子中我们就用双击来代表 Alfred 中的点击
111        """
112        text = self.item_list.selectedItems()[0].text()
113        print(f"item list item double clicked: {text!r}")
114        clipboard = QtWidgets.QApplication.clipboard()
115        clipboard.setText(text)
116
117    def add_input_box(self):
118        # 创建一个 LineEdit 作为输入的文本框
119        self.input_box = QtWidgets.QLineEdit()
120        self.input_box.setPlaceholderText("enter a 1 - 10 number here")
121        self.input_box.textChanged.connect(self.input_box_text_changed_event_handler)
122
123    def input_box_text_changed_event_handler(self):
124        """
125        当输入框的内容发生变化时, 会触发这个函数. 这个相当于 Alfred Workflow 里面的
126        Script Filter.
127        """
128        text = self.input_box.text()
129        print(f"input box text changed to {text!r}")
130        try:
131            n = int(text)
132            if n > 10:
133                n = 10
134            elif n < 0:
135                n = 0
136            else:
137                pass
138        except Exception:
139            n = 3
140        self.set_n_items(n)
141
142    def set_layout(self):
143        # QVBoxLayout is a vertical layout, which means it will stack widgets vertically.
144        # there's another layout called QHBoxLayout (horizontal layout).
145        layout = QtWidgets.QVBoxLayout()
146        layout.addWidget(self.input_box)
147        layout.addWidget(self.item_list)
148        self.setLayout(layout)
149
150
151path_settings = Path(__file__).absolute().parent.joinpath("settings.json")
152
153
154def read_settings() -> dict:
155    try:
156        return json.loads(path_settings.read_text())
157    except FileNotFoundError:
158        path_settings.write_text(json.dumps("{}"))
159        return {}
160
161
162def write_settings(data: dict):
163    path_settings.write_text(json.dumps(data, indent=4))
164
165
166class SettingsWidget(QtWidgets.QWidget):
167    """
168    用于设置的 Widget, 包含了许多 Key Value 的输入框.
169    """
170
171    def __init__(self, parent: QtWidgets.QWidget):
172        super().__init__(parent)
173        self.setting_keys = ["key1", "key2"] # 定义我们的 config 有哪些 Key
174        self.add_settings_form()
175        self.add_buttons()
176        self.set_layout()
177
178    def add_settings_form(self):
179        # 创建一个用于展示 settings 中的 key value 的表格
180        self.setting_form: dict[str, tuple[QtWidgets.QLabel, QtWidgets.QLineEdit]] = {}
181        data = read_settings()
182        # 从列表动态生成 key value 的表单
183        for key in self.setting_keys:
184            # 其中 key 是一个 label
185            label = QtWidgets.QLabel(f"{key}:")
186            # 而 value 是一个输入框
187            edit = QtWidgets.QLineEdit()
188            edit.setPlaceholderText("type something here")
189            # 默认第一次打开的时候会从 settings 中读取数据
190            if key in data:
191                edit.setText(str(data.get(key)))
192            self.setting_form[key] = (label, edit)
193
194    def add_buttons(self):
195        # 添加两个 button, 一个用于从 settings 中加载数据, 另一个用于将数据写入 settings
196        self.load_button = QtWidgets.QPushButton("Load")
197        self.load_button.clicked.connect(self.load_button_clicked_event_handler)
198        self.apply_button = QtWidgets.QPushButton("Apply")
199        self.apply_button.clicked.connect(self.apply_button_clicked_event_handler)
200
201    @QtCore.Slot()
202    def load_button_clicked_event_handler(self):
203        data = read_settings()
204        for key, value in self.setting_form.items():
205            if key in data:
206                value[1].setText(str(data.get(key)))
207
208    @QtCore.Slot()
209    def apply_button_clicked_event_handler(self):
210        write_settings(
211            {key: value[1].text() for key, value in self.setting_form.items()}
212        )
213
214    def set_layout(self):
215        # 设置一个嵌套的 layout, 其中 form layout 是子 layout
216        form_layout = QtWidgets.QVBoxLayout()
217        for key, value in self.setting_form.items():
218            form_layout.addWidget(value[0])
219            form_layout.addWidget(value[1])
220
221        # button 的 layout 也是一个子 layout
222        button_layout = QtWidgets.QHBoxLayout()
223        button_layout.addWidget(self.load_button)
224        button_layout.addWidget(self.apply_button)
225
226        # 最后将两个子 layout 组合起来
227        layout = QtWidgets.QVBoxLayout()
228        layout.addLayout(form_layout)
229        layout.addLayout(button_layout)
230
231        self.setLayout(layout)
232
233
234if __name__ == "__main__":
235    app = QtWidgets.QApplication(sys.argv)
236
237    tab_dialog = TabDialog()
238
239    window = MainWindow(tab_dialog)  # 将主要的 widget 添加到 MainWindow 中
240    window.resize(400, 200)
241    window.show()
242
243    sys.exit(app.exec())

做好了 App 之后, 我就希望将它 package 成可执行文件, 能在其他电脑上运行而不依赖 Python 解释器. 根据 QT Deployment 文档, pyside6-deploy 是一个基于 Nuitka Python 打包工具的命令行工具, 能自动的将所需要的依赖, 数据文件, QT 的配置文件等打包成一个可执行文件. 在你第一次运行 pyside6-deploy main.py 的时候, 它会自动生成一个默认配置的 pysidedeploy.spec 文件. 这个配置文件可以用来调整打包的细节. 其中有一个坑是, 我的主力开发环境是 Mac + Pyenv 中的 Python, Nuitka 会报错, 需要加上 --static-libpython=no 参数. 这个参数需要被写入到 pysidedeploy.spec 文件中.

最终打包的命令就是:

pyside6-deploy main.py -c pysidedeploy.spec