PySide6でデスクトップアプリを作る #3 シグナルとスロット — イベント処理の核心

読了 6分

前回作ったToDoアプリは、画面だけあって動作がありません。ボタンを押したときに何が起きるかを決めるのが今回のテーマで、Qtでその接続を担うモデルがシグナルとスロットです。このモデル1つがQtプログラミング全体を貫くため、ここで正確につかんでおけば残りの回がすべて楽になります。

全7回で構成します。

  • #1 PySide6とは — QtとPythonでデスクトップアプリ
  • #2 ウィジェットとレイアウト — 画面を組み立てる
  • #3 シグナルとスロット — イベント処理の核心 ← この記事
  • #4 Qt DesignerとUIファイル — 画面を描いて読み込む
  • #5 モデルとビュー — リスト・テーブルにデータをつなぐ
  • #6 スレッドとタイマー — 止まらないUI
  • #7 パッケージングと配布 — PyInstallerで実行ファイルを作る

シグナルとスロット — 発信と受信 #

モデルは2つの言葉に要約できます。ウィジェットに何かが起きるとシグナルが発信され、そのシグナルにスロット(受け取って実行される関数)をつないでおけば自動的に呼び出されます。

もっとも単純な接続
def on_add_clicked():
    print("追加ボタンが押されました")

self.add_button.clicked.connect(on_add_clicked)

clickedはQPushButtonがあらかじめ備えているシグナルです。ボタンが押された瞬間、Qtがつながれた関数を呼び出します。関数を呼び出すコードをどこにも書いていない点が重要です。いつ実行するかはイベントループが決め、何を実行するかだけをconnectで登録する構造です。

接続先はふつうクラスのメソッドです。前回のTodoWindowにつなぐとこうなります。

メソッドをスロットとして接続
class TodoWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        # ... #2で作った画面組み立てコード ...

        self.add_button.clicked.connect(self.add_todo)

    def add_todo(self):
        print("追加ボタンが押されました")

引数を運ぶシグナル #

シグナルは単純な通知を超えて、値を一緒に届けます。入力欄のtextChangedは変わったテキストをstrで、コンボボックスのcurrentIndexChangedは選ばれた位置をintで渡してくれます。

引数つきシグナル
def on_text_changed(text: str):
    print(f"入力内容: {text}")

def on_index_changed(index: int):
    print(f"選択位置: {index}")

self.todo_input.textChanged.connect(on_text_changed)
combo.currentIndexChanged.connect(on_index_changed)

スロットのパラメータが、シグナルの送る値をそのまま受け取ります。どのシグナルがどんな値を送るかは公式ドキュメントの各ウィジェットのページに整理されており、実務では自動補完でシグナル名を確認する流れが一般的です。

ToDoアプリに動作をつなぐ #

いよいよ画面の骨組みに実際の動作をつなぎます。追加、削除、すべて削除の3つです。

TodoWindow — 動作の接続
class TodoWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        # ... #2で作った画面組み立てコード ...

        # シグナル接続 — 画面組み立ての後にまとめて
        self.add_button.clicked.connect(self.add_todo)
        self.todo_input.returnPressed.connect(self.add_todo)
        self.delete_button.clicked.connect(self.delete_selected)
        self.clear_button.clicked.connect(self.todo_list.clear)

    def add_todo(self):
        text = self.todo_input.text().strip()
        if not text:
            return
        self.todo_list.addItem(text)
        self.todo_input.clear()

    def delete_selected(self):
        for item in self.todo_list.selectedItems():
            row = self.todo_list.row(item)
            self.todo_list.takeItem(row)

3種類の接続方式がすべて入っています。

  • 1つのスロットに複数のシグナル — 追加ボタンのclickedと入力欄のreturnPressed(Enterキー)を同じadd_todoにつなぎました。ボタンクリックとEnter入力が同じ動作をする理由です。
  • ウィジェットの組み込みスロットを直接接続 — すべて削除は関数を作る必要がなく、QListWidget.clearをそのままつなぎました。シグナルを受け取れるのは自分で作った関数だけではありません。
  • 入力の検証はスロットの中で — 空文字列なら追加しない処理のように、スロットが実行時点の状態を読んで判断します。

実行してみると、入力、追加、選択削除、すべて削除がすべて動くアプリになります。

カスタムシグナル — 自分のクラスも発信者になる #

組み込みシグナルだけでは足りない瞬間が来ます。たとえば入力部分を別のウィジェットクラスに分離した場合、そのウィジェットは「新しいToDoが送信された」という事実を外へ知らせる必要があります。このときSignalでシグナルを直接宣言します。

カスタムシグナルの宣言と発信
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QWidget, QHBoxLayout, QLineEdit, QPushButton


class TodoInput(QWidget):
    submitted = Signal(str)          # クラス属性として宣言

    def __init__(self):
        super().__init__()
        self.line = QLineEdit()
        button = QPushButton("追加")
        layout = QHBoxLayout(self)
        layout.addWidget(self.line)
        layout.addWidget(button)

        button.clicked.connect(self.submit)
        self.line.returnPressed.connect(self.submit)

    def submit(self):
        text = self.line.text().strip()
        if text:
            self.submitted.emit(text)   # シグナルの発信
            self.line.clear()

使う側は、このウィジェットの内部構造をまったく知らないまま接続だけを行います。

使う側
self.todo_input = TodoInput()
self.todo_input.submitted.connect(self.todo_list.addItem)

シグナルはクラス属性としてSignal(型)を宣言し、発信はemit(値)で行います。こうすると入力ウィジェットは「送信された」と知らせるだけで、その値をリストに入れるのかファイルに保存するのかは、つなぐ側が決めます。

コールバックと何が違うのか #

関数を登録しておいて後から呼ばれるようにするという点で、シグナルとスロットはコールバックに似て見えます。違いは結合の方向にあります。

  • 発信者は受信者を知りません。TodoInputはsubmittedを誰が受け取るのか知らず、知る必要もありません。コールバックのように「呼び出す関数」を発信者が持っていません。
  • 多対多の接続ができます。1つのシグナルに複数のスロットをつなげられ、複数のシグナルが1つのスロットに集まることもできます。接続のたびにコードが増えることはありません。

この性質のおかげで、ウィジェットを部品のように分離しても結合が緩やかに保たれます。アプリが大きくなるほど効果が増す構造です。

注記
接続を切る必要があるときはbutton.clicked.disconnect(スロット)を使い、値をコードで変更する間にシグナルが連鎖発信されるのを防ぎたいときはwidget.blockSignals(True)でいったん沈黙させ、Falseで戻します。スロットの中でウィジェットの値を変えるコードが同じスロットをまた呼ぶ無限ループが疑われるとき、最初に確認する道具です。

まとめ #

今回の核心は3つです。

  • Qtのイベント処理はシグナルの発信とスロットの接続で成り立ち、実行のタイミングはイベントループが決めます。
  • 1つのスロットに複数のシグナルをつなげられ、組み込みスロットも接続先になります。ToDoアプリの追加・削除がこの方式で完成しました。
  • Signal(型)の宣言とemitで自分のクラスも発信者になり、発信者が受信者を知らない緩やかな構造を保てます。

次回の「PySide6でデスクトップアプリを作る #4 Qt DesignerとUIファイル — 画面を描いて読み込む」では、画面の組み立てをコードではなくマウスで行う方法を扱います。Qt Designerで画面を描き、その成果物をPythonコードから読み込んでシグナルをつなぐ流れです。

X