From 34f668c7b7f04432f9f46ea53f02f52c59cf42a1 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Thu, 1 Feb 2024 08:26:09 +0100
Subject: [PATCH 01/20] Update libscratchcpp to latest master

---
 libscratchcpp       | 2 +-
 src/spritemodel.cpp | 8 ++++++++
 src/spritemodel.h   | 3 +++
 src/stagemodel.cpp  | 8 ++++++++
 src/stagemodel.h    | 3 +++
 5 files changed, 23 insertions(+), 1 deletion(-)

diff --git a/libscratchcpp b/libscratchcpp
index 1b01316..d14fe3b 160000
--- a/libscratchcpp
+++ b/libscratchcpp
@@ -1 +1 @@
-Subproject commit 1b01316866ca0f681445c7dfafdc3ae531f65536
+Subproject commit d14fe3b07835637504dc8855b606c6094ddd3879
diff --git a/src/spritemodel.cpp b/src/spritemodel.cpp
index cca1c02..25ba8ec 100644
--- a/src/spritemodel.cpp
+++ b/src/spritemodel.cpp
@@ -113,6 +113,14 @@ void SpriteModel::onGraphicsEffectsCleared()
         m_renderedTarget->clearGraphicEffects();
 }
 
+void SpriteModel::onBubbleTypeChanged(libscratchcpp::Target::BubbleType type)
+{
+}
+
+void SpriteModel::onBubbleTextChanged(const std::string &text)
+{
+}
+
 libscratchcpp::Rect SpriteModel::boundingRect() const
 {
     return m_renderedTarget->getBounds();
diff --git a/src/spritemodel.h b/src/spritemodel.h
index c289d85..f196e3f 100644
--- a/src/spritemodel.h
+++ b/src/spritemodel.h
@@ -48,6 +48,9 @@ class SpriteModel
         void onGraphicsEffectChanged(libscratchcpp::IGraphicsEffect *effect, double value) override;
         void onGraphicsEffectsCleared() override;
 
+        void onBubbleTypeChanged(libscratchcpp::Target::BubbleType type) override;
+        void onBubbleTextChanged(const std::string &text) override;
+
         libscratchcpp::Rect boundingRect() const override;
 
         libscratchcpp::Sprite *sprite() const;
diff --git a/src/stagemodel.cpp b/src/stagemodel.cpp
index 36a15b0..ad657c1 100644
--- a/src/stagemodel.cpp
+++ b/src/stagemodel.cpp
@@ -50,6 +50,14 @@ void StageModel::onGraphicsEffectsCleared()
         m_renderedTarget->clearGraphicEffects();
 }
 
+void StageModel::onBubbleTypeChanged(libscratchcpp::Target::BubbleType type)
+{
+}
+
+void StageModel::onBubbleTextChanged(const std::string &text)
+{
+}
+
 void StageModel::loadCostume()
 {
     if (m_renderedTarget && m_stage) {
diff --git a/src/stagemodel.h b/src/stagemodel.h
index 51a7066..2a69740 100644
--- a/src/stagemodel.h
+++ b/src/stagemodel.h
@@ -33,6 +33,9 @@ class StageModel
         void onGraphicsEffectChanged(libscratchcpp::IGraphicsEffect *effect, double value) override;
         void onGraphicsEffectsCleared() override;
 
+        void onBubbleTypeChanged(libscratchcpp::Target::BubbleType type) override;
+        void onBubbleTextChanged(const std::string &text) override;
+
         Q_INVOKABLE void loadCostume();
 
         libscratchcpp::Stage *stage() const;

From a98a22206d779f725f69a63e86df62c391698edf Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 4 Feb 2024 12:59:38 +0100
Subject: [PATCH 02/20] Enable convex hull points for stage

---
 src/renderedtarget.cpp                      |  3 --
 test/renderedtarget/renderedtarget_test.cpp | 49 +++++++++++++++++++--
 2 files changed, 45 insertions(+), 7 deletions(-)

diff --git a/src/renderedtarget.cpp b/src/renderedtarget.cpp
index 5a175a8..0738ad5 100644
--- a/src/renderedtarget.cpp
+++ b/src/renderedtarget.cpp
@@ -463,9 +463,6 @@ void RenderedTarget::clearGraphicEffects()
 
 void RenderedTarget::updateHullPoints(QOpenGLFramebufferObject *fbo)
 {
-    if (m_stageModel)
-        return; // hull points are useless for the stage
-
     Q_ASSERT(fbo);
     int width = fbo->width();
     int height = fbo->height();
diff --git a/test/renderedtarget/renderedtarget_test.cpp b/test/renderedtarget/renderedtarget_test.cpp
index 57f0b47..10c438a 100644
--- a/test/renderedtarget/renderedtarget_test.cpp
+++ b/test/renderedtarget/renderedtarget_test.cpp
@@ -326,10 +326,6 @@ TEST_F(RenderedTargetTest, HullPoints)
     target.updateHullPoints(&fbo);
     ASSERT_EQ(target.hullPoints(), std::vector<QPointF>({ { 1, 1 }, { 2, 1 }, { 3, 1 }, { 1, 2 }, { 3, 2 }, { 1, 3 }, { 2, 3 }, { 3, 3 } }));
 
-    // Release
-    fbo.release();
-    context.doneCurrent();
-
     // Test contains()
     ASSERT_FALSE(target.contains({ 0, 0 }));
     ASSERT_FALSE(target.contains({ 1, 0 }));
@@ -351,6 +347,51 @@ TEST_F(RenderedTargetTest, HullPoints)
     ASSERT_TRUE(target.contains({ 2, 3 }));
     ASSERT_TRUE(target.contains({ 3, 3 }));
     ASSERT_FALSE(target.contains({ 3.3, 3.5 }));
+
+    // Stage: hull points
+    Stage stage;
+    StageModel stageModel;
+    stageModel.init(&stage);
+    target.setSpriteModel(nullptr);
+    target.setStageModel(&stageModel);
+
+    target.setWidth(3);
+    target.setHeight(3);
+    fbo.release();
+    QOpenGLFramebufferObject emptyFbo(fbo.size(), format);
+    emptyFbo.bind();
+    target.updateHullPoints(&emptyFbo); // clear the convex hull points list
+    ASSERT_TRUE(target.hullPoints().empty());
+    emptyFbo.release();
+    fbo.bind();
+    target.updateHullPoints(&fbo);
+    ASSERT_EQ(target.hullPoints(), std::vector<QPointF>({ { 1, 1 }, { 2, 1 }, { 3, 1 }, { 1, 2 }, { 3, 2 }, { 1, 3 }, { 2, 3 }, { 3, 3 } }));
+
+    // Stage: contains()
+    ASSERT_TRUE(target.contains({ 0, 0 }));
+    ASSERT_TRUE(target.contains({ 1, 0 }));
+    ASSERT_TRUE(target.contains({ 2, 0 }));
+    ASSERT_TRUE(target.contains({ 3, 0 }));
+
+    ASSERT_TRUE(target.contains({ 0, 1 }));
+    ASSERT_TRUE(target.contains({ 1, 1 }));
+    ASSERT_TRUE(target.contains({ 1.4, 1.25 }));
+    ASSERT_TRUE(target.contains({ 2, 1 }));
+    ASSERT_TRUE(target.contains({ 3, 1 }));
+
+    ASSERT_TRUE(target.contains({ 1, 2 }));
+    ASSERT_TRUE(target.contains({ 2, 2 }));
+    ASSERT_TRUE(target.contains({ 3, 2 }));
+    ASSERT_TRUE(target.contains({ 3.5, 2.1 }));
+
+    ASSERT_TRUE(target.contains({ 1, 3 }));
+    ASSERT_TRUE(target.contains({ 2, 3 }));
+    ASSERT_TRUE(target.contains({ 3, 3 }));
+    ASSERT_TRUE(target.contains({ 3.3, 3.5 }));
+
+    // Release
+    fbo.release();
+    context.doneCurrent();
 }
 
 TEST_F(RenderedTargetTest, SpriteDragging)

From 8c6a502e3284ce10a7ba031d2eabf76a3638e9a7 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 4 Feb 2024 13:19:04 +0100
Subject: [PATCH 03/20] Add getBoundsForBubble() method to IRenderedTarget

---
 src/irenderedtarget.h                       |  1 +
 src/renderedtarget.cpp                      | 13 ++++++++
 src/renderedtarget.h                        |  1 +
 test/mocks/renderedtargetmock.h             |  1 +
 test/renderedtarget/renderedtarget_test.cpp | 34 +++++++++++++++++++++
 5 files changed, 50 insertions(+)

diff --git a/src/irenderedtarget.h b/src/irenderedtarget.h
index b1742ae..6d70c57 100644
--- a/src/irenderedtarget.h
+++ b/src/irenderedtarget.h
@@ -70,6 +70,7 @@ class IRenderedTarget : public QNanoQuickItem
         virtual void setHeight(qreal width) = 0;
 
         virtual libscratchcpp::Rect getBounds() const = 0;
+        virtual QRectF getBoundsForBubble() const = 0;
 
         virtual QPointF mapFromScene(const QPointF &point) const = 0;
 
diff --git a/src/renderedtarget.cpp b/src/renderedtarget.cpp
index 0738ad5..030400d 100644
--- a/src/renderedtarget.cpp
+++ b/src/renderedtarget.cpp
@@ -366,6 +366,19 @@ Rect RenderedTarget::getBounds() const
     return Rect(left + m_x, top + m_y, right + m_x, bottom + m_y);
 }
 
+QRectF RenderedTarget::getBoundsForBubble() const
+{
+    // https://github.com/scratchfoundation/scratch-render/blob/86dcb0151a04bc8c1ff39559e8531e7921102b56/src/Drawable.js#L536-L551
+    Rect rect = getBounds();
+    const int slice = 8; // px, how tall the top slice to measure should be
+
+    if (rect.height() > slice)
+        rect.setBottom(rect.top() - slice);
+
+    Q_ASSERT(rect.height() <= 8);
+    return QRectF(QPointF(rect.left(), rect.top()), QPointF(rect.right(), rect.bottom()));
+}
+
 QPointF RenderedTarget::mapFromScene(const QPointF &point) const
 {
     return QNanoQuickItem::mapFromScene(point);
diff --git a/src/renderedtarget.h b/src/renderedtarget.h
index edf57e5..7d80182 100644
--- a/src/renderedtarget.h
+++ b/src/renderedtarget.h
@@ -75,6 +75,7 @@ class RenderedTarget : public IRenderedTarget
         void setHeight(qreal height) override;
 
         libscratchcpp::Rect getBounds() const override;
+        Q_INVOKABLE QRectF getBoundsForBubble() const override;
 
         QPointF mapFromScene(const QPointF &point) const override;
 
diff --git a/test/mocks/renderedtargetmock.h b/test/mocks/renderedtargetmock.h
index ec0fd99..aa31376 100644
--- a/test/mocks/renderedtargetmock.h
+++ b/test/mocks/renderedtargetmock.h
@@ -56,6 +56,7 @@ class RenderedTargetMock : public IRenderedTarget
         MOCK_METHOD(QPointF, mapFromScene, (const QPointF &), (const, override));
 
         MOCK_METHOD(libscratchcpp::Rect, getBounds, (), (const, override));
+        MOCK_METHOD(QRectF, getBoundsForBubble, (), (const, override));
 
         MOCK_METHOD(bool, mirrorHorizontally, (), (const, override));
 
diff --git a/test/renderedtarget/renderedtarget_test.cpp b/test/renderedtarget/renderedtarget_test.cpp
index 10c438a..ba79e3b 100644
--- a/test/renderedtarget/renderedtarget_test.cpp
+++ b/test/renderedtarget/renderedtarget_test.cpp
@@ -674,6 +674,12 @@ TEST_F(RenderedTargetTest, GetBounds)
     ASSERT_EQ(std::round(bounds.right() * 100) / 100, 66.72);
     ASSERT_EQ(std::round(bounds.bottom() * 100) / 100, -125.11);
 
+    QRectF bubbleBounds = target.getBoundsForBubble();
+    ASSERT_EQ(std::round(bubbleBounds.left() * 100) / 100, 66.13);
+    ASSERT_EQ(std::round(bubbleBounds.top() * 100) / 100, -124.52);
+    ASSERT_EQ(std::round(bubbleBounds.right() * 100) / 100, 66.72);
+    ASSERT_EQ(std::round(bubbleBounds.bottom() * 100) / 100, -125.11);
+
     EXPECT_CALL(engine, stageWidth()).WillOnce(Return(480));
     EXPECT_CALL(engine, stageHeight()).WillOnce(Return(360));
     target.updateRotationStyle(Sprite::RotationStyle::LeftRight);
@@ -684,6 +690,12 @@ TEST_F(RenderedTargetTest, GetBounds)
     ASSERT_EQ(std::round(bounds.right() * 100) / 100, 72.29);
     ASSERT_EQ(std::round(bounds.bottom() * 100) / 100, -110.89);
 
+    bubbleBounds = target.getBoundsForBubble();
+    ASSERT_EQ(std::round(bubbleBounds.left() * 100) / 100, 71.87);
+    ASSERT_EQ(std::round(bubbleBounds.top() * 100) / 100, -110.47);
+    ASSERT_EQ(std::round(bubbleBounds.right() * 100) / 100, 72.29);
+    ASSERT_EQ(std::round(bubbleBounds.bottom() * 100) / 100, -110.89);
+
     EXPECT_CALL(engine, stageWidth()).WillOnce(Return(480));
     EXPECT_CALL(engine, stageHeight()).WillOnce(Return(360));
     target.setStageScale(20.75);
@@ -694,5 +706,27 @@ TEST_F(RenderedTargetTest, GetBounds)
     ASSERT_EQ(std::round(bounds.right() * 100) / 100, 72.29);
     ASSERT_EQ(std::round(bounds.bottom() * 100) / 100, -110.89);
 
+    bubbleBounds = target.getBoundsForBubble();
+    ASSERT_EQ(std::round(bubbleBounds.left() * 100) / 100, 71.87);
+    ASSERT_EQ(std::round(bubbleBounds.top() * 100) / 100, -110.47);
+    ASSERT_EQ(std::round(bubbleBounds.right() * 100) / 100, 72.29);
+    ASSERT_EQ(std::round(bubbleBounds.bottom() * 100) / 100, -110.89);
+
+    EXPECT_CALL(engine, stageWidth()).WillOnce(Return(480));
+    EXPECT_CALL(engine, stageHeight()).WillOnce(Return(360));
+    target.updateSize(9780.6);
+
+    bounds = target.getBounds();
+    ASSERT_EQ(std::round(bounds.left() * 100) / 100, -466.05);
+    ASSERT_EQ(std::round(bounds.top() * 100) / 100, 1294.13);
+    ASSERT_EQ(std::round(bounds.right() * 100) / 100, -405.87);
+    ASSERT_EQ(std::round(bounds.bottom() * 100) / 100, 1233.94);
+
+    bubbleBounds = target.getBoundsForBubble();
+    ASSERT_EQ(std::round(bubbleBounds.left() * 100) / 100, -466.05);
+    ASSERT_EQ(std::round(bubbleBounds.top() * 100) / 100, 1294.13);
+    ASSERT_EQ(std::round(bubbleBounds.right() * 100) / 100, -405.87);
+    ASSERT_EQ(std::round(bubbleBounds.bottom() * 100) / 100, 1286.13);
+
     context.doneCurrent();
 }

From 003121cd925f6fe338d69c6927f486491404767e Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 4 Feb 2024 15:53:08 +0100
Subject: [PATCH 04/20] Add text bubble shape component

---
 src/CMakeLists.txt                            |   4 +
 src/textbubblepainter.cpp                     |  88 +++++++++++++
 src/textbubblepainter.h                       |  22 ++++
 src/textbubbleshape.cpp                       |  92 ++++++++++++++
 src/textbubbleshape.h                         |  64 ++++++++++
 test/CMakeLists.txt                           |   2 +
 test/say_bubble.png                           | Bin 0 -> 1880 bytes
 test/textbubblepainter/CMakeLists.txt         |  15 +++
 .../textbubblepainter_test.cpp                | 116 ++++++++++++++++++
 test/textbubbleshape/CMakeLists.txt           |  14 +++
 test/textbubbleshape/textbubbleshape_test.cpp |  96 +++++++++++++++
 test/think_bubble.png                         | Bin 0 -> 818 bytes
 12 files changed, 513 insertions(+)
 create mode 100644 src/textbubblepainter.cpp
 create mode 100644 src/textbubblepainter.h
 create mode 100644 src/textbubbleshape.cpp
 create mode 100644 src/textbubbleshape.h
 create mode 100644 test/say_bubble.png
 create mode 100644 test/textbubblepainter/CMakeLists.txt
 create mode 100644 test/textbubblepainter/textbubblepainter_test.cpp
 create mode 100644 test/textbubbleshape/CMakeLists.txt
 create mode 100644 test/textbubbleshape/textbubbleshape_test.cpp
 create mode 100644 test/think_bubble.png

diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index aef42a9..80914bb 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -62,6 +62,10 @@ qt_add_qml_module(scratchcpp-render
 	shadermanager.h
 	graphicseffect.cpp
 	graphicseffect.h
+	textbubbleshape.cpp
+	textbubbleshape.h
+	textbubblepainter.cpp
+	textbubblepainter.h
 	blocks/penextension.cpp
 	blocks/penextension.h
 	blocks/penblocks.cpp
diff --git a/src/textbubblepainter.cpp b/src/textbubblepainter.cpp
new file mode 100644
index 0000000..e4e50af
--- /dev/null
+++ b/src/textbubblepainter.cpp
@@ -0,0 +1,88 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+
+#include "textbubblepainter.h"
+#include "textbubbleshape.h"
+
+using namespace scratchcpprender;
+
+// https://github.com/scratchfoundation/scratch-render/blob/ac935423afe3ba79235750eecb1e443474c6eb09/src/TextBubbleSkin.js#L7-L26
+static const int STROKE_WIDTH = 4;
+static const int CORNER_RADIUS = 16;
+static const int TAIL_HEIGHT = 12;
+static const QNanoColor BUBBLE_FILL_COLOR = QNanoColor(255, 255, 255);
+static const QNanoColor BUBBLE_STROKE_COLOR = QNanoColor(0, 0, 0, 38);
+
+static const double pi = std::acos(-1); // TODO: Use std::numbers::pi in C++20
+
+void TextBubblePainter::paint(QNanoPainter *painter)
+{
+    // https://github.com/scratchfoundation/scratch-render/blob/ac935423afe3ba79235750eecb1e443474c6eb09/src/TextBubbleSkin.js#L149-L242
+    if (!m_item)
+        return;
+
+    const double scale = m_item->stageScale();
+    const double width = m_item->nativeWidth() - STROKE_WIDTH;
+    const double height = m_item->nativeHeight() - STROKE_WIDTH - TAIL_HEIGHT;
+
+    painter->resetTransform();
+    painter->scale(scale, scale);
+    painter->translate(STROKE_WIDTH * 0.5, STROKE_WIDTH * 0.5);
+
+    // If the text bubble points leftward, flip the canvas
+    painter->save();
+
+    if (m_item->onSpriteRight()) {
+        painter->scale(-1, 1);
+        painter->translate(-width, 0);
+    }
+
+    // Draw the bubble's rounded borders
+    painter->beginPath();
+    painter->moveTo(CORNER_RADIUS, height);
+    painter->arcTo(0, height, 0, height - CORNER_RADIUS, CORNER_RADIUS);
+    painter->arcTo(0, 0, width, 0, CORNER_RADIUS);
+    painter->arcTo(width, 0, width, height, CORNER_RADIUS);
+    painter->arcTo(width, height, width - CORNER_RADIUS, height, CORNER_RADIUS);
+
+    // Translate the canvas so we don't have to do a bunch of width/height arithmetic
+    painter->save();
+    painter->translate(width - CORNER_RADIUS, height);
+
+    // Draw the bubble's "tail"
+    if (m_item->type() == TextBubbleShape::Type::Say) {
+        // For a speech bubble, draw one swoopy thing
+        painter->bezierTo(0, 4, 4, 8, 4, 10);
+        painter->arcTo(4, 12, 2, 12, 2);
+        painter->bezierTo(-1, 12, -11, 8, -16, 0);
+
+        painter->closePath();
+    } else {
+        // For a thinking bubble, draw a partial circle attached to the bubble...
+        painter->arc(-16, 0, 4, 0, pi);
+
+        painter->closePath();
+
+        // and two circles detached from it
+        painter->moveTo(-7, 7.25);
+        painter->arc(-9.25, 7.25, 2.25, 0, pi * 2);
+
+        painter->moveTo(0, 9.5);
+        painter->arc(-1.5, 9.5, 1.5, 0, pi * 2);
+    }
+
+    // Un-translate the canvas and fill + stroke the text bubble
+    painter->restore();
+
+    painter->setFillStyle(BUBBLE_FILL_COLOR);
+    painter->setStrokeStyle(BUBBLE_STROKE_COLOR);
+    painter->setLineWidth(STROKE_WIDTH);
+
+    painter->stroke();
+    painter->fill();
+}
+
+void TextBubblePainter::synchronize(QNanoQuickItem *item)
+{
+    Q_ASSERT(item);
+    m_item = static_cast<TextBubbleShape *>(item);
+}
diff --git a/src/textbubblepainter.h b/src/textbubblepainter.h
new file mode 100644
index 0000000..265c8e8
--- /dev/null
+++ b/src/textbubblepainter.h
@@ -0,0 +1,22 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+
+#pragma once
+
+#include <qnanoquickitempainter.h>
+
+namespace scratchcpprender
+{
+
+class TextBubbleShape;
+
+class TextBubblePainter : public QNanoQuickItemPainter
+{
+    public:
+        void paint(QNanoPainter *painter) override;
+        void synchronize(QNanoQuickItem *item) override;
+
+    private:
+        TextBubbleShape *m_item = nullptr;
+};
+
+} // namespace scratchcpprender
diff --git a/src/textbubbleshape.cpp b/src/textbubbleshape.cpp
new file mode 100644
index 0000000..dd73fcb
--- /dev/null
+++ b/src/textbubbleshape.cpp
@@ -0,0 +1,92 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+
+#include "textbubbleshape.h"
+#include "textbubblepainter.h"
+
+using namespace scratchcpprender;
+
+TextBubbleShape::TextBubbleShape(QQuickItem *parent) :
+    QNanoQuickItem(parent)
+{
+}
+
+QNanoQuickItemPainter *TextBubbleShape::createItemPainter() const
+{
+    return new TextBubblePainter;
+}
+
+TextBubbleShape::Type TextBubbleShape::type() const
+{
+    return m_type;
+}
+
+void TextBubbleShape::setType(Type newType)
+{
+    if (m_type == newType)
+        return;
+
+    m_type = newType;
+    update();
+    emit typeChanged();
+}
+
+bool TextBubbleShape::onSpriteRight() const
+{
+    return m_onSpriteRight;
+}
+
+void TextBubbleShape::setOnSpriteRight(bool newOnSpriteRight)
+{
+    if (m_onSpriteRight == newOnSpriteRight)
+        return;
+
+    m_onSpriteRight = newOnSpriteRight;
+    update();
+    emit onSpriteRightChanged();
+}
+
+double TextBubbleShape::stageScale() const
+{
+    return m_stageScale;
+}
+
+void TextBubbleShape::setStageScale(double newStageScale)
+{
+    if (qFuzzyCompare(m_stageScale, newStageScale))
+        return;
+
+    m_stageScale = newStageScale;
+    setWidth(m_nativeWidth * m_stageScale);
+    setHeight(m_nativeHeight * m_stageScale);
+    emit stageScaleChanged();
+}
+
+double TextBubbleShape::nativeWidth() const
+{
+    return m_nativeWidth;
+}
+
+void TextBubbleShape::setNativeWidth(double newNativeWidth)
+{
+    if (qFuzzyCompare(m_nativeWidth, newNativeWidth))
+        return;
+
+    m_nativeWidth = newNativeWidth;
+    setWidth(m_nativeWidth * m_stageScale);
+    emit nativeWidthChanged();
+}
+
+double TextBubbleShape::nativeHeight() const
+{
+    return m_nativeHeight;
+}
+
+void TextBubbleShape::setNativeHeight(double newNativeHeight)
+{
+    if (qFuzzyCompare(m_nativeHeight, newNativeHeight))
+        return;
+
+    m_nativeHeight = newNativeHeight;
+    setHeight(m_nativeHeight * m_stageScale);
+    emit nativeHeightChanged();
+}
diff --git a/src/textbubbleshape.h b/src/textbubbleshape.h
new file mode 100644
index 0000000..4f85439
--- /dev/null
+++ b/src/textbubbleshape.h
@@ -0,0 +1,64 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+
+#pragma once
+
+#include <qnanoquickitem.h>
+
+namespace scratchcpprender
+{
+
+class TextBubbleShape : public QNanoQuickItem
+{
+        Q_OBJECT
+        QML_ELEMENT
+        Q_PROPERTY(Type type READ type WRITE setType NOTIFY typeChanged)
+        Q_PROPERTY(bool onSpriteRight READ onSpriteRight WRITE setOnSpriteRight NOTIFY onSpriteRightChanged)
+        Q_PROPERTY(double stageScale READ stageScale WRITE setStageScale NOTIFY stageScaleChanged)
+        Q_PROPERTY(double nativeWidth READ nativeWidth WRITE setNativeWidth NOTIFY nativeWidthChanged)
+        Q_PROPERTY(double nativeHeight READ nativeHeight WRITE setNativeHeight NOTIFY nativeHeightChanged)
+
+    public:
+        enum class Type
+        {
+            Say,
+            Think
+        };
+
+        Q_ENUM(Type)
+
+        TextBubbleShape(QQuickItem *parent = nullptr);
+
+        Type type() const;
+        void setType(Type newType);
+
+        bool onSpriteRight() const;
+        void setOnSpriteRight(bool newOnSpriteRight);
+
+        double stageScale() const;
+        void setStageScale(double newStageScale);
+
+        double nativeWidth() const;
+        void setNativeWidth(double newNativeWidth);
+
+        double nativeHeight() const;
+        void setNativeHeight(double newNativeHeight);
+
+    signals:
+        void typeChanged();
+        void onSpriteRightChanged();
+        void stageScaleChanged();
+        void nativeWidthChanged();
+        void nativeHeightChanged();
+
+    protected:
+        QNanoQuickItemPainter *createItemPainter() const override;
+
+    private:
+        Type m_type = Type::Say;
+        bool m_onSpriteRight = true;
+        double m_stageScale = 1;
+        double m_nativeWidth = 0;
+        double m_nativeHeight = 0;
+};
+
+} // namespace scratchcpprender
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 857d27e..463fd1f 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -38,3 +38,5 @@ add_subdirectory(penlayerpainter)
 add_subdirectory(blocks)
 add_subdirectory(graphicseffect)
 add_subdirectory(shadermanager)
+add_subdirectory(textbubbleshape)
+add_subdirectory(textbubblepainter)
diff --git a/test/say_bubble.png b/test/say_bubble.png
new file mode 100644
index 0000000000000000000000000000000000000000..5645c653bf80d8512089185a82a3e81bcfaef287
GIT binary patch
diff --git a/test/textbubblepainter/CMakeLists.txt b/test/textbubblepainter/CMakeLists.txt
new file mode 100644
index 0000000..a356558
--- /dev/null
+++ b/test/textbubblepainter/CMakeLists.txt
@@ -0,0 +1,15 @@
+add_executable(
+  textbubblepainter_test
+  textbubblepainter_test.cpp
+)
+
+target_link_libraries(
+  textbubblepainter_test
+  GTest::gtest_main
+  scratchcpp-render
+  ${QT_LIBS}
+  qnanopainter
+)
+
+add_test(textbubblepainter_test)
+gtest_discover_tests(textbubblepainter_test)
diff --git a/test/textbubblepainter/textbubblepainter_test.cpp b/test/textbubblepainter/textbubblepainter_test.cpp
new file mode 100644
index 0000000..739095b
--- /dev/null
+++ b/test/textbubblepainter/textbubblepainter_test.cpp
@@ -0,0 +1,116 @@
+#include <textbubblepainter.h>
+#include <textbubbleshape.h>
+
+#include "../common.h"
+
+using namespace scratchcpprender;
+
+class TextBubblePainterTest : public testing::Test
+{
+    public:
+        void createContextAndSurface(QOpenGLContext *context, QOffscreenSurface *surface)
+        {
+            QSurfaceFormat surfaceFormat;
+            surfaceFormat.setMajorVersion(4);
+            surfaceFormat.setMinorVersion(3);
+
+            context->setFormat(surfaceFormat);
+            context->create();
+            ASSERT_TRUE(context->isValid());
+
+            surface->setFormat(surfaceFormat);
+            surface->create();
+            ASSERT_TRUE(surface->isValid());
+
+            context->makeCurrent(surface);
+            ASSERT_EQ(QOpenGLContext::currentContext(), context);
+        }
+};
+
+TEST_F(TextBubblePainterTest, PaintSayBubble)
+{
+    QOpenGLContext context;
+    QOffscreenSurface surface;
+    createContextAndSurface(&context, &surface);
+
+    QOpenGLFramebufferObjectFormat format;
+    format.setAttachment(QOpenGLFramebufferObject::CombinedDepthStencil);
+
+    // Begin painting
+    QNanoPainter painter;
+    QOpenGLFramebufferObject fbo(425, 250, format);
+    fbo.bind();
+    glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
+    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
+    painter.beginFrame(fbo.width(), fbo.height());
+    painter.setAntialias(0);
+
+    // Create text bubble painter
+    TextBubblePainter bubblePainter;
+    TextBubbleShape bubble;
+    bubble.setType(TextBubbleShape::Type::Say);
+    bubble.setStageScale(2.5);
+    bubble.setNativeWidth(fbo.width() / bubble.stageScale());
+    bubble.setNativeHeight(fbo.height() / bubble.stageScale());
+    bubblePainter.synchronize(&bubble);
+
+    // Paint
+    bubblePainter.paint(&painter);
+    painter.endFrame();
+
+    // Compare with reference image
+    QBuffer buffer;
+    fbo.toImage().save(&buffer, "png");
+    QFile ref("say_bubble.png");
+    ref.open(QFile::ReadOnly);
+    buffer.open(QFile::ReadOnly);
+    ASSERT_EQ(ref.readAll(), buffer.readAll());
+
+    // Release
+    fbo.release();
+    context.doneCurrent();
+}
+
+TEST_F(TextBubblePainterTest, PaintThinkBubble)
+{
+    QOpenGLContext context;
+    QOffscreenSurface surface;
+    createContextAndSurface(&context, &surface);
+
+    QOpenGLFramebufferObjectFormat format;
+    format.setAttachment(QOpenGLFramebufferObject::CombinedDepthStencil);
+
+    // Begin painting
+    QNanoPainter painter;
+    QOpenGLFramebufferObject fbo(156, 117, format);
+    fbo.bind();
+    glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
+    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
+    painter.beginFrame(fbo.width(), fbo.height());
+    painter.setAntialias(0);
+
+    // Create text bubble painter
+    TextBubblePainter bubblePainter;
+    TextBubbleShape bubble;
+    bubble.setType(TextBubbleShape::Type::Think);
+    bubble.setStageScale(1.25);
+    bubble.setNativeWidth(fbo.width() / bubble.stageScale());
+    bubble.setNativeHeight(fbo.height() / bubble.stageScale());
+    bubblePainter.synchronize(&bubble);
+
+    // Paint
+    bubblePainter.paint(&painter);
+    painter.endFrame();
+
+    // Compare with reference image
+    QBuffer buffer;
+    fbo.toImage().save(&buffer, "png");
+    QFile ref("think_bubble.png");
+    ref.open(QFile::ReadOnly);
+    buffer.open(QFile::ReadOnly);
+    ASSERT_EQ(ref.readAll(), buffer.readAll());
+
+    // Release
+    fbo.release();
+    context.doneCurrent();
+}
diff --git a/test/textbubbleshape/CMakeLists.txt b/test/textbubbleshape/CMakeLists.txt
new file mode 100644
index 0000000..7edf397
--- /dev/null
+++ b/test/textbubbleshape/CMakeLists.txt
@@ -0,0 +1,14 @@
+add_executable(
+  textbubbleshape_test
+  textbubbleshape_test.cpp
+)
+
+target_link_libraries(
+  textbubbleshape_test
+  GTest::gtest_main
+  scratchcpp-render
+  ${QT_LIBS}
+)
+
+add_test(textbubbleshape_test)
+gtest_discover_tests(textbubbleshape_test)
diff --git a/test/textbubbleshape/textbubbleshape_test.cpp b/test/textbubbleshape/textbubbleshape_test.cpp
new file mode 100644
index 0000000..bc6d9c2
--- /dev/null
+++ b/test/textbubbleshape/textbubbleshape_test.cpp
@@ -0,0 +1,96 @@
+#include <QOpenGLContext>
+#include <QOffscreenSurface>
+#include <textbubbleshape.h>
+
+#include "../common.h"
+
+using namespace scratchcpprender;
+
+class TextBubbleShapeTest : public testing::Test
+{
+    public:
+        void SetUp() override
+        {
+            m_context.create();
+            ASSERT_TRUE(m_context.isValid());
+
+            m_surface.setFormat(m_context.format());
+            m_surface.create();
+            Q_ASSERT(m_surface.isValid());
+            m_context.makeCurrent(&m_surface);
+        }
+
+        void TearDown() override
+        {
+            ASSERT_EQ(m_context.surface(), &m_surface);
+            m_context.doneCurrent();
+        }
+
+        QOpenGLContext m_context;
+        QOffscreenSurface m_surface;
+};
+
+TEST_F(TextBubbleShapeTest, Constructors)
+{
+    TextBubbleShape bubble1;
+    TextBubbleShape bubble2(&bubble1);
+    ASSERT_EQ(bubble2.parent(), &bubble1);
+    ASSERT_EQ(bubble2.parentItem(), &bubble1);
+}
+
+TEST_F(TextBubbleShapeTest, Type)
+{
+    TextBubbleShape bubble;
+    ASSERT_EQ(bubble.type(), TextBubbleShape::Type::Say);
+
+    bubble.setType(TextBubbleShape::Type::Think);
+    ASSERT_EQ(bubble.type(), TextBubbleShape::Type::Think);
+
+    bubble.setType(TextBubbleShape::Type::Think);
+    ASSERT_EQ(bubble.type(), TextBubbleShape::Type::Think);
+
+    bubble.setType(TextBubbleShape::Type::Say);
+    ASSERT_EQ(bubble.type(), TextBubbleShape::Type::Say);
+}
+
+TEST_F(TextBubbleShapeTest, OnSpriteRight)
+{
+    TextBubbleShape bubble;
+    ASSERT_TRUE(bubble.onSpriteRight());
+
+    bubble.setOnSpriteRight(false);
+    ASSERT_FALSE(bubble.onSpriteRight());
+
+    bubble.setOnSpriteRight(false);
+    ASSERT_FALSE(bubble.onSpriteRight());
+
+    bubble.setOnSpriteRight(true);
+    ASSERT_TRUE(bubble.onSpriteRight());
+}
+
+TEST_F(TextBubbleShapeTest, StageScale)
+{
+    TextBubbleShape bubble;
+    ASSERT_EQ(bubble.stageScale(), 1);
+
+    bubble.setStageScale(6.48);
+    ASSERT_EQ(bubble.stageScale(), 6.48);
+}
+
+TEST_F(TextBubbleShapeTest, NativeWidth)
+{
+    TextBubbleShape bubble;
+    ASSERT_EQ(bubble.nativeWidth(), 0);
+
+    bubble.setNativeWidth(48.1);
+    ASSERT_EQ(bubble.nativeWidth(), 48.1);
+}
+
+TEST_F(TextBubbleShapeTest, NativeHeight)
+{
+    TextBubbleShape bubble;
+    ASSERT_EQ(bubble.nativeHeight(), 0);
+
+    bubble.setNativeHeight(87.5);
+    ASSERT_EQ(bubble.nativeHeight(), 87.5);
+}
diff --git a/test/think_bubble.png b/test/think_bubble.png
new file mode 100644
index 0000000000000000000000000000000000000000..860ee526fd7ceef3d139f70ba5eb1e97086f74b5
GIT binary patch

From 51728e755eb7a49d525582a43d2cd05049683088 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 4 Feb 2024 16:03:18 +0100
Subject: [PATCH 05/20] Implement text bubble methods in SpriteModel

---
 src/spritemodel.cpp                     | 29 +++++++++++++++++
 src/spritemodel.h                       | 11 +++++++
 test/target_models/spritemodel_test.cpp | 42 +++++++++++++++++++++++++
 3 files changed, 82 insertions(+)

diff --git a/src/spritemodel.cpp b/src/spritemodel.cpp
index 25ba8ec..706bed7 100644
--- a/src/spritemodel.cpp
+++ b/src/spritemodel.cpp
@@ -115,10 +115,29 @@ void SpriteModel::onGraphicsEffectsCleared()
 
 void SpriteModel::onBubbleTypeChanged(libscratchcpp::Target::BubbleType type)
 {
+    if (type == libscratchcpp::Target::BubbleType::Say) {
+        if (m_bubbleType == TextBubbleShape::Type::Say)
+            return;
+
+        m_bubbleType = TextBubbleShape::Type::Say;
+    } else {
+        if (m_bubbleType == TextBubbleShape::Type::Think)
+            return;
+
+        m_bubbleType = TextBubbleShape::Type::Think;
+    }
+
+    emit bubbleTypeChanged();
 }
 
 void SpriteModel::onBubbleTextChanged(const std::string &text)
 {
+    QString newText = QString::fromStdString(text);
+
+    if (m_bubbleText != newText) {
+        m_bubbleText = newText;
+        emit bubbleTextChanged();
+    }
 }
 
 libscratchcpp::Rect SpriteModel::boundingRect() const
@@ -195,4 +214,14 @@ SpriteModel *SpriteModel::cloneRoot() const
         return m_cloneRoot;
 }
 
+const TextBubbleShape::Type &SpriteModel::bubbleType() const
+{
+    return m_bubbleType;
+}
+
+const QString &SpriteModel::bubbleText() const
+{
+    return m_bubbleText;
+}
+
 } // namespace scratchcpprender
diff --git a/src/spritemodel.h b/src/spritemodel.h
index f196e3f..cb4903e 100644
--- a/src/spritemodel.h
+++ b/src/spritemodel.h
@@ -7,6 +7,7 @@
 #include <scratchcpp/ispritehandler.h>
 
 #include "penstate.h"
+#include "textbubbleshape.h"
 
 Q_MOC_INCLUDE("renderedtarget.h");
 Q_MOC_INCLUDE("ipenlayer.h");
@@ -25,6 +26,8 @@ class SpriteModel
         QML_ELEMENT
         Q_PROPERTY(IRenderedTarget *renderedTarget READ renderedTarget WRITE setRenderedTarget NOTIFY renderedTargetChanged)
         Q_PROPERTY(IPenLayer *penLayer READ penLayer WRITE setPenLayer NOTIFY penLayerChanged)
+        Q_PROPERTY(TextBubbleShape::Type bubbleType READ bubbleType NOTIFY bubbleTypeChanged)
+        Q_PROPERTY(QString bubbleText READ bubbleText NOTIFY bubbleTextChanged)
 
     public:
         SpriteModel(QObject *parent = nullptr);
@@ -69,9 +72,15 @@ class SpriteModel
 
         SpriteModel *cloneRoot() const;
 
+        const TextBubbleShape::Type &bubbleType() const;
+
+        const QString &bubbleText() const;
+
     signals:
         void renderedTargetChanged();
         void penLayerChanged();
+        void bubbleTypeChanged();
+        void bubbleTextChanged();
         void cloned(SpriteModel *cloneModel);
         void cloneDeleted(SpriteModel *clone);
 
@@ -81,6 +90,8 @@ class SpriteModel
         IPenLayer *m_penLayer = nullptr;
         PenState m_penState;
         SpriteModel *m_cloneRoot = nullptr;
+        TextBubbleShape::Type m_bubbleType = TextBubbleShape::Type::Say;
+        QString m_bubbleText;
 };
 
 } // namespace scratchcpprender
diff --git a/test/target_models/spritemodel_test.cpp b/test/target_models/spritemodel_test.cpp
index 250a504..b2eca9e 100644
--- a/test/target_models/spritemodel_test.cpp
+++ b/test/target_models/spritemodel_test.cpp
@@ -244,6 +244,48 @@ TEST(SpriteModelTest, OnGraphicsEffectsCleared)
     model.onGraphicsEffectsCleared();
 }
 
+TEST(SpriteModelTest, OnBubbleTypeChanged)
+{
+    SpriteModel model;
+    QSignalSpy spy(&model, &SpriteModel::bubbleTypeChanged);
+    ASSERT_EQ(model.bubbleType(), TextBubbleShape::Type::Say);
+
+    model.onBubbleTypeChanged(Target::BubbleType::Think);
+    ASSERT_EQ(model.bubbleType(), TextBubbleShape::Type::Think);
+    ASSERT_EQ(spy.count(), 1);
+
+    model.onBubbleTypeChanged(Target::BubbleType::Think);
+    ASSERT_EQ(model.bubbleType(), TextBubbleShape::Type::Think);
+    ASSERT_EQ(spy.count(), 1);
+
+    model.onBubbleTypeChanged(Target::BubbleType::Say);
+    ASSERT_EQ(model.bubbleType(), TextBubbleShape::Type::Say);
+    ASSERT_EQ(spy.count(), 2);
+
+    model.onBubbleTypeChanged(Target::BubbleType::Say);
+    ASSERT_EQ(model.bubbleType(), TextBubbleShape::Type::Say);
+    ASSERT_EQ(spy.count(), 2);
+}
+
+TEST(SpriteModelTest, OnBubbleTextChanged)
+{
+    SpriteModel model;
+    QSignalSpy spy(&model, &SpriteModel::bubbleTextChanged);
+    ASSERT_TRUE(model.bubbleText().isEmpty());
+
+    model.onBubbleTextChanged("Hello!");
+    ASSERT_EQ(model.bubbleText(), "Hello!");
+    ASSERT_EQ(spy.count(), 1);
+
+    model.onBubbleTextChanged("Hello!");
+    ASSERT_EQ(model.bubbleText(), "Hello!");
+    ASSERT_EQ(spy.count(), 1);
+
+    model.onBubbleTextChanged("test");
+    ASSERT_EQ(model.bubbleText(), "test");
+    ASSERT_EQ(spy.count(), 2);
+}
+
 TEST(SpriteModelTest, BoundingRect)
 {
     SpriteModel model;

From 54fbd20b45890d1f67d48a47249e0fe9457296bb Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 4 Feb 2024 16:03:44 +0100
Subject: [PATCH 06/20] Implement text bubble methods in StageModel

---
 src/stagemodel.cpp                     | 29 ++++++++++++++++++
 src/stagemodel.h                       | 12 ++++++++
 test/target_models/stagemodel_test.cpp | 42 ++++++++++++++++++++++++++
 3 files changed, 83 insertions(+)

diff --git a/src/stagemodel.cpp b/src/stagemodel.cpp
index ad657c1..5c600ec 100644
--- a/src/stagemodel.cpp
+++ b/src/stagemodel.cpp
@@ -52,10 +52,29 @@ void StageModel::onGraphicsEffectsCleared()
 
 void StageModel::onBubbleTypeChanged(libscratchcpp::Target::BubbleType type)
 {
+    if (type == libscratchcpp::Target::BubbleType::Say) {
+        if (m_bubbleType == TextBubbleShape::Type::Say)
+            return;
+
+        m_bubbleType = TextBubbleShape::Type::Say;
+    } else {
+        if (m_bubbleType == TextBubbleShape::Type::Think)
+            return;
+
+        m_bubbleType = TextBubbleShape::Type::Think;
+    }
+
+    emit bubbleTypeChanged();
 }
 
 void StageModel::onBubbleTextChanged(const std::string &text)
 {
+    QString newText = QString::fromStdString(text);
+
+    if (m_bubbleText != newText) {
+        m_bubbleText = newText;
+        emit bubbleTextChanged();
+    }
 }
 
 void StageModel::loadCostume()
@@ -86,3 +105,13 @@ void StageModel::setRenderedTarget(IRenderedTarget *newRenderedTarget)
 
     emit renderedTargetChanged();
 }
+
+const TextBubbleShape::Type &StageModel::bubbleType() const
+{
+    return m_bubbleType;
+}
+
+const QString &StageModel::bubbleText() const
+{
+    return m_bubbleText;
+}
diff --git a/src/stagemodel.h b/src/stagemodel.h
index 2a69740..11e3929 100644
--- a/src/stagemodel.h
+++ b/src/stagemodel.h
@@ -5,6 +5,8 @@
 #include <QObject>
 #include <scratchcpp/istagehandler.h>
 
+#include "textbubbleshape.h"
+
 Q_MOC_INCLUDE("renderedtarget.h");
 
 namespace scratchcpprender
@@ -18,6 +20,8 @@ class StageModel
 {
         Q_OBJECT
         Q_PROPERTY(IRenderedTarget *renderedTarget READ renderedTarget WRITE setRenderedTarget NOTIFY renderedTargetChanged)
+        Q_PROPERTY(TextBubbleShape::Type bubbleType READ bubbleType NOTIFY bubbleTypeChanged)
+        Q_PROPERTY(QString bubbleText READ bubbleText NOTIFY bubbleTextChanged)
 
     public:
         explicit StageModel(QObject *parent = nullptr);
@@ -43,12 +47,20 @@ class StageModel
         IRenderedTarget *renderedTarget() const;
         void setRenderedTarget(IRenderedTarget *newRenderedTarget);
 
+        const TextBubbleShape::Type &bubbleType() const;
+
+        const QString &bubbleText() const;
+
     signals:
         void renderedTargetChanged();
+        void bubbleTypeChanged();
+        void bubbleTextChanged();
 
     private:
         libscratchcpp::Stage *m_stage = nullptr;
         IRenderedTarget *m_renderedTarget = nullptr;
+        TextBubbleShape::Type m_bubbleType = TextBubbleShape::Type::Say;
+        QString m_bubbleText;
 };
 
 } // namespace scratchcpprender
diff --git a/test/target_models/stagemodel_test.cpp b/test/target_models/stagemodel_test.cpp
index d1c292f..8d73cda 100644
--- a/test/target_models/stagemodel_test.cpp
+++ b/test/target_models/stagemodel_test.cpp
@@ -64,6 +64,48 @@ TEST(StageModelTest, OnGraphicsEffectsCleared)
     model.onGraphicsEffectsCleared();
 }
 
+TEST(StageModelTest, OnBubbleTypeChanged)
+{
+    StageModel model;
+    QSignalSpy spy(&model, &StageModel::bubbleTypeChanged);
+    ASSERT_EQ(model.bubbleType(), TextBubbleShape::Type::Say);
+
+    model.onBubbleTypeChanged(Target::BubbleType::Think);
+    ASSERT_EQ(model.bubbleType(), TextBubbleShape::Type::Think);
+    ASSERT_EQ(spy.count(), 1);
+
+    model.onBubbleTypeChanged(Target::BubbleType::Think);
+    ASSERT_EQ(model.bubbleType(), TextBubbleShape::Type::Think);
+    ASSERT_EQ(spy.count(), 1);
+
+    model.onBubbleTypeChanged(Target::BubbleType::Say);
+    ASSERT_EQ(model.bubbleType(), TextBubbleShape::Type::Say);
+    ASSERT_EQ(spy.count(), 2);
+
+    model.onBubbleTypeChanged(Target::BubbleType::Say);
+    ASSERT_EQ(model.bubbleType(), TextBubbleShape::Type::Say);
+    ASSERT_EQ(spy.count(), 2);
+}
+
+TEST(StageModelTest, OnBubbleTextChanged)
+{
+    StageModel model;
+    QSignalSpy spy(&model, &StageModel::bubbleTextChanged);
+    ASSERT_TRUE(model.bubbleText().isEmpty());
+
+    model.onBubbleTextChanged("Hello!");
+    ASSERT_EQ(model.bubbleText(), "Hello!");
+    ASSERT_EQ(spy.count(), 1);
+
+    model.onBubbleTextChanged("Hello!");
+    ASSERT_EQ(model.bubbleText(), "Hello!");
+    ASSERT_EQ(spy.count(), 1);
+
+    model.onBubbleTextChanged("test");
+    ASSERT_EQ(model.bubbleText(), "test");
+    ASSERT_EQ(spy.count(), 2);
+}
+
 TEST(StageModelTest, RenderedTarget)
 {
     StageModel model;

From e8a25f92c56f027f33952702ec16a381d6b481a0 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 4 Feb 2024 16:04:21 +0100
Subject: [PATCH 07/20] Add TextBubble component

---
 src/CMakeLists.txt          |   1 +
 src/internal/TextBubble.qml | 106 ++++++++++++++++++++++++++++++++++++
 2 files changed, 107 insertions(+)
 create mode 100644 src/internal/TextBubble.qml

diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 80914bb..288117b 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -12,6 +12,7 @@ qt_add_qml_module(scratchcpp-render
         internal/ValueMonitor.qml
 	internal/MonitorSlider.qml
 	internal/ListMonitor.qml
+	internal/TextBubble.qml
 	shaders/sprite.vert
 	shaders/sprite.frag
     SOURCES
diff --git a/src/internal/TextBubble.qml b/src/internal/TextBubble.qml
new file mode 100644
index 0000000..8a77084
--- /dev/null
+++ b/src/internal/TextBubble.qml
@@ -0,0 +1,106 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+
+import QtQuick
+import ScratchCPP.Render
+
+TextBubbleShape {
+    id: root
+    property string text: ""
+    property RenderedTarget target: null
+    property double stageWidth: 0
+    property double stageHeight: 0
+
+    QtObject {
+        // https://github.com/scratchfoundation/scratch-render/blob/ac935423afe3ba79235750eecb1e443474c6eb09/src/TextBubbleSkin.js#L7-L26
+        id: priv
+        readonly property int maxLineWidth: 170
+        readonly property int minWidth: 50
+        readonly property int padding: 10
+        readonly property int tailHeight: 12
+
+        readonly property string fontFamily: "Helvetica"
+        readonly property int fontPixelSize: 14
+        readonly property int lineHeight: 16
+
+        readonly property color textFill: '#575E75'
+
+        function translateX(x) {
+            // Translates Scratch X-coordinate to the scene coordinate system
+            return root.stageScale * (root.stageWidth / 2 + x)
+        }
+
+        function translateY(y) {
+            // Translates Scratch Y-coordinate to the scene coordinate system
+            return root.stageScale * (root.stageHeight / 2 - y)
+        }
+    }
+
+    nativeWidth: Math.max(bubbleText.contentWidth, priv.minWidth) + 2 * priv.padding
+    nativeHeight: bubbleText.height + 2 * priv.padding + priv.tailHeight
+
+    function positionBubble() {
+        // https://github.com/scratchfoundation/scratch-vm/blob/7313ce5199f8a3da7850085d0f7f6a3ca2c89bf6/src/blocks/scratch3_looks.js#L158
+        if(!target.visible)
+            return;
+
+        const targetBounds = target.getBoundsForBubble();
+        const stageBounds = Qt.rect(-root.stageWidth / 2, root.stageHeight / 2, root.stageWidth, root.stageHeight);
+
+        if (onSpriteRight && nativeWidth + targetBounds.right > stageBounds.right &&
+            (targetBounds.left - nativeWidth > stageBounds.left)) { // Only flip if it would fit
+            onSpriteRight = false;
+        } else if (!onSpriteRight && targetBounds.left - nativeWidth < stageBounds.left &&
+            (nativeWidth + targetBounds.right < stageBounds.right)) { // Only flip if it would fit
+            onSpriteRight = true;
+        }
+
+        const pos = [
+            onSpriteRight ? (
+                Math.max(
+                    stageBounds.left, // Bubble should not extend past left edge of stage
+                    Math.min(stageBounds.right - nativeWidth, targetBounds.right)
+                )
+            ) : (
+                Math.min(
+                    stageBounds.right - nativeWidth, // Bubble should not extend past right edge of stage
+                    Math.max(stageBounds.left, targetBounds.left - nativeWidth)
+                )
+            ),
+            // Bubble should not extend past the top of the stage
+            Math.min(stageBounds.top, targetBounds.bottom + nativeHeight)
+        ];
+
+        x = priv.translateX(pos[0]);
+        y = priv.translateY(pos[1]);
+    }
+
+    Connections {
+        target: root.target
+
+        function onXChanged() { positionBubble() }
+        function onYChanged() { positionBubble() }
+        function onRotationChanged() { positionBubble() }
+        function onWidthChanged() { positionBubble() }
+        function onHeightChanged() { positionBubble() }
+        function onScaleChanged() { positionBubble() }
+    }
+
+    Text {
+        id: bubbleText
+        anchors.left: parent.left
+        anchors.top: parent.top
+        anchors.margins: priv.padding * root.stageScale
+        width: priv.maxLineWidth
+        scale: root.stageScale
+        transformOrigin: Item.TopLeft
+        text: root.text
+        wrapMode: Text.Wrap
+        color: priv.textFill
+        lineHeight: priv.lineHeight
+        lineHeightMode: Text.FixedHeight
+        font.family: priv.fontFamily
+        font.pixelSize: priv.fontPixelSize
+    }
+
+    Component.onCompleted: positionBubble()
+}

From 6071533f4663ba800569bf7923884b7fe0af3c1f Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 4 Feb 2024 16:04:43 +0100
Subject: [PATCH 08/20] ProjectPlayer: Implement text bubbles

---
 src/ProjectPlayer.qml | 52 ++++++++++++++++++++++++++++++++++---------
 1 file changed, 42 insertions(+), 10 deletions(-)

diff --git a/src/ProjectPlayer.qml b/src/ProjectPlayer.qml
index 0627a42..ea79080 100644
--- a/src/ProjectPlayer.qml
+++ b/src/ProjectPlayer.qml
@@ -109,6 +109,20 @@ ProjectScene {
             onStageModelChanged: stageModel.renderedTarget = this
         }
 
+        Loader {
+            readonly property alias model: stageTarget.stageModel
+            active: model ? model.bubbleText !== "" : false
+
+            sourceComponent: TextBubble {
+                type: model ? model.bubbleType : TextBubbleShape.Say
+                text: model ? model.bubbleText : ""
+                target: stageTarget
+                stageScale: root.stageScale
+                stageWidth: root.stageWidth
+                stageHeight: root.stageHeight
+            }
+        }
+
         PenLayer {
             id: projectPenLayer
             engine: loader.engine
@@ -118,16 +132,34 @@ ProjectScene {
         Component {
             id: renderedSprite
 
-            RenderedTarget {
-                id: target
-                mouseArea: sceneMouseArea
-                stageScale: root.stageScale
-                transform: Scale { xScale: mirrorHorizontally ? -1 : 1 }
-                Component.onCompleted: {
-                    engine = loader.engine;
-                    spriteModel = modelData;
-                    spriteModel.renderedTarget = this;
-                    spriteModel.penLayer = projectPenLayer;
+            Item {
+                anchors.fill: parent
+
+                RenderedTarget {
+                    id: targetItem
+                    mouseArea: sceneMouseArea
+                    stageScale: root.stageScale
+                    transform: Scale { xScale: targetItem.mirrorHorizontally ? -1 : 1 }
+                    Component.onCompleted: {
+                        engine = loader.engine;
+                        spriteModel = modelData;
+                        spriteModel.renderedTarget = this;
+                        spriteModel.penLayer = projectPenLayer;
+                    }
+                }
+
+                Loader {
+                    readonly property alias model: targetItem.spriteModel
+                    active: model ? model.bubbleText !== "" : false
+
+                    sourceComponent: TextBubble {
+                        type: model ? model.bubbleType : TextBubbleShape.Say
+                        text: model ? model.bubbleText : ""
+                        target: targetItem
+                        stageScale: root.stageScale
+                        stageWidth: root.stageWidth
+                        stageHeight: root.stageHeight
+                    }
                 }
             }
         }

From ddfc9cc2702628485204ad085e0b00ef924a781a Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 4 Feb 2024 16:25:30 +0100
Subject: [PATCH 09/20] README: Mark text bubbles as finished

---
 README.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 5c64831..9b194fe 100644
--- a/README.md
+++ b/README.md
@@ -145,7 +145,7 @@ qputenv("QSG_RENDER_LOOP", "basic");
 - [ ] Pen blocks (all blocks except the stamp block are implemented)
 - [x] Monitors
 - [ ] Graphics effects (color, brightness and ghost are implemented)
-- [ ] Speech and thought bubbles
+- [x] Speech and thought bubbles
 - [ ] Question text box ("ask and wait" block)
 
 See the [open issues](https://github.com/scratchcpp/scratchcpp-render/issues) for a full list of proposed features (and known issues).

From b2624616dea6f0e967fae79d4dc7978888c45a98 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 4 Feb 2024 17:53:38 +0100
Subject: [PATCH 10/20] ProjectPlayer: Fix sprite layering after adding bubbles

---
 src/ProjectPlayer.qml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/ProjectPlayer.qml b/src/ProjectPlayer.qml
index ea79080..1c27795 100644
--- a/src/ProjectPlayer.qml
+++ b/src/ProjectPlayer.qml
@@ -134,6 +134,7 @@ ProjectScene {
 
             Item {
                 anchors.fill: parent
+                z: targetItem.z
 
                 RenderedTarget {
                     id: targetItem

From 20b9e73c0a9d696c31274bcd48be0e13c0aaa68f Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Mon, 5 Feb 2024 21:04:39 +0100
Subject: [PATCH 11/20] RenderedTarget: Disable smooth sampling

---
 src/renderedtarget.cpp | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/renderedtarget.cpp b/src/renderedtarget.cpp
index 030400d..9ddb101 100644
--- a/src/renderedtarget.cpp
+++ b/src/renderedtarget.cpp
@@ -23,6 +23,7 @@ static const double pi = std::acos(-1);    // TODO: Use std::numbers::pi in C++2
 RenderedTarget::RenderedTarget(QNanoQuickItem *parent) :
     IRenderedTarget(parent)
 {
+    setSmooth(false);
 }
 
 RenderedTarget::~RenderedTarget()

From 7da869d2e4cc935331bcde4d626ed9a4791db629 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Tue, 6 Feb 2024 16:23:26 +0100
Subject: [PATCH 12/20] fix #89: Allow clones to clone themselves

---
 src/projectloader.cpp |   1 +
 test/clones.sb3       | Bin 1352 -> 1752 bytes
 2 files changed, 1 insertion(+)

diff --git a/src/projectloader.cpp b/src/projectloader.cpp
index c9d9b96..85fafba 100644
--- a/src/projectloader.cpp
+++ b/src/projectloader.cpp
@@ -295,6 +295,7 @@ void ProjectLoader::redraw()
 void ProjectLoader::addClone(SpriteModel *model)
 {
     connect(model, &SpriteModel::cloneDeleted, this, &ProjectLoader::deleteClone);
+    connect(model, &SpriteModel::cloned, this, &ProjectLoader::addClone);
     m_clones.push_back(model);
     emit cloneCreated(model);
     emit clonesChanged();
diff --git a/test/clones.sb3 b/test/clones.sb3
index 315ed3f0710021473e2aa12596008867b694ac91..b92fc12c29373c944986d125954687fc46665200 100644
GIT binary patch

From 4cbea03d97cf8f765d0a1bba522a0deb7f10beef Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 11 Feb 2024 16:29:33 +0100
Subject: [PATCH 13/20] Update libscratchcpp to v0.8.0

---
 libscratchcpp           |  2 +-
 test/mocks/enginemock.h | 13 +++++++++++++
 2 files changed, 14 insertions(+), 1 deletion(-)

diff --git a/libscratchcpp b/libscratchcpp
index d14fe3b..4f51860 160000
--- a/libscratchcpp
+++ b/libscratchcpp
@@ -1 +1 @@
-Subproject commit d14fe3b07835637504dc8855b606c6094ddd3879
+Subproject commit 4f51860662928084cddd053ad1fed8bce274f80f
diff --git a/test/mocks/enginemock.h b/test/mocks/enginemock.h
index 6d5f988..bae97e2 100644
--- a/test/mocks/enginemock.h
+++ b/test/mocks/enginemock.h
@@ -49,6 +49,9 @@ class EngineMock : public IEngine
         MOCK_METHOD(void, setKeyState, (const KeyEvent &, bool), (override));
         MOCK_METHOD(void, setAnyKeyPressed, (bool), (override));
 
+        MOCK_METHOD(void, mouseWheelUp, (), (override));
+        MOCK_METHOD(void, mouseWheelDown, (), (override));
+
         MOCK_METHOD(double, mouseX, (), (const, override));
         MOCK_METHOD(void, setMouseX, (double x), (override));
 
@@ -103,6 +106,7 @@ class EngineMock : public IEngine
         MOCK_METHOD(void, addBackdropChangeScript, (std::shared_ptr<Block>, int), (override));
         MOCK_METHOD(void, addCloneInitScript, (std::shared_ptr<Block>), (override));
         MOCK_METHOD(void, addKeyPressScript, (std::shared_ptr<Block>, int), (override));
+        MOCK_METHOD(void, addTargetClickScript, (std::shared_ptr<Block>), (override));
 
         MOCK_METHOD(const std::vector<std::shared_ptr<Target>> &, targets, (), (const, override));
         MOCK_METHOD(void, setTargets, (const std::vector<std::shared_ptr<Target>> &), (override));
@@ -122,10 +126,19 @@ class EngineMock : public IEngine
         MOCK_METHOD(void, setAddMonitorHandler, (const std::function<void(Monitor *)> &), (override));
         MOCK_METHOD(void, setRemoveMonitorHandler, (const std::function<void(Monitor *, IMonitorHandler *)> &), (override));
 
+        MOCK_METHOD(const std::function<void(const std::string &)> &, questionAsked, (), (const, override));
+        MOCK_METHOD(void, setQuestionAsked, (const std::function<void(const std::string &)> &), (override));
+
+        MOCK_METHOD(const std::function<void(const std::string &)> &, questionAnswered, (), (const, override));
+        MOCK_METHOD(void, setQuestionAnswered, (const std::function<void(const std::string &)> &), (override));
+
         MOCK_METHOD(std::vector<std::string> &, extensions, (), (const, override));
         MOCK_METHOD(void, setExtensions, (const std::vector<std::string> &), (override));
 
         MOCK_METHOD(const ScriptMap &, scripts, (), (const, override));
+
+        MOCK_METHOD(const std::string &, userAgent, (), (const, override));
+        MOCK_METHOD(void, setUserAgent, (const std::string &), (override));
 };
 
 } // namespace scratchcpprender

From 39d12ef07f7505ac49bcb9b235b433a9402e06ed Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 11 Feb 2024 16:49:16 +0100
Subject: [PATCH 14/20] Add question API to ProjectLoader

---
 src/projectloader.cpp                     | 12 +++++++
 src/projectloader.h                       |  3 ++
 test/projectloader/projectloader_test.cpp | 38 +++++++++++++++++++++++
 3 files changed, 53 insertions(+)

diff --git a/src/projectloader.cpp b/src/projectloader.cpp
index 85fafba..9def881 100644
--- a/src/projectloader.cpp
+++ b/src/projectloader.cpp
@@ -166,6 +166,16 @@ void ProjectLoader::stop()
     }
 }
 
+void ProjectLoader::answerQuestion(const QString &answer)
+{
+    if (m_engine) {
+        auto f = m_engine->questionAnswered();
+
+        if (f)
+            f(answer.toStdString());
+    }
+}
+
 void ProjectLoader::timerEvent(QTimerEvent *event)
 {
     if (m_loadThread.isRunning())
@@ -220,6 +230,8 @@ void ProjectLoader::load()
     auto removeMonitorHandler = std::bind(&ProjectLoader::removeMonitor, this, std::placeholders::_1, std::placeholders::_2);
     m_engine->setRemoveMonitorHandler(std::function<void(Monitor *, IMonitorHandler *)>(removeMonitorHandler));
 
+    m_engine->setQuestionAsked([this](const std::string &question) { emit questionAsked(QString::fromStdString(question)); });
+
     // Load targets
     const auto &targets = m_engine->targets();
 
diff --git a/src/projectloader.h b/src/projectloader.h
index 06ac070..06731ae 100644
--- a/src/projectloader.h
+++ b/src/projectloader.h
@@ -64,6 +64,8 @@ class ProjectLoader : public QObject
         Q_INVOKABLE void start();
         Q_INVOKABLE void stop();
 
+        Q_INVOKABLE void answerQuestion(const QString &answer);
+
         double fps() const;
         void setFps(double newFps);
 
@@ -107,6 +109,7 @@ class ProjectLoader : public QObject
         void cloneDeleted(SpriteModel *model);
         void monitorAdded(MonitorModel *model);
         void monitorRemoved(MonitorModel *model);
+        void questionAsked(QString question);
 
     protected:
         void timerEvent(QTimerEvent *event) override;
diff --git a/test/projectloader/projectloader_test.cpp b/test/projectloader/projectloader_test.cpp
index 47420f6..23ae4a0 100644
--- a/test/projectloader/projectloader_test.cpp
+++ b/test/projectloader/projectloader_test.cpp
@@ -14,6 +14,7 @@ using namespace scratchcpprender;
 using namespace libscratchcpp;
 
 using ::testing::Return;
+using ::testing::ReturnRef;
 
 class ProjectLoaderTest : public testing::Test
 {
@@ -59,6 +60,11 @@ class ProjectLoaderTest : public testing::Test
         }
 };
 
+struct AnswerQuestionMock
+{
+        MOCK_METHOD(void, answer, (const std::string &), ());
+};
+
 TEST_F(ProjectLoaderTest, Constructors)
 {
     ProjectLoader loader1;
@@ -162,6 +168,38 @@ TEST_F(ProjectLoaderTest, TimerEvent)
     QCoreApplication::sendEvent(&loader, &event);
 }
 
+TEST_F(ProjectLoaderTest, QuestionAsked)
+{
+    ProjectLoader loader;
+    QSignalSpy spy(&loader, &ProjectLoader::questionAsked);
+
+    load(&loader, "load_test.sb3");
+
+    auto engine = loader.engine();
+    auto f = engine->questionAsked();
+    ASSERT_TRUE(f);
+    ASSERT_TRUE(spy.isEmpty());
+    f("test");
+    ASSERT_EQ(spy.count(), 1);
+
+    auto args = spy.takeFirst();
+    ASSERT_EQ(args.size(), 1);
+    ASSERT_EQ(args.first().toString(), "test");
+}
+
+TEST_F(ProjectLoaderTest, AnswerQuestion)
+{
+    ProjectLoader loader;
+    EngineMock engine;
+    loader.setEngine(&engine);
+
+    AnswerQuestionMock mock;
+    std::function<void(const std::string &)> f = std::bind(&AnswerQuestionMock::answer, &mock, std::placeholders::_1);
+    EXPECT_CALL(engine, questionAnswered()).WillOnce(ReturnRef(f));
+    EXPECT_CALL(mock, answer("hello"));
+    loader.answerQuestion("hello");
+}
+
 TEST_F(ProjectLoaderTest, Fps)
 {
     ProjectLoader loader;

From e3ffc6dd529ea76e4303d0bbd7c68785a559e0d4 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 11 Feb 2024 16:50:10 +0100
Subject: [PATCH 15/20] Add Question component

---
 src/CMakeLists.txt        |   2 +
 src/icons/enter.svg       |  81 ++++++++++++++++++++++++++++++
 src/internal/Question.qml | 100 ++++++++++++++++++++++++++++++++++++++
 3 files changed, 183 insertions(+)
 create mode 100644 src/icons/enter.svg
 create mode 100644 src/internal/Question.qml

diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 288117b..c133a57 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -13,8 +13,10 @@ qt_add_qml_module(scratchcpp-render
 	internal/MonitorSlider.qml
 	internal/ListMonitor.qml
 	internal/TextBubble.qml
+	internal/Question.qml
 	shaders/sprite.vert
 	shaders/sprite.frag
+	icons/enter.svg
     SOURCES
         global.h
 	projectloader.cpp
diff --git a/src/icons/enter.svg b/src/icons/enter.svg
new file mode 100644
index 0000000..2238638
--- /dev/null
+++ b/src/icons/enter.svg
@@ -0,0 +1,81 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   width="20"
+   height="20"
+   viewBox="0 0 5.2916668 5.2916668"
+   version="1.1"
+   id="svg1"
+   inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
+   sodipodi:docname="enter.svg"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <sodipodi:namedview
+     id="namedview1"
+     pagecolor="#505050"
+     bordercolor="#eeeeee"
+     borderopacity="1"
+     inkscape:showpageshadow="0"
+     inkscape:pageopacity="0"
+     inkscape:pagecheckerboard="0"
+     inkscape:deskcolor="#d1d1d1"
+     inkscape:document-units="mm"
+     inkscape:zoom="32"
+     inkscape:cx="13.125"
+     inkscape:cy="12.71875"
+     inkscape:window-width="1864"
+     inkscape:window-height="1009"
+     inkscape:window-x="56"
+     inkscape:window-y="36"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="layer1" />
+  <defs
+     id="defs1">
+    <path
+       d="m 7.8614406,15.402878 c -0.426172,0 -0.852344,-0.163144 -1.1769672,-0.487768 l -3.19629,-3.19629 c -0.6509112,-0.650911 -0.6509112,-1.703023 0,-2.353934 0.6509111,-0.6509111 1.7030232,-0.6509111 2.3539344,0 l 2.0193228,2.019323 6.2976904,-6.2960256 c 0.649246,-0.6509112 1.703023,-0.6509112 2.353934,0 0.649247,0.6492464 0.649247,1.7030232 0,2.3539344 L 9.0384078,14.91511 c -0.3246232,0.324624 -0.7507952,0.487768 -1.1769672,0.487768"
+       id="path-1" />
+    <path
+       d="M7.86144059,15.4028776 C7.43526859,15.4028776 7.00909658,15.2397336 6.68447338,14.9151104 L3.48818336,11.7188204 C2.83727221,11.0679093 2.83727221,10.0157971 3.48818336,9.364886 C4.1390945,8.71397485 5.19120664,8.71397485 5.84211778,9.364886 L7.86144059,11.3842088 L14.1591308,5.08818336 C14.8083772,4.43727221 15.862154,4.43727221 16.5130652,5.08818336 C17.1623116,5.73742977 17.1623116,6.79120664 16.5130652,7.44211778 L9.0384078,14.9151104 C8.7137846,15.2397336 8.28761259,15.4028776 7.86144059,15.4028776"
+       id="path-1-1" />
+    <path
+       d="M7.86144059,15.4028776 C7.43526859,15.4028776 7.00909658,15.2397336 6.68447338,14.9151104 L3.48818336,11.7188204 C2.83727221,11.0679093 2.83727221,10.0157971 3.48818336,9.364886 C4.1390945,8.71397485 5.19120664,8.71397485 5.84211778,9.364886 L7.86144059,11.3842088 L14.1591308,5.08818336 C14.8083772,4.43727221 15.862154,4.43727221 16.5130652,5.08818336 C17.1623116,5.73742977 17.1623116,6.79120664 16.5130652,7.44211778 L9.0384078,14.9151104 C8.7137846,15.2397336 8.28761259,15.4028776 7.86144059,15.4028776"
+       id="path-1-3" />
+  </defs>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1">
+    <!-- Generator: Sketch 51.2 (57519) - http://www.bohemiancoding.com/sketch -->
+    <title
+       id="title1">General/Check</title>
+    <desc
+       id="desc1">Created with Sketch.</desc>
+    <!-- Generator: Sketch 51.2 (57519) - http://www.bohemiancoding.com/sketch -->
+    <title
+       id="title1-6">General/Check</title>
+    <desc
+       id="desc1-2">Created with Sketch.</desc>
+    <!-- Generator: Sketch 51.2 (57519) - http://www.bohemiancoding.com/sketch -->
+    <title
+       id="title1-9">General/Check</title>
+    <desc
+       id="desc1-20">Created with Sketch.</desc>
+    <g
+       id="g3"
+       style="stroke:#ffffff;stroke-opacity:1">
+      <path
+         style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:0.89;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
+         d="M 1.2377352,2.7917573 2.0802264,3.6351038"
+         id="path1"
+         sodipodi:nodetypes="cc" />
+      <path
+         style="opacity:1;fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:0.89;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
+         d="M 4.0574962,1.6594676 2.0802264,3.6351038"
+         id="path2"
+         sodipodi:nodetypes="cc" />
+    </g>
+  </g>
+</svg>
diff --git a/src/internal/Question.qml b/src/internal/Question.qml
new file mode 100644
index 0000000..5bc47b0
--- /dev/null
+++ b/src/internal/Question.qml
@@ -0,0 +1,100 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+
+import QtQuick
+import QtQuick.Controls
+
+Rectangle {
+    id: root
+    property string question: ""
+    readonly property alias answer: textField.text
+    signal closed()
+
+    QtObject {
+        id: priv
+        readonly property int margin: 18
+        readonly property int spacing: 6
+    }
+
+    color: "white"
+    border.color: Qt.rgba(0.85, 0.85, 0.85, 1)
+    border.width: 2
+    radius: 9
+    implicitHeight: labelLoader.height + textField.height + 2 * priv.margin + priv.spacing
+    onActiveFocusChanged: {
+        if(activeFocus)
+            textField.forceActiveFocus();
+    }
+
+    function clear() {
+        textField.clear();
+        question = "";
+    }
+
+    Loader {
+        id: labelLoader
+        anchors.left: parent.left
+        anchors.right: parent.right
+        anchors.top: parent.top
+        anchors.leftMargin: priv.margin
+        anchors.topMargin: priv.margin
+        anchors.rightMargin: priv.margin
+        active: root.question !== ""
+
+        sourceComponent: Text {
+            text: root.question
+            color: "#575E75"
+            font.family: "Helvetica"
+            font.pointSize: 9
+            font.bold: true
+        }
+    }
+
+    TextField {
+        id: textField
+        anchors.left: parent.left
+        anchors.right: parent.right
+        anchors.top: labelLoader.bottom
+        anchors.leftMargin: priv.margin - leftInset
+        anchors.topMargin: priv.spacing - topInset
+        anchors.rightMargin: priv.margin - rightInset
+        anchors.bottomMargin: priv.margin - bottomInset
+        color: "#575E75"
+        font.family: "Helvetica"
+        font.pointSize: 8
+        leftInset: 5
+        topInset: 0
+        rightInset: 0
+        bottomInset: 0
+        padding: 0
+        Keys.onReturnPressed: closed()
+        Keys.onEnterPressed: closed()
+
+        background: Rectangle {
+            color: root.color
+            border.color: root.border.color
+            radius: height / 2
+            implicitHeight: 30
+        }
+
+        Rectangle {
+            anchors.right: parent.right
+            anchors.top: parent.top
+            anchors.rightMargin: 4
+            anchors.topMargin: 3.5
+            color: "#4D97FF"
+            width: 25
+            height: 25
+            radius: width / 2
+
+            Image {
+                anchors.fill: parent
+                source: "qrc:/qt/qml/ScratchCPP/Render/icons/enter.svg"
+
+                MouseArea {
+                    anchors.fill: parent
+                    onClicked: closed()
+                }
+            }
+        }
+    }
+}

From 36d233a80e86ff2242fff0a03c82c83ec22f6d64 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 11 Feb 2024 17:10:19 +0100
Subject: [PATCH 16/20] ProjectPlayer: Add question text box

---
 src/ProjectPlayer.qml | 24 ++++++++++++++++++++++++
 1 file changed, 24 insertions(+)

diff --git a/src/ProjectPlayer.qml b/src/ProjectPlayer.qml
index 1c27795..702d02e 100644
--- a/src/ProjectPlayer.qml
+++ b/src/ProjectPlayer.qml
@@ -81,6 +81,12 @@ ProjectScene {
             if(i !== monitors.model.count)
                 monitors.model.remove(i);
         }
+
+        onQuestionAsked: (question)=> {
+            questionLoader.active = true;
+            questionLoader.item.clear();
+            questionLoader.item.question = question;
+        }
     }
 
     function start() {
@@ -268,4 +274,22 @@ ProjectScene {
             }
         }
     }
+
+    Loader {
+        id: questionLoader
+        anchors.left: contentRect.left
+        anchors.right: contentRect.right
+        anchors.bottom: contentRect.bottom
+        anchors.margins: 9
+        active: false
+
+        sourceComponent: Question {
+            onClosed: {
+                loader.answerQuestion(answer);
+                questionLoader.active = false;
+            }
+
+            Component.onCompleted: forceActiveFocus()
+        }
+    }
 }

From 45475b0896b949a2ed5c3f789a4ca2628f6a3bfa Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Mon, 12 Feb 2024 09:19:53 +0100
Subject: [PATCH 17/20] Add mouse wheel support to MouseEventHandler

---
 src/mouseeventhandler.cpp                     | 12 ++++++++++
 src/mouseeventhandler.h                       |  2 ++
 .../mouseeventhandler_test.cpp                | 22 +++++++++++++++++++
 3 files changed, 36 insertions(+)

diff --git a/src/mouseeventhandler.cpp b/src/mouseeventhandler.cpp
index 571771d..a8db22e 100644
--- a/src/mouseeventhandler.cpp
+++ b/src/mouseeventhandler.cpp
@@ -77,6 +77,18 @@ bool MouseEventHandler::eventFilter(QObject *obj, QEvent *event)
             return true;
         }
 
+        case QEvent::Wheel: {
+            QWheelEvent *wheelEvent = static_cast<QWheelEvent *>(event);
+            const int delta = wheelEvent->angleDelta().y();
+
+            if (delta > 0)
+                emit mouseWheelUp();
+            else if (delta < 0)
+                emit mouseWheelDown();
+
+            break;
+        }
+
         default:
             break;
     }
diff --git a/src/mouseeventhandler.h b/src/mouseeventhandler.h
index 407a0e5..6b05846 100644
--- a/src/mouseeventhandler.h
+++ b/src/mouseeventhandler.h
@@ -34,6 +34,8 @@ class MouseEventHandler : public QObject
         void mouseMoved(qreal x, qreal y);
         void mousePressed();
         void mouseReleased();
+        void mouseWheelUp();
+        void mouseWheelDown();
 
     private:
         void getSprites();
diff --git a/test/mouseeventhandler/mouseeventhandler_test.cpp b/test/mouseeventhandler/mouseeventhandler_test.cpp
index ab0134a..c034158 100644
--- a/test/mouseeventhandler/mouseeventhandler_test.cpp
+++ b/test/mouseeventhandler/mouseeventhandler_test.cpp
@@ -405,3 +405,25 @@ TEST(MouseEventHandlerTest, MousePressReleaseEvent)
     pressedSpy.clear();
     releasedSpy.clear();
 }
+
+TEST(MouseEventHandlerTest, WheelEvent)
+{
+    MouseEventHandler handler;
+    QSignalSpy upSpy(&handler, &MouseEventHandler::mouseWheelUp);
+    QSignalSpy downSpy(&handler, &MouseEventHandler::mouseWheelDown);
+    QWheelEvent event1(QPointF(), QPointF(), QPoint(2, 3), QPoint(10, 15), Qt::LeftButton, Qt::NoModifier, Qt::NoScrollPhase, false);    // up
+    QWheelEvent event2(QPointF(), QPointF(), QPoint(1, -5), QPoint(10, -50), Qt::LeftButton, Qt::NoModifier, Qt::NoScrollPhase, false);  // down
+    QWheelEvent event3(QPointF(), QPointF(), QPoint(-10, 0), QPoint(-100, 0), Qt::LeftButton, Qt::NoModifier, Qt::NoScrollPhase, false); // none
+
+    handler.eventFilter(nullptr, &event1);
+    ASSERT_EQ(upSpy.count(), 1);
+    ASSERT_EQ(downSpy.count(), 0);
+
+    handler.eventFilter(nullptr, &event2);
+    ASSERT_EQ(upSpy.count(), 1);
+    ASSERT_EQ(downSpy.count(), 1);
+
+    handler.eventFilter(nullptr, &event3);
+    ASSERT_EQ(upSpy.count(), 1);
+    ASSERT_EQ(downSpy.count(), 1);
+}

From a5636039ad043489765c5ad5138454b424ed5b9f Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Mon, 12 Feb 2024 09:21:03 +0100
Subject: [PATCH 18/20] Add mouse wheel support to SceneMouseArea

---
 src/scenemousearea.cpp | 2 ++
 src/scenemousearea.h   | 2 ++
 2 files changed, 4 insertions(+)

diff --git a/src/scenemousearea.cpp b/src/scenemousearea.cpp
index 1632695..7095c5c 100644
--- a/src/scenemousearea.cpp
+++ b/src/scenemousearea.cpp
@@ -15,6 +15,8 @@ SceneMouseArea::SceneMouseArea(QQuickItem *parent) :
     connect(m_mouseHandler, &MouseEventHandler::mouseMoved, this, &SceneMouseArea::mouseMoved);
     connect(m_mouseHandler, &MouseEventHandler::mousePressed, this, &SceneMouseArea::mousePressed);
     connect(m_mouseHandler, &MouseEventHandler::mouseReleased, this, &SceneMouseArea::mouseReleased);
+    connect(m_mouseHandler, &MouseEventHandler::mouseWheelUp, this, &SceneMouseArea::mouseWheelUp);
+    connect(m_mouseHandler, &MouseEventHandler::mouseWheelDown, this, &SceneMouseArea::mouseWheelDown);
 
     setAcceptHoverEvents(true);
     setAcceptTouchEvents(true);
diff --git a/src/scenemousearea.h b/src/scenemousearea.h
index e9e2262..d687c0b 100644
--- a/src/scenemousearea.h
+++ b/src/scenemousearea.h
@@ -34,6 +34,8 @@ class SceneMouseArea : public QQuickItem
         void mouseMoved(qreal x, qreal y);
         void mousePressed();
         void mouseReleased();
+        void mouseWheelUp();
+        void mouseWheelDown();
         void stageChanged();
         void projectLoaderChanged();
 

From 2ab42ce45ff80ff48559f0236f979ba97cc1bf37 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Mon, 12 Feb 2024 09:28:35 +0100
Subject: [PATCH 19/20] Add mouse wheel methods to ProjectScene

---
 src/projectscene.cpp                    | 13 +++++++++++++
 src/projectscene.h                      |  2 ++
 test/projectscene/projectscene_test.cpp | 13 +++++++++++++
 3 files changed, 28 insertions(+)

diff --git a/src/projectscene.cpp b/src/projectscene.cpp
index 58b1f39..625b5ba 100644
--- a/src/projectscene.cpp
+++ b/src/projectscene.cpp
@@ -71,6 +71,19 @@ void ProjectScene::handleMouseRelease()
         m_engine->setMousePressed(false);
 }
 
+void ProjectScene::handleMouseWheelUp()
+{
+    if (m_engine)
+        m_engine->mouseWheelUp();
+}
+
+void ProjectScene::handleMouseWheelDown()
+{
+
+    if (m_engine)
+        m_engine->mouseWheelDown();
+}
+
 void ProjectScene::handleKeyPress(Qt::Key key, const QString &text)
 {
     m_pressedKeys.insert(key);
diff --git a/src/projectscene.h b/src/projectscene.h
index 96eda3a..7916c2f 100644
--- a/src/projectscene.h
+++ b/src/projectscene.h
@@ -34,6 +34,8 @@ class ProjectScene : public QQuickItem
         Q_INVOKABLE void handleMouseMove(qreal x, qreal y);
         Q_INVOKABLE void handleMousePress();
         Q_INVOKABLE void handleMouseRelease();
+        Q_INVOKABLE void handleMouseWheelUp();
+        Q_INVOKABLE void handleMouseWheelDown();
 
         void handleKeyPress(Qt::Key key, const QString &text);
         void handleKeyRelease(Qt::Key key, const QString &text);
diff --git a/test/projectscene/projectscene_test.cpp b/test/projectscene/projectscene_test.cpp
index 4c88ed7..5920b39 100644
--- a/test/projectscene/projectscene_test.cpp
+++ b/test/projectscene/projectscene_test.cpp
@@ -67,6 +67,19 @@ TEST(ProjectSceneTest, HandleMouseRelease)
     scene.handleMouseRelease();
 }
 
+TEST(ProjectSceneTest, HandleMouseWheel)
+{
+    ProjectScene scene;
+    EngineMock engine;
+    scene.setEngine(&engine);
+
+    EXPECT_CALL(engine, mouseWheelUp());
+    scene.handleMouseWheelUp();
+
+    EXPECT_CALL(engine, mouseWheelDown());
+    scene.handleMouseWheelDown();
+}
+
 TEST(ProjectSceneTest, HandleKeyPressAndRelease)
 {
     static const std::unordered_map<Qt::Key, KeyEvent::Type> SPECIAL_KEY_MAP = {

From c82f8eaefefe8078f3b3b8175905871c029bc492 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Mon, 12 Feb 2024 09:29:05 +0100
Subject: [PATCH 20/20] ProjectPlayer: Add mouse wheel support

---
 src/ProjectPlayer.qml | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/src/ProjectPlayer.qml b/src/ProjectPlayer.qml
index 702d02e..4765be9 100644
--- a/src/ProjectPlayer.qml
+++ b/src/ProjectPlayer.qml
@@ -191,6 +191,8 @@ ProjectScene {
             onMouseMoved: (x, y)=> root.handleMouseMove(x, y)
             onMousePressed: root.handleMousePress()
             onMouseReleased: root.handleMouseRelease()
+            onMouseWheelUp: root.handleMouseWheelUp()
+            onMouseWheelDown: root.handleMouseWheelDown()
         }
 
         Component {
