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

JavaScript

NodeGUI で、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 を実行しているのがわかる。
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 されるため。

[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という割とドンピシャなトピックが上がっていた…。