Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 142 additions & 5 deletions src/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ ApplicationWindow {

Flickable {
id: editorFlick
objectName: "editorFlick"
anchors.fill: parent
anchors.leftMargin: 24
anchors.rightMargin: 24
Expand All @@ -353,7 +354,8 @@ ApplicationWindow {
// Wheel scrolling moves contentY directly rather than
// flicking the Flickable, so the bar has to be told about
// that activity; linger briefly after the last event.
active: hovered || pressed || wheelScroll.running || scrollLinger.running
active: hovered || pressed || wheelScroll.running
|| touchpadMomentum.running || scrollLinger.running
// Stop above the footer strip so the bar doesn't overlap
// the word count in the bottom-right corner. Padding and
// inset, not anchors: the attached-ScrollBar layout overrides
Expand All @@ -376,6 +378,72 @@ ApplicationWindow {
// the new curve, so sustained spinning keeps picking up speed.
readonly property real wheelStep: win.scaledSize(120)

// Pixel-precise touchpad deltas follow the fingers directly at a
// larger scale. Recent deltas provide the velocity for a short,
// frame-rate-independent coast when the gesture ends.
readonly property real touchpadScale: 2.0
readonly property int touchpadEventGapMs: 80
readonly property real touchpadVelocityBlend: 0.35
readonly property real touchpadMomentumDecay: 0.90
readonly property real touchpadMinVelocity: 20
readonly property real touchpadMaxVelocity: 2400
property bool touchpadGestureActive: false
property bool platformMomentumActive: false
property real touchpadVelocity: 0
property double touchpadLastEventTime: 0

Timer {
id: touchpadEventGap
interval: editorFlick.touchpadEventGapMs
onTriggered: {
if (editorFlick.platformMomentumActive) {
// Some platforms omit ScrollEnd after their momentum
// phase. Do not let that suppress the next gesture.
editorFlick.platformMomentumActive = false;
} else {
editorFlick.finishTouchpadGesture();
}
}
}

FrameAnimation {
id: touchpadMomentum
running: false

property real velocity: 0
property real previousElapsedTime: 0

onTriggered: {
var dt = elapsedTime - previousElapsedTime;
previousElapsedTime = elapsedTime;
if (dt <= 0)
return;

var maxY = Math.max(0, editorFlick.contentHeight - editorFlick.height);
var nextY = editorFlick.clampContentY(editorFlick.contentY + velocity * dt);
if ((velocity < 0 && nextY <= 0)
|| (velocity > 0 && nextY >= maxY)) {
editorFlick.contentY = nextY;
stop();
return;
}

editorFlick.contentY = editorFlick.snapToPixel(nextY);
velocity *= Math.pow(editorFlick.touchpadMomentumDecay, dt * 60);
if (Math.abs(velocity) < editorFlick.touchpadMinVelocity)
stop();
}

function begin(initialVelocity) {
velocity = Math.max(-editorFlick.touchpadMaxVelocity,
Math.min(editorFlick.touchpadMaxVelocity,
initialVelocity));
previousElapsedTime = 0;
if (Math.abs(velocity) >= editorFlick.touchpadMinVelocity)
restart();
}
}

FrameAnimation {
id: wheelScroll
running: false
Expand Down Expand Up @@ -464,17 +532,86 @@ ApplicationWindow {
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
onWheel: function(wheel) {
scrollLinger.restart();
if (wheel.pixelDelta.y !== 0)
editorFlick.scrollTo(editorFlick.clampContentY(editorFlick.contentY - wheel.pixelDelta.y));
if (wheel.pixelDelta.y !== 0 || wheel.phase === Qt.ScrollMomentum
|| wheel.phase === Qt.ScrollEnd)
editorFlick.scrollByTouchpad(wheel);
else
editorFlick.scrollByWheel(wheel);
wheel.accepted = true;
}
}

onMovementStarted: wheelScroll.stop()
onMovementStarted: stopAnimatedScrolling()

function scrollByTouchpad(wheel) {
if (wheel.phase === Qt.ScrollMomentum) {
touchpadMomentum.stop();
touchpadGestureActive = false;
platformMomentumActive = true;
touchpadEventGap.restart();
applyTouchpadDelta(wheel.pixelDelta.y);
return;
}

if (wheel.phase === Qt.ScrollBegin || !touchpadGestureActive) {
touchpadMomentum.stop();
platformMomentumActive = false;
touchpadGestureActive = true;
touchpadVelocity = 0;
touchpadLastEventTime = Date.now();
}

if (wheel.pixelDelta.y !== 0) {
var now = Date.now();
var movement = -wheel.pixelDelta.y * touchpadScale;
var elapsed = now - touchpadLastEventTime;
if (elapsed > 0 && elapsed <= touchpadEventGapMs) {
var measuredVelocity = movement * 1000 / elapsed;
touchpadVelocity += (measuredVelocity - touchpadVelocity)
* touchpadVelocityBlend;
}
touchpadLastEventTime = now;
applyTouchpadDelta(wheel.pixelDelta.y);
touchpadEventGap.restart();
}

if (wheel.phase === Qt.ScrollEnd) {
touchpadEventGap.stop();
finishTouchpadGesture();
}
}

function applyTouchpadDelta(pixelDeltaY) {
wheelScroll.stop();
var maxY = Math.max(0, contentHeight - height);
var target = clampContentY(contentY - pixelDeltaY * touchpadScale);
contentY = target === 0 || target === maxY ? target : snapToPixel(target);
if (target === 0 || target === maxY)
touchpadVelocity = 0;
}

function finishTouchpadGesture() {
if (!touchpadGestureActive)
return;
touchpadGestureActive = false;
touchpadMomentum.begin(touchpadVelocity);
}

function stopTouchpadScrolling() {
touchpadEventGap.stop();
touchpadMomentum.stop();
touchpadGestureActive = false;
platformMomentumActive = false;
touchpadVelocity = 0;
}

function stopAnimatedScrolling() {
wheelScroll.stop();
stopTouchpadScrolling();
}

function scrollByWheel(wheel) {
stopTouchpadScrolling();
// High-resolution wheels report fractional notches; feed
// those through the same animated path, like Chromium does
// for every wheel-source event.
Expand Down Expand Up @@ -511,7 +648,7 @@ ApplicationWindow {

// Jump to a position, abandoning any wheel animation still running.
function scrollTo(y) {
wheelScroll.stop();
stopAnimatedScrolling();
contentY = snapToPixel(y);
}

Expand Down
63 changes: 63 additions & 0 deletions tests/tst_omawrite.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
#include <QQmlComponent>
#include <QQmlContext>
#include <QQmlEngine>
#include <QQuickWindow>
#include <QQuickStyle>
#include <QWheelEvent>

#include "backend.h"
#include "markdownhighlighter.h"
Expand Down Expand Up @@ -218,6 +220,59 @@ private slots:
QCOMPARE(editor->property("font").value<QFont>().pixelSize(), 15);
}

void scalesTouchpadScrollingAndCarriesMomentum() {
const QString mainQmlPath = QFINDTESTDATA("../src/Main.qml");
QVERIFY(!mainQmlPath.isEmpty());

Backend backend;
QQmlEngine engine;
engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend);
QQmlComponent component(&engine, QUrl::fromLocalFile(mainQmlPath));
QVERIFY2(component.isReady(), qPrintable(component.errorString()));
QScopedPointer<QObject> root(component.create());
QVERIFY2(root, qPrintable(component.errorString()));

auto *window = qobject_cast<QQuickWindow *>(root.data());
QObject *editor = root->findChild<QObject *>(QStringLiteral("sourceEditor"));
QObject *flick = root->findChild<QObject *>(QStringLiteral("editorFlick"));
QVERIFY(window);
QVERIFY(editor);
QVERIFY(flick);

QStringList lines;
for (int i = 0; i < 200; ++i)
lines.append(QStringLiteral("A line long enough to make the editor scroll."));
editor->setProperty("text", lines.join(QLatin1Char('\n')));
QTRY_VERIFY(flick->property("contentHeight").toReal()
> flick->property("height").toReal() + 400);
flick->setProperty("contentY", 200.0);

const QPointF position(window->width() / 2.0, window->height() / 2.0);
sendTouchpadWheel(window, position, -8, Qt::ScrollBegin);
QTest::qWait(20);
sendTouchpadWheel(window, position, -10, Qt::ScrollUpdate);
QTest::qWait(20);
sendTouchpadWheel(window, position, -10, Qt::ScrollUpdate);

const qreal gestureEndY = flick->property("contentY").toReal();
QVERIFY2(qAbs(gestureEndY - 256.0) <= 2.0,
qPrintable(QStringLiteral("Expected scaled contentY near 256, got %1")
.arg(gestureEndY)));

sendTouchpadWheel(window, position, 0, Qt::ScrollEnd);
QTRY_VERIFY_WITH_TIMEOUT(flick->property("contentY").toReal()
> gestureEndY + 5.0, 500);

// Platform momentum replaces the fallback rather than adding to it.
sendTouchpadWheel(window, position, -2, Qt::ScrollBegin);
sendTouchpadWheel(window, position, -6, Qt::ScrollMomentum);
const qreal platformMomentumY = flick->property("contentY").toReal();
QVERIFY(flick->property("platformMomentumActive").toBool());
QTRY_VERIFY_WITH_TIMEOUT(!flick->property("platformMomentumActive").toBool(),
300);
QVERIFY(qAbs(flick->property("contentY").toReal() - platformMomentumY) <= 1.0);
}

void remembersLastSaveDirectory() {
QTemporaryDir saveDirectory;
QVERIFY(saveDirectory.isValid());
Expand Down Expand Up @@ -247,6 +302,14 @@ private slots:
}

private:
static void sendTouchpadWheel(QQuickWindow *window, const QPointF &position,
int pixelDeltaY, Qt::ScrollPhase phase) {
QWheelEvent event(position, window->mapToGlobal(position),
QPoint(0, pixelDeltaY), QPoint(), Qt::NoButton,
Qt::NoModifier, phase, false);
QCoreApplication::sendEvent(window, &event);
}

QTemporaryDir m_settingsDirectory;
};

Expand Down