NodeGUIのEventが何をしてるのか調べる

NodeGUIで、JavaScriptのEventをどうやってQtに渡しているのか気になったので調べました。
(NodeGUI、React NodeGUIについては前回を参照してください。)

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を実行しているのがわかります。
connectclickedシグナルを受け取り、スロット関数を実行しています。
スロット関数内では、emitOnNodeNapi::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だった場合はsubscribedEventsinsertされるようになっています。

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されるためです。

nodegui02 1

[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

まとめ

  1. NodeGUIがaddEventListenerされたものを拾って、EventEmitterとQtに流す
  2. それをQtのinitNodeEventEmitterが、emitOnNodeとして格納する
  3. ウィジェットのconnectに対応するシグナルを検知したら、スロット関数を実行する
  4. EventsMap::eventTypesコンテナに含まれるEventはSubscribeする

参考

この記事を書いている途中、公式ドキュメントでSignal and Event Handlingという割とドンピシャなトピックが上がりました…。