From 6b6defbeadd9375ca58e4459da6955b165cf2bb6 Mon Sep 17 00:00:00 2001
From: Automatic Release Builder <build@flightgear.org>
Date: Tue, 10 Nov 2020 11:09:46 +0000
Subject: [PATCH] Add getting-started tips to the launcher

QML implementation of getting started tips, with a nice styled
background box. Tips are defined+positioned inline, and displayed
when their enclosing scope is active.
---
 src/GUI/CMakeLists.txt                        |   8 +
 src/GUI/GettingStartedScope.cxx               |  29 ++
 src/GUI/GettingStartedScope.hxx               |  43 ++
 src/GUI/GettingStartedTip.cxx                 | 147 ++++++
 src/GUI/GettingStartedTip.hxx                 | 110 +++++
 src/GUI/GettingStartedTipsController.cxx      | 463 ++++++++++++++++++
 src/GUI/GettingStartedTipsController.hxx      | 150 ++++++
 src/GUI/LauncherController.cxx                |  21 +
 src/GUI/LauncherController.hxx                |   3 +
 src/GUI/LauncherMainWindow.cxx                |   1 +
 src/GUI/QtLauncher.cxx                        |   3 +
 src/GUI/TipBackgroundBox.cxx                  | 192 ++++++++
 src/GUI/TipBackgroundBox.hxx                  |  61 +++
 src/GUI/qml/AircraftCompactDelegate.qml       |  19 +
 src/GUI/qml/AircraftDetailsView.qml           |  23 +-
 src/GUI/qml/AircraftList.qml                  |  60 +++
 src/GUI/qml/AircraftPreviewPanel.qml          |  13 +-
 src/GUI/qml/AircraftRatingsPanel.qml          |  21 +
 .../DidMigrateOfficialCatalogNotification.qml |   2 +-
 src/GUI/qml/GettingStartedTipDisplay.qml      | 134 +++++
 src/GUI/qml/GettingStartedTipLayer.qml        |  45 ++
 src/GUI/qml/HelpSupport.qml                   |  15 +
 src/GUI/qml/ListHeaderBox.qml                 |   1 +
 src/GUI/qml/Section.qml                       |  14 +-
 src/GUI/qml/Settings.qml                      |  21 +
 src/GUI/qml/Summary.qml                       |  64 +++
 src/GUI/resources.qrc                         |   2 +
 27 files changed, 1660 insertions(+), 5 deletions(-)
 create mode 100644 src/GUI/GettingStartedScope.cxx
 create mode 100644 src/GUI/GettingStartedScope.hxx
 create mode 100644 src/GUI/GettingStartedTip.cxx
 create mode 100644 src/GUI/GettingStartedTip.hxx
 create mode 100755 src/GUI/GettingStartedTipsController.cxx
 create mode 100644 src/GUI/GettingStartedTipsController.hxx
 create mode 100644 src/GUI/TipBackgroundBox.cxx
 create mode 100644 src/GUI/TipBackgroundBox.hxx
 create mode 100644 src/GUI/qml/GettingStartedTipDisplay.qml
 create mode 100644 src/GUI/qml/GettingStartedTipLayer.qml

diff --git a/src/GUI/CMakeLists.txt b/src/GUI/CMakeLists.txt
index adbd667b8..f31fbf604 100644
--- a/src/GUI/CMakeLists.txt
+++ b/src/GUI/CMakeLists.txt
@@ -201,6 +201,14 @@ if (HAVE_QT)
                         PathUrlHelper.hxx
                         DialogStateController.cxx
                         DialogStateController.hxx
+                        GettingStartedTip.hxx
+                        GettingStartedTip.cxx
+                        GettingStartedTipsController.cxx
+                        GettingStartedTipsController.hxx
+                        TipBackgroundBox.cxx
+                        TipBackgroundBox.hxx
+                        GettingStartedScope.hxx
+                        GettingStartedScope.cxx
                         ${QQUI_SOURCES}
                         )
 
diff --git a/src/GUI/GettingStartedScope.cxx b/src/GUI/GettingStartedScope.cxx
new file mode 100644
index 000000000..99ae3eb73
--- /dev/null
+++ b/src/GUI/GettingStartedScope.cxx
@@ -0,0 +1,29 @@
+#include "GettingStartedScope.hxx"
+
+#include "GettingStartedTipsController.hxx"
+
+
+GettingStartedScope::GettingStartedScope(QObject *parent) : QObject(parent)
+{
+
+}
+
+GettingStartedScopeAttached *GettingStartedScope::qmlAttachedProperties(QObject *object)
+{
+    auto c = new GettingStartedScopeAttached(object);
+    return c;
+}
+
+GettingStartedScopeAttached::GettingStartedScopeAttached(QObject *parent) : QObject(parent)
+{
+
+}
+
+void GettingStartedScopeAttached::setController(GettingStartedTipsController *controller)
+{
+    if (_controller == controller)
+        return;
+
+    _controller = controller;
+    emit controllerChanged();
+}
diff --git a/src/GUI/GettingStartedScope.hxx b/src/GUI/GettingStartedScope.hxx
new file mode 100644
index 000000000..c3abeb793
--- /dev/null
+++ b/src/GUI/GettingStartedScope.hxx
@@ -0,0 +1,43 @@
+#pragma once
+
+#include <QObject>
+#include <QQmlEngine>
+
+class GettingStartedTipsController;
+
+class GettingStartedScopeAttached : public QObject
+{
+    Q_OBJECT
+
+    Q_PROPERTY(GettingStartedTipsController* controller READ controller WRITE setController NOTIFY controllerChanged)
+public:
+    GettingStartedScopeAttached(QObject* parent);
+
+    GettingStartedTipsController* controller() const
+    {
+        return _controller;
+    }
+
+public slots:
+    void setController(GettingStartedTipsController* controller);
+
+signals:
+    void controllerChanged();
+
+private:
+    GettingStartedTipsController* _controller = nullptr;
+};
+
+class GettingStartedScope : public QObject
+{
+    Q_OBJECT
+
+public:
+    explicit GettingStartedScope(QObject *parent = nullptr);
+
+     static GettingStartedScopeAttached* qmlAttachedProperties(QObject *object);
+signals:
+
+};
+
+QML_DECLARE_TYPEINFO(GettingStartedScope, QML_HAS_ATTACHED_PROPERTIES)
diff --git a/src/GUI/GettingStartedTip.cxx b/src/GUI/GettingStartedTip.cxx
new file mode 100644
index 000000000..153fa876e
--- /dev/null
+++ b/src/GUI/GettingStartedTip.cxx
@@ -0,0 +1,147 @@
+#include "GettingStartedTip.hxx"
+
+#include "GettingStartedScope.hxx"
+#include "GettingStartedTipsController.hxx"
+
+static bool static_globalEnableTips = true;
+
+GettingStartedTip::GettingStartedTip(QQuickItem *parent) :
+    QQuickItem(parent)
+{
+    _enabled = static_globalEnableTips;
+
+    setImplicitHeight(1);
+    setImplicitWidth(1);
+
+    if (_enabled) {
+        registerWithScope();
+    }
+}
+
+GettingStartedTip::~GettingStartedTip()
+{
+    if (_enabled) {
+        unregisterFromScope();
+    }
+}
+
+GettingStartedTipsController *GettingStartedTip::controller() const
+{
+    return _controller.data();
+}
+
+void GettingStartedTip::componentComplete()
+{
+    if (_enabled && !_controller) {
+        registerWithScope();
+    }
+
+    QQuickItem::componentComplete();
+}
+
+void GettingStartedTip::setGlobalTipsEnabled(bool enable)
+{
+    static_globalEnableTips = enable;
+}
+
+void GettingStartedTip::setEnabled(bool enabled)
+{
+    if (_enabled == enabled)
+        return;
+
+    _enabled = enabled & static_globalEnableTips;
+    if (enabled) {
+        registerWithScope();
+    } else {
+        unregisterFromScope();
+    }
+
+    emit enabledChanged();
+}
+
+void GettingStartedTip::showOneShot()
+{
+    auto ctl = findController();
+    if (ctl) {
+        ctl->showOneShotTip(this);
+    }
+}
+
+void GettingStartedTip::setStandalone(bool standalone)
+{
+    if (_standalone == standalone)
+        return;
+
+    _standalone = standalone;
+    emit standaloneChanged(_standalone);
+
+    // re-register with our scope, which will also unregister us if
+    // necessary.
+    if (_enabled) {
+        registerWithScope();
+    }
+}
+
+void GettingStartedTip::itemChange(QQuickItem::ItemChange change, const QQuickItem::ItemChangeData &value)
+{
+    const bool isParentChanged = (change == ItemParentHasChanged);
+    if (isParentChanged && _enabled) {
+        unregisterFromScope();
+    }
+
+    QQuickItem::itemChange(change, value);
+
+    if (isParentChanged && _enabled) {
+        registerWithScope();
+    }
+}
+
+void GettingStartedTip::registerWithScope()
+{
+    if (_controller) {
+        unregisterFromScope();
+    }
+
+    if (_standalone) {
+        return; // standalone tips don't register
+    }
+
+    auto ctl = findController();
+    if (!ctl) {
+        return;
+    }
+
+    bool ok = ctl->addTip(this);
+    if (ok) {
+        _controller = ctl;
+        emit controllerChanged();
+    }
+}
+
+GettingStartedTipsController* GettingStartedTip::findController()
+{
+    QQuickItem* pr = const_cast<QQuickItem*>(parentItem());
+    if (!pr) {
+        return nullptr;
+    }
+
+    while (pr) {
+        auto sa = qobject_cast<GettingStartedScopeAttached*>(qmlAttachedPropertiesObject<GettingStartedScope>(pr, false));
+        if (sa && sa->controller()) {
+            return sa->controller();
+        }
+
+        pr = pr->parentItem();
+    }
+
+    return nullptr;
+}
+
+void GettingStartedTip::unregisterFromScope()
+{
+    if (_controller) {
+        _controller->removeTip(this);
+        _controller.clear();
+        emit controllerChanged();
+    }
+}
diff --git a/src/GUI/GettingStartedTip.hxx b/src/GUI/GettingStartedTip.hxx
new file mode 100644
index 000000000..4ea540b59
--- /dev/null
+++ b/src/GUI/GettingStartedTip.hxx
@@ -0,0 +1,110 @@
+#ifndef GETTINGSTARTEDTIP_HXX
+#define GETTINGSTARTEDTIP_HXX
+
+#include <QQuickItem>
+#include <QString>
+#include <QPointer>
+
+#include "GettingStartedTipsController.hxx"
+
+class GettingStartedTip : public QQuickItem
+{
+    Q_OBJECT
+
+    Q_PROPERTY(QString tipId MEMBER _id NOTIFY tipChanged)
+    Q_PROPERTY(QString text MEMBER _text NOTIFY tipChanged)
+    Q_PROPERTY(QString nextTip MEMBER _nextId NOTIFY tipChanged)
+
+    Q_PROPERTY(Arrow arrow MEMBER _arrow NOTIFY tipChanged)
+
+    Q_PROPERTY(bool enabled READ isEnabled WRITE setEnabled NOTIFY enabledChanged)
+
+    Q_PROPERTY(GettingStartedTipsController* controller READ controller NOTIFY controllerChanged)
+
+    /// standalone tips are excluded from their scope when activated;
+    /// instead they need to be activated manually
+    Q_PROPERTY(bool standalone READ standalone WRITE setStandalone NOTIFY standaloneChanged)
+public:
+    enum class Arrow
+    {
+        TopCenter,  // directly below the item, centered
+        LeftCenter,
+        RightCenter,
+        BottomCenter,
+        BottomRight,
+        TopRight,
+        TopLeft,
+        LeftTop, // on the left side, at the top
+    };
+
+    Q_ENUM(Arrow)
+
+    explicit GettingStartedTip(QQuickItem *parent = nullptr);
+    ~GettingStartedTip() override;
+
+    QString tipId() const
+    {
+        return _id;
+    }
+
+    Arrow arrow() const
+    {
+        return _arrow;
+    }
+
+    bool isEnabled() const
+    {
+        return _enabled;
+    }
+
+    GettingStartedTipsController* controller() const;
+
+    void componentComplete() override; // from QQmlParserStatus
+
+    bool standalone() const
+    {
+        return _standalone;
+    }
+
+    QString nextTip() const
+    {
+        return _nextId;
+    }
+
+    // allow disabling all tips progrmatically : this is a temporary
+    // measure to make life less annoying for our translators
+    static void setGlobalTipsEnabled(bool enable);
+public slots:
+    void setEnabled(bool enabled);
+
+    void showOneShot();
+    void setStandalone(bool standalone);
+
+signals:
+
+    void tipChanged();
+    void enabledChanged();
+    void controllerChanged();
+
+    void standaloneChanged(bool standalone);
+
+protected:
+    void itemChange(QQuickItem::ItemChange change, const QQuickItem::ItemChangeData &value);
+
+private:
+    void registerWithScope();
+    void unregisterFromScope();
+
+    GettingStartedTipsController* findController();
+
+    QString _id,
+        _text,
+        _nextId;
+
+    Arrow _arrow = Arrow::LeftCenter;
+    bool _enabled = true;
+    QPointer<GettingStartedTipsController> _controller;
+    bool _standalone = false;
+};
+
+#endif // GETTINGSTARTEDTIP_HXX
diff --git a/src/GUI/GettingStartedTipsController.cxx b/src/GUI/GettingStartedTipsController.cxx
new file mode 100755
index 000000000..e133e4521
--- /dev/null
+++ b/src/GUI/GettingStartedTipsController.cxx
@@ -0,0 +1,463 @@
+#include "GettingStartedTipsController.hxx"
+
+#include <algorithm>
+
+#include <QSettings>
+#include <QDebug>
+#include <QQmlContext>
+#include <QTimer>
+
+#include <QtQml> // qmlContext
+
+#include "GettingStartedTip.hxx"
+#include "TipBackgroundBox.hxx"
+
+struct TipGeometryByArrowLocation
+{
+    TipGeometryByArrowLocation(GettingStartedTip::Arrow a, const QRectF& g, Qt::Alignment al) :
+        arrow(a),
+        geometry(g),
+        verticalAlignment(al)
+    {
+    }
+
+    GettingStartedTip::Arrow arrow;
+    QRectF geometry;
+    // specify how vertical space is adjusted; is the top, bottom or center fixed
+    Qt::Alignment verticalAlignment = Qt::AlignVCenter;
+};
+
+const double tipBoxWidth = 300.0;
+const double halfBoxWidth = tipBoxWidth * 0.5;
+const double arrowSideOffset = TipBackgroundBox::arrowSideOffset();
+const double rightSideOffset = -tipBoxWidth + arrowSideOffset;
+const double dummyHeight = 200.0;
+const double topHeightOffset = -TipBackgroundBox::arrowHeight();
+
+static std::initializer_list<TipGeometryByArrowLocation> static_tipGeometries = {
+    {GettingStartedTip::Arrow::BottomRight, QRectF{rightSideOffset, 0.0, tipBoxWidth, dummyHeight}, Qt::AlignBottom},
+    {GettingStartedTip::Arrow::BottomCenter, QRectF{halfBoxWidth, 0.0, tipBoxWidth, dummyHeight}, Qt::AlignBottom},
+    {GettingStartedTip::Arrow::TopCenter, QRectF{-halfBoxWidth, 0.0, tipBoxWidth, dummyHeight}, Qt::AlignTop},
+    {GettingStartedTip::Arrow::TopRight, QRectF{rightSideOffset, 0.0, tipBoxWidth, dummyHeight}, Qt::AlignTop},
+    {GettingStartedTip::Arrow::TopLeft, QRectF{-arrowSideOffset, 0.0, tipBoxWidth, dummyHeight}, Qt::AlignTop},
+    {GettingStartedTip::Arrow::LeftCenter, QRectF{0.0, 0.0, tipBoxWidth, dummyHeight}, Qt::AlignVCenter},
+    {GettingStartedTip::Arrow::RightCenter, QRectF{-(tipBoxWidth + TipBackgroundBox::arrowHeight()), 0.0, tipBoxWidth, dummyHeight}, Qt::AlignVCenter},
+    {GettingStartedTip::Arrow::LeftTop, QRectF{0.0, topHeightOffset, tipBoxWidth, dummyHeight}, Qt::AlignTop},
+
+};
+
+/**
+ * @brief The GettingStartedTipsController::ItemPositionObserver class
+ *
+ * This is a helper to observe the full position (and in the future, transform if required)
+ * of a QQuickItem, so we can update a signal when the on-screen position changes. This
+ * is necessary to re-transform the tooltip location if the item it's 'attached' to
+ * moves, or some ancestor does.
+ *
+ * At present this does not handle arbitrary scaling or rotation, but observing those
+ * signals would also be possible.
+ */
+class GettingStartedTipsController::ItemPositionObserver : public QObject
+{
+    Q_OBJECT
+public:
+    ItemPositionObserver(QObject* pr) :
+        QObject(pr)
+    {
+        _notMovingTimeout = new QTimer(this);
+        _notMovingTimeout->setSingleShot(true);
+        _notMovingTimeout->setInterval(1000);
+
+        connect(this, SIGNAL(itemPositionChanged()),
+                _notMovingTimeout, SLOT(start()));
+
+        connect(_notMovingTimeout, &QTimer::timeout,
+                this, &ItemPositionObserver::itemNotMoving);
+    }
+
+    void setObservedItem(QQuickItem* obs)
+    {
+        if (obs == _observedItem)
+            return;
+
+        if (_observedItem) {
+            for (auto o = _observedItem; o; o = o->parentItem()) {
+                disconnect(o, nullptr, this, nullptr);
+            }
+        }
+
+        _observedItem = obs;
+        if (obs) {
+            startObserving(_observedItem);
+        }
+    }
+
+    bool hasRecentlyMoved() const
+    {
+        return _notMovingTimeout->isActive();
+    }
+signals:
+    void itemPositionChanged();
+
+    void itemNotMoving();
+private:
+    void startObserving(QQuickItem* obs)
+    {
+
+        connect(obs, &QQuickItem::xChanged, this, &ItemPositionObserver::itemPositionChanged);
+        connect(obs, &QQuickItem::yChanged, this, &ItemPositionObserver::itemPositionChanged);
+        connect(obs, &QQuickItem::widthChanged, this, &ItemPositionObserver::itemPositionChanged);
+        connect(obs, &QQuickItem::heightChanged, this, &ItemPositionObserver::itemPositionChanged);
+
+        // recurse up the item hierarchy
+        if (obs->parentItem()) {
+            startObserving(obs->parentItem());
+        }
+    }
+
+    QPointer<QQuickItem> _observedItem;
+    QTimer* _notMovingTimeout = nullptr;
+};
+
+GettingStartedTipsController::GettingStartedTipsController(QObject *parent) : QObject(parent)
+{
+    // observer for the tip item
+    _positionObserver = new ItemPositionObserver(this);
+    connect(_positionObserver, &ItemPositionObserver::itemPositionChanged,
+            this, &GettingStartedTipsController::tipPositionInVisualAreaChanged);
+
+    // observer for the visual area (which could also be scrolled)
+    _viewAreaObserver = new ItemPositionObserver(this);
+    connect(_viewAreaObserver, &ItemPositionObserver::itemPositionChanged,
+            this, &GettingStartedTipsController::tipPositionInVisualAreaChanged);
+
+    connect(_positionObserver, &ItemPositionObserver::itemNotMoving,
+            this, &GettingStartedTipsController::tipPositionInVisualAreaChanged);
+    connect(_viewAreaObserver, &ItemPositionObserver::itemNotMoving,
+            this, &GettingStartedTipsController::tipPositionInVisualAreaChanged);
+
+    auto qqParent = qobject_cast<QQuickItem*>(parent);
+    if (qqParent) {
+        setVisualArea(qqParent);
+    }
+}
+
+GettingStartedTipsController::~GettingStartedTipsController()
+{
+}
+
+int GettingStartedTipsController::count() const
+{
+    if (_oneShotTip) {
+        return 1;
+    }
+
+    return _tips.size();
+}
+
+int GettingStartedTipsController::index() const
+{
+    if (_oneShotTip) {
+        return 0;
+    }
+
+    return _index;
+}
+
+GettingStartedTip *GettingStartedTipsController::tip() const
+{
+    if (_oneShotTip) {
+        return _oneShotTip;
+    }
+
+    if (_tips.empty())
+        return nullptr;
+
+    if ((_index < 0) || (_index >= _tips.size()))
+        return nullptr;
+
+    return _tips.at(_index);
+}
+
+void GettingStartedTipsController::setVisualArea(QQuickItem *visualArea)
+{
+    if (_visualArea == visualArea)
+        return;
+
+    _visualArea = visualArea;
+    _viewAreaObserver->setObservedItem(_visualArea);
+
+    emit tipPositionInVisualAreaChanged();
+    emit visualAreaChanged(_visualArea);
+}
+
+void GettingStartedTipsController::setActiveTipHeight(int activeTipHeight)
+{
+    if (_activeTipHeight == activeTipHeight)
+        return;
+
+    _activeTipHeight = activeTipHeight;
+    emit activeTipHeightChanged(_activeTipHeight);
+    emit tipGeometryChanged();
+}
+
+void GettingStartedTipsController::showOneShotTip(GettingStartedTip *tip)
+{
+    if (_scopeActive) {
+        return;
+    }
+
+    QSettings settings;
+    settings.beginGroup("GettingStarted-DontShow");
+    if (settings.value(tip->tipId()).toBool()) {
+        return;
+    }
+
+    // mark the tip as shown
+    settings.setValue(tip->tipId(), true);
+    _oneShotTip = tip;
+
+    connect(_oneShotTip, &QObject::destroyed, this, &GettingStartedTipsController::onOneShotDestroyed);
+
+    currentTipUpdated();
+    emit indexChanged(0);
+    emit countChanged(count());
+}
+
+void GettingStartedTipsController::tipsWereReset()
+{
+    bool a = shouldShowScope();
+    if (a != _scopeActive) {
+        _scopeActive = a;
+        emit activeChanged();
+        currentTipUpdated();
+    }
+}
+
+void GettingStartedTipsController::currentTipUpdated()
+{
+    _positionObserver->setObservedItem(tip());
+
+    emit activeChanged();
+    emit tipChanged();
+    emit tipPositionInVisualAreaChanged();
+    emit tipGeometryChanged();
+}
+
+bool GettingStartedTipsController::addTip(GettingStartedTip *t)
+{
+    if (_tips.contains(t)) {
+        qWarning() << Q_FUNC_INFO << "Duplicate tip" << t;
+        return false;
+    }
+
+    // this logic is important to suppress duplicate tips inside a ListView or Repeater;
+    // effectively, we only show a tip on the first registered instance.
+    Q_FOREACH(GettingStartedTip* tip, _tips) {
+        if (tip->tipId() == t->tipId()) {
+            return false;
+        }
+    }
+
+    _tips.append(t);
+    // order tips by nextTip ID, if defined
+    std::sort(_tips.begin(), _tips.end(), [](const GettingStartedTip* a, GettingStartedTip *b) {
+       return a->nextTip() == b->tipId();
+    });
+
+    currentTipUpdated();
+    emit countChanged(count());
+    return true;
+}
+
+void GettingStartedTipsController::removeTip(GettingStartedTip *t)
+{
+    const bool removedActive = (tip() == t);
+    if (!_tips.removeOne(t)) {
+        qWarning() << Q_FUNC_INFO << "tip not found";
+    }
+
+    if (removedActive) {
+        _index = qMax(_index - 1, 0);
+    }
+
+    currentTipUpdated();
+    emit countChanged(count());
+}
+
+void GettingStartedTipsController::onOneShotDestroyed()
+{
+    if (_oneShotTip == sender()) {
+        emit activeChanged();
+        currentTipUpdated();
+    }
+}
+
+bool GettingStartedTipsController::isActive() const
+{
+    if (_oneShotTip)
+        return true;
+
+    return _scopeActive && !_tips.empty();
+}
+
+QPointF GettingStartedTipsController::tipPositionInVisualArea() const
+{
+    auto t = tip();
+    if (!_visualArea || !t) {
+        return {};
+    }
+
+    return _visualArea->mapFromItem(t, QPointF{0,0});
+}
+
+QRectF GettingStartedTipsController::tipGeometry() const
+{
+    auto t = tip();
+    if (!t)
+        return {};
+
+    const auto arrow = t->arrow();
+    auto it = std::find_if(static_tipGeometries.begin(), static_tipGeometries.end(),
+                           [arrow](const TipGeometryByArrowLocation& tg)
+    {
+        return tg.arrow == arrow;
+    });
+
+    if (it == static_tipGeometries.end()) {
+        qWarning() << Q_FUNC_INFO << "Missing tip geometry" << arrow;
+        return {};
+    }
+
+    QRectF g = it->geometry;
+    if ((arrow == GettingStartedTip::Arrow::LeftCenter) || (arrow == GettingStartedTip::Arrow::RightCenter)
+            || (arrow == GettingStartedTip::Arrow::LeftTop)) {
+        g.setHeight(_activeTipHeight);
+    } else {
+        g.setHeight(_activeTipHeight + TipBackgroundBox::arrowHeight());
+    }
+
+
+    switch (it->verticalAlignment) {
+    case Qt::AlignBottom:
+        g.moveBottom(0);
+        break;
+
+    case Qt::AlignTop:
+        g.moveTop(0);
+        break;
+
+    case Qt::AlignVCenter:
+        g.moveTop(_activeTipHeight * -0.5);
+        break;
+    }
+
+    return g;
+}
+
+bool GettingStartedTipsController::tipPositionValid() const
+{
+    if (!_visualArea || !isActive())
+        return false;
+
+    // hide tips when resizing the window or scrolling; it's visually distracting otherwise
+    if (_positionObserver->hasRecentlyMoved() || _viewAreaObserver->hasRecentlyMoved()) {
+        return false;
+    }
+
+    if (_oneShotTip)
+        return true;
+
+    return !_tips.empty();
+}
+
+int GettingStartedTipsController::activeTipHeight() const
+{
+    return _activeTipHeight;
+}
+
+QRectF GettingStartedTipsController::contentGeometry() const
+{
+    QRectF g(0.0, 0.0, tipBoxWidth, 200.0);
+
+    auto t = tip();
+    if (!t)
+        return g;
+
+    const auto arrow = t->arrow();
+    if ((arrow == GettingStartedTip::Arrow::TopCenter) ||
+            (arrow == GettingStartedTip::Arrow::TopLeft) ||
+            (arrow == GettingStartedTip::Arrow::TopRight))
+    {
+        g.moveTop(TipBackgroundBox::arrowHeight());
+    }
+
+    if (arrow == GettingStartedTip::Arrow::RightCenter) {
+        g.setWidth(tipBoxWidth - TipBackgroundBox::arrowHeight());
+    }
+
+    if (arrow == GettingStartedTip::Arrow::LeftCenter) {
+        g.setWidth(tipBoxWidth - TipBackgroundBox::arrowHeight());
+        g.moveLeft(TipBackgroundBox::arrowHeight());
+    }
+
+    return g;
+}
+
+void GettingStartedTipsController::close()
+{
+    // one-shot tips handle this logic differently; we set the don't show
+    // when the tip first appears
+    if (_oneShotTip) {
+        disconnect(_oneShotTip, nullptr, this, nullptr);
+        _oneShotTip = nullptr;
+    } else {
+        QSettings settings;
+        settings.beginGroup("GettingStarted-DontShow");
+        settings.setValue(_scopeId, true);
+        _scopeActive = false;
+    }
+
+    emit activeChanged();
+    currentTipUpdated();
+}
+
+void GettingStartedTipsController::setIndex(int index)
+{
+    if (_oneShotTip) {
+        return;
+    }
+
+    if (_index == index)
+        return;
+
+    _index = qBound(0, index, _tips.size() - 1);
+    _positionObserver->setObservedItem(tip());
+
+    emit indexChanged(_index);
+    currentTipUpdated();
+}
+
+void GettingStartedTipsController::setScopeId(QString scopeId)
+{
+    if (_scopeId == scopeId)
+        return;
+
+    _scopeId = scopeId;
+    _scopeActive = shouldShowScope();
+    emit scopeIdChanged(_scopeId);
+    emit activeChanged();
+}
+
+bool GettingStartedTipsController::shouldShowScope() const
+{
+    if (_scopeId.isEmpty())
+        return true;
+
+    QSettings settings;
+    settings.beginGroup("GettingStarted-DontShow");
+    return settings.value(_scopeId).toBool() == false;
+}
+
+
+#include "GettingStartedTipsController.moc"
diff --git a/src/GUI/GettingStartedTipsController.hxx b/src/GUI/GettingStartedTipsController.hxx
new file mode 100644
index 000000000..37bcfa8d7
--- /dev/null
+++ b/src/GUI/GettingStartedTipsController.hxx
@@ -0,0 +1,150 @@
+#ifndef GETTINGSTARTEDTIPSCONTROLLER_HXX
+#define GETTINGSTARTEDTIPSCONTROLLER_HXX
+
+#include <QObject>
+#include <QVector>
+#include <QQuickItem>
+#include <QPointer>
+
+// forward decls
+class GettingStartedTip;
+
+/**
+ * @brief Manage presentation of getting started tips on a screen/page
+ */
+class GettingStartedTipsController : public QObject
+{
+    Q_OBJECT
+
+    Q_PROPERTY(QString scopeId READ scopeId WRITE setScopeId NOTIFY scopeIdChanged)
+
+    Q_PROPERTY(GettingStartedTip* tip READ tip NOTIFY tipChanged)
+
+    Q_PROPERTY(int count READ count NOTIFY countChanged)
+    Q_PROPERTY(int index READ index WRITE setIndex NOTIFY indexChanged)
+
+    Q_PROPERTY(bool active READ isActive NOTIFY activeChanged)
+
+    Q_PROPERTY(QPointF tipPositionInVisualArea READ tipPositionInVisualArea NOTIFY tipPositionInVisualAreaChanged)
+    Q_PROPERTY(bool tipPositionValid READ tipPositionValid NOTIFY tipPositionInVisualAreaChanged)
+
+    Q_PROPERTY(QRectF tipGeometry READ tipGeometry NOTIFY tipGeometryChanged)
+    Q_PROPERTY(int activeTipHeight READ activeTipHeight WRITE setActiveTipHeight NOTIFY activeTipHeightChanged)
+    Q_PROPERTY(QRectF contentGeometry READ contentGeometry NOTIFY tipChanged)
+
+    Q_PROPERTY(QQuickItem* visualArea READ visualArea WRITE setVisualArea NOTIFY visualAreaChanged)
+
+public:
+    explicit GettingStartedTipsController(QObject *parent = nullptr);
+    ~GettingStartedTipsController();
+
+    int count() const;
+
+    int index() const;
+
+    GettingStartedTip* tip() const;
+
+    QString scopeId() const
+    {
+        return _scopeId;
+    }
+
+    bool isActive() const;
+
+    QPointF tipPositionInVisualArea() const;
+
+    QQuickItem* visualArea() const
+    {
+        return _visualArea;
+    }
+
+    QRectF tipGeometry() const;
+
+    bool tipPositionValid() const;
+
+    int activeTipHeight() const;
+
+    /**
+     * @brief contentGeometry - based on the active tip, return the box
+     * (relative to the total tipGeometry) which content should occupy.
+     * This allows for offseting due to the arrow position, and also specifies
+     * the width for computing wrapped text height
+     *
+     * The actual height is the maximum height; the computed value should be
+     * set by activeTipHeight
+     */
+    QRectF contentGeometry() const;
+
+public slots:
+
+    void close();
+
+    void setIndex(int index);
+
+    void setScopeId(QString scopeId);
+
+    void setVisualArea(QQuickItem* visualArea);
+
+    void setActiveTipHeight(int activeTipHeight);
+
+    /**
+     * @brief showOneShotTip - show a single tip on its own, if it has not
+     * previously been shown before.
+     *
+     * This is used for pieces of UI which are not always present; the first
+     * time the UI is displayed to the user, we can show a tip describing it.
+     * Once the user closes the tip, it will not reappear.
+     *
+     * The tip will be activated if the controller is currently inactive.
+     * If the controller was alreayd active, the tip will be shown, and when
+     * closed, the other tips will be shown. If the one-shot tip is part of
+     * the currently active list, this actually does nothing, to avoid showing
+     * the same tip twice
+     */
+    void showOneShotTip(GettingStartedTip* tip);
+
+    void tipsWereReset();
+signals:
+
+    void countChanged(int count);
+    void indexChanged(int index);
+    void tipChanged();
+    void scopeChanged(QObject* scope);
+    void scopeIdChanged(QString scopeId);
+    void activeChanged();
+    void tipGeometryChanged();
+
+    void tipPositionInVisualAreaChanged();
+
+    void visualAreaChanged(QQuickItem* visualArea);
+
+    void activeTipHeightChanged(int activeTipHeight);
+
+private:
+    friend class GettingStartedTip;
+
+    bool shouldShowScope() const;
+
+    void currentTipUpdated();
+
+    bool addTip(GettingStartedTip* t);
+    void removeTip(GettingStartedTip* t);
+
+    void onOneShotDestroyed();
+private:
+
+    class ItemPositionObserver;
+
+    bool _scopeActive = false;
+    int _index = 0;
+    QString _scopeId;
+    ItemPositionObserver* _positionObserver = nullptr;
+    ItemPositionObserver* _viewAreaObserver = nullptr;
+
+    QPointer<GettingStartedTip> _oneShotTip;
+    QVector<GettingStartedTip*> _tips;
+    QQuickItem* _visualArea = nullptr;
+    int _activeTipHeight = 0;
+};
+
+#endif // GETTINGSTARTEDTIPSCONTROLLER_HXX
diff --git a/src/GUI/LauncherController.cxx b/src/GUI/LauncherController.cxx
index a40e181ff..ae7fd93ff 100644
--- a/src/GUI/LauncherController.cxx
+++ b/src/GUI/LauncherController.cxx
@@ -59,6 +59,10 @@
 #include "ThumbnailImageItem.hxx"
 #include "UnitsModel.hxx"
 #include "UpdateChecker.hxx"
+#include "GettingStartedTipsController.hxx"
+#include "GettingStartedTip.hxx"
+#include "TipBackgroundBox.hxx"
+#include "GettingStartedScope.hxx"
 
 using namespace simgear::pkg;
 
@@ -199,6 +203,11 @@ void LauncherController::initQML()
 
     qmlRegisterSingletonType(QUrl("qrc:/qml/OverlayShared.qml"), "FlightGear", 1, 0, "OverlayShared");
 
+    qmlRegisterType<GettingStartedScope>("FlightGear", 1, 0, "GettingStartedScope");
+    qmlRegisterType<GettingStartedTipsController>("FlightGear", 1, 0, "GettingStartedController");
+    qmlRegisterType<GettingStartedTip>("FlightGear", 1, 0, "GettingStartedTip");
+    qmlRegisterType<TipBackgroundBox>("FlightGear", 1, 0, "TipBackgroundBox");
+
     QNetworkDiskCache* diskCache = new QNetworkDiskCache(this);
     SGPath cachePath = globals->get_fg_home() / "PreviewsCache";
     diskCache->setCacheDirectory(QString::fromStdString(cachePath.utf8Str()));
@@ -876,6 +885,18 @@ void LauncherController::setAircraftGridMode(bool aircraftGridMode)
     emit aircraftGridModeChanged(m_aircraftGridMode);
 }
 
+void LauncherController::resetGettingStartedTips()
+{
+    {
+        QSettings settings;
+        settings.beginGroup("GettingStarted-DontShow");
+        settings.remove(""); // remove all keys in the current group
+        settings.endGroup();
+    } // ensure settings are written, before we emit the signal
+
+    emit didResetGettingStartedTips();
+}
+
 void LauncherController::setMinWindowSize(QSize sz)
 {
     if (sz == m_minWindowSize)
diff --git a/src/GUI/LauncherController.hxx b/src/GUI/LauncherController.hxx
index 933de078e..1cc23d2b1 100644
--- a/src/GUI/LauncherController.hxx
+++ b/src/GUI/LauncherController.hxx
@@ -243,6 +243,7 @@ signals:
     void inAppChanged();
     void installedAircraftCountChanged(int installedAircraftCount);
 
+    void didResetGettingStartedTips();
 public slots:
     void setSelectedAircraft(QUrl selectedAircraft);
 
@@ -263,6 +264,8 @@ public slots:
     void saveConfigAs();
     void setAircraftGridMode(bool aircraftGridMode);
 
+    void resetGettingStartedTips();
+
 private slots:
 
     void onAircraftInstalledCompleted(QModelIndex index);
diff --git a/src/GUI/LauncherMainWindow.cxx b/src/GUI/LauncherMainWindow.cxx
index 98fea487b..f9bfe3902 100755
--- a/src/GUI/LauncherMainWindow.cxx
+++ b/src/GUI/LauncherMainWindow.cxx
@@ -28,6 +28,7 @@
 #include "LocationController.hxx"
 #include "QtLauncher.hxx"
 #include "UpdateChecker.hxx"
+#include "GettingStartedTip.hxx"
 
 //////////////////////////////////////////////////////////////////////////////
 
diff --git a/src/GUI/QtLauncher.cxx b/src/GUI/QtLauncher.cxx
index f332749b8..9708fc656 100644
--- a/src/GUI/QtLauncher.cxx
+++ b/src/GUI/QtLauncher.cxx
@@ -75,6 +75,7 @@
 #include "LocalAircraftCache.hxx"
 #include "PathListModel.hxx"
 #include "UnitsModel.hxx"
+#include "GettingStartedTip.hxx"
 
 #if defined(SG_MAC)
 #include <GUI/CocoaHelpers.h>
@@ -110,6 +111,7 @@ void initNavCache()
         const char* waitForOtherMsg = QT_TRANSLATE_NOOP("initNavCache", "Another copy of FlightGear is creating the navigation database. Waiting for it to finish.");
         QString m = qApp->translate("initNavCache", waitForOtherMsg);
 
+        addSentryBreadcrumb("Launcher: showing wait for other process NavCache rebuild dialog", "info");
         QProgressDialog waitForRebuild(m,
                                        QString() /* cancel text */,
                                        0, 0, Q_NULLPTR,
@@ -133,6 +135,7 @@ void initNavCache()
         updateTimer.start(); // timer won't actually run until we process events
         waitForRebuild.exec();
         updateTimer.stop();
+        addSentryBreadcrumb("Launcher: done waiting for other process NavCache rebuild dialog", "info");
     }
 
     NavDataCache* cache = NavDataCache::createInstance();
diff --git a/src/GUI/TipBackgroundBox.cxx b/src/GUI/TipBackgroundBox.cxx
new file mode 100644
index 000000000..843e382ad
--- /dev/null
+++ b/src/GUI/TipBackgroundBox.cxx
@@ -0,0 +1,192 @@
+#include "TipBackgroundBox.hxx"
+
+#include <QPainter>
+#include <QPainterPath>
+#include <QDebug>
+
+namespace {
+
+void pathLineBy(QPainterPath& pp, double x, double y)
+{
+    const auto c = pp.currentPosition();
+    pp.lineTo(c.x() + x, c.y() + y);
+}
+
+const double arrowDim1 = 20.0;
+const double arrowEndOffset = 30;
+
+QPainterPath pathFromArrowAndGeometry(GettingStartedTip::Arrow arrow, const QRectF& g)
+{
+    QPainterPath pp;
+
+    switch (arrow) {
+    case GettingStartedTip::Arrow::TopCenter:
+        pp.moveTo(g.center().x(), 0.0);
+        pp.lineTo(g.center().x() + arrowDim1, arrowDim1);
+        pp.lineTo(g.right(), arrowDim1);
+        pp.lineTo(g.right(), g.bottom());
+        pp.lineTo(g.left(), g.bottom());
+        pp.lineTo(g.left(), arrowDim1);
+        pp.lineTo(g.center().x() - arrowDim1, arrowDim1);
+        pp.closeSubpath();
+        break;
+
+    case GettingStartedTip::Arrow::BottomRight:
+        pp.moveTo(g.right() - arrowEndOffset, g.bottom());
+        pathLineBy(pp, arrowDim1, -arrowDim1);
+        pp.lineTo(g.right(), g.bottom() - arrowDim1);
+        pp.lineTo(g.right(), g.top());
+        pp.lineTo(g.left(), g.top());
+        pp.lineTo(g.left(), g.bottom() - arrowDim1);
+        pp.lineTo(g.right() - arrowEndOffset - arrowDim1, g.bottom() - arrowDim1);
+        pp.closeSubpath();
+        break;
+
+    case GettingStartedTip::Arrow::TopRight:
+        pp.moveTo(g.right() - arrowEndOffset, 0.0);
+        pathLineBy(pp, arrowDim1, arrowDim1);
+        pp.lineTo(g.right(), arrowDim1);
+        pp.lineTo(g.right(), g.bottom());
+        pp.lineTo(g.left(), g.bottom());
+        pp.lineTo(g.left(), arrowDim1);
+        pp.lineTo(g.right() - (arrowEndOffset + arrowDim1), arrowDim1);
+        pp.closeSubpath();
+        break;
+
+    case GettingStartedTip::Arrow::TopLeft:
+        pp.moveTo(arrowEndOffset, 0.0);
+        pathLineBy(pp, arrowDim1, arrowDim1);
+        pp.lineTo(g.right(), arrowDim1);
+        pp.lineTo(g.right(), g.bottom());
+        pp.lineTo(g.left(), g.bottom());
+        pp.lineTo(g.left(), arrowDim1);
+        pp.lineTo(arrowEndOffset - arrowDim1, arrowDim1);
+        pp.closeSubpath();
+        break;
+
+    case GettingStartedTip::Arrow::LeftCenter:
+        pp.moveTo(0.0, g.center().y());
+        pathLineBy(pp, arrowDim1, -arrowDim1);
+        pp.lineTo(arrowDim1, g.top());
+        pp.lineTo(g.right(), g.top());
+        pp.lineTo(g.right(), g.bottom());
+        pp.lineTo(arrowDim1, g.bottom());
+        pp.lineTo(arrowDim1, g.center().y() + arrowDim1);
+        pp.closeSubpath();
+        break;
+
+    case GettingStartedTip::Arrow::RightCenter:
+        pp.moveTo(g.right(), g.center().y());
+        pathLineBy(pp, -arrowDim1, arrowDim1);
+        pp.lineTo(g.right() - arrowDim1, g.bottom());
+        pp.lineTo(g.left(), g.bottom());
+        pp.lineTo(g.left(), g.top());
+        pp.lineTo(g.right() - arrowDim1, g.top());
+        pp.lineTo(g.right() - arrowDim1, g.center().y() - arrowDim1);
+        pp.closeSubpath();
+        break;
+
+
+    case GettingStartedTip::Arrow::LeftTop:
+        pp.moveTo(0.0, g.top() + arrowDim1);
+        pathLineBy(pp, arrowDim1, -arrowDim1);
+        pp.lineTo(g.right(), g.top());
+        pp.lineTo(g.right(), g.bottom());
+        pp.lineTo(g.left() + arrowDim1, g.bottom());
+        pp.lineTo(g.left() + arrowDim1, -g.left() + arrowDim1);
+        pp.closeSubpath();
+        break;
+
+    default:
+        qWarning() << Q_FUNC_INFO << "unhandled:" << arrow;
+        break;
+    }
+
+    return pp;
+}
+
+
+} // of anonymous
+
+int TipBackgroundBox::arrowSideOffset()
+{
+    return static_cast<int>(arrowEndOffset);
+}
+
+int TipBackgroundBox::arrowHeight()
+{
+    return static_cast<int>(arrowDim1);
+}
+
+TipBackgroundBox::TipBackgroundBox(QQuickItem* pr) :
+    QQuickPaintedItem(pr)
+{
+}
+
+GettingStartedTip::Arrow TipBackgroundBox::arrowPosition() const
+{
+    return _arrow;
+}
+
+QColor TipBackgroundBox::borderColor() const
+{
+    return _borderColor;
+}
+
+int TipBackgroundBox::borderWidth() const
+{
+    return _borderWidth;
+}
+
+QColor TipBackgroundBox::fill() const
+{
+    return _fill;
+}
+
+void TipBackgroundBox::setArrowPosition(GettingStartedTip::Arrow arrow)
+{
+    if (_arrow == arrow)
+        return;
+
+    _arrow = arrow;
+    emit arrowPositionChanged(_arrow);
+    update();
+}
+
+void TipBackgroundBox::setBorderColor(QColor borderColor)
+{
+    if (_borderColor == borderColor)
+        return;
+
+    _borderColor = borderColor;
+    emit borderColorChanged(_borderColor);
+    update();
+}
+
+void TipBackgroundBox::setBorderWidth(int borderWidth)
+{
+    if (_borderWidth == borderWidth)
+        return;
+
+    _borderWidth = borderWidth;
+    emit borderWidthChanged(_borderWidth);
+    update();
+}
+
+void TipBackgroundBox::setFill(QColor fill)
+{
+    if (_fill == fill)
+        return;
+
+    _fill = fill;
+    emit fillChanged(_fill);
+    update();
+}
+
+void TipBackgroundBox::paint(QPainter *painter)
+{
+    QPainterPath pp = pathFromArrowAndGeometry(_arrow, QRectF{0.0,0.0,width(), height()});
+    painter->setBrush(_fill);
+    painter->setPen(QPen{_borderColor, static_cast<double>(_borderWidth)});
+    painter->drawPath(pp);
+}
diff --git a/src/GUI/TipBackgroundBox.hxx b/src/GUI/TipBackgroundBox.hxx
new file mode 100644
index 000000000..77963b5b9
--- /dev/null
+++ b/src/GUI/TipBackgroundBox.hxx
@@ -0,0 +1,61 @@
+#pragma once
+
+#include <QQuickPaintedItem>
+#include <QColor>
+
+#include <GUI/GettingStartedTip.hxx>
+
+class TipBackgroundBox : public QQuickPaintedItem
+{
+    Q_OBJECT
+
+    Q_PROPERTY(GettingStartedTip::Arrow arrow READ arrowPosition WRITE setArrowPosition NOTIFY arrowPositionChanged)
+
+    Q_PROPERTY(QColor fill READ fill WRITE setFill NOTIFY fillChanged)
+    Q_PROPERTY(QColor borderColor READ borderColor WRITE setBorderColor NOTIFY borderColorChanged)
+    Q_PROPERTY(int borderWidth READ borderWidth WRITE setBorderWidth NOTIFY borderWidthChanged)
+
+public:
+    TipBackgroundBox(QQuickItem* parent = nullptr);
+
+    GettingStartedTip::Arrow arrowPosition() const;
+
+    QColor borderColor() const;
+
+    int borderWidth() const;
+
+    QColor fill() const;
+
+    static int arrowSideOffset();
+
+    static int arrowHeight();
+
+public slots:
+    void setArrowPosition(GettingStartedTip::Arrow arrow);
+
+    void setBorderColor(QColor borderColor);
+
+    void setBorderWidth(int borderWidth);
+
+    void setFill(QColor fill);
+
+signals:
+    void arrowPositionChanged(GettingStartedTip::Arrow arrow);
+
+    void borderColorChanged(QColor borderColor);
+
+    void borderWidthChanged(int borderWidth);
+
+    void fillChanged(QColor fill);
+
+protected:
+    void paint(QPainter* painter) override;
+
+private:
+    GettingStartedTip::Arrow _arrow = GettingStartedTip::Arrow::TopCenter;
+
+    QColor _borderColor;
+    int _borderWidth = -1; // off
+    QColor _fill;
+};
+
diff --git a/src/GUI/qml/AircraftCompactDelegate.qml b/src/GUI/qml/AircraftCompactDelegate.qml
index b97daee98..61bb9ec88 100644
--- a/src/GUI/qml/AircraftCompactDelegate.qml
+++ b/src/GUI/qml/AircraftCompactDelegate.qml
@@ -107,6 +107,25 @@ Item {
                     model.activeVariant = index
                     root.select(model.uri)
                 }
+
+                GettingStartedTip {
+                    tipId: "aircraftVariantTip"
+                    enabled: model.variantCount > 0
+                    standalone: true
+
+                    Component.onCompleted: {
+                        if (enabled) {
+                           showOneShot();
+                        }
+                    }
+
+                    anchors {
+                        horizontalCenter: parent.horizontalCenter
+                        top: parent.bottom
+                    }
+                    arrow: GettingStartedTip.TopCenter
+                    text: qsTr("Click here to select different variants or models of this aircraft")
+                }
             }
         }
 
diff --git a/src/GUI/qml/AircraftDetailsView.qml b/src/GUI/qml/AircraftDetailsView.qml
index 5e9ae5df5..4f401992c 100644
--- a/src/GUI/qml/AircraftDetailsView.qml
+++ b/src/GUI/qml/AircraftDetailsView.qml
@@ -1,6 +1,7 @@
 import QtQuick 2.4
 import QtQuick.Controls 2.2
 import FlightGear.Launcher 1.0
+import FlightGear 1.0
 import "."
 
 Rectangle {
@@ -24,16 +25,20 @@ Rectangle {
 
         anchors.fill: parent
         contentWidth: parent.width
-        contentHeight: content.childrenRect.height
+        contentHeight: content.height
         boundsBehavior: Flickable.StopAtBounds
         ScrollBar.vertical: ScrollBar {}
 
         Item {
             id: content
+
             width: root.width
             height: childrenRect.height
 
+            GettingStartedScope.controller: tipsLayer.controller
+
             Column {
+                id: contentColumn
                 width: content.width - (Style.margin * 2)
                 spacing: Style.margin
                 anchors.horizontalCenter: parent.horizontalCenter
@@ -172,6 +177,15 @@ Rectangle {
                         onToggle: {
                             aircraft.favourite = on;
                         }
+
+                        GettingStartedTip {
+                            anchors {
+                                verticalCenter: parent.verticalCenter
+                                left: parent.right
+                            }
+                            arrow: GettingStartedTip.LeftCenter
+                            text: qsTr("Click here to mark this as a favourite aircraft")
+                        }
                     }
 
                     Grid {
@@ -237,7 +251,12 @@ Rectangle {
                 }
 
             } // main layout column
-        } // of main item
 
+            GettingStartedTipLayer {
+                id: tipsLayer
+                anchors.fill: parent
+                scopeId: "aircraft-details"
+            }
+        } // of main item
     } // of Flickable
 } // of Rect
diff --git a/src/GUI/qml/AircraftList.qml b/src/GUI/qml/AircraftList.qml
index 454e34b37..dda24ca5d 100644
--- a/src/GUI/qml/AircraftList.qml
+++ b/src/GUI/qml/AircraftList.qml
@@ -1,5 +1,6 @@
 import QtQuick 2.2
 import FlightGear.Launcher 1.0 as FG
+import FlightGear 1.0
 import "." // -> forces the qmldir to be loaded
 
 FocusScope
@@ -30,6 +31,8 @@ FocusScope
         }
     }
 
+    GettingStartedScope.controller: tipsLayer.controller
+
     Rectangle
     {
         id: tabBar
@@ -43,6 +46,17 @@ FocusScope
             anchors.leftMargin: Style.margin
             gridMode: !_launcher.aircraftGridMode
             onClicked: _launcher.aircraftGridMode = !_launcher.aircraftGridMode
+
+            GettingStartedTip {
+                tipId: "gridModeTip"
+
+                anchors {
+                    horizontalCenter: parent.horizontalCenter
+                    top: parent.bottom
+                }
+                arrow: GettingStartedTip.TopLeft
+                text: qsTr("Click here to switch between grid and list mode")
+            }
         }
 
         Row {
@@ -57,6 +71,18 @@ FocusScope
                     root.updateSelectionFromLauncher();
                 }
                 active: root.state == "installed"
+
+                GettingStartedTip {
+                    tipId: "installedAircraftTip"
+                    nextTip: "gridModeTip"
+
+                    anchors {
+                        horizontalCenter: parent.horizontalCenter
+                        top: parent.bottom
+                    }
+                    arrow: GettingStartedTip.TopCenter
+                    text: qsTr("Use this tab to view installed aircraft")
+                }
             }
 
             TabButton {
@@ -77,6 +103,18 @@ FocusScope
                     root.updateSelectionFromLauncher();
                 }
                 active: root.state == "browse"
+
+                GettingStartedTip {
+                    tipId: "browseTip"
+                    nextTip: "searchAircraftTip"
+
+                    anchors {
+                        horizontalCenter: parent.horizontalCenter
+                        top: parent.bottom
+                    }
+                    arrow: GettingStartedTip.TopCenter
+                    text: qsTr("Use this tab to view available aircraft to download")
+                }
             }
 
             TabButton {
@@ -107,6 +145,18 @@ FocusScope
             }
 
             active: root.state == "search"
+
+            GettingStartedTip {
+                tipId: "searchAircraftTip"
+                nextTip: "installedAircraftTip"
+
+                anchors {
+                    horizontalCenter: parent.horizontalCenter
+                    top: parent.bottom
+                }
+                arrow: GettingStartedTip.TopRight
+                text: qsTr("Enter text here to search aircraft names and descriptions.")
+            }
         }
     }
 
@@ -123,6 +173,7 @@ FocusScope
         id: ratingsHeader
         AircraftRatingsPanel {
             width: aircraftContent.width
+            tips: tipsLayer
             onClearSelection: {
                 _launcher.selectedAircraft = "";
                 root.updateSelectionFromLauncher()
@@ -347,6 +398,13 @@ FocusScope
         detailsView.visible = false;
     }
 
+    // we don't want our tips to interfere with the details views
+    GettingStartedTipLayer {
+        id: tipsLayer
+        anchors.fill: parent
+        scopeId: "aircraft"
+    }
+
     AircraftDetailsView {
         id: detailsView
         anchors.fill: parent
@@ -358,5 +416,7 @@ FocusScope
             onClicked: root.goBack();
         }
     }
+
+
 }
 
diff --git a/src/GUI/qml/AircraftPreviewPanel.qml b/src/GUI/qml/AircraftPreviewPanel.qml
index ef9608262..5bda65142 100644
--- a/src/GUI/qml/AircraftPreviewPanel.qml
+++ b/src/GUI/qml/AircraftPreviewPanel.qml
@@ -1,5 +1,7 @@
 import QtQuick 2.4
 import FlightGear.Launcher 1.0
+import FlightGear 1.0
+import "." // -> forces the qmldir to be loaded
 
 Rectangle {
     id: root
@@ -66,7 +68,7 @@ Rectangle {
                 height: 8
                 width: 8
                 radius: 4
-                color: (model.index == root.activePreview) ? "white" : "#cfcfcf"
+                color: (model.index == root.activePreview) ? "white" : Style.themeColor
             }
         }
     }
@@ -107,5 +109,14 @@ Rectangle {
                 root.activePreview = Math.min(root.activePreview + 1, root.previews.length - 1)
             }
         }
+
+        GettingStartedTip {
+            anchors {
+                verticalCenter: parent.verticalCenter
+                right: parent.left
+            }
+            arrow: GettingStartedTip.RightCenter
+            text: qsTr("Click here to cycle through preview images")
+        }
     }
 }
diff --git a/src/GUI/qml/AircraftRatingsPanel.qml b/src/GUI/qml/AircraftRatingsPanel.qml
index 9df0585be..87c503c75 100644
--- a/src/GUI/qml/AircraftRatingsPanel.qml
+++ b/src/GUI/qml/AircraftRatingsPanel.qml
@@ -1,5 +1,7 @@
 import QtQuick 2.2
 import FlightGear.Launcher 1.0 as FG
+import FlightGear 1.0
+
 import "."
 
 ListHeaderBox
@@ -7,6 +9,8 @@ ListHeaderBox
     id: root
     signal clearSelection();
 
+    GettingStartedScope.controller: tips.controller
+
     contents: [
 
         ToggleSwitch {
@@ -45,6 +49,23 @@ ListHeaderBox
                 editRatingsPanel.visible = true
 
             }
+
+            Component.onCompleted: {
+                editTip.showOneShot()
+            }
+
+            GettingStartedTip {
+                id: editTip
+                tipId: "editRatingsTip"
+
+                anchors {
+                    horizontalCenter: parent.horizontalCenter
+                    top: parent.bottom
+                }
+                standalone: true
+                arrow: GettingStartedTip.TopRight
+                text: qsTr("Click here to change which aircraft are shown or hidden based on their ratings")
+            }
         },
 
         // mouse are behind panel to consume clicks
diff --git a/src/GUI/qml/DidMigrateOfficialCatalogNotification.qml b/src/GUI/qml/DidMigrateOfficialCatalogNotification.qml
index 09d240174..b0f2a6647 100644
--- a/src/GUI/qml/DidMigrateOfficialCatalogNotification.qml
+++ b/src/GUI/qml/DidMigrateOfficialCatalogNotification.qml
@@ -12,7 +12,7 @@ Text {
 
     wrapMode: Text.WordWrap
     font.pixelSize: Style.subHeadingFontPixelSize
-    color: "white"
+    color: Style.themeContrastTextColor
 
     onLinkActivated: {
         _launcher.requestUpdateAllAircraft();
diff --git a/src/GUI/qml/GettingStartedTipDisplay.qml b/src/GUI/qml/GettingStartedTipDisplay.qml
new file mode 100644
index 000000000..a56d0c5d6
--- /dev/null
+++ b/src/GUI/qml/GettingStartedTipDisplay.qml
@@ -0,0 +1,134 @@
+import QtQuick 2.4
+import QtQml 2.4
+import FlightGear 1.0
+import "."
+
+
+Item {
+    id: root
+
+    width: 100
+    height: 100
+
+    property GettingStartedController controller: nil
+
+    property rect contentGeom: controller.contentGeometry
+    property rect tipGeom: controller.tipGeometry
+
+    // pass the tip height into the controller, when it's valid
+    Binding {
+        target: controller
+        property: "activeTipHeight"
+        value: contentBox.height
+    }
+
+    // the visible tip box
+    TipBackgroundBox {
+        id: tipBox
+
+        fill: Style.themeColor
+        borderWidth: 1
+        borderColor: Qt.darker(Style.themeColor)
+
+        x: tipGeom.x
+        y: tipGeom.y
+        width: tipGeom.width
+        height: tipGeom.height
+        arrow: controller.tip.arrow
+
+        Item {
+            id: contentBox
+            x: contentGeom.x
+            y: contentGeom.y
+            width: contentGeom.width
+            height: tipText.height + closeText.height + (Style.margin * 3)
+
+            Text {
+                id: tipText
+
+                font.pixelSize: Style.subHeadingFontPixelSize
+                color: Style.themeContrastTextColor
+
+                anchors {
+                    top: parent.top
+                    left: parent.left
+                    right: parent.right
+                    margins: Style.margin
+                }
+
+                // size this to the text, after wrapping
+                height: implicitHeight
+
+                text: controller.tip.text
+                wrapMode: Text.WordWrap
+            }
+
+            Text {
+                anchors {
+                    margins: Style.margin
+                    left: parent.left
+                    top: tipText.bottom
+                }
+
+                text: "<"
+                color: prevMouseArea.containsMouse ? Style.themeContrastLinkColor : Style.themeContrastTextColor
+                visible: (controller.index > 0)
+                MouseArea {
+                    id: prevMouseArea
+                    anchors.fill: parent
+                    cursorShape: Qt.PointingHandCursor
+                    hoverEnabled: true
+
+                    onClicked:  {
+                        controller.index = controller.index - 1
+                    }
+                }
+            }
+
+            Text {
+                id: closeText
+
+                anchors {
+                    margins: Style.margin
+                    horizontalCenter: parent.horizontalCenter
+                    top: tipText.bottom
+                }
+
+                text: qsTr("Close")
+                color: closeMouseArea.containsMouse ? Style.themeContrastLinkColor : Style.themeContrastTextColor
+                width: implicitWidth
+                horizontalAlignment: Text.AlignHCenter
+
+                MouseArea {
+                    id: closeMouseArea
+                    hoverEnabled: true
+                    anchors.fill: parent
+                    cursorShape: Qt.PointingHandCursor
+                    onClicked: controller.close();
+                }
+            }
+
+            Text {
+                anchors {
+                    margins: Style.margin
+                    right: parent.right
+                    top: tipText.bottom
+                }
+
+                text: ">"
+                color: nextMouseArea.containsMouse ? Style.themeContrastLinkColor : Style.themeContrastTextColor
+                visible: controller.index < (controller.count - 1)
+
+                MouseArea {
+                    id: nextMouseArea
+                    anchors.fill: parent
+                    hoverEnabled: true
+                    onClicked:  {
+                        controller.index = controller.index + 1
+                    }
+                    cursorShape: Qt.PointingHandCursor
+                }
+            }
+        } // of tip content box
+    } // of tip background shape
+}
diff --git a/src/GUI/qml/GettingStartedTipLayer.qml b/src/GUI/qml/GettingStartedTipLayer.qml
new file mode 100644
index 000000000..4aa644c20
--- /dev/null
+++ b/src/GUI/qml/GettingStartedTipLayer.qml
@@ -0,0 +1,45 @@
+import QtQuick 2.4
+import QtQml 2.4
+
+import FlightGear 1.0
+import "."
+
+Item {
+    id: root
+
+    property GettingStartedController controller: ctl
+
+    property alias scopeId: ctl.scopeId
+    property alias active: ctl.active
+
+    function showOneShot(tip) {
+        ctl.showOneShotTip(tip);
+    }
+
+    GettingStartedController {
+        id: ctl
+        visualArea: root
+    }
+
+    // ensure active-ness is updated, if the 'reset all tips' function is used
+    Connections {
+        target: _launcher
+        onDidResetGettingStartedTips: ctl.tipsWereReset();
+    }
+
+    // use a Loader to handle tip display, so we're not creating visual items
+    // for the tip, in the common case that no tip is displayed.
+    Loader {
+        id: load
+        source: (ctl.active && ctl.tipPositionValid) ? "qrc:///qml/GettingStartedTipDisplay.qml" : ""
+        x: ctl.tipPositionInVisualArea.x
+        y: ctl.tipPositionInVisualArea.y
+    }
+
+    // pass the controller into our loaded item
+    Binding {
+        target: load.item
+        property: "controller"
+        value: ctl
+    }
+}
diff --git a/src/GUI/qml/HelpSupport.qml b/src/GUI/qml/HelpSupport.qml
index c37f1db94..a1c97ef10 100644
--- a/src/GUI/qml/HelpSupport.qml
+++ b/src/GUI/qml/HelpSupport.qml
@@ -2,6 +2,7 @@ import QtQuick 2.4
 import QtQuick.Controls 2.2
 
 import FlightGear.Launcher 1.0
+import FlightGear 1.0
 import "."
 
 Item {
@@ -56,6 +57,20 @@ Item {
                 }
             }
 
+            Text {
+                width: parent.width
+                font.pixelSize: Style.baseFontPixelSize * 1.5
+                color: Style.baseTextColor
+                wrapMode: Text.WordWrap
+
+                text: qsTr("<p>For help using this launcher, <a %1>try enabling the getting started hints</a>.</p>\n").arg("href=\"enable-tips\"");
+
+                onLinkActivated: {
+                    // reset tips, so they are shown again
+                   _launcher.resetGettingStartedTips();
+                }
+            }
+
             Text {
                 width: parent.width
                 font.pixelSize: Style.baseFontPixelSize * 1.5
diff --git a/src/GUI/qml/ListHeaderBox.qml b/src/GUI/qml/ListHeaderBox.qml
index 664dbacea..daf315199 100644
--- a/src/GUI/qml/ListHeaderBox.qml
+++ b/src/GUI/qml/ListHeaderBox.qml
@@ -7,6 +7,7 @@ Item {
     height: visible ? contentBox.height + (Style.margin * 2) : 0
     z: 100
 
+    property GettingStartedTipLayer tips
     property alias contents: contentBox.children
 
     Rectangle {
diff --git a/src/GUI/qml/Section.qml b/src/GUI/qml/Section.qml
index 4515b8419..e6ccd7173 100644
--- a/src/GUI/qml/Section.qml
+++ b/src/GUI/qml/Section.qml
@@ -1,6 +1,7 @@
 import QtQuick 2.4
 import "."
 import FlightGear.Launcher 1.0
+import FlightGear 1.0
 
 Item {
     id: root
@@ -11,7 +12,6 @@ Item {
     property string summary: ""
     readonly property bool haveAdvancedSettings: anyAdvancedSettings(contents)
 
-
     implicitWidth: parent.width
     implicitHeight: headerRect.height + contentBox.height + (Style.margin * 2)
 
@@ -89,6 +89,18 @@ Item {
             anchors.verticalCenter: parent.verticalCenter
             height: parent.height
             visible: root.haveAdvancedSettings
+
+            GettingStartedTip {
+                tipId: "expandSectionTip"
+                enabled: root.haveAdvancedSettings
+
+                anchors {
+                    horizontalCenter: parent.horizontalCenter
+                    top: parent.bottom
+                }
+                arrow: GettingStartedTip.TopRight
+                text: qsTr("Click here to show advanced settings in this section")
+            }
         }
     }
 
diff --git a/src/GUI/qml/Settings.qml b/src/GUI/qml/Settings.qml
index decce99f3..13a3b41be 100644
--- a/src/GUI/qml/Settings.qml
+++ b/src/GUI/qml/Settings.qml
@@ -2,6 +2,7 @@ import QtQuick 2.4
 import QtQuick.Controls 2.2
 
 import FlightGear.Launcher 1.0
+import FlightGear 1.0
 import "."
 
 Item {
@@ -38,6 +39,8 @@ Item {
             id: sectionColumn
             width: parent.width
 
+            GettingStartedScope.controller: tips.controller
+
             Item {
                 // top margin
                 width: parent.width
@@ -67,6 +70,18 @@ Item {
                     onSearch: {
                         _launcher.settingsSearchTerm = term
                     }
+
+                    GettingStartedTip {
+                        tipId: "searchSettingsTip"
+                        nextTip: "expandSectionTip"
+
+                        anchors {
+                            horizontalCenter: parent.horizontalCenter
+                            top: parent.bottom
+                        }
+                        arrow: GettingStartedTip.TopRight
+                        text: qsTr("Enter text here to search for a setting")
+                    }
                 }
             }
 
@@ -490,5 +505,11 @@ Item {
                 ]
             }
         } // of Column
+
+        GettingStartedTipLayer {
+            id: tips
+            anchors.fill: parent
+            scopeId: "settings"
+        }
     } // of Flickable
 }
diff --git a/src/GUI/qml/Summary.qml b/src/GUI/qml/Summary.qml
index 10f97f5bb..3a313fb88 100644
--- a/src/GUI/qml/Summary.qml
+++ b/src/GUI/qml/Summary.qml
@@ -1,6 +1,8 @@
 import QtQuick 2.4
 import QtQml 2.4
 import FlightGear.Launcher 1.0
+import FlightGear 1.0
+
 import "."
 
 Item {
@@ -10,6 +12,8 @@ Item {
     signal showSelectedLocation();
     signal showFlightPlan();
 
+    GettingStartedScope.controller: tips.controller
+
     Rectangle {
         anchors.fill: parent
         color: "#7f7f7f"
@@ -166,6 +170,18 @@ Item {
                 onSelected: {
                     _launcher.selectedAircraft = _launcher.aircraftHistory.uriAt(index)
                 }
+
+                GettingStartedTip {
+                    tipId: "aircraftHistoryTip"
+                    nextTip: "locationHistoryTip"
+                    arrow: GettingStartedTip.BottomRight
+
+                    anchors {
+                        horizontalCenter: parent.horizontalCenter
+                        bottom: parent.top
+                    }
+                    text: qsTr("Click here to select a recently used aircraft.")
+                }
             }
 
             // empty space in next row (thumbnail, long aircraft description)
@@ -240,6 +256,27 @@ Item {
                             _launcher.selectedAircraftState = model.tagForState(index);
                         }
                     }
+
+                    GettingStartedTip {
+                        id: aircraftStateTip
+                        tipId: "aircraftStateTip"
+                        arrow: GettingStartedTip.LeftCenter
+                        enabled: stateSelectionGroup.visible
+                        standalone: true
+
+                        // show as a one-shot tip, first time we're enabled
+                        onEnabledChanged: {
+                            if (enabled) {
+                                tips.showOneShot(this)
+                            }
+                        }
+
+                        x: parent.implicitWidth
+                        anchors {
+                            verticalCenter: parent.verticalCenter
+                        }
+                        text: qsTr("Use this menu to choose the starting state of the aircraft")
+                    }
                 }
 
                 StyledText {
@@ -282,6 +319,17 @@ Item {
                 font.pixelSize: Style.headingFontPixelSize
                 width: summaryGrid.middleColumnWidth
                 onClicked: root.showSelectedLocation()
+
+                GettingStartedTip {
+                    tipId: "currentLocationTextTip"
+                    anchors {
+                        top: parent.bottom
+                        left: parent.left
+                        leftMargin: Style.strutSize
+                    }
+                    arrow: GettingStartedTip.TopLeft
+                    text: qsTr("Click this description to view and change the current location.")
+                }
             }
 
             HistoryPopup {
@@ -291,6 +339,16 @@ Item {
                 onSelected: {
                     _launcher.restoreLocation(_launcher.locationHistory.locationAt(index))
                 }
+
+                GettingStartedTip {
+                    tipId: "locationHistoryTip"
+                    anchors {
+                        horizontalCenter: parent.horizontalCenter
+                        bottom: parent.top
+                    }
+                    arrow: GettingStartedTip.BottomRight
+                    text: qsTr("Click here to access recently used locations")
+                }
             }
 
             // flight plan summary row
@@ -338,4 +396,10 @@ Item {
             }
         }
     } // of summary box
+
+    GettingStartedTipLayer {
+        id: tips
+        anchors.fill: parent
+        scopeId: "summary"
+    }
 }
diff --git a/src/GUI/resources.qrc b/src/GUI/resources.qrc
index 052229e73..2aeda27af 100644
--- a/src/GUI/resources.qrc
+++ b/src/GUI/resources.qrc
@@ -146,6 +146,8 @@
         <file>qml/DownloadsInDocumentsWarning.qml</file>
         <file>qml/BackButton.qml</file>
         <file>qml/ScrollToBottomHint.qml</file>
+        <file>qml/GettingStartedTipLayer.qml</file>
+        <file>qml/GettingStartedTipDisplay.qml</file>
     </qresource>
     <qresource prefix="/preview">
         <file alias="close-icon">assets/preview-close.png</file>