Created
February 3, 2022 17:35
-
-
Save jasonbeverage/cdf366738b561a4404bae13e51464532 to your computer and use it in GitHub Desktop.
Multithreaded QT implementation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#include <QApplication> | |
#include <QEvent> | |
#include <QResizeEvent> | |
#include <QtOpenGL/QGLWidget> | |
#include <QTimer> | |
#include <sstream> | |
#include <osgViewer/GraphicsWindow> | |
#include <osgViewer/Renderer> | |
#include <osgDB/ReadFile> | |
#include <osgEarth/MapNode> | |
#include <osgEarth/EarthManipulator> | |
#include <osgEarth/Metrics> | |
#include <osgGA/TrackballManipulator> | |
#include <osgGA/StateSetManipulator> | |
#include <osgViewer/ViewerEventHandlers> | |
#include <osgViewer/CompositeViewer> | |
#include <thread> | |
#include <iostream> | |
using namespace osgEarth; | |
class OSGWidgetContext : public osgViewer::GraphicsWindowEmbedded | |
{ | |
public: | |
OSGWidgetContext(QGLWidget* widget, osg::GraphicsContext::Traits* traits): | |
osgViewer::GraphicsWindowEmbedded(traits), | |
_widget(widget) | |
{ | |
} | |
virtual bool valid() const | |
{ | |
return _widget->context()->isValid(); | |
} | |
virtual bool realizeImplementation() | |
{ | |
_realized = true; | |
return _realized; | |
} | |
virtual bool isRealizedImplementation() const | |
{ | |
return _realized; | |
} | |
virtual void closeImplementation() | |
{ | |
_realized = false; | |
} | |
virtual void grabFocus() | |
{ | |
_widget->setFocus(Qt::OtherFocusReason); | |
} | |
virtual void grabFocusIfPointerInWindow() | |
{ | |
QPoint globalPos = QCursor::pos(); | |
QPoint localPos = _widget->mapFromGlobal(globalPos); | |
if (_widget->rect().contains(localPos)) | |
{ | |
grabFocus(); | |
} | |
} | |
virtual bool makeCurrentImplementation() | |
{ | |
OE_PROFILING_ZONE; | |
_widget->makeCurrent(); | |
return true; | |
} | |
virtual bool releaseContextImplementation() | |
{ | |
OE_PROFILING_ZONE; | |
_widget->doneCurrent(); | |
return true; | |
} | |
virtual void swapBuffersImplementation() | |
{ | |
OE_PROFILING_ZONE; | |
_widget->swapBuffers(); | |
} | |
virtual void requestWarpPointer(float x, float y) | |
{ | |
} | |
virtual void useCursor(bool onOff) | |
{ | |
} | |
private: | |
QGLWidget* _widget; | |
bool _realized = false; | |
}; | |
QGLFormat formatFromTraits(osg::GraphicsContext::Traits* traits) | |
{ | |
QGLFormat format; | |
format.setDoubleBuffer(traits->doubleBuffer); | |
format.setSwapInterval(traits->vsync ? 1 : 0); | |
format.setDepthBufferSize(traits->depth); | |
format.setStencilBufferSize(traits->stencil); | |
format.setRedBufferSize(traits->red); | |
format.setGreenBufferSize(traits->green); | |
format.setBlueBufferSize(traits->blue); | |
format.setAlphaBufferSize(traits->alpha); | |
format.setSampleBuffers(traits->sampleBuffers > 0 ? true : false); | |
format.setSamples(traits->samples); | |
format.setStereo(traits->quadBufferStereo); | |
return format; | |
} | |
static osgViewer::CompositeViewer* s_viewer; | |
static osg::ref_ptr < osg::Node > s_loadedNode; | |
static bool vsync = false; | |
static osgGA::GUIEventAdapter::KeySymbol osgKeyFromQtKeyEvent( | |
QKeyEvent* event) | |
{ | |
int key = event->key(); | |
// These are for determining left vs right ctrl/alt/shift on Windows | |
#ifdef WIN32 | |
#define EXTENDED_KEY_MASK 0x01000000 | |
#define RSHIFT_MASK 0X36 | |
quint32 mods = event->nativeModifiers(); | |
bool extended = (bool)(mods & EXTENDED_KEY_MASK); | |
quint32 scode = event->nativeScanCode(); | |
#endif | |
switch (key) | |
{ | |
case Qt::Key_Escape: | |
return osgGA::GUIEventAdapter::KEY_Escape; | |
case Qt::Key_Delete: | |
return osgGA::GUIEventAdapter::KEY_Delete; | |
case Qt::Key_Backspace: | |
return osgGA::GUIEventAdapter::KEY_BackSpace; | |
case Qt::Key_SysReq: | |
return osgGA::GUIEventAdapter::KEY_Sys_Req; | |
case Qt::Key_Pause: | |
return osgGA::GUIEventAdapter::KEY_Pause; | |
case Qt::Key_Enter: | |
case Qt::Key_Return: | |
return osgGA::GUIEventAdapter::KEY_Return; | |
break; | |
case Qt::Key_Left: | |
return osgGA::GUIEventAdapter::KEY_Left; | |
case Qt::Key_Up: | |
return osgGA::GUIEventAdapter::KEY_Up; | |
case Qt::Key_Right: | |
return osgGA::GUIEventAdapter::KEY_Right; | |
case Qt::Key_Down: | |
return osgGA::GUIEventAdapter::KEY_Down; | |
case Qt::Key_Insert: | |
return osgGA::GUIEventAdapter::KEY_Insert; | |
case Qt::Key_Home: | |
return osgGA::GUIEventAdapter::KEY_Home; | |
case Qt::Key_PageUp: | |
return osgGA::GUIEventAdapter::KEY_Page_Up; | |
case Qt::Key_PageDown: | |
return osgGA::GUIEventAdapter::KEY_Page_Down; | |
case Qt::Key_End: | |
return osgGA::GUIEventAdapter::KEY_End; | |
case Qt::Key_Clear: | |
return osgGA::GUIEventAdapter::KEY_Clear; | |
case Qt::Key_F1: | |
return osgGA::GUIEventAdapter::KEY_F1; | |
case Qt::Key_F2: | |
return osgGA::GUIEventAdapter::KEY_F2; | |
case Qt::Key_F3: | |
return osgGA::GUIEventAdapter::KEY_F3; | |
case Qt::Key_F4: | |
return osgGA::GUIEventAdapter::KEY_F4; | |
case Qt::Key_F5: | |
return osgGA::GUIEventAdapter::KEY_F5; | |
case Qt::Key_F6: | |
return osgGA::GUIEventAdapter::KEY_F6; | |
case Qt::Key_F7: | |
return osgGA::GUIEventAdapter::KEY_F7; | |
case Qt::Key_F8: | |
return osgGA::GUIEventAdapter::KEY_F8; | |
case Qt::Key_F9: | |
return osgGA::GUIEventAdapter::KEY_F9; | |
case Qt::Key_F10: | |
return osgGA::GUIEventAdapter::KEY_F10; | |
case Qt::Key_F11: | |
return osgGA::GUIEventAdapter::KEY_F11; | |
case Qt::Key_F12: | |
return osgGA::GUIEventAdapter::KEY_F12; | |
#ifdef WIN32 | |
case Qt::Key_Shift: | |
// Unfortunately shift doesn't use the extended flag, so we need to look at the scan code | |
if ((scode ^ RSHIFT_MASK) == 0) | |
{ | |
return osgGA::GUIEventAdapter::KEY_Shift_R; | |
} | |
else | |
{ | |
return osgGA::GUIEventAdapter::KEY_Shift_L; | |
} | |
case Qt::Key_Control: | |
if (extended) | |
{ | |
return osgGA::GUIEventAdapter::KEY_Control_R; | |
} | |
else | |
{ | |
return osgGA::GUIEventAdapter::KEY_Control_L; | |
} | |
case Qt::Key_Alt: | |
if (extended) | |
{ | |
return osgGA::GUIEventAdapter::KEY_Alt_R; | |
} | |
else | |
{ | |
return osgGA::GUIEventAdapter::KEY_Alt_L; | |
} | |
#else | |
case Qt::Key_Shift: | |
return osgGA::GUIEventAdapter::KEY_Shift_L; | |
case Qt::Key_Control: | |
return osgGA::GUIEventAdapter::KEY_Control_L; | |
case Qt::Key_Alt: | |
return osgGA::GUIEventAdapter::KEY_Alt_L; | |
#endif | |
} | |
// ascii representation of these does not work if ctrl modifier is | |
// down... | |
/* | |
if (key >= Qt::Key_A && key <= Qt::Key_Z) | |
{ | |
return (osgGA::GUIEventAdapter::KeySymbol)key; | |
} | |
*/ | |
// If we get here, use the ascii representation | |
int keyRep = *(event->text().toLatin1().data()); | |
if (keyRep != 0) | |
{ | |
return (osgGA::GUIEventAdapter::KeySymbol)keyRep; | |
} | |
return (osgGA::GUIEventAdapter::KeySymbol)0; | |
} | |
class ProfiledRenderer : public osgViewer::Renderer | |
{ | |
public: | |
ProfiledRenderer(osg::Camera* camera): | |
osgViewer::Renderer(camera) | |
{ | |
} | |
virtual void cull() | |
{ | |
OE_PROFILING_ZONE; | |
osgViewer::Renderer::cull(); | |
} | |
virtual void draw() | |
{ | |
OE_PROFILING_ZONE; | |
osgViewer::Renderer::draw(); | |
} | |
virtual void cull_draw() | |
{ | |
OE_PROFILING_ZONE; | |
osgViewer::Renderer::cull_draw(); | |
} | |
}; | |
class ProfiledView : public osgViewer::View | |
{ | |
public: | |
ProfiledView() : | |
osgViewer::View() | |
{ | |
} | |
protected: | |
virtual osg::GraphicsOperation* createRenderer(osg::Camera* camera) override | |
{ | |
return new ProfiledRenderer(camera); | |
} | |
}; | |
class OSGWidget : public QGLWidget | |
{ | |
public: | |
OSGWidget(osg::GraphicsContext::Traits* traits, QWidget* parent = nullptr, QGLWidget* sharedContext = nullptr): | |
QGLWidget(formatFromTraits(traits), parent, sharedContext) | |
{ | |
_osgContext = new OSGWidgetContext(this, traits); | |
std::cout << "Context id " << _osgContext->getState()->getContextID() << std::endl; | |
//_view = new osgViewer::View(); | |
_view = new ProfiledView(); | |
// We do this here b/c we can't override createRenderer b/c it's called in the constructor | |
_view->getCamera()->setRenderer(new ProfiledRenderer(_view->getCamera())); | |
_view->setCameraManipulator(new osgEarth::EarthManipulator); | |
//_view->setCameraManipulator(new osgGA::TrackballManipulator); | |
_view->getCamera()->setGraphicsContext(_osgContext); | |
_view->getCamera()->setViewport(0, 0, traits->width, traits->height); | |
_view->getCamera()->setProjectionMatrixAsPerspective(45, 1, 1, 10); | |
_view->getCamera()->setClearColor(osg::Vec4(1, 0, 0, 1)); | |
static bool firstView = true; | |
if (firstView) | |
{ | |
_view->addEventHandler(new osgViewer::StatsHandler()); | |
_view->addEventHandler(new osgViewer::ThreadingHandler); | |
firstView = false; | |
} | |
_view->addEventHandler(new osgGA::StateSetManipulator(_view->getCamera()->getOrCreateStateSet())); | |
setAutoBufferSwap(false); | |
doneCurrent(); | |
} | |
OSGWidgetContext* osgContext() | |
{ | |
return _osgContext.get(); | |
} | |
osgViewer::View* view() | |
{ | |
return _view.get(); | |
} | |
void resizeGL(int width, int height) | |
{ | |
std::cout << "resizegl" << std::endl; | |
_osgContext->getEventQueue()->windowResize(0, 0, width, height); | |
_osgContext->resized(0, 0, width, height); | |
_view->getCamera()->resize(width, height, osg::Camera::RESIZE_VIEWPORT | osg::Camera::RESIZE_PROJECTIONMATRIX); | |
} | |
void resizeEvent(QResizeEvent* e) | |
{ | |
_osgContext->getEventQueue()->windowResize(0, 0, e->size().width(), e->size().height()); | |
_osgContext->resized(0, 0, e->size().width(), e->size().height()); | |
_view->getCamera()->resize(e->size().width(), e->size().height(), osg::Camera::RESIZE_VIEWPORT | osg::Camera::RESIZE_PROJECTIONMATRIX); | |
} | |
// Disable automatic painting | |
void paintEvent(QPaintEvent* e) | |
{ | |
return; | |
} | |
// If you see this message QT is painting on on it's own | |
void paintGL() | |
{ | |
std::cout << "QT calling paintGL, this is bad" << std::endl; | |
} | |
void closeEvent(QCloseEvent* event) | |
{ | |
s_viewer->removeView(_view.get()); | |
std::cout << "Window closed. Number of views " << s_viewer->getNumViews() << std::endl; | |
} | |
void keyPressEvent(QKeyEvent* event) | |
{ | |
auto key = osgKeyFromQtKeyEvent(event); | |
_osgContext->getEventQueue()->keyPress(key); | |
} | |
void keyReleaseEvent(QKeyEvent* event) | |
{ | |
auto key = osgKeyFromQtKeyEvent(event); | |
_osgContext->getEventQueue()->keyRelease(key); | |
} | |
int qtButtonToOsgButton(QMouseEvent* event) | |
{ | |
int button; | |
switch (event->button()) | |
{ | |
case(Qt::LeftButton): | |
button = 1; | |
break; | |
case(Qt::MidButton): | |
button = 2; | |
break; | |
case(Qt::RightButton): | |
button = 3; | |
break; | |
case(Qt::NoButton): | |
button = 0; | |
break; | |
default: | |
button = 0; | |
break; | |
} | |
return button; | |
} | |
void mousePressEvent(QMouseEvent* event) | |
{ | |
int button = qtButtonToOsgButton(event); | |
if (event->button() == Qt::LeftButton) | |
{ | |
_leftDown = true; | |
} | |
else if (event->button() == Qt::MidButton) | |
{ | |
_middleDown = true; | |
} | |
else if (event->button() == Qt::RightButton) | |
{ | |
_rightDown = true; | |
} | |
_osgContext->getEventQueue()->mouseButtonPress( | |
widgetToNativeCoordinate(event->x()), | |
widgetToNativeCoordinate(event->y()), button); | |
} | |
void mouseReleaseEvent(QMouseEvent* event) | |
{ | |
int button = qtButtonToOsgButton(event); | |
if (event->button() == Qt::LeftButton) | |
{ | |
if (_leftDown) | |
{ | |
_leftDown = false; | |
_osgContext->getEventQueue()->mouseButtonRelease( | |
widgetToNativeCoordinate(event->x()), | |
widgetToNativeCoordinate(event->y()), button); | |
} | |
} | |
else if (event->button() == Qt::MidButton) | |
{ | |
if (_middleDown) | |
{ | |
_middleDown = false; | |
_osgContext->getEventQueue()->mouseButtonRelease( | |
widgetToNativeCoordinate(event->x()), | |
widgetToNativeCoordinate(event->y()), button); | |
} | |
} | |
else if (event->button() == Qt::RightButton) | |
{ | |
if (_rightDown) | |
{ | |
_rightDown = false; | |
_osgContext->getEventQueue()->mouseButtonRelease( | |
widgetToNativeCoordinate(event->x()), | |
widgetToNativeCoordinate(event->y()), button); | |
} | |
} | |
} | |
void mouseMoveEvent(QMouseEvent* event) | |
{ | |
if (event->button() == Qt::LeftButton) | |
{ | |
return; | |
} | |
_osgContext->getEventQueue()->mouseMotion( | |
widgetToNativeCoordinate(event->x()), | |
widgetToNativeCoordinate(event->y())); | |
} | |
void mouseDoubleClickEvent(QMouseEvent* event) | |
{ | |
int button = qtButtonToOsgButton(event); | |
if (event->button() == Qt::LeftButton) | |
{ | |
_leftDown = true; | |
} | |
else if (event->button() == Qt::MidButton) | |
{ | |
_middleDown = true; | |
} | |
else if (event->button() == Qt::RightButton) | |
{ | |
_rightDown = true; | |
} | |
_osgContext->getEventQueue()->mouseDoubleButtonPress( | |
widgetToNativeCoordinate(event->x()), | |
widgetToNativeCoordinate(event->y()), button); | |
} | |
int widgetToNativeCoordinate(int widgetValue) | |
{ | |
return widgetValue * devicePixelRatio(); | |
} | |
int nativeToWidgetCoordinate(int nativeValue) | |
{ | |
return nativeValue / devicePixelRatio(); | |
} | |
void wheelEvent(QWheelEvent* wheelEvent) | |
{ | |
osgGA::GUIEventAdapter::ScrollingMotion motion; | |
// If alt IS down, orientation is farked due to Qt being stupid. Ignore it. | |
if (wheelEvent->orientation() == Qt::Horizontal && wheelEvent->modifiers() != Qt::ALT) | |
{ | |
if (wheelEvent->delta() < 0) | |
{ | |
motion = osgGA::GUIEventAdapter::SCROLL_RIGHT; | |
} | |
else | |
{ | |
motion = osgGA::GUIEventAdapter::SCROLL_LEFT; | |
} | |
} | |
else | |
{ | |
if (wheelEvent->delta() < 0) | |
{ | |
motion = osgGA::GUIEventAdapter::SCROLL_DOWN; | |
} | |
else | |
{ | |
motion = osgGA::GUIEventAdapter::SCROLL_UP; | |
} | |
} | |
osgGA::GUIEventAdapter* event = | |
_osgContext->getEventQueue()->createEvent(); | |
event->setEventType(osgGA::GUIEventAdapter::SCROLL); | |
event->setScrollingMotionDelta(0., (float)(wheelEvent->delta())); | |
// set scrollingMotionDelta automagically sets the scrolling motion to | |
// Scroll2D. We get around that be creating the event explicitly | |
// so we can call setScrollingMotion ourselves | |
event->setScrollingMotion(motion); | |
event->setTime(_osgContext->getEventQueue()->getTime()); | |
_osgContext->getEventQueue()->addEvent(event); | |
} | |
private: | |
osg::ref_ptr< OSGWidgetContext > _osgContext; | |
osg::ref_ptr< osgViewer::View > _view; | |
bool _leftDown = false; | |
bool _rightDown = false; | |
bool _middleDown = false; | |
}; | |
struct AddWindowHandler : public osgGA::GUIEventHandler | |
{ | |
AddWindowHandler() | |
{ | |
} | |
bool handle(const osgGA::GUIEventAdapter& ea, osgGA::GUIActionAdapter& aa) | |
{ | |
switch (ea.getEventType()) | |
{ | |
case(osgGA::GUIEventAdapter::KEYUP): | |
if (ea.getKey() == 'q') | |
{ | |
osg::GraphicsContext::Traits* traits = new osg::GraphicsContext::Traits(); | |
traits->x = 0; | |
traits->y = 0; | |
traits->width = 500; | |
traits->height = 500; | |
traits->windowDecoration = false; | |
traits->doubleBuffer = true; | |
traits->vsync = vsync; | |
#if 0 | |
if (firstWindow) | |
{ | |
traits->sharedContext = firstWindow->osgContext(); | |
} | |
#endif | |
OSGWidget* osgWidget = new OSGWidget(traits, nullptr, nullptr); | |
osgWidget->move(0, 0); | |
osgWidget->resize(traits->width, traits->height); | |
std::stringstream buf; | |
buf << "OSG " << s_viewer->getNumViews(); | |
osgWidget->setWindowTitle(QString(buf.str().c_str())); | |
osgWidget->show(); | |
osgWidget->view()->setSceneData(s_loadedNode.get()); | |
osgWidget->osgContext()->realize(); | |
s_viewer->addView(osgWidget->view()); | |
osgWidget->view()->addEventHandler(new AddWindowHandler()); | |
std::cout << "Window closed. Number of views " << s_viewer->getNumViews() << std::endl; | |
return true; | |
} | |
break; | |
default: | |
break; | |
}; | |
return false; | |
} | |
}; | |
class FrameRunner : public QObject | |
{ | |
//Q_OBJECT | |
public: | |
FrameRunner() | |
{ | |
timer = new QTimer(this); | |
timer->setInterval(0); | |
connect(timer, &QTimer::timeout, this, &FrameRunner::runFrame); | |
} | |
void start() | |
{ | |
timer->start(); | |
} | |
private slots: | |
void runFrame() | |
{ | |
#if 0 | |
s_viewer->frame(); | |
#else | |
{ | |
OE_PROFILING_ZONE_NAMED("advance"); | |
s_viewer->advance(); | |
} | |
{ | |
OE_PROFILING_ZONE_NAMED("eventTraversal"); | |
s_viewer->eventTraversal(); | |
} | |
{ | |
OE_PROFILING_ZONE_NAMED("updateTraversal"); | |
s_viewer->updateTraversal(); | |
} | |
{ | |
OE_PROFILING_ZONE_NAMED("renderingTraversals"); | |
s_viewer->renderingTraversals(); | |
} | |
OE_PROFILING_FRAME_MARK; | |
#endif | |
} | |
QTimer* timer; | |
}; | |
int | |
main(int argc, char** argv) | |
{ | |
osgEarth::initialize(); | |
osg::ArgumentParser args(&argc, argv); | |
// Disable checking of opengl thread afinity. We still need to make sure that it's only called on a single thread ourselves but this will allow us to avoid using qthreads for the render thread. | |
QApplication::setAttribute(Qt::AA_DontCheckOpenGLContextThreadAffinity); | |
QApplication app(argc, argv); | |
// Disable checking of opengl thread afinity. We still need to make sure that it's only called on a single thread ourselves but this will allow us to avoid using qthreads for the render thread. | |
//QApplication::setAttribute(Qt::AA_DontCheckOpenGLContextThreadAffinity); | |
osg::ref_ptr< osg::Node > loadedNode = osgDB::readNodeFiles(args); | |
s_loadedNode = loadedNode.get(); | |
osgViewer::CompositeViewer viewer(args); | |
//viewer.setThreadingModel(osgViewer::ViewerBase::SingleThreaded); | |
//viewer.setThreadingModel(osgViewer::ViewerBase::CullDrawThreadPerContext); | |
//viewer.setThreadingModel(osgViewer::ViewerBase::DrawThreadPerContext); | |
//viewer.setThreadingModel(osgViewer::ViewerBase::CullThreadPerCameraDrawThreadPerContext); | |
s_viewer = &viewer; | |
unsigned int numViews = 2; | |
args.read("--views", numViews); | |
// vsync on/off? | |
if (args.read("--vsync")) | |
vsync = true; | |
else if (args.read("--novsync")) | |
vsync = false; | |
unsigned int x = 0; | |
unsigned int y = 0; | |
OSGWidget* firstWindow = nullptr; | |
for (unsigned int i = 0; i < numViews; ++i) | |
{ | |
osg::GraphicsContext::Traits* traits = new osg::GraphicsContext::Traits(); | |
traits->x = 0; | |
traits->y = 0; | |
traits->width = 500; | |
traits->height = 500; | |
traits->windowDecoration = false; | |
traits->doubleBuffer = true; | |
traits->vsync = vsync; | |
// context sharing doesn't actually seem to work b/c osg doesn't properly share vao's. It thinks it can share them between contexts but it actually can't. | |
#if 0 | |
if (firstWindow) | |
{ | |
traits->sharedContext = firstWindow->osgContext(); | |
} | |
#endif | |
OSGWidget* osgWidget = new OSGWidget(traits, nullptr, firstWindow); | |
if (!firstWindow) | |
{ | |
firstWindow = osgWidget; | |
} | |
osgWidget->move(x, y); | |
osgWidget->resize(traits->width, traits->height); | |
std::stringstream buf; | |
buf << "OSG " << i; | |
osgWidget->setWindowTitle(QString(buf.str().c_str())); | |
osgWidget->show(); | |
// Need to call realize b/c osg won't do it manually... | |
osgWidget->osgContext()->realize(); | |
osgWidget->view()->setSceneData(loadedNode.get()); | |
viewer.addView(osgWidget->view()); | |
osgWidget->view()->addEventHandler(new AddWindowHandler()); | |
x += 500; | |
if (x >= 2000) | |
{ | |
x = 0; | |
y += 500; | |
} | |
} | |
// This is normally done in CompositeViewer::run() but we aren't calling that.... | |
s_viewer->setReleaseContextAtEndOfFrameHint(false); | |
s_viewer->realize(); | |
FrameRunner runner; | |
// Wait for the views to start up before we start calling frame. | |
QTimer::singleShot(5000, [&] { | |
runner.start(); | |
}); | |
return app.exec(); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment