Skip to content

Commit c654fae

Browse files
committed
Loop scopes - optimisations - paint opaquely and tick with the frame pacer, a third off the GUI's cost of a live loop
1 parent 674746d commit c654fae

4 files changed

Lines changed: 135 additions & 19 deletions

File tree

‎app/gui-tests/loopscope_lifecycle.test.cpp‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#include <QApplication>
1313
#include <QSettings>
1414
#include <QTest>
15+
#include <QImage>
1516

1617
#include <memory>
1718

@@ -21,6 +22,7 @@
2122
#include "utils/scintilla_api.h"
2223
#include "widgets/sonicpilexer.h"
2324
#include "widgets/sonicpiscintilla.h"
25+
#include "utils/framepacer.h"
2426

2527
namespace {
2628

@@ -211,3 +213,47 @@ TEST_CASE("loop scope: beside another loop's header it hides; beside a name work
211213
CHECK_FALSE(e.scope("t")->isVisible());
212214
CHECK(e.scope("drums")->isVisible());
213215
}
216+
217+
// What a scope costs the editor every frame. A child widget that is not
218+
// opaque makes Qt repaint the editor beneath it before it paints, and that
219+
// repaint walks the whole widget tree: with two loops playing, a third of the
220+
// main thread went on painting. An opaque widget that paints every pixel of
221+
// its rectangle leaves the editor alone.
222+
TEST_CASE("loop scope: is opaque, painting every pixel of its rectangle", "[loopscope]")
223+
{
224+
Editor e(kCode);
225+
Slot s;
226+
s.live();
227+
e.ed->setLiveLoopScope("t", 0, 10, s.reader());
228+
QTest::qWait(60);
229+
QWidget* w = e.scope("t");
230+
REQUIRE(w);
231+
CHECK(w->testAttribute(Qt::WA_OpaquePaintEvent));
232+
QImage img(w->size(), QImage::Format_ARGB32_Premultiplied);
233+
img.fill(Qt::transparent);
234+
w->render(&img, QPoint(), QRegion(), QWidget::DrawChildren); // its own paint, no window background
235+
int clear = 0;
236+
for (int y = 0; y < img.height(); ++y)
237+
for (int x = 0; x < img.width(); ++x)
238+
if (qAlpha(img.pixel(x, y)) != 255) ++clear;
239+
CHECK(clear == 0);
240+
}
241+
242+
// Every animated thing in the GUI ticks off the one frame pacer, which runs
243+
// only while something holds it: the loop scopes too, rather than a timer of
244+
// the editor's own at twice the rate.
245+
TEST_CASE("loop scope: ticks off the shared frame pacer while any scope lives", "[loopscope]")
246+
{
247+
Editor e(kCode);
248+
const int before = SonicPi::FramePacer::instance()->clients();
249+
Slot s;
250+
s.live();
251+
e.ed->setLiveLoopScope("t", 0, 10, s.reader());
252+
CHECK(SonicPi::FramePacer::instance()->clients() == before + 1);
253+
e.ed->setLiveLoopScope("u", 2, 11, s.reader());
254+
CHECK(SonicPi::FramePacer::instance()->clients() == before + 1); // one hold for all of them
255+
e.ed->endLiveLoopScope("t");
256+
CHECK(SonicPi::FramePacer::instance()->clients() == before + 1);
257+
e.ed->endLiveLoopScope("u");
258+
CHECK(SonicPi::FramePacer::instance()->clients() == before);
259+
}

‎app/gui/utils/framepacer.h‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ class FramePacer : public QObject
3737

3838
void retain();
3939
void release();
40+
// How many animated things hold the pacer running: 0 and it is stopped.
41+
int clients() const { return m_clients; }
4042

4143
signals:
4244
void tick();

‎app/gui/widgets/sonicpiscintilla.cpp‎

Lines changed: 83 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
#include <QLabel>
3232
#include <QKeyEvent>
3333
#include <QTimer>
34+
#include "utils/framepacer.h"
3435
#include <QElapsedTimer>
3536
#include <QFocusEvent>
3637
#include <QMouseEvent>
@@ -121,8 +122,33 @@ class LiveLoopScopeWidget : public QWidget
121122
: QWidget(parent)
122123
{
123124
setAttribute(Qt::WA_TransparentForMouseEvents, true);
125+
// Opaque: every pixel of the rectangle is painted here, over what
126+
// the editor would have drawn (setBase), so Qt never repaints the
127+
// editor beneath the scope — a repaint that walked the whole widget
128+
// tree on every frame of every live loop.
129+
setAttribute(Qt::WA_OpaquePaintEvent, true);
124130
setFocusPolicy(Qt::NoFocus);
125131
}
132+
// A style change (the app's stylesheet applied, a theme switch) has
133+
// Qt re-polish every widget, and the stylesheet style clears the
134+
// opaque-paint attribute as it goes: it is asserted again after each.
135+
bool event(QEvent* e) override
136+
{
137+
const bool r = QWidget::event(e);
138+
if (e->type() == QEvent::StyleChange || e->type() == QEvent::Polish)
139+
setAttribute(Qt::WA_OpaquePaintEvent, true);
140+
return r;
141+
}
142+
// What the editor draws under this rectangle: its paper, or the caret
143+
// line's colour while the header is the current line. The editor says,
144+
// as it positions the scope.
145+
void setBase(const QColor& base)
146+
{
147+
if (m_base == base)
148+
return;
149+
m_base = base;
150+
update();
151+
}
126152

127153
~LiveLoopScopeWidget() override
128154
{
@@ -241,6 +267,7 @@ class LiveLoopScopeWidget : public QWidget
241267
void paintEvent(QPaintEvent*) override
242268
{
243269
QPainter p(this);
270+
p.fillRect(rect(), m_base.isValid() ? m_base : palette().base().color());
244271
p.setRenderHint(QPainter::Antialiasing, true);
245272
const qreal w = width();
246273
const qreal h = height();
@@ -443,6 +470,7 @@ class LiveLoopScopeWidget : public QWidget
443470
}
444471
}
445472

473+
QColor m_base; // the editor's colour beneath (setBase)
446474
QColor m_wave;
447475
QColor m_quiet;
448476
QColor m_panel;
@@ -706,7 +734,7 @@ SonicPiScintilla::SonicPiScintilla(SonicPiLexer* lexer, SonicPiTheme* theme, QSt
706734
// changed: SCN_UPDATEUI covers edits and both scroll axes in one hook, and
707735
// the pill reports its own moves/resizes (zoom, viewport reshapes).
708736
connect(this, &QsciScintillaBase::SCN_UPDATEUI, this,
709-
[this](int) { updateEditorToolbarOcclusion(); });
737+
[this](int) { updateEditorToolbarOcclusion(); positionLiveLoopScopes(); });
710738
connect(m_editorToolbar, &EditorToolbar::geometryChanged, this,
711739
&SonicPiScintilla::updateEditorToolbarOcclusion);
712740
// Keep the highlights and counter live while the buffer is edited — but
@@ -1209,6 +1237,7 @@ void SonicPiScintilla::setLiveLoopScope(const QString& name, int runLine, int sc
12091237
w = new LiveLoopScopeWidget(viewport());
12101238
w->setObjectName(QStringLiteral("liveLoopScope:") + name);
12111239
w->setScroll(m_loopScopeScroll);
1240+
w->raise(); // above the editor's other children, once
12121241
m_loopScopes[name] = w;
12131242
}
12141243
w->setReader(reader);
@@ -1217,16 +1246,20 @@ void SonicPiScintilla::setLiveLoopScope(const QString& name, int runLine, int sc
12171246
m_loopScopeSlots[name] = scopeNum;
12181247
applyLoopScopeColours();
12191248

1220-
if (!m_loopScopeTimer)
1221-
{
1222-
m_loopScopeTimer = new QTimer(this);
1223-
connect(m_loopScopeTimer, &QTimer::timeout, this, [this]() {
1224-
// Hidden tab / minimized window: skip the per-scope Scintilla
1225-
// position queries and stream reads. The ring overwrites itself,
1226-
// so nothing backs up while polling is skipped.
1249+
// The scopes tick off the one frame pacer every animated thing shares,
1250+
// held while any scope lives. The stream is lossless at any poll rate,
1251+
// so the rate is the display's; a tick reads the streams only, and the
1252+
// scopes are placed when something moves a line (SCN_UPDATEUI), not on
1253+
// every tick.
1254+
if (!m_loopScopesPaced)
1255+
{
1256+
m_loopScopesPaced = true;
1257+
SonicPi::FramePacer::instance()->retain();
1258+
m_loopScopeTick = connect(SonicPi::FramePacer::instance(), &SonicPi::FramePacer::tick, this, [this]() {
1259+
// Hidden tab / minimized window: skip the stream reads. The ring
1260+
// overwrites itself, so nothing backs up while polling is skipped.
12271261
if (!isVisible())
12281262
return;
1229-
positionLiveLoopScopes();
12301263
QStringList gone;
12311264
for (auto it = m_loopScopes.constBegin(); it != m_loopScopes.constEnd(); ++it)
12321265
if (it.value()->poll())
@@ -1235,10 +1268,6 @@ void SonicPiScintilla::setLiveLoopScope(const QString& name, int runLine, int sc
12351268
endLiveLoopScope(name);
12361269
});
12371270
}
1238-
// 16ms is purely the display frame rate: the stream is lossless at any
1239-
// poll rate, so this trades smoothness against repaint cost only.
1240-
if (!m_loopScopeTimer->isActive())
1241-
m_loopScopeTimer->start(16);
12421271
positionLiveLoopScopes();
12431272
}
12441273

@@ -1265,8 +1294,8 @@ void SonicPiScintilla::endLiveLoopScope(const QString& name)
12651294
w->deleteLater();
12661295
m_loopScopeLines.remove(name);
12671296
m_loopScopeSlots.remove(name);
1268-
if (m_loopScopes.isEmpty() && m_loopScopeTimer)
1269-
m_loopScopeTimer->stop();
1297+
if (m_loopScopes.isEmpty())
1298+
unpaceLiveLoopScopes();
12701299
}
12711300

12721301
void SonicPiScintilla::clearLiveLoopScopes()
@@ -1276,8 +1305,23 @@ void SonicPiScintilla::clearLiveLoopScopes()
12761305
m_loopScopes.clear();
12771306
m_loopScopeLines.clear();
12781307
m_loopScopeSlots.clear();
1279-
if (m_loopScopeTimer)
1280-
m_loopScopeTimer->stop();
1308+
unpaceLiveLoopScopes();
1309+
}
1310+
1311+
// The last scope gone: the pacer is let go, so it stops when nothing else is
1312+
// animating.
1313+
void SonicPiScintilla::unpaceLiveLoopScopes()
1314+
{
1315+
if (!m_loopScopesPaced)
1316+
return;
1317+
m_loopScopesPaced = false;
1318+
disconnect(m_loopScopeTick);
1319+
SonicPi::FramePacer::instance()->release();
1320+
}
1321+
1322+
SonicPiScintilla::~SonicPiScintilla()
1323+
{
1324+
unpaceLiveLoopScopes();
12811325
}
12821326

12831327
// Whether `line` is the header of the live_loop called `name`: live_loop, then
@@ -1314,6 +1358,27 @@ void SonicPiScintilla::positionLiveLoopScopes()
13141358
int w = ScaleWidthForDPI(230);
13151359
int viewW = viewport()->width();
13161360
int viewH = viewport()->height();
1361+
// What the editor draws beneath a scope, for it to paint over (it is
1362+
// opaque): the paper, or on the current line the caret line's colour,
1363+
// blended over the paper as Scintilla blends it.
1364+
const QColor paperColour = paper();
1365+
const int caretLine = (int)SendScintilla(SCI_LINEFROMPOSITION, SendScintilla(SCI_GETCURRENTPOS));
1366+
QColor caretLineColour = paperColour;
1367+
if (SendScintilla(SCI_GETCARETLINEVISIBLE))
1368+
{
1369+
const long bgr = SendScintilla(SCI_GETCARETLINEBACK);
1370+
const QColor back(bgr & 0xff, (bgr >> 8) & 0xff, (bgr >> 16) & 0xff);
1371+
const long alpha = SendScintilla(SCI_GETCARETLINEBACKALPHA); // SC_ALPHA_NOALPHA: opaque
1372+
if (alpha >= 256)
1373+
caretLineColour = back;
1374+
else
1375+
{
1376+
const qreal t = alpha / 255.0;
1377+
caretLineColour = QColor::fromRgbF(paperColour.redF() + (back.redF() - paperColour.redF()) * t,
1378+
paperColour.greenF() + (back.greenF() - paperColour.greenF()) * t,
1379+
paperColour.blueF() + (back.blueF() - paperColour.blueF()) * t);
1380+
}
1381+
}
13171382
for (auto it = m_loopScopes.begin(); it != m_loopScopes.end(); ++it)
13181383
{
13191384
int cur = runLineToCurrent(m_loopScopeLines.value(it.key(), -1));
@@ -1344,9 +1409,9 @@ void SonicPiScintilla::positionLiveLoopScopes()
13441409
int x = (int)SendScintilla(SCI_POINTXFROMPOSITION, (unsigned long)0, endPos)
13451410
+ ScaleWidthForDPI(12);
13461411
x = qBound(0, x, qMax(0, viewW - w - ScaleWidthForDPI(6)));
1412+
it.value()->setBase(cur == caretLine ? caretLineColour : paperColour);
13471413
it.value()->setGeometry(x, y, w, h);
13481414
it.value()->show();
1349-
it.value()->raise();
13501415
}
13511416
}
13521417

‎app/gui/widgets/sonicpiscintilla.h‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ class SonicPiScintilla : public QsciScintilla
5151
// each ctor opens its own when null.
5252
SonicPiScintilla(SonicPiLexer* lexer, SonicPiTheme* theme, QString fileName, bool autoIndent,
5353
QSettings* keyBindings = nullptr);
54+
~SonicPiScintilla() override;
5455

5556
virtual QStringList apiContext(int pos, int& context_start,
5657
int& last_word_start);
@@ -356,7 +357,9 @@ public slots:
356357
bool m_loopScopeScroll = false;
357358
QHash<QString, int> m_loopScopeLines;
358359
QHash<QString, int> m_loopScopeSlots; // name → the scope slot it reads
359-
QTimer* m_loopScopeTimer = nullptr;
360+
bool m_loopScopesPaced = false; // holding the FramePacer for the scopes
361+
QMetaObject::Connection m_loopScopeTick; // its tick, while held
362+
void unpaceLiveLoopScopes();
360363
SonicPi::SonicPiAPI* m_audioApi = nullptr;
361364
void positionLiveLoopScopes();
362365
void applyLoopScopeColours();

0 commit comments

Comments
 (0)