9. (Л) Меню, панелі інструментів і дії (QAction, QMenuBar, QToolBar, QStatusBar)¶
Зміст лекції¶
- Що вже є і що додаємо
QActionяк носій стану і даних- Іконки для дій
- Гарячі клавіші детальніше
- Керування станом дій
QActionGroup— взаємовиключний вибір- Динамічні меню: список останніх файлів
- Контекстні меню
QToolBarдетальніше- Кнопка з випадаючим меню:
QToolButton QStatusBarдетальніше- Збірка: застосунок з повним набором команд
- Типові помилки
- Підсумок
Що вже є і що додаємо¶
У лекції 7 ми зібрали каркас головного вікна: menuBar(), addToolBar(), statusBar() і QAction як спільна команда для меню й панелі. Цього досить, щоб застосунок мав меню — але не досить, щоб він поводився як звичний користувачеві застосунок.
Порівняйте два тексти редактора:
| Наївна реалізація | Те, чого очікує користувач |
|---|---|
Пункт Paste завжди активний |
Paste сірий, поки буфер обміну порожній |
| Кнопки на панелі без іконок | Іконки, а підпис — за бажанням користувача |
| Правий клік нічого не робить | Правий клік дає контекстне меню з доречними командами |
Меню File завжди однакове |
У File є список останніх відкритих файлів |
| Режим перегляду — три окремі галочки | Три режими, з яких активний рівно один |
Ця лекція — саме про ці деталі. Центральна ідея лишається тією самою: QAction — єдине джерело правди про команду. Її текст, іконка, гаряча клавіша, доступність і стан перемикача живуть в одному об'єкті, а меню, панель інструментів і контекстне меню лише показують його.
graph TD
subgraph MODEL["Один об'єкт QAction"]
A["text · icon · shortcut<br/>enabled · checked · data"]
end
subgraph VIEWS["Місця показу"]
M["Пункт меню"]
T["Кнопка панелі"]
C["Контекстне меню"]
end
A --> M
A --> T
A --> C
style A fill:#fab005,stroke:#333,color:#000
style M fill:#339af0,stroke:#333,color:#fff
style T fill:#339af0,stroke:#333,color:#fff
style C fill:#339af0,stroke:#333,color:#fff
Змінили action.setEnabled(False) — посіріли всі три місця одночасно. Саме тому стан ніколи не тримають у самій кнопці.
QAction як носій стану і даних¶
Крім тексту й сигналу, QAction має повний набір властивостей, які Qt показує в інтерфейсі автоматично.
| Властивість | Метод | Де видно |
|---|---|---|
| Текст | setText("Save") |
пункт меню, підпис кнопки |
| Текст для панелі | setIconText("Save") |
тільки на QToolBar |
| Іконка | setIcon(QIcon(...)) |
меню й панель |
| Гаряча клавіша | setShortcut(...) |
праворуч у меню |
| Підказка | setToolTip("Save file") |
спливає над кнопкою |
| Підказка в рядку стану | setStatusTip("Save the file to disk") |
QStatusBar при наведенні |
| Доступність | setEnabled(False) |
сірий пункт, клік ігнорується |
| Видимість | setVisible(False) |
пункт зникає зовсім |
| Перемикач | setCheckable(True) + setChecked(...) |
галочка / втиснута кнопка |
| Довільні дані | setData(value) |
ніде, це для програміста |
Найцікавіше тут — setData(). Воно дозволяє прив'язати до дії довільне значення Python і потім прочитати його в спільному обробнику через self.sender().
import sys
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QApplication, QLabel, QMainWindow
class ActionDataDemo(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("QAction: data and properties")
self.label = QLabel("Pick a zoom level from the View menu")
self.setCentralWidget(self.label)
view_menu = self.menuBar().addMenu("View")
# Один обробник на всі пункти: значення живе всередині самої дії.
for percent in (50, 100, 150, 200):
action = QAction(f"Zoom {percent}%", self)
action.setData(percent)
action.setStatusTip(f"Set zoom level to {percent} percent")
action.triggered.connect(self.on_zoom)
view_menu.addAction(action)
self.statusBar().showMessage("Ready")
def on_zoom(self):
# sender() повертає об'єкт, який надіслав сигнал, тобто саму QAction.
action = self.sender()
percent = action.data()
self.label.setText(f"Zoom: {percent}%")
self.statusBar().showMessage(f"Zoom changed to {percent}%", 2000)
def main():
app = QApplication(sys.argv)
window = ActionDataDemo()
window.resize(440, 200)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Без setData() довелось би або писати чотири майже однакові методи, або городити lambda із замиканням на змінну циклу — класичне джерело помилок.
У setData() кладіть прості значення
Дані проходять через тип Qt QVariant, і при цьому список та словник Python копіюються. Об'єкт, що повернувся з data(), — це вже інший об'єкт:
config = {"zoom": 100}
action.setData(config)
action.data() is config # False - це копія
action.data()["zoom"] = 150 # змінює копію, config лишається без змін
Тому в setData() кладуть ідентифікатор — число, рядок, індекс у списку — і за ним знаходять справжній об'єкт у своїх структурах даних. Числа, рядки й булеві значення повертаються без сюрпризів. Те саме стосується QListWidgetItem.setData().
triggered передає checked
Сигнал triggered має параметр — булеве значення checked. Для звичайної дії воно завжди False, але воно є, і Qt його передає. Тому lambda треба писати з першим параметром:
action.triggered.connect(lambda: self.open_file(path)) # спрацює, але
action.triggered.connect(lambda checked=False: self.open_file(path)) # надійніше
Якщо слот приймає рівно один аргумент, Qt мовчки передасть туди checked замість очікуваного вами значення — і ви отримаєте True/False там, де чекали рядок.
Текст, підказка й & для мнемоніки¶
Символ & перед літерою робить її мнемонікою: у меню літера підкреслюється, і меню відкривається з клавіатури через Alt.
file_menu = self.menuBar().addMenu("&File") # Alt+F відкриє меню
save_action = QAction("&Save", self) # потім S виконає команду
Якщо треба показати справжній амперсанд — подвойте його: "Search && Replace".
setToolTip() за замовчуванням
Якщо не викликати setToolTip(), підказкою стає текст дії без &. Окремий setToolTip() варто задавати тоді, коли хочете розгорнутіше пояснення, ніж напис на кнопці.
Іконки для дій¶
Іконка задається об'єктом QIcon. Взяти її можна з трьох джерел.
1. Файл на диску — найпростіший і найпередбачуваніший спосіб:
2. Стандартна іконка поточного стилю — Qt має вбудований набір, який працює на будь-якій платформі без жодних файлів:
3. Системна тема іконок — на Linux бере іконку з теми робочого столу за стандартним ім'ям:
Третій спосіб дає найкращий вигляд у системі користувача, але на машині без теми іконок повертає порожню QIcon — тому завжди задають запасний варіант другим аргументом.
import sys
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QAction, QIcon
from PySide6.QtWidgets import (
QApplication,
QLabel,
QMainWindow,
QStyle,
)
class IconDemo(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Icons for actions")
self.label = QLabel("Hover over the toolbar buttons")
self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setCentralWidget(self.label)
toolbar = self.addToolBar("Main")
toolbar.setIconSize(QSize(24, 24))
# Показувати і іконку, і текст під нею.
toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon)
specs = (
("New", "document-new", QStyle.StandardPixmap.SP_FileIcon),
("Open", "document-open", QStyle.StandardPixmap.SP_DirOpenIcon),
("Save", "document-save", QStyle.StandardPixmap.SP_DialogSaveButton),
("Delete", "edit-delete", QStyle.StandardPixmap.SP_TrashIcon),
)
for text, theme_name, standard in specs:
# Спочатку пробуємо системну тему, інакше беремо іконку стилю Qt.
fallback = self.style().standardIcon(standard)
icon = QIcon.fromTheme(theme_name, fallback)
action = QAction(icon, text, self)
action.setStatusTip(f"{text} command")
action.triggered.connect(self.on_action)
toolbar.addAction(action)
self.statusBar().showMessage("Ready")
def on_action(self):
self.label.setText(f"Triggered: {self.sender().text()}")
def main():
app = QApplication(sys.argv)
window = IconDemo()
window.resize(480, 240)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Іконки в меню на різних платформах
Та сама QAction з іконкою на Linux і Windows покаже іконку і в меню, і на панелі. На macOS іконки в меню за традицією не показуються — це рішення платформи, а не помилка коду.
Список стандартних іконок великий (SP_FileIcon, SP_DirIcon, SP_DialogOkButton, SP_BrowserReload, SP_MediaPlay, SP_ArrowLeft, ...). Побачити їх усі можна цим фрагментом:
import sys
from PySide6.QtWidgets import (
QApplication,
QGridLayout,
QLabel,
QScrollArea,
QStyle,
QWidget,
)
class StandardIconGallery(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Standard Qt icons")
content = QWidget()
grid = QGridLayout()
content.setLayout(grid)
style = self.style()
# StandardPixmap - це перелічення (enum), його можна обійти циклом.
for index, pixmap_id in enumerate(QStyle.StandardPixmap):
icon = style.standardIcon(pixmap_id)
icon_label = QLabel()
icon_label.setPixmap(icon.pixmap(24, 24))
name_label = QLabel(pixmap_id.name)
row, column = divmod(index, 3)
grid.addWidget(icon_label, row, column * 2)
grid.addWidget(name_label, row, column * 2 + 1)
area = QScrollArea()
area.setWidget(content)
area.setWidgetResizable(True)
layout = QGridLayout()
layout.addWidget(area, 0, 0)
self.setLayout(layout)
def main():
app = QApplication(sys.argv)
window = StandardIconGallery()
window.resize(700, 500)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Гарячі клавіші детальніше¶
Кілька комбінацій на одну дію¶
setShortcut() задає одну комбінацію, setShortcuts() — список:
Стандартні клавіші теж можуть мати кілька варіантів на платформі. QKeySequence.keyBindings() повертає їх усі:
Область дії: setShortcutContext()¶
За замовчуванням гаряча клавіша спрацьовує, коли активне вікно, якому належить дія. Це можна змінити:
| Контекст | Коли працює |
|---|---|
Qt.ShortcutContext.WidgetShortcut |
лише коли віджет має фокус |
Qt.ShortcutContext.WidgetWithChildrenShortcut |
коли фокус у віджеті або його дочірніх |
Qt.ShortcutContext.WindowShortcut |
коли активне вікно (за замовчуванням) |
Qt.ShortcutContext.ApplicationShortcut |
коли активне будь-яке вікно застосунку |
import sys
from PySide6.QtCore import Qt
from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtWidgets import (
QApplication,
QLabel,
QMainWindow,
QTextEdit,
QVBoxLayout,
QWidget,
)
class ShortcutDemo(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Shortcut contexts")
self.log = QLabel("Try Ctrl+D, F5 and Ctrl+Shift+U")
self.editor = QTextEdit()
self.editor.setPlaceholderText("Ctrl+U works only while this box has focus")
layout = QVBoxLayout()
layout.addWidget(self.log)
layout.addWidget(self.editor)
container = QWidget()
container.setLayout(layout)
self.setCentralWidget(container)
# Дія вікна: працює, поки активне це вікно.
duplicate_action = QAction("Duplicate line", self)
duplicate_action.setShortcut(QKeySequence("Ctrl+D"))
duplicate_action.triggered.connect(lambda: self.report("Ctrl+D"))
# Одна дія - дві комбінації.
refresh_action = QAction("Refresh", self)
refresh_action.setShortcuts(
[QKeySequence("F5"), QKeySequence("Ctrl+R")]
)
refresh_action.triggered.connect(lambda: self.report("Refresh"))
# Дія віджета: спрацьовує лише коли фокус у редакторі.
upper_action = QAction("Uppercase", self.editor)
upper_action.setShortcut(QKeySequence("Ctrl+U"))
upper_action.setShortcutContext(Qt.ShortcutContext.WidgetShortcut)
upper_action.triggered.connect(self.on_uppercase)
self.editor.addAction(upper_action)
edit_menu = self.menuBar().addMenu("&Edit")
edit_menu.addAction(duplicate_action)
edit_menu.addAction(refresh_action)
edit_menu.addAction(upper_action)
def report(self, name):
self.log.setText(f"Triggered: {name}")
def on_uppercase(self):
self.editor.setPlainText(self.editor.toPlainText().upper())
self.report("Ctrl+U (widget shortcut)")
def main():
app = QApplication(sys.argv)
window = ShortcutDemo()
window.resize(520, 320)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Конфлікт комбінацій
Якщо дві дії в одному вікні мають однакову комбінацію, Qt не може вибрати, яку викликати, — жодна не спрацює, а в консоль піде попередження QAction::event: Ambiguous shortcut overload. Побачили таке — шукайте дублікат.
Керування станом дій¶
Дія, яку зараз не можна виконати, має бути сірою, а не мовчки нічого не робити. Наївний підхід — розкидати setEnabled() по всьому коду — швидко стає некерованим: легко забути одну з десяти точок, де стан змінюється.
Робочий підхід: один метод update_actions(), який перераховує стан усіх дій із поточного стану даних, і його виклик у кожній точці, де ці дані змінились.
graph LR
E1["Змінився текст"] --> U
E2["Змінився вибір у списку"] --> U
E3["Файл збережено"] --> U
U["update_actions()<br/>перераховує все"] --> A1["save_action.setEnabled(...)"]
U --> A2["delete_action.setEnabled(...)"]
U --> A3["undo_action.setEnabled(...)"]
style U fill:#fab005,stroke:#333,color:#000
style E1 fill:#339af0,stroke:#333,color:#fff
style E2 fill:#339af0,stroke:#333,color:#fff
style E3 fill:#339af0,stroke:#333,color:#fff
style A1 fill:#51cf66,stroke:#333,color:#000
style A2 fill:#51cf66,stroke:#333,color:#000
style A3 fill:#51cf66,stroke:#333,color:#000
import sys
from PySide6.QtGui import QAction
from PySide6.QtWidgets import (
QApplication,
QListWidget,
QMainWindow,
)
class EnabledStateDemo(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Action state")
self.list_widget = QListWidget()
self.list_widget.addItems(["alpha", "beta", "gamma"])
self.list_widget.currentRowChanged.connect(self.update_actions)
self.setCentralWidget(self.list_widget)
self.add_action = QAction("Add", self)
self.add_action.triggered.connect(self.on_add)
self.delete_action = QAction("Delete", self)
self.delete_action.triggered.connect(self.on_delete)
self.up_action = QAction("Move up", self)
self.up_action.triggered.connect(self.on_move_up)
self.clear_action = QAction("Clear all", self)
self.clear_action.triggered.connect(self.on_clear)
edit_menu = self.menuBar().addMenu("&Edit")
toolbar = self.addToolBar("Edit")
for action in (
self.add_action,
self.delete_action,
self.up_action,
self.clear_action,
):
edit_menu.addAction(action)
toolbar.addAction(action)
self.statusBar().showMessage("Ready")
self.update_actions()
def update_actions(self):
# Єдине місце, де вирішується доступність команд.
row = self.list_widget.currentRow()
has_items = self.list_widget.count() > 0
has_selection = row >= 0
self.delete_action.setEnabled(has_selection)
self.up_action.setEnabled(has_selection and row > 0)
self.clear_action.setEnabled(has_items)
def on_add(self):
count = self.list_widget.count()
self.list_widget.addItem(f"item {count + 1}")
self.update_actions()
def on_delete(self):
row = self.list_widget.currentRow()
self.list_widget.takeItem(row)
self.update_actions()
def on_move_up(self):
row = self.list_widget.currentRow()
item = self.list_widget.takeItem(row)
self.list_widget.insertItem(row - 1, item)
self.list_widget.setCurrentRow(row - 1)
self.update_actions()
def on_clear(self):
self.list_widget.clear()
self.update_actions()
def main():
app = QApplication(sys.argv)
window = EnabledStateDemo()
window.resize(420, 300)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Виділіть перший рядок — Move up посіріє. Очистіть список — посіріють Delete і Clear all. Кнопки на панелі змінюються синхронно з меню, бо це та сама дія.
Готові дії редагування
QTextEdit уже має власні дії з правильною логікою доступності: editor.createStandardContextMenu() віддає готове меню Undo/Redo/Cut/Copy/Paste/Select All, а сигнали copyAvailable, undoAvailable, redoAvailable дозволяють керувати вашими власними діями без ручних перевірок.
QActionGroup — взаємовиключний вибір¶
Коли з кількох перемикачів активним має бути рівно один (режим перегляду, мова, вирівнювання), не треба вручну знімати галочки з решти. Для цього є QActionGroup із модуля QtGui.
import sys
from PySide6.QtGui import QAction, QActionGroup
from PySide6.QtWidgets import QApplication, QLabel, QMainWindow
class ActionGroupDemo(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("QActionGroup")
self.label = QLabel("Current mode: List")
self.setCentralWidget(self.label)
view_menu = self.menuBar().addMenu("&View")
toolbar = self.addToolBar("View")
# Група робить перемикачі взаємовиключними.
self.mode_group = QActionGroup(self)
self.mode_group.setExclusive(True)
self.mode_group.triggered.connect(self.on_mode_changed)
for name in ("List", "Icons", "Details"):
action = QAction(name, self)
action.setCheckable(True)
action.setData(name)
self.mode_group.addAction(action)
view_menu.addAction(action)
toolbar.addAction(action)
# Перша дія групи стає активною за замовчуванням.
self.mode_group.actions()[0].setChecked(True)
def on_mode_changed(self, action):
# Сигнал групи приносить саме ту дію, яку обрали.
self.label.setText(f"Current mode: {action.data()}")
def main():
app = QApplication(sys.argv)
window = ActionGroupDemo()
window.resize(420, 200)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Головна вигода: сигнал triggered(QAction) самої групи. Один обробник отримує обрану дію — не треба підключати кожну окремо.
Метод QActionGroup |
Призначення |
|---|---|
addAction(action) |
Додати дію до групи |
setExclusive(True) |
Активною може бути лише одна (за замовчуванням) |
checkedAction() |
Яка дія зараз обрана |
setEnabled(False) |
Вимкнути всю групу одним викликом |
triggered |
Сигнал з обраною QAction |
Група не додає пункти в меню
QActionGroup — це логічний контейнер, а не візуальний. Дії все одно треба додати в меню чи панель самостійно; група лише стежить за їхнім взаємним станом.
Динамічні меню: список останніх файлів¶
Деякі меню не можна побудувати один раз назавжди — їхній вміст залежить від стану застосунку. Класичний приклад: File → Recent files.
Є два підходи:
- перебудовувати меню при кожному відкритті — сигнал
aboutToShowменю спрацьовує безпосередньо перед показом; - тримати фіксований набір дій і лише міняти їхній текст та
visible.
Перший спосіб простіший і його достатньо для навчальних задач.
import sys
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QApplication, QLabel, QMainWindow
MAX_RECENT = 5
class RecentFilesDemo(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Dynamic menu")
self._recent = []
self.label = QLabel("Open some files, then check File -> Recent files")
self.setCentralWidget(self.label)
file_menu = self.menuBar().addMenu("&File")
open_action = QAction("Open next demo file", self)
open_action.setShortcut("Ctrl+O")
open_action.triggered.connect(self.on_open)
file_menu.addAction(open_action)
self.recent_menu = file_menu.addMenu("Recent files")
# Меню перебудовується щоразу перед показом.
self.recent_menu.aboutToShow.connect(self.rebuild_recent_menu)
file_menu.addSeparator()
quit_action = QAction("Quit", self)
quit_action.setShortcut("Ctrl+Q")
quit_action.triggered.connect(self.close)
file_menu.addAction(quit_action)
self.statusBar().showMessage("Ready")
self._counter = 0
def on_open(self):
self._counter += 1
path = f"/home/user/documents/file_{self._counter}.txt"
self.open_path(path)
def open_path(self, path):
self.label.setText(f"Opened: {path}")
self.statusBar().showMessage(f"Opened {path}", 2000)
self.remember(path)
def remember(self, path):
# Свіжий шлях завжди першим, без дублікатів, не довше за MAX_RECENT.
if path in self._recent:
self._recent.remove(path)
self._recent.insert(0, path)
del self._recent[MAX_RECENT:]
def rebuild_recent_menu(self):
self.recent_menu.clear()
if not self._recent:
empty = self.recent_menu.addAction("(empty)")
empty.setEnabled(False)
return
for index, path in enumerate(self._recent, start=1):
# &1, &2 ... дають швидкий доступ з клавіатури.
action = QAction(f"&{index} {path}", self)
action.setData(path)
action.triggered.connect(self.on_recent)
self.recent_menu.addAction(action)
self.recent_menu.addSeparator()
clear_action = self.recent_menu.addAction("Clear list")
clear_action.triggered.connect(self._recent.clear)
def on_recent(self):
self.open_path(self.sender().data())
def main():
app = QApplication(sys.argv)
window = RecentFilesDemo()
window.resize(520, 220)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Зверніть увагу на self.recent_menu.clear(): він видаляє попередні дії, тож старі об'єкти не накопичуються. І на menu.addAction("Clear list") — коротка форма, яка сама створює QAction і повертає її.
Не перебудовуйте меню всередині обробника його ж пункту
Видаляти дію в момент, коли Qt обробляє її triggered, небезпечно. Тому в прикладі Clear list лише очищає список Python, а меню перебудується наступного разу в aboutToShow.
Контекстні меню¶
Контекстне меню — те, що з'являється за правим кліком. У Qt є три способи його зробити, від найпростішого до найгнучкішого.
Спосіб 1: ActionsContextMenu¶
Найкоротший: додайте дії у сам віджет і скажіть Qt показувати їх за правим кліком.
widget.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
widget.addAction(copy_action)
widget.addAction(delete_action)
Меню будується автоматично зі списку widget.actions(). Підходить, коли набір команд фіксований.
Спосіб 2: CustomContextMenu + сигнал¶
Дає повний контроль: ви самі будуєте QMenu в момент кліку і знаєте координати кліку.
widget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
widget.customContextMenuRequested.connect(self.show_menu)
Спосіб 3: перевизначення contextMenuEvent()¶
Для власного класу-віджета. Метод отримує подію з готовими координатами.
Наступний приклад показує другий і третій способи поряд.
import sys
from PySide6.QtCore import Qt
from PySide6.QtGui import QAction
from PySide6.QtWidgets import (
QApplication,
QHBoxLayout,
QLabel,
QListWidget,
QMainWindow,
QMenu,
QWidget,
)
class ContextLabel(QLabel):
# Віджет із власним контекстним меню через contextMenuEvent().
def __init__(self, text):
super().__init__(text)
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setStyleSheet("background: #eeeeee;")
def contextMenuEvent(self, event):
menu = QMenu(self)
upper_action = menu.addAction("To upper case")
lower_action = menu.addAction("To lower case")
menu.addSeparator()
reset_action = menu.addAction("Reset")
# exec() блокує, доки користувач не обере пункт,
# і повертає обрану дію (або None, якщо меню закрили).
chosen = menu.exec(event.globalPos())
if chosen is upper_action:
self.setText(self.text().upper())
elif chosen is lower_action:
self.setText(self.text().lower())
elif chosen is reset_action:
self.setText("Right-click me")
class ContextMenuDemo(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Context menus")
self.list_widget = QListWidget()
self.list_widget.addItems(["alpha", "beta", "gamma"])
# Спосіб 2: власне меню за сигналом.
self.list_widget.setContextMenuPolicy(
Qt.ContextMenuPolicy.CustomContextMenu
)
self.list_widget.customContextMenuRequested.connect(self.show_list_menu)
layout = QHBoxLayout()
layout.addWidget(self.list_widget)
layout.addWidget(ContextLabel("Right-click me"))
container = QWidget()
container.setLayout(layout)
self.setCentralWidget(container)
self.statusBar().showMessage("Right-click the list or the grey label")
def show_list_menu(self, position):
# position - координати в системі віджета, меню треба глобальні.
item = self.list_widget.itemAt(position)
menu = QMenu(self)
add_action = QAction("Add item", self)
add_action.triggered.connect(self.on_add)
menu.addAction(add_action)
rename_action = QAction("Rename", self)
rename_action.triggered.connect(self.on_rename)
# Пункти, що стосуються елемента, неактивні при кліку в порожнечу.
rename_action.setEnabled(item is not None)
menu.addAction(rename_action)
remove_action = QAction("Remove", self)
remove_action.triggered.connect(self.on_remove)
remove_action.setEnabled(item is not None)
menu.addSeparator()
menu.addAction(remove_action)
menu.exec(self.list_widget.mapToGlobal(position))
def on_add(self):
self.list_widget.addItem(f"item {self.list_widget.count() + 1}")
def on_rename(self):
item = self.list_widget.currentItem()
item.setText(item.text() + " (renamed)")
def on_remove(self):
self.list_widget.takeItem(self.list_widget.currentRow())
def main():
app = QApplication(sys.argv)
window = ContextMenuDemo()
window.resize(520, 300)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
mapToGlobal() обов'язковий
customContextMenuRequested передає координати відносно віджета, а QMenu.exec() чекає координати екрана. Без mapToGlobal() меню вискочить у лівому верхньому куті екрана.
menu.exec(pos) повертає обрану QAction або None. Тому можливі два стилі: підключати triggered до кожної дії (як у списку) або порівнювати результат exec() (як у мітці). Перший зручніший, коли дії створюються один раз, другий — коли меню одноразове.
QToolBar детальніше¶
Вигляд кнопок¶
setToolButtonStyle() керує тим, що видно на кнопці:
Значення Qt.ToolButtonStyle |
Вигляд |
|---|---|
ToolButtonIconOnly |
лише іконка (за замовчуванням) |
ToolButtonTextOnly |
лише текст |
ToolButtonTextBesideIcon |
текст праворуч від іконки |
ToolButtonTextUnderIcon |
текст під іконкою |
ToolButtonFollowStyle |
як прийнято в поточному стилі системи |
Розмір іконок задається окремо: toolbar.setIconSize(QSize(32, 32)).
Довільні віджети на панелі¶
Крім кнопок, у панель можна покласти будь-який віджет — поле пошуку, випадаючий список масштабу, повзунок:
Кілька панелей і перенесення рядка¶
addToolBarBreak() починає новий рядок панелей:
self.addToolBar("Main")
self.addToolBarBreak() # наступна панель буде під першою
self.addToolBar("Format")
Готове меню керування панелями¶
QMainWindow уміє сам зібрати меню зі списком усіх панелей і доків — self.createPopupMenu(). Воно ж показується за правим кліком по будь-якій панелі.
import sys
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QAction
from PySide6.QtWidgets import (
QApplication,
QComboBox,
QLabel,
QLineEdit,
QMainWindow,
QStyle,
)
class ToolBarDemo(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("QToolBar in depth")
self.label = QLabel("Toolbar playground")
self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setCentralWidget(self.label)
style = self.style()
# --- Перша панель: кнопки з іконками ---
main_bar = self.addToolBar("Main")
main_bar.setObjectName("main_toolbar")
main_bar.setIconSize(QSize(22, 22))
main_bar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
main_bar.setMovable(False)
icons = (
("New", QStyle.StandardPixmap.SP_FileIcon),
("Open", QStyle.StandardPixmap.SP_DirOpenIcon),
("Save", QStyle.StandardPixmap.SP_DialogSaveButton),
)
for text, pixmap_id in icons:
action = QAction(style.standardIcon(pixmap_id), text, self)
action.setStatusTip(f"{text} the document")
action.triggered.connect(
lambda checked=False, name=text: self.label.setText(name)
)
main_bar.addAction(action)
# --- Друга панель у новому рядку: віджети ---
self.addToolBarBreak()
search_bar = self.addToolBar("Search")
search_bar.setObjectName("search_toolbar")
search_bar.addWidget(QLabel("Zoom: "))
zoom_box = QComboBox()
zoom_box.addItems(["50%", "100%", "150%", "200%"])
zoom_box.setCurrentText("100%")
zoom_box.currentTextChanged.connect(
lambda value: self.label.setText(f"Zoom: {value}")
)
search_bar.addWidget(zoom_box)
search_bar.addSeparator()
search_field = QLineEdit()
search_field.setPlaceholderText("Search...")
search_field.setMaximumWidth(200)
search_field.returnPressed.connect(
lambda: self.label.setText(f"Search: {search_field.text()}")
)
search_bar.addWidget(search_field)
# --- Меню керування панелями ---
view_menu = self.menuBar().addMenu("&View")
view_menu.addAction(main_bar.toggleViewAction())
view_menu.addAction(search_bar.toggleViewAction())
self.statusBar().showMessage("Right-click any toolbar to see the built-in menu")
def main():
app = QApplication(sys.argv)
window = ToolBarDemo()
window.resize(620, 280)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Метод QToolBar |
Призначення |
|---|---|
addAction(action) |
Кнопка з дії |
addWidget(widget) |
Довільний віджет |
addSeparator() |
Роздільник |
insertAction(before, action) |
Вставити перед іншою дією |
setMovable(False) |
Заборонити перетягування |
setFloatable(False) |
Заборонити відривати у плаваюче вікно |
setAllowedAreas(areas) |
Дозволені краї вікна |
setIconSize(QSize) |
Розмір іконок |
setToolButtonStyle(style) |
Іконка / текст / обидва |
toggleViewAction() |
Готова дія показати/сховати панель |
widgetForAction(action) |
Отримати QToolButton, створений для дії |
Кнопка з випадаючим меню: QToolButton¶
Кнопки на панелі — це насправді об'єкти QToolButton, які Qt створює за вашими діями. Іноді потрібна кнопка з меню: "Створити" з вибором типу документа, "Скасувати" з історією.
Для цього створюють QToolButton вручну, вішають на нього QMenu і кладуть у панель через addWidget().
Режим QToolButton.ToolButtonPopupMode |
Поведінка |
|---|---|
DelayedPopup |
меню з'являється, якщо тримати кнопку натиснутою |
MenuButtonPopup |
окрема стрілка праворуч відкриває меню, сама кнопка виконує дію |
InstantPopup |
будь-який клік відкриває меню, основної дії немає |
import sys
from PySide6.QtCore import Qt
from PySide6.QtGui import QAction
from PySide6.QtWidgets import (
QApplication,
QLabel,
QMainWindow,
QMenu,
QStyle,
QToolButton,
)
class ToolButtonDemo(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("QToolButton with a menu")
self.label = QLabel("Use the toolbar buttons")
self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setCentralWidget(self.label)
toolbar = self.addToolBar("Main")
toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
style = self.style()
# --- Кнопка з основною дією + стрілкою меню ---
new_action = QAction(
style.standardIcon(QStyle.StandardPixmap.SP_FileIcon), "New", self
)
new_action.triggered.connect(lambda: self.report("New: text document"))
new_menu = QMenu(self)
for kind in ("Text document", "Spreadsheet", "Presentation"):
action = new_menu.addAction(kind)
action.setData(kind)
action.triggered.connect(self.on_new_kind)
new_button = QToolButton()
new_button.setDefaultAction(new_action)
new_button.setMenu(new_menu)
new_button.setPopupMode(
QToolButton.ToolButtonPopupMode.MenuButtonPopup
)
new_button.setToolButtonStyle(
Qt.ToolButtonStyle.ToolButtonTextBesideIcon
)
toolbar.addWidget(new_button)
toolbar.addSeparator()
# --- Кнопка, що є лише меню ---
export_menu = QMenu(self)
for fmt in ("PDF", "PNG", "SVG"):
action = export_menu.addAction(f"Export as {fmt}")
action.setData(fmt)
action.triggered.connect(self.on_export)
export_button = QToolButton()
export_button.setText("Export")
export_button.setIcon(
style.standardIcon(QStyle.StandardPixmap.SP_DialogSaveButton)
)
export_button.setMenu(export_menu)
export_button.setPopupMode(
QToolButton.ToolButtonPopupMode.InstantPopup
)
export_button.setToolButtonStyle(
Qt.ToolButtonStyle.ToolButtonTextBesideIcon
)
toolbar.addWidget(export_button)
self.statusBar().showMessage("Ready")
def report(self, text):
# Метод не можна назвати show(): це перевизначило б QWidget.show().
self.label.setText(text)
self.statusBar().showMessage(text, 2000)
def on_new_kind(self):
self.report(f"New: {self.sender().data()}")
def on_export(self):
self.report(f"Exporting to {self.sender().data()}")
def main():
app = QApplication(sys.argv)
window = ToolButtonDemo()
window.resize(560, 260)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
setDefaultAction()
button.setDefaultAction(action) прив'язує кнопку до дії: текст, іконка, підказка й доступність беруться з дії, а клік викликає її triggered. Це той самий механізм, яким QToolBar.addAction() користується всередині.
QStatusBar детальніше¶
Рядок стану має три зони, а не одну.
graph LR
subgraph SB["QStatusBar"]
direction LR
L["Тимчасове повідомлення<br/>showMessage()"]
W["Звичайні віджети<br/>addWidget()"]
P["Постійні віджети<br/>addPermanentWidget()"]
end
style L fill:#fab005,stroke:#333,color:#000
style W fill:#339af0,stroke:#333,color:#fff
style P fill:#51cf66,stroke:#333,color:#000
Різниця між addWidget() і addPermanentWidget() принципова: тимчасове повідомлення перекриває звичайні віджети, але не перекриває постійні. Тому індикатори, які мають бути видні завжди (позиція курсора, кодування, режим вставки), додають як постійні.
Метод QStatusBar |
Призначення |
|---|---|
showMessage(text, timeout=0) |
Тимчасове повідомлення; 0 — доки не приберуть |
clearMessage() |
Прибрати тимчасове повідомлення |
currentMessage() |
Поточний текст повідомлення |
addWidget(w, stretch=0) |
Віджет ліворуч (ховається під повідомленням) |
addPermanentWidget(w, stretch=0) |
Віджет праворуч (не ховається) |
removeWidget(w) |
Прибрати віджет (він лишається живим, просто ховається) |
setSizeGripEnabled(True) |
Куточок для зміни розміру вікна |
messageChanged |
Сигнал: текст повідомлення змінився |
import sys
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QAction
from PySide6.QtWidgets import (
QApplication,
QLabel,
QMainWindow,
QProgressBar,
QTextEdit,
)
class StatusBarDemo(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("QStatusBar in depth")
self.editor = QTextEdit()
self.editor.setPlainText("Type here and watch the status bar.")
self.editor.cursorPositionChanged.connect(self.update_position)
self.editor.textChanged.connect(self.update_counters)
self.setCentralWidget(self.editor)
status = self.statusBar()
status.setSizeGripEnabled(True)
# Звичайний віджет: його перекриє тимчасове повідомлення.
self.hint_label = QLabel("Ready to edit")
status.addWidget(self.hint_label)
# Індикатор прогресу, схований до початку роботи.
self.progress = QProgressBar()
self.progress.setMaximumWidth(160)
self.progress.setVisible(False)
status.addPermanentWidget(self.progress)
# Постійні віджети праворуч: їх видно завжди.
self.chars_label = QLabel()
self.position_label = QLabel()
status.addPermanentWidget(self.chars_label)
status.addPermanentWidget(self.position_label)
# Реакція на зміну повідомлення: порожній рядок означає, що воно зникло.
status.messageChanged.connect(self.on_message_changed)
task_action = QAction("Run task", self)
task_action.setStatusTip("Start a fake long-running task")
task_action.triggered.connect(self.start_task)
self.menuBar().addMenu("&Tools").addAction(task_action)
self._timer = QTimer(self)
self._timer.setInterval(120)
self._timer.timeout.connect(self.on_tick)
self.update_counters()
self.update_position()
def update_counters(self):
text = self.editor.toPlainText()
self.chars_label.setText(f"Chars: {len(text)}")
def update_position(self):
cursor = self.editor.textCursor()
line = cursor.blockNumber() + 1
column = cursor.columnNumber() + 1
self.position_label.setText(f"Ln {line}, Col {column}")
def on_message_changed(self, text):
# Порожній text - повідомлення зникло, повертаємо звичайну підказку.
if not text:
self.hint_label.setText("Ready to edit")
def start_task(self):
self.progress.setValue(0)
self.progress.setVisible(True)
self.statusBar().showMessage("Task is running...")
self._timer.start()
def on_tick(self):
value = self.progress.value() + 5
self.progress.setValue(value)
if value >= 100:
self._timer.stop()
self.progress.setVisible(False)
self.statusBar().showMessage("Task finished", 3000)
def main():
app = QApplication(sys.argv)
window = StatusBarDemo()
window.resize(640, 380)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Наведіть мишу на пункт Tools → Run task — Ready to edit тимчасово зникне під текстом із setStatusTip(), а Chars і Ln, Col праворуч лишаться на місці. Це і є різниця між двома видами віджетів.
Рядок стану — не місце для помилок
Повідомлення в QStatusBar легко пропустити: воно зникає само й не вимагає реакції. Про справжню помилку повідомляють через QMessageBox, а рядок стану лишають для нейтральних станів: "Saved", "Connected", "3 items selected".
Збірка: застосунок з повним набором команд¶
Зберемо все в один застосунок — менеджер завдань. У ньому є:
- дії з іконками, гарячими клавішами й підказками в рядку стану;
QActionGroupдля фільтра списку;QToolButtonз меню вибору пріоритету;- контекстне меню списку;
- централізований
update_actions(); - рядок стану з тимчасовими повідомленнями й постійними лічильниками.
import sys
from PySide6.QtCore import Qt
from PySide6.QtGui import QAction, QActionGroup, QIcon, QKeySequence
from PySide6.QtWidgets import (
QApplication,
QInputDialog,
QLabel,
QListWidget,
QListWidgetItem,
QMainWindow,
QMenu,
QMessageBox,
QStyle,
QToolButton,
)
PRIORITIES = ("Low", "Normal", "High")
class TaskBoard(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Task Board")
# Модель даних: звичайний список словників.
self._tasks = [
{"text": "Read the lecture", "done": True, "priority": "Normal"},
{"text": "Run every example", "done": False, "priority": "High"},
{"text": "Do the homework", "done": False, "priority": "Normal"},
]
self._filter = "All"
self.list_widget = QListWidget()
self.list_widget.currentRowChanged.connect(self.update_actions)
self.list_widget.itemDoubleClicked.connect(self.on_toggle_done)
self.list_widget.setContextMenuPolicy(
Qt.ContextMenuPolicy.CustomContextMenu
)
self.list_widget.customContextMenuRequested.connect(self.show_list_menu)
self.setCentralWidget(self.list_widget)
self._create_actions()
self._create_menus()
self._create_toolbar()
self._create_status_bar()
self.setMinimumSize(520, 360)
self.resize(680, 440)
self.refresh()
# --- побудова інтерфейсу ---
def _icon(self, theme_name, standard):
# Іконка з теми системи, а якщо її немає - стандартна іконка стилю.
return QIcon.fromTheme(theme_name, self.style().standardIcon(standard))
def _create_actions(self):
self.add_action = QAction(
self._icon("list-add", QStyle.StandardPixmap.SP_FileDialogNewFolder),
"&Add task",
self,
)
self.add_action.setShortcut(QKeySequence("Ctrl+N"))
self.add_action.setStatusTip("Create a new task")
self.add_action.triggered.connect(self.on_add)
self.rename_action = QAction("&Rename", self)
self.rename_action.setShortcut(QKeySequence("F2"))
self.rename_action.setStatusTip("Rename the selected task")
self.rename_action.triggered.connect(self.on_rename)
self.toggle_action = QAction(
self._icon("dialog-ok", QStyle.StandardPixmap.SP_DialogApplyButton),
"&Toggle done",
self,
)
self.toggle_action.setShortcut(QKeySequence("Ctrl+Space"))
self.toggle_action.setStatusTip("Mark the task as done or not done")
self.toggle_action.triggered.connect(self.on_toggle_done)
self.delete_action = QAction(
self._icon("edit-delete", QStyle.StandardPixmap.SP_TrashIcon),
"&Delete",
self,
)
self.delete_action.setShortcut(QKeySequence.StandardKey.Delete)
self.delete_action.setStatusTip("Delete the selected task")
self.delete_action.triggered.connect(self.on_delete)
self.clear_done_action = QAction("&Clear completed", self)
self.clear_done_action.setStatusTip("Remove every completed task")
self.clear_done_action.triggered.connect(self.on_clear_done)
self.quit_action = QAction("&Quit", self)
self.quit_action.setShortcut(QKeySequence.StandardKey.Quit)
self.quit_action.triggered.connect(self.close)
self.about_action = QAction("&About", self)
self.about_action.triggered.connect(self.on_about)
# Взаємовиключний фільтр.
self.filter_group = QActionGroup(self)
self.filter_group.setExclusive(True)
self.filter_group.triggered.connect(self.on_filter_changed)
for name in ("All", "Active", "Completed"):
action = QAction(name, self)
action.setCheckable(True)
action.setData(name)
action.setStatusTip(f"Show {name.lower()} tasks")
self.filter_group.addAction(action)
self.filter_group.actions()[0].setChecked(True)
def _create_menus(self):
menu_bar = self.menuBar()
task_menu = menu_bar.addMenu("&Task")
task_menu.addAction(self.add_action)
task_menu.addAction(self.rename_action)
task_menu.addAction(self.toggle_action)
task_menu.addSeparator()
task_menu.addAction(self.delete_action)
task_menu.addAction(self.clear_done_action)
task_menu.addSeparator()
task_menu.addAction(self.quit_action)
view_menu = menu_bar.addMenu("&View")
for action in self.filter_group.actions():
view_menu.addAction(action)
help_menu = menu_bar.addMenu("&Help")
help_menu.addAction(self.about_action)
def _create_toolbar(self):
toolbar = self.addToolBar("Main")
toolbar.setObjectName("main_toolbar")
toolbar.setMovable(False)
toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
# Кнопка "Add" з випадаючим меню пріоритетів.
priority_menu = QMenu(self)
for priority in PRIORITIES:
action = priority_menu.addAction(f"Add {priority.lower()} priority")
action.setData(priority)
action.triggered.connect(self.on_add_with_priority)
add_button = QToolButton()
add_button.setDefaultAction(self.add_action)
add_button.setMenu(priority_menu)
add_button.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
add_button.setToolButtonStyle(
Qt.ToolButtonStyle.ToolButtonTextBesideIcon
)
toolbar.addWidget(add_button)
toolbar.addAction(self.toggle_action)
toolbar.addAction(self.delete_action)
toolbar.addSeparator()
for action in self.filter_group.actions():
toolbar.addAction(action)
def _create_status_bar(self):
self.total_label = QLabel()
self.done_label = QLabel()
status = self.statusBar()
status.addPermanentWidget(self.done_label)
status.addPermanentWidget(self.total_label)
status.showMessage("Ready")
# --- дані та оновлення вигляду ---
def visible_indexes(self):
# Фільтр повертає позиції задач у self._tasks, а не самі задачі.
if self._filter == "Active":
return [i for i, task in enumerate(self._tasks) if not task["done"]]
if self._filter == "Completed":
return [i for i, task in enumerate(self._tasks) if task["done"]]
return list(range(len(self._tasks)))
def current_index(self):
row = self.list_widget.currentRow()
if row < 0:
return None
# У самому елементі списку лежить лише номер задачі.
return self.list_widget.item(row).data(Qt.ItemDataRole.UserRole)
def refresh(self, keep_index=None):
self.list_widget.clear()
for index in self.visible_indexes():
task = self._tasks[index]
mark = "[x]" if task["done"] else "[ ]"
item = QListWidgetItem(
f"{mark} {task['text']} ({task['priority']})"
)
item.setData(Qt.ItemDataRole.UserRole, index)
self.list_widget.addItem(item)
if index == keep_index:
self.list_widget.setCurrentItem(item)
self.update_counters()
self.update_actions()
def update_counters(self):
done = sum(1 for task in self._tasks if task["done"])
self.total_label.setText(f"Total: {len(self._tasks)}")
self.done_label.setText(f"Done: {done}")
def update_actions(self):
# Єдине місце, де вирішується доступність команд.
has_selection = self.current_index() is not None
has_done = any(task["done"] for task in self._tasks)
self.rename_action.setEnabled(has_selection)
self.toggle_action.setEnabled(has_selection)
self.delete_action.setEnabled(has_selection)
self.clear_done_action.setEnabled(has_done)
# --- обробники команд ---
def on_add(self):
self.add_task("Normal")
def on_add_with_priority(self):
self.add_task(self.sender().data())
def add_task(self, priority):
text, accepted = QInputDialog.getText(
self, "New task", f"Task text ({priority} priority):"
)
if not accepted or not text.strip():
return
self._tasks.append(
{"text": text.strip(), "done": False, "priority": priority}
)
self.refresh(keep_index=len(self._tasks) - 1)
self.statusBar().showMessage(f"Added: {text.strip()}", 2000)
def on_rename(self):
index = self.current_index()
if index is None:
return
text, accepted = QInputDialog.getText(
self, "Rename task", "New text:", text=self._tasks[index]["text"]
)
if not accepted or not text.strip():
return
self._tasks[index]["text"] = text.strip()
self.refresh(keep_index=index)
self.statusBar().showMessage("Task renamed", 2000)
def on_toggle_done(self):
index = self.current_index()
if index is None:
return
task = self._tasks[index]
task["done"] = not task["done"]
# Після зміни стану задача може зникнути з поточного фільтра.
self.refresh(keep_index=index)
state = "done" if task["done"] else "active"
self.statusBar().showMessage(f"Task marked as {state}", 2000)
def on_delete(self):
index = self.current_index()
if index is None:
return
answer = QMessageBox.question(
self,
"Delete task",
f"Delete '{self._tasks[index]['text']}'?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if answer != QMessageBox.StandardButton.Yes:
return
del self._tasks[index]
self.refresh()
self.statusBar().showMessage("Task deleted", 2000)
def on_clear_done(self):
removed = [task for task in self._tasks if task["done"]]
self._tasks = [task for task in self._tasks if not task["done"]]
self.refresh()
self.statusBar().showMessage(f"Removed {len(removed)} tasks", 2000)
def on_filter_changed(self, action):
self._filter = action.data()
self.refresh()
self.statusBar().showMessage(f"Filter: {self._filter}", 2000)
def on_about(self):
QMessageBox.information(
self,
"About",
"Task Board\nActions, menus, toolbars and status bar demo.",
)
# --- контекстне меню ---
def show_list_menu(self, position):
item = self.list_widget.itemAt(position)
if item is not None:
self.list_widget.setCurrentItem(item)
self.update_actions()
menu = QMenu(self)
menu.addAction(self.toggle_action)
menu.addAction(self.rename_action)
menu.addSeparator()
menu.addAction(self.delete_action)
menu.addSeparator()
menu.addAction(self.add_action)
menu.exec(self.list_widget.mapToGlobal(position))
def main():
app = QApplication(sys.argv)
window = TaskBoard()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Ключове спостереження: у контекстному меню немає жодної нової дії. Ті самі об'єкти self.toggle_action, self.rename_action, self.delete_action показані втретє — у меню, на панелі й тут. Їхня доступність порахована один раз в update_actions() і діє скрізь.
Спробуйте додати команду Duplicate task: створити QAction в _create_actions(), додати рядок у _create_menus(), рядок у show_list_menu() і одну перевірку в update_actions(). Обробник — один.
Типові помилки¶
1. lambda з одним параметром під triggered
Qt передасть у перший параметр checked (False), і path перетвориться на булеве значення. Правильно — залишити місце під checked:
Ще краще — action.setData(path) і читати його через self.sender().data().
2. Мутація словника, покладеного в setData()
action.setData(task) # task - словник
action.data()["done"] = True # ПОМИЛКА: змінили копію, оригінал не чіпали
Через QVariant списки й словники копіюються. Зберігайте ідентифікатор (індекс, id, рядковий ключ), а сам об'єкт беріть зі своєї структури даних — саме так зроблено в TaskBoard.
3. Стан тримається в кнопці, а не в дії
Вимикати треба дію: self.save_action.setEnabled(False) — тоді посіріють усі місця, де вона показана.
4. QMenu.exec() з локальними координатами
menu.exec(position) # меню вискочить у куті екрана
menu.exec(widget.mapToGlobal(position)) # правильно
5. Контекстне меню не з'являється без setContextMenuPolicy()
Підключити customContextMenuRequested замало — треба ще й widget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu), інакше сигнал ніколи не надійде.
6. Локальний QMenu без батька
Меню без батька живе лише доки на нього є посилання Python. Всередині exec() це ще працює, але для меню, які показують асинхронно (popup()), об'єкт може зникнути. Задавайте батька: QMenu(self).
7. Дублікат гарячої клавіші
Дві дії з Ctrl+S в одному вікні — і не працює жодна (Ambiguous shortcut overload). Перевіряйте, чи комбінація вже не зайнята, у тому числі стандартними діями віджетів.
8. Перемикачі без QActionGroup
Три checkable дії й ручне зняття галочок в обробнику — це десяток рядків, які легко розсинхронізувати. QActionGroup робить те саме одним викликом addAction().
9. Індикатор у рядку стану доданий через addWidget()
Тоді перше ж showMessage() його перекриє, і користувач вирішить, що лічильник зник. Для постійних індикаторів — addPermanentWidget().
Підсумок¶
QActionтримає весь стан команди: текст, іконку, гарячу клавішу, підказки, доступність, стан перемикача й довільні дані черезsetData(). Меню, панель і контекстне меню — лише вікна в цей об'єкт.- Сигнал
triggeredпередає параметрchecked— це треба враховувати вlambda;self.sender().data()дозволяє обійтись одним обробником на групу однотипних дій. - Іконки беруть з файлу, зі стилю Qt (
style().standardIcon(...)) або з теми системи (QIcon.fromTheme(name, fallback)); третій варіант завжди задають із запасним. setShortcuts()дає кілька комбінацій на дію,setShortcutContext()— область її дії; дублікат комбінації вимикає обидві дії.- Доступність команд перераховують в одному методі
update_actions(), який викликають після кожної зміни даних, — а не розкидуютьsetEnabled()по всьому коду. QActionGroupробить перемикачі взаємовиключними й дає сигналtriggered(QAction)з обраною дією.- Динамічні меню перебудовують у
aboutToShow, попередньо викликавшиmenu.clear(). - Контекстні меню роблять трьома способами:
ActionsContextMenu,CustomContextMenu+ сигнал, або перевизначенняcontextMenuEvent(); координати дляmenu.exec()завжди глобальні. QToolBarприймає не лише дії, а й довільні віджети;setToolButtonStyle()іsetIconSize()керують виглядом,toggleViewAction()дає готовий пункт меню, аQToolButtonзsetMenu()— кнопку з випадаючим списком команд.- У
QStatusBarтимчасові повідомлення перекривають віджети зaddWidget(), але не зaddPermanentWidget()— постійні індикатори додають другим методом.
Корисні посилання¶
- QAction
- QActionGroup
- QMenu
- QMenuBar
- QToolBar
- QToolButton
- QStatusBar
- QKeySequence
- QIcon
- Freedesktop Icon Naming Specification — імена іконок для
fromTheme
Домашнє завдання¶
- Запустити всі приклади лекції та переконатись, що вони працюють.
- У
IconDemoдодати менюFileз тими самими діями і порівняти, як іконки виглядають у меню та на панелі. ПеремкнутиsetToolButtonStyle()на всі п'ять значень і описати різницю. - У
EnabledStateDemoдодати діюMove downз правильною логікою доступності (неактивна на останньому рядку) і додати обидві діїMove up/Move downу контекстне меню списку. - У
RecentFilesDemoобмежити список трьома записами і додати до кожного пункту гарячу клавішуCtrl+1...Ctrl+3. - У
TaskBoardдодати:- дію
Duplicate task(Ctrl+D) у меню, на панель і в контекстне меню; - підменю
Task → PriorityзQActionGroupна три пріоритети, яке змінює пріоритет обраної задачі й показує поточний значенням галочки; - постійний віджет у рядку стану з кількістю задач високого пріоритету.
- дію
Знайшли помилку чи бажаєте додати інформацію, щоб покращити курс? Створіть issue на GitHub