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