NodeGUIのEventが何をしてるのか調べる
JavaScriptNodeGUI で、JavaScript の Event をどうやって Qt に渡しているのか気になったので調べた。
useEventHandlerから
React NodeGUI で useEventHandler
を使用してから、コールバックが走るまでを見る。
下記は、ボタンを押したら、time を現在の時刻で更新するコード。
ボタン(<Button />
)は、QPushButton
ウィジェットの wrapper コンポーネント。
src/index.tsx
const [time, setTime] = React.useState();
const btnHandler = useEventHandler(
{ clicked: () => setTime(new Date()) }, []
);
// 省略
<Button text="Update Time" on={btnHandler} />
まず useEventHandler
について、これ自体は React.useMemo
を wrap した Hooks。
src/hooks/index.ts
import { useMemo, DependencyList } from "react";
type EventHandlerMap = {
[key: string]: (...args: any[]) => void;
};
export const useEventHandler = (
eventHandlerMap: EventHandlerMap,
deps: DependencyList
) => {
const handler = useMemo(() => {
return eventHandlerMap;
}, deps);
return handler;
};
on
は React NodeGUI 上にある。
EventType と Listener(Callback) を、NodeGUI の NodeWidget に渡す。
react-nodegui/src/components/View/index.ts
set on(listenerMap: ListenerMap) {
const listenerMapLatest = Object.assign({}, listenerMap);
const oldListenerMap = Object.assign({}, oldProps.on);
Object.entries(oldListenerMap).forEach(([eventType, oldEvtListener]) => {
const newEvtListener = listenerMapLatest[eventType];
if (oldEvtListener !== newEvtListener) {
widget.removeEventListener(eventType, oldEvtListener);
} else {
delete listenerMapLatest[eventType];
}
});
Object.entries(listenerMapLatest).forEach(
([eventType, newEvtListener]) => {
widget.addEventListener(eventType, newEvtListener);
}
);
},
NodeGUI の EventWedget には addEventListener
メソッドがある。
native
から subscribeToQtEvent
に、EventType を渡している。
さらに emitter(EventEmitter インスタンス)に、同様の Event を addListener
している。
nodegui/src/lib/core/EventWidget/index.ts
addEventListener = (
eventType: string,
callback: (payload?: NativeEvent | any) => void
) => {
this.native.subscribeToQtEvent(eventType);
this.emitter.addListener(eventType, callback);
};
eventwidget.cpp の subscribeToQtEvent
に来た。
EventType を subscribedEvents
に insert して、subscribe を扱っているけど、これだけだとよくわからない。
しかも、なんか catch もする。
nodegui/src/cpp/core/Events/eventwidget.cpp
void EventWidget::subscribeToQtEvent(std::string evtString){
try {
int evtType = EventsMap::eventTypes.at(evtString);
this->subscribedEvents.insert({static_cast<QEvent::Type>(evtType), evtString});
spdlog::info("EventWidget: subscribed to {} : {}, size: {}", evtString.c_str(), evtType, subscribedEvents.size());
} catch (...) {
spdlog::info("EventWidget: Couldn't subscribe to qt event {}. If this is a signal you can safely ignore this warning", evtString.c_str());
}
}
いったん、JavaScript の EventWedget に戻り、何をしてるのか調べると、constructor で下記のような処理を見つける。
先ほど addListener
していた emitter は、ここで生成されていて、initNodeEventEmitter
に emit()を bind している。
nodegui/src/lib/core/EventWidget/index.ts
constructor(native: NativeElement) {
super();
if (native.initNodeEventEmitter) {
this.emitter = new EventEmitter();
native.initNodeEventEmitter(this.emitter.emit.bind(this.emitter));
} else {
throw new Error("initNodeEventEmitter not implemented on native side");
}
}
その initNodeEventEmitter
というのは、以下のマクロ。
nodegui/src/cpp/core/Events/eventwidget.macro.h
Napi::Value initNodeEventEmitter(const Napi::CallbackInfo& info) { \
Napi::Env env = info.Env(); \
this->instance->emitOnNode = Napi::Persistent(info[0].As<Napi::Function>()); \
this->instance->connectWidgetSignalsToEventEmitter(); \
return env.Null(); \
}
先ほどの emit が参照渡しされていて、それを emitOnNode
に格納している。
そして新たに connectWidgetSignalsToEventEmitter
というのが出てきた。
connectWidgetSignalsToEventEmitter
自体は抽象メソッド。
シグナルがどうたらこうたら書いてある。
nodegui/src/cpp/core/Events/eventwidget.cpp
void EventWidget::connectWidgetSignalsToEventEmitter(){
// Do nothing
// This method should be overriden in sub classes to connect all signals
// to event emiiter of node. See Push button
}
これは何
「Qt のシグナル/スロットと、Node.js の EventEmitter を紐づける」仕組み。
NodeGUI の画面は Qt で動くので、JavaScript 側で処理した Event を Qt に渡さないといけない。
しかし Qt には、Event Loop で動く Event だけでなく、シグナル/スロットと呼ばれるものがある。
シグナル/スロットは、受け取ったアクションを「シグナル関数」として認識させ、
そのシグナルが送られた時に、対応した「スロット関数」を呼び出すようにする仕組み。
この 2 つは、あらかじめウィジェット内の connect
関数で結びつけておき、検知させる必要がある。
NodeGUI は、Event の場合とシグナルの場合とで処理を変えている。
先に、シグナルの場合から調べてみる。
シグナルの場合
JavaScript から取得する clicked
は、Qt ではシグナルにあたる。
つまり、JavaScript で発火した Button
ウィジェットの clicked
(シグナル)を受け取って、何か処理する(スロット)記述がウィジェット内にある。
nodegui/src/cpp/QtWidgets/QPushButton/npushbutton.h
#pragma once
#include <QPushButton>
#include "src/cpp/core/NodeWidget/nodewidget.h"
#include "napi.h"
class NPushButton: public QPushButton, public NodeWidget
{
NODEWIDGET_IMPLEMENTATIONS
public:
using QPushButton::QPushButton; //inherit all constructors of QPushButton
void connectWidgetSignalsToEventEmitter() {
// Qt Connects: Implement all signal connects here
QObject::connect(this, &QPushButton::clicked, [=](bool checked) {
Napi::Env env = this->emitOnNode.Env();
Napi::HandleScope scope(env);
this->emitOnNode.Call({ Napi::String::New(env, "clicked"),
Napi::Value::From(env, checked) });
});
QObject::connect(this, &QPushButton::released, [=]() {
Napi::Env env = this->emitOnNode.Env();
Napi::HandleScope scope(env);
this->emitOnNode.Call({ Napi::String::New(env, "released") });
});
QObject::connect(this, &QPushButton::pressed, [=]() {
Napi::Env env = this->emitOnNode.Env();
Napi::HandleScope scope(env);
this->emitOnNode.Call({ Napi::String::New(env, "pressed") });
});
QObject::connect(this, &QPushButton::toggled, [=](bool checked) {
Napi::Env env = this->emitOnNode.Env();
Napi::HandleScope scope(env);
this->emitOnNode.Call({ Napi::String::New(env, "toggled"),
Napi::Value::From(env, checked) });
});
}
};
NPushButton というクラスが、QPushButton の connectWidgetSignalsToEventEmitter
関数をオーバーライドして、その中で connect
を実行しているのがわかる。
connect
は clicked
シグナルを受け取り、スロット関数を実行している。
スロット関数内では、emitOnNode
の Napi::Function::Call
で、JavaScript の EventEmitter をネイティブで呼び出している。
JavaScript 側で指定した clicked
は、実際は Qt の clicked
シグナルに変換される。
Eventの場合
シグナルじゃなくて Event が入ってきた場合はどうなるのか。
そのヒントが、最初の方で見た subscribeToQtEvent
にある。
nodegui/src/cpp/core/Events/eventwidget.cpp
void EventWidget::subscribeToQtEvent(std::string evtString){
try {
int evtType = EventsMap::eventTypes.at(evtString);
this->subscribedEvents.insert({static_cast<QEvent::Type>(evtType), evtString});
spdlog::info("EventWidget: subscribed to {} : {}, size: {}", evtString.c_str(), evtType, subscribedEvents.size());
} catch (...) {
spdlog::info("EventWidget: Couldn't subscribe to qt event {}. If this is a signal you can safely ignore this warning", evtString.c_str());
}
}
EventsMap::eventTypes
は Event を格納した連想コンテナだけど、シグナルは入っていない。
at
でシグナルを参照しようとしても例外を出すが、ウィジェットの Connect に拾われる。
それで、Event だった場合は subscribedEvents
に insert
される。
Event は、eventwidget で行われる。
nodegui/src/cpp/core/Events/eventwidget.h
void EventWidget::event(QEvent* event){
if(this->emitOnNode){
try {
QEvent::Type evtType = event->type();
std::string eventTypeString = subscribedEvents.at(evtType);
Napi::Env env = this->emitOnNode.Env();
Napi::HandleScope scope(env);
Napi::Value nativeEvent = Napi::External<QEvent>::New(env, event);
std::vector<napi_value> args = { Napi::String::New(env, eventTypeString),
nativeEvent };
this->emitOnNode.Call(args);
} catch (...) {
// Do nothing
}
}
}
ここまで見ていくと、下記のような実装で…。
src/index.tsx
// Signal
button1.addEventListener(QPushButtonEvents.clicked, () => {});
// Event
button2.addEventListener(QPushButtonEvents.Resize, () => {});
以下のような結果になることがわかる。
clicked
はシグナルなので例外処理になるが、Resize
は Event として Subscribe されるため。
[info] EventWidget: Couldn't subscribe to qt event clicked. If this is a signal you can safely ignore this warning
[info] EventWidget: subscribed to Resize : 14, size: 1
まとめ
- NodeGUI が addEventListener されたものを拾って、EventEmitter と Qt に流す
- それを Qt の initNodeEventEmitter が、emitOnNode として格納する
- ウィジェットの connect に対応するシグナルを検知したら、スロット関数を実行する
- EventsMap::eventTypes コンテナに含まれる Event は Subscribe する
参考
この記事を書いている途中、公式ドキュメントでSignal and Event Handlingという割とドンピシャなトピックが上がっていた…。