/ PROGRAMMING, PYTHON, GUI

PyQt6 Signals, Slots & Events

이제 PyQt로 제작한 GUI 어플리케이션이 원하는 기능을 수행하도록 해 보자.

PyQt 정보

Signals & Slots

Signal은 특정한 이벤트가 일어날 때 위젯이 보내는 알림이다.

Signal은 이벤트 발생 자체의 알림에 더해, 발생한 이벤트에 대한 추가적인 맥락을 제공할 수도 있다.

Slot은 signal의 수신자를 뜻하는 Qt의 용어이다. Signal을 연결함으로써, Python에서는 어떠한 함수(또는 메소드)도 slot으로 사용될 수 있다. 많은 Qt 위젯은 자기 자신의 내장 slot을 갖고 있는 경우가 많다. 즉, 원하는 Qt 위젯에 직접 후킹이 가능하다.

QButton Signals

import sys

from PyQt6.QtCore import QSize, Qt
from PyQt6.QtWidgets import QApplication, QWidget, QMainWindow, QPushButton

class MyApp(QMainWindow):

    def __init__(self):
        super().__init__() #QWidget()
        self.initUI()

    def initUI(self):
        self.setWindowTitle("MyApp")
        button = QPushButton("Press Me!")
        button.clicked.connect(self.button_clicked)
        self.setFixedSize(QSize(400,300))
        self.setCentralWidget(button)
        self.show()
    
    def button_clicked(self):
        print("nihehe")

def main():
    app = QApplication(sys.argv)
    window = MyApp()

    # Start the event loop
    app.exec()

if __name__ == '__main__':
    main()

위와 같이 버튼 위젯이 눌렸을 때 실행할, 즉 slot이 될 임의의 메소드를 작성하고, .clicked 시그널을 .connect 메소드를 통해 slot과 연결해 준다.

이제 버튼을 누를 때마다 “nihehe” 라는 메시지가 출력된다.

데이터 수신

signal을 통해 원하는 데이터를 보낼 수도 있다.

예를 들어, .clicked signal은 버튼에 대한 chcked state를 제공한다. 일반 버튼은 항상 이 값이 False 이지만, checkable 한 버튼에서는 유효하다.

import sys

from PyQt6.QtCore import QSize, Qt
from PyQt6.QtWidgets import QApplication, QWidget, QMainWindow, QPushButton

class MyApp(QMainWindow):

    def __init__(self):
        super().__init__() #QWidget()
        self.initUI()

    def initUI(self):
        self.setWindowTitle("MyApp")
        button = QPushButton("Press Me!")
        button.setCheckable(True)

        button.clicked.connect(self.button_clicked)
        button.clicked.connect(self.button_toggled)

        self.setCentralWidget(button)
        self.show()
    
    def button_clicked(self):
        print("nihehe")

    def button_toggled(self,checked):
        print(f"Checked: {checked}")

def main():
    app = QApplication(sys.argv)
    window = MyApp()

    # Start the event loop
    app.exec()

if __name__ == '__main__':
    main()

.setCheckable(True) 메소드로 버튼을 toggle 가능한 유형으로 설정한다.

clicked 시그널을 checked state에 접근하는 슬롯과 연결하면, 해당 슬롯의 첫 번째 argument로 버튼의 chcked state가 잘 전달됨을 확인할 수 있다.

위젯의 각종 state는 변수에 저장하여 기본값 등을 지정할 수도 있다. clicked와 같이 기본적으로 state를 제공하지 않는 시그널들의 경우에는 변수를 이용하여 state를 따로 관리해줄 필요가 있다. .isChecked 메소드로 직접 버튼의 state에 접근할 수도 있다.

slot 함수 내에서는 .setText.setEnabled 메소드 등을 통해 버튼의 상태를 변경할 수도 있다. (단, 이 경우 reference를 유지하기 위해 self 에 위젯을 할당하는 것이 권장된다.)

다양한 시그널

대부분의 위젯이 자신만의 시그널을 갖고 있다. 예를 들어, QMainWindowwindowTitleChanged 라는 창 이름이 변경되면 발생하는 시그널을 갖고 있다.

시그널이 발생되는 조건을 제대로 확인하는 것이 중요하다.

시그널의 연쇄 작용(버튼 클릭 -> 다른 이벤트 발생)은 GUI 어플리케이션을 만들 때 이해해야 하는 핵심 개념 중 하나라고 볼 수 있다.

위젯들을 직접 연결

시그널을 핸들링하기 위한 슬롯으로 함수를 사용할 수도 있지만, 함수가 아닌 Qt widget들을 연결할 수도 있다.

import sys

from PyQt6.QtCore import QSize, Qt
from PyQt6.QtWidgets import QApplication, QWidget, QMainWindow, QPushButton, QLabel, QLineEdit, QVBoxLayout

class MyApp(QMainWindow):

    def __init__(self):
        super().__init__() #QWidget()
        self.initUI()

    def initUI(self):
        self.setWindowTitle("MyApp")

        self.label = QLabel()

        self.input = QLineEdit()
        self.input.textChanged.connect(self.label.setText)

        button = QPushButton("Press Me!")
        button.setCheckable(True)

        button.clicked.connect(self.button_clicked)
        button.clicked.connect(self.button_toggled)

        layout = QVBoxLayout()
        layout.addWidget(self.input)
        layout.addWidget(self.label)
        layout.addWidget(button)

        container = QWidget()
        container.setLayout(layout)

        self.setCentralWidget(container)
    
    def button_clicked(self):
        print("nihehe")

    def button_toggled(self,checked):
        print(f"Checked: {checked}")

def main():
    app = QApplication(sys.argv)
    window = MyApp()
    window.show()

    # Start the event loop
    app.exec()

if __name__ == '__main__':
    main()

위 예제에서는 QVBoxLayout 이라는 클래스를 통해 레이아웃을 생성하고, 버튼, 라벨, 입력창 위젯을 각각 해당 레이아웃에 추가한다. QWidget 으로 비어있는 위젯을 만들어 해당 위젯의 레이아웃을 앞서 생성한 레이아웃으로 설정한다. 그 후 레이아웃을 담고 있는 위젯을 .setCentralWidget 으로 QMainWindow 내에 표시하도록 하면 된다.

QLineEdit 위젯의 .textChanged 시그널이 QLabel.setText 메소드로 연결된다. 실행하면 입력창에 텍스트를 입력할 때마다 아래의 label에 같은 텍스트가 표시되는 것을 확인할 수 있다.

대부분의 Qt 위젯은 이용 가능한 기본 슬롯(메소드)을 탑재하고 있으며, 해당 슬롯이 받아들일 수 있는 같은 타입을 발생시키는 어떤 시그널이든 연결할 수 있다.

위젯 문서Public Slots 섹션에서 각 위젯의 이용 가능한 슬롯을 확인할 수 있다.

Events

Qt 어플리케이션이 유저와 갖는 모든 상호작용은 event이다. 많은 유형의 서로 다른 유형을 나타내는 event가 존재한다. Qt는 이러한 이벤트를 event objects를 이용해 표현한다. 이벤트 오브젝트는 발생한 이벤트에 대한 정보를 담고 있다. 이러한 이벤트들이 상호작용이 발생한 위젯의 event handlers 에게 넘겨진다.

custom, 또는 extended 이벤트 핸들러를 정의함으로써 위젯이 이러한 이벤트에 대응할 방식을 바꿀 수 있다. 이벤트 핸들러는 다른 메소드들과 마찬가지의 방식으로 정의되지만, 이름은 이벤트의 유형에 따라 정해져 있다.

위젯이 받는 주요 이벤트의 예로 QMouseEvent 가 있다. QMouseEvent 이벤트는 모든 각각의 마우스 움직임과 위젯에 대한 버튼 클릭에 의해 생성된다. 다음과 같은 이벤트 핸들러를 통해 핸들링할 수 있다.

이벤트 핸들러 moved 이벤트 타입
mouseMoveEvent 마우스 이동
mousePressEvent 마우스 버튼 눌림
mouseReleaseEvent 마우스 버튼 떼짐
mouseDoubleClickEvent 더블 클릭 감지됨

예를 들어, 위젯을 클릭하면 QMouseEvent 이벤트가 발생하여 그 위젯의 .mousePressEvent 이벤트 핸들러에 전달된다. 이 핸들러에서는 해당 이벤트에 대한 여러가지 정보에 접근할 수도 있다.

클래스 상속과 오버라이딩을 통해 이벤트 핸들러를 재정의할 수 있다. 이때, super() 를 통해 부모 클래스의 메소드를 호출함으로써 일반적인 핸들러를 사용하도록 할 수도 있다.

def mouseMoveEvent(self, e):
    self.label.setText("mouseMoveEvent")

위의 예제에서 이벤트 오브젝트는 첫 번째 parameter e를 통해 전달된다.

마우스 이벤트 오브젝트

메소드 리턴 값
.button() 해당 이벤트를 발생시킨 버튼
.buttons() 모든 마우스 버튼의 상태(OR된 flag값)
.position() 위젯에 대한 상대적인 위치를 나타내는 QPoint 정수

위치 관련 메소드는 globallocal(위젯 상대 위치) 정보를 QPoint 오브젝트로써 제공한다.

마우스 버튼 값은 Qt 네임스페이스에 정의된 마우스 버튼 타입을 사용하여 보고된다.

 def mousePressEvent(self, e):
        if e.button() == Qt.MouseButton.LeftButton:
            # handle the left-button press in here
            self.label.setText("mousePressEvent LEFT")

        elif e.button() == Qt.MouseButton.MiddleButton:
            # handle the middle-button press in here.
            self.label.setText("mousePressEvent MIDDLE")

        elif e.button() == Qt.MouseButton.RightButton:
            # handle the right-button press in here.
            self.label.setText("mousePressEvent RIGHT")

위와 같은 코드를 통해 마우스 버튼의 종류에 따라 이벤트 핸들러가 다른 action을 수행하도록 할 수 있다.

Context menus

창을 오른쪽 클릭했을 때 나타나는 컨텍스트 메뉴를 만들어내는 것도 Qt가 지원한다. 위젯들은 메뉴 생성에 대한 특정 이벤트 핸들러들을 갖고 있다. QMainWindow.contextMenuEvent 이벤트 핸들러가 이에 해당한다. 이 이벤트 핸들러에 전달되는 이벤트 QContextMenuEvent 는 컨텍스트 메뉴가 보여지려고 하는 참에 발생한다.

def contextMenuEvent(self, e):
        context = QMenu(self)
        context.addAction(QAction("test 1", self))
        context.addAction(QAction("test 2", self))
        context.addAction(QAction("test 3", self))
        context.exec(e.globalPos())

위와 같이 이벤트 핸들러를 재정의할 수 있다.

이벤트 핸들러가 아닌, 시그널 기반으로 컨텍스트 메뉴를 정의할 수도 있다.

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.show()

        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.customContextMenuRequested.connect(self.on_context_menu)

    def on_context_menu(self, pos):
        context = QMenu(self)
        context.addAction(QAction("test 1", self))
        context.addAction(QAction("test 2", self))
        context.addAction(QAction("test 3", self))
        context.exec(self.mapToGlobal(pos))

이벤트 계층 구조

PyQt에서는 모든 위젯이 두 계층 구조에 속해 있다.

  • 파이썬 계층 구조

  • Qt 레이아웃 계층 구조

이벤트에 어떻게 대응하느냐에 따라 UI가 다르게 행동할 수 있다.

파이썬 상속 forwarding

파이썬 계층 구조에 따라, 이벤트 핸들러의 경우 super().이벤트 핸들러(event) 를 통해 표준 이벤트 핸들러를 임의로 호출할 수도 있다.

def mousePressEvent(self, event):
    print("Mouse pressed!")
    super().mousePressEvent(event)
레이아웃 forwarding

위젯의 레이아웃 계층 구조에서의 부모는 .parent()로 접근 가능하다. 때때로 이러한 부모를 직접 명시할 수도 있다. 예를 들면 QMenu 또는 QDialog 에 대하여 할 수 있다. 대부분 자동이다.

메인 윈도우에 위젯을 추가하면 메인 윈도우는 해당 위젯의 부모가 된다. 유저 상호작용에 의한 이벤트가 발생하면, 가장 바깥쪽의 위젯에 먼저 전달된다. 처음 전달된 위젯이 이벤트를 핸들링하지 않을 경우, 핸들링되거나 메인 윈도우에 도달할 때까지 계층 구조에서 부모 방향으로 거슬러 올라가며 전달된다.

재정의된 이벤트 핸들러 내에서는 .accept() 를 통해 이벤트가 처리된 것으로 mark 할 수 있고, .ignore() 를 통해 이벤트가 처리되지 않은 것으로 mark 할 수 있다.


QAction

유용한 기능을 제공하는 클래스라 소개한다.

from PyQt6.QtGui import QAction

같은 기능을 하는 서로 다른 인터페이스는 하나의 QAction에 연결할 수 있다. 한 번만 야기되는 action을 정의하면 여러 인터페이스가 같은 동작을 하도록 설계 가능하다.

QAction은 이름, status 메시지, 아이콘 및 슬롯에 연결 가능한 signal 등을 갖고 있다.

QAction() 으로 인스턴스 생성 시에는:

첫 번째 argument로 액션의 아이콘을 QIcon('상대경로/파일명.png') 형태로 전달한다.

두 번째 argument로 액션의 이름을 스트링으로 전달한다.

마지막 argument로 parent 역할을 할 QObject를 전달한다.

메소드 및 시그널

  • .setCheckable(): 버튼 위젯과 유사하게 체크 가능 여부를 설정 가능하다.

  • .triggered: 액션이 활성화되었을 때의 시그널이다. 버튼의 checked state를 함께 전달한다.

  • setKeySequence(): 키 조합을 넘겨줌으로써 해당 액션이 실행될 단축키를 지정할 수 있다. argument로는 Qt 네임스페이스에 정의되어 있는 키 조합을 사용할 것이 권장된다.

# You can enter keyboard shortcuts using key names (e.g. Ctrl+p)
    # Qt.namespace identifiers (e.g. Qt.CTRL + Qt.Key_P)
    # or system agnostic identifiers (e.g. QKeySequence.StandardKey.Print)
    button_action.setShortcut(QKeySequence("Ctrl+p"))

QMainWindow.menuBar() 메소드를 호출함으로써 메뉴바를 만들 수 있고, .addMenu() 메소드를 호출하면서 argument로 메뉴 이름을 넘겨줌으로써 메뉴바에 메뉴를 추가할 수 있다.

메뉴의 .addAction() 메소드를 호출하면서 QAction 오브젝트를 넘겨주면 메뉴에 대응되는 액션을 추가할 수 있다.

메뉴의 .addSepartor() 메소드를 호출하면 해당 메뉴 다음에 분리선을 그릴 수 있다.

메뉴바가 아닌 메뉴 오브젝트에서 .addMenu() 를 호출하면 트리 구조의 서브메뉴를 만들 수 있다.


참고 문헌