Add: UI tour to complement the Mudlet tutorial (#9385)

<!-- Keep the title short & concise so anyone non-technical can
understand it,
     the title appears in PTB changelogs -->
#### Brief overview of PR changes/additions
Added a UI tour of the main Mudlet features to complement the tutorial
we already have that explains how MUDs work.
#### Motivation for adding to Mudlet
#2823, helps first-time players.
#### Other info (issues closed, discussion etc)


https://github.com/user-attachments/assets/a65d10fd-3fd2-4dae-85f2-2a83cf85fb1f

---------

Signed-off-by: Vadim Peretokin <vperetokin@hey.com>
This commit is contained in:
Vadim Peretokin 2026-07-12 20:59:07 +02:00 committed by GitHub
parent b59d5a90b7
commit 69d7d41694
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 577 additions and 0 deletions

View file

@ -206,6 +206,7 @@ set(mudlet_SRCS
TToolBar.cpp
TTreeWidget.cpp
TTrigger.cpp
TUiTour.cpp
TVar.cpp
VarUnit.cpp
WideComboBox.cpp
@ -435,6 +436,7 @@ set(mudlet_HDRS
TToolBar.h
TTreeWidget.h
TTrigger.h
TUiTour.h
TVar.h
utils.h
VarUnit.h

422
src/TUiTour.cpp Normal file
View file

@ -0,0 +1,422 @@
/***************************************************************************
* Copyright (C) 2026 by Vadim Peretokin - vperetokin@hey.com *
* *
* This program is free software; you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation; either version 2 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program; if not, write to the *
* Free Software Foundation, Inc., *
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
***************************************************************************/
#include "TUiTour.h"
#include "Host.h"
#include "TCommandLine.h"
#include "TMainConsole.h"
#include "mudlet.h"
#include <QFrame>
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QLabel>
#include <QMenu>
#include <QMenuBar>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QPushButton>
#include <QSettings>
#include <QTextDocumentFragment>
#include <QToolBar>
#include <QVBoxLayout>
namespace {
constexpr int spotlightMargin = 6;
constexpr int spotlightRadius = 8;
constexpr int cardMaxWidth = 420;
constexpr int cardScreenMargin = 12;
constexpr int cardSpotlightGap = 16;
const QLatin1String settingsKeyTourShown("uiTourShown");
}
TUiTour::TUiTour(mudlet* pMainWindow)
: QWidget(pMainWindow)
, mpMainWindow(pMainWindow)
{
setObjectName(qsl("uiTour"));
setAttribute(Qt::WA_DeleteOnClose);
setFocusPolicy(Qt::StrongFocus);
//: Name of the interface tour overlay, announced by screen readers
setAccessibleName(tr("Mudlet interface tour"));
parentWidget()->installEventFilter(this);
createCard();
buildSteps();
}
// The tour is meant to run automatically only once ever, and only for players
// new to Mudlet - it can always be revisited via Help menu
bool TUiTour::shouldShowOnFirstProfile()
{
if (mudlet::self()->experiencedMudletPlayer()) {
return false;
}
return !mudlet::getQSettings()->value(settingsKeyTourShown, false).toBool();
}
void TUiTour::rememberShown()
{
auto* settings = mudlet::getQSettings();
settings->setValue(settingsKeyTourShown, true);
settings->sync();
}
void TUiTour::start()
{
resizeToParent();
show();
raise();
setFocus();
setStep(0, 1);
}
void TUiTour::createCard()
{
mpCard = new QFrame(this);
mpCard->setObjectName(qsl("uiTourCard"));
mpCard->setAutoFillBackground(true);
mpCard->setFrameShape(QFrame::StyledPanel);
mpCard->setStyleSheet(qsl("QFrame#uiTourCard {"
" background-color: palette(window);"
" border: 1px solid palette(mid);"
" border-radius: 8px;"
"}"));
mpTitleLabel = new QLabel(mpCard);
mpTitleLabel->setWordWrap(true);
QFont titleFont = mpTitleLabel->font();
titleFont.setBold(true);
titleFont.setPointSize(titleFont.pointSize() + 2);
mpTitleLabel->setFont(titleFont);
mpBodyLabel = new QLabel(mpCard);
mpBodyLabel->setWordWrap(true);
mpBodyLabel->setTextFormat(Qt::RichText);
mpProgressLabel = new QLabel(mpCard);
//: Button on the interface tour that dismisses the tour
mpSkipButton = new QPushButton(tr("Skip tour"), mpCard);
mpSkipButton->setFlat(true);
//: Button on the interface tour that goes back to the previous step
mpBackButton = new QPushButton(tr("Back"), mpCard);
//: Button on the interface tour that advances to the next step
mpNextButton = new QPushButton(tr("Next"), mpCard);
mpNextButton->setDefault(true);
connect(mpSkipButton, &QPushButton::clicked, this, &TUiTour::slot_finish);
connect(mpBackButton, &QPushButton::clicked, this, &TUiTour::slot_back);
connect(mpNextButton, &QPushButton::clicked, this, &TUiTour::slot_next);
auto* buttonLayout = new QHBoxLayout();
buttonLayout->addWidget(mpProgressLabel);
buttonLayout->addStretch();
buttonLayout->addWidget(mpSkipButton);
buttonLayout->addWidget(mpBackButton);
buttonLayout->addWidget(mpNextButton);
auto* cardLayout = new QVBoxLayout(mpCard);
cardLayout->setContentsMargins(16, 16, 16, 12);
cardLayout->setSpacing(8);
cardLayout->addWidget(mpTitleLabel);
cardLayout->addWidget(mpBodyLabel);
cardLayout->addLayout(buttonLayout);
}
void TUiTour::buildSteps()
{
auto activeConsole = []() -> TMainConsole* {
Host* pHost = mudlet::self()->getActiveHost();
return pHost ? pHost->mpConsole.data() : nullptr;
};
auto widgetRect = [this](QWidget* widget) -> QRect {
if (!widget || !widget->isVisible()) {
return {};
}
return {widget->mapTo(mpMainWindow, QPoint(0, 0)), widget->size()};
};
auto toolbarButtonRect = [this, widgetRect](const QString& name) -> QRect {
QToolBar* toolbar = mpMainWindow->mpMainToolBar;
if (!toolbar || !toolbar->isVisible()) {
return {};
}
return widgetRect(toolbar->findChild<QWidget*>(name));
};
auto menuTitleRect = [this](QMenu* menu) -> QRect {
QMenuBar* menuBar = mpMainWindow->menuBar();
if (!menu || !menuBar || !menuBar->isVisible()) {
return {};
}
const QRect titleRect = menuBar->actionGeometry(menu->menuAction());
if (titleRect.isEmpty()) {
return {};
}
return {menuBar->mapTo(mpMainWindow, titleRect.topLeft()), titleRect.size()};
};
mSteps.clear();
mSteps.push_back({nullptr,
//: Title of the first step of the interface tour
tr("Welcome to Mudlet!"),
//: Body of the first step of the interface tour
tr("New here? This quick tour points out the most important parts of Mudlet - it takes less than a minute. "
"Click anywhere or use the arrow keys to move through it.")});
mSteps.push_back({[activeConsole, widgetRect]() -> QRect {
TMainConsole* console = activeConsole();
return console ? widgetRect(console->mpMainDisplay) : QRect();
},
//: Title of the interface tour step highlighting the main text display
tr("The game window"),
//: Body of the interface tour step highlighting the main text display
tr("Text from the game appears here. Scroll up to review earlier text - the newest text stays visible in a split view while you do.")});
mSteps.push_back({[activeConsole, widgetRect]() -> QRect {
TMainConsole* console = activeConsole();
return console ? widgetRect(console->mpCommandLine.data()) : QRect();
},
//: Title of the interface tour step highlighting the command input line
tr("The input line"),
//: Body of the interface tour step highlighting the command input line
tr("Type game commands here and press Enter to send them. Use the up and down arrow keys to bring back commands you typed before.")});
// The toolbar is hidden by default, so these two steps point at the
// equivalent menu when its button isn't on screen
mSteps.push_back({[this, toolbarButtonRect, menuTitleRect]() -> QRect {
const QRect buttonRect = toolbarButtonRect(qsl("triggers_action"));
return buttonRect.isEmpty() ? menuTitleRect(mpMainWindow->menuEditor) : buttonRect;
},
//: Title of the interface tour step highlighting the scripting tools
tr("Automate your game"),
//: Body of the interface tour step highlighting the scripting tools
tr("Triggers, aliases, timers and scripts let Mudlet react to the game for you and shorten what you type. "
"You will find them in the script editor, right here - start simple, no programming needed.")});
mSteps.push_back({[this, toolbarButtonRect, menuTitleRect]() -> QRect {
const QRect buttonRect = toolbarButtonRect(qsl("settings_action"));
return buttonRect.isEmpty() ? menuTitleRect(mpMainWindow->menuOptions) : buttonRect;
},
//: Title of the interface tour step highlighting the preferences
tr("Make Mudlet yours"),
//: Body of the interface tour step highlighting the preferences
tr("Fonts, colors, the map, accessibility options and much more can be adjusted in the settings, right here.")});
mSteps.push_back({nullptr,
//: Title of the last step of the interface tour
tr("That's it - have fun!"),
//: Body of the last step of the interface tour. The tour can be re-run via the named menu entry.
tr("For a hands-on lesson, connect to the <b>Mudlet Tutorial</b> game. "
"And if you ever want to see this tour again, it lives in Help → Take a UI tour.")});
}
void TUiTour::setStep(int index, int direction)
{
const int stepCount = static_cast<int>(mSteps.size());
// A step's target can be off screen right now, e.g. a toolbar button
// tucked away in the overflow area
while (index >= 0 && index < stepCount) {
const TourStep& step = mSteps.at(index);
if (!step.spotlightResolver || !step.spotlightResolver().isEmpty()) {
break;
}
index += direction;
}
if (index < 0) {
index = 0;
}
if (index >= stepCount) {
slot_finish();
return;
}
mCurrentStep = index;
updateCard();
update();
}
void TUiTour::updateCard()
{
const TourStep& step = mSteps.at(mCurrentStep);
mpTitleLabel->setText(step.title);
mpBodyLabel->setText(step.body);
//: Progress through the interface tour, %1 is the current step number, %2 the total number of steps
mpProgressLabel->setText(tr("%1 of %2").arg(QString::number(mCurrentStep + 1), QString::number(mSteps.size())));
const bool lastStep = mCurrentStep == static_cast<int>(mSteps.size()) - 1;
mpBackButton->setEnabled(mCurrentStep > 0);
mpSkipButton->setVisible(!lastStep);
//: Button on the last step of the interface tour that closes it. The other label option is "Next".
mpNextButton->setText(lastStep ? tr("Finish") : tr("Next"));
positionCard();
mudlet::self()->announce(qsl("%1. %2").arg(step.title, QTextDocumentFragment::fromHtml(step.body).toPlainText()));
}
QRect TUiTour::spotlightRect() const
{
const TourStep& step = mSteps.at(mCurrentStep);
if (!step.spotlightResolver) {
return {};
}
const QRect targetRect = step.spotlightResolver();
if (targetRect.isEmpty()) {
return {};
}
return targetRect.adjusted(-spotlightMargin, -spotlightMargin, spotlightMargin, spotlightMargin).intersected(rect());
}
void TUiTour::positionCard()
{
mpCard->setFixedWidth(qMin(cardMaxWidth, width() - 2 * cardScreenMargin));
mpCard->adjustSize();
const QRect within = rect().adjusted(cardScreenMargin, cardScreenMargin, -cardScreenMargin, -cardScreenMargin);
const QRect spot = spotlightRect();
QRect cardRect = mpCard->rect();
if (spot.isEmpty()) {
cardRect.moveCenter(rect().center());
} else {
// Clamped so a spotlight near a window corner still gets its card
// alongside rather than falling back to the window center
const int clampedX = qBound(within.left(), spot.center().x() - cardRect.width() / 2, qMax(within.left(), within.right() - cardRect.width()));
const int clampedY = qBound(within.top(), spot.center().y() - cardRect.height() / 2, qMax(within.top(), within.bottom() - cardRect.height()));
QRect below = cardRect;
below.moveLeft(clampedX);
below.moveTop(spot.bottom() + cardSpotlightGap);
QRect above = cardRect;
above.moveLeft(clampedX);
above.moveBottom(spot.top() - cardSpotlightGap);
QRect right = cardRect;
right.moveTop(clampedY);
right.moveLeft(spot.right() + cardSpotlightGap);
QRect left = cardRect;
left.moveTop(clampedY);
left.moveRight(spot.left() - cardSpotlightGap);
// When nothing fits (the spotlight covers most of the window) the
// card floats over the spotlit area
cardRect.moveCenter(rect().center());
for (const QRect& candidate : {below, above, right, left}) {
if (within.contains(candidate)) {
cardRect = candidate;
break;
}
}
}
cardRect.moveLeft(qBound(within.left(), cardRect.left(), qMax(within.left(), within.right() - cardRect.width())));
cardRect.moveTop(qBound(within.top(), cardRect.top(), qMax(within.top(), within.bottom() - cardRect.height())));
mpCard->setGeometry(cardRect);
}
void TUiTour::paintEvent(QPaintEvent* event)
{
Q_UNUSED(event)
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
const QRect spot = spotlightRect();
QPainterPath dimPath;
dimPath.addRect(rect());
if (!spot.isEmpty()) {
QPainterPath cutout;
cutout.addRoundedRect(spot, spotlightRadius, spotlightRadius);
dimPath -= cutout;
}
painter.fillPath(dimPath, QColor(0, 0, 0, 180));
if (!spot.isEmpty()) {
painter.setPen(QPen(palette().color(QPalette::Highlight), 2));
painter.drawRoundedRect(spot, spotlightRadius, spotlightRadius);
}
}
void TUiTour::keyPressEvent(QKeyEvent* event)
{
const bool leftToRight = QGuiApplication::isLeftToRight();
const Qt::Key nextKey = leftToRight ? Qt::Key_Right : Qt::Key_Left;
const Qt::Key backKey = leftToRight ? Qt::Key_Left : Qt::Key_Right;
if (event->key() == Qt::Key_Escape) {
event->accept();
slot_finish();
} else if (event->key() == backKey || event->key() == Qt::Key_PageUp) {
event->accept();
slot_back();
} else if (event->key() == nextKey || event->key() == Qt::Key_Space || event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter || event->key() == Qt::Key_PageDown) {
event->accept();
slot_next();
} else {
QWidget::keyPressEvent(event);
}
}
void TUiTour::mouseReleaseEvent(QMouseEvent* event)
{
event->accept();
slot_next();
}
bool TUiTour::eventFilter(QObject* watched, QEvent* event)
{
if (watched == parent() && (event->type() == QEvent::Resize || event->type() == QEvent::LayoutRequest)) {
resizeToParent();
}
return QWidget::eventFilter(watched, event);
}
void TUiTour::resizeToParent()
{
setGeometry(parentWidget()->rect());
positionCard();
update();
}
void TUiTour::slot_next()
{
if (mCurrentStep >= static_cast<int>(mSteps.size()) - 1) {
slot_finish();
return;
}
setStep(mCurrentStep + 1, 1);
}
void TUiTour::slot_back()
{
if (mCurrentStep > 0) {
setStep(mCurrentStep - 1, -1);
}
}
void TUiTour::slot_finish()
{
emit signal_tourFinished();
close();
}

99
src/TUiTour.h Normal file
View file

@ -0,0 +1,99 @@
#ifndef MUDLET_TUITOUR_H
#define MUDLET_TUITOUR_H
/***************************************************************************
* Copyright (C) 2026 by Vadim Peretokin - vperetokin@hey.com *
* *
* This program is free software; you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation; either version 2 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program; if not, write to the *
* Free Software Foundation, Inc., *
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
***************************************************************************/
#include <QWidget>
#include <functional>
#include <vector>
class QFrame;
class QKeyEvent;
class QLabel;
class QMouseEvent;
class QPaintEvent;
class QPushButton;
class mudlet;
// A one-time spotlight tour over the main window that points out the most
// important parts of the interface to first-time players
class TUiTour : public QWidget
{
Q_OBJECT
public:
explicit TUiTour(mudlet* pMainWindow);
static bool shouldShowOnFirstProfile();
static void rememberShown();
void start();
signals:
// Emitted on user-driven dismissal only, not when the widget goes down
// with the application - handlers touch state that no longer exists then
void signal_tourFinished();
protected:
void paintEvent(QPaintEvent* event) override;
void keyPressEvent(QKeyEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
bool eventFilter(QObject* watched, QEvent* event) override;
private slots:
void slot_next();
void slot_back();
void slot_finish();
private:
struct TourStep
{
// Returns the area to spotlight in main window coordinates, empty when
// the target is currently unavailable; a step without a resolver shows
// a centered card over a fully dimmed window instead
std::function<QRect()> spotlightResolver;
QString title;
QString body;
};
void buildSteps();
void createCard();
void setStep(int index, int direction);
void updateCard();
void positionCard();
QRect spotlightRect() const;
void resizeToParent();
mudlet* mpMainWindow = nullptr;
std::vector<TourStep> mSteps;
int mCurrentStep = 0;
QFrame* mpCard = nullptr;
QLabel* mpTitleLabel = nullptr;
QLabel* mpBodyLabel = nullptr;
QLabel* mpProgressLabel = nullptr;
QPushButton* mpSkipButton = nullptr;
QPushButton* mpBackButton = nullptr;
QPushButton* mpNextButton = nullptr;
};
#endif // MUDLET_TUITOUR_H

Binary file not shown.

View file

@ -42,6 +42,7 @@
#include "TGameDetails.h"
#include "TRoomDB.h"
#include "TTabBar.h"
#include "TUiTour.h"
#include "XMLimport.h"
#include "dlgAboutDialog.h"
#include "dlgConnectionProfiles.h"
@ -581,6 +582,7 @@ void mudlet::init()
connect(dactionNewMapWindow, &QAction::triggered, this, &mudlet::slot_newMapWindow);
connect(dactionHelp, &QAction::triggered, this, &mudlet::slot_showHelpDialog);
connect(dactionUiTour, &QAction::triggered, this, &mudlet::slot_showUiTour);
connect(dactionVideo, &QAction::triggered, this, &mudlet::slot_showHelpDialogVideo);
connect(dactionForum, &QAction::triggered, this, &mudlet::slot_showHelpDialogForum);
connect(dactionDiscord, &QAction::triggered, this, &mudlet::slot_profileDiscord);
@ -3924,6 +3926,31 @@ void mudlet::slot_showHelpDialog()
QDesktopServices::openUrl(QUrl("https://wiki.mudlet.org/w/Manual:Contents"));
}
void mudlet::slot_showUiTour()
{
if (mpUiTour) {
mpUiTour->raise();
mpUiTour->setFocus();
return;
}
mpUiTour = new TUiTour(this);
connect(mpUiTour, &TUiTour::signal_tourFinished, this, &mudlet::slot_uiTourClosed);
TUiTour::rememberShown();
mpUiTour->start();
}
// Covers every way the tour can go away: Finish, Skip and Esc all close it
void mudlet::slot_uiTourClosed()
{
for (auto pHost : mHostManager) {
pHost->getLuaInterpreter()->compileAndExecuteScript(qsl("if mudlet then mudlet.uiTourPending = false end"));
TEvent event{};
event.mArgumentList.append(qsl("sysUiTourFinished"));
event.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
pHost->raiseEvent(event);
}
}
void mudlet::slot_showHelpDialogVideo()
{
QDesktopServices::openUrl(QUrl("https://www.mudlet.org/media/"));
@ -4768,6 +4795,14 @@ void mudlet::slot_connectionDialogueFinished(const QString& profile, bool connec
mudlet::installModulesList(pHost, modules);
}
// Decided before packages install so their scripts can see the flag and
// hold off on their own introductions until the tour is done - the
// tutorial package does this
const bool showUiTour = TUiTour::shouldShowOnFirstProfile();
if (showUiTour) {
pHost->getLuaInterpreter()->compileAndExecuteScript(qsl("mudlet = mudlet or {} mudlet.uiTourPending = true"));
}
// install default packages
for (const auto& package : std::as_const(mPackagesToInstallList)) {
pHost->installPackage(package, enums::PackageModuleType::Package);
@ -4828,6 +4863,12 @@ void mudlet::slot_connectionDialogueFinished(const QString& profile, bool connec
pHost->raiseEvent(event);
pHost->mIsProfileLoadingSequence = false;
emit signal_profileLoaded();
if (showUiTour) {
// give the freshly opened profile a moment to finish laying out before
// the tour starts highlighting parts of it
QTimer::singleShot(1000, this, &mudlet::slot_showUiTour);
}
}
void mudlet::installModulesList(Host* pHost, QStringList modules)

View file

@ -98,6 +98,7 @@ class TScrollBox;
class TTabBar;
class TTimer;
class TToolBar;
class TUiTour;
class mudlet : public QMainWindow, public Ui::main_window
{
@ -486,6 +487,8 @@ public slots:
void slot_showKeyDialog();
void slot_showPreferencesDialog();
void slot_showScriptDialog();
void slot_showUiTour();
void slot_uiTourClosed();
static void restoreProfileFocus(const QString& profileName);
static void setupEditorFocusRestoration(dlgTriggerEditor* pEditor, const QString& profileName, QWidget* targetWindow = nullptr);
void setupNotepadFocusRestoration(dlgNotepad* pNotepad);
@ -706,6 +709,7 @@ private:
qreal mBlinkTimeMs = 0.0;
int mBlinkClientCount = 0;
QPointer<QToolBar> mpToolBarReplay;
QPointer<TUiTour> mpUiTour;
QWidget* mpWidget_profileContainer = nullptr;
// read-only value to see if the interface is light or dark. To set the value,
// use setAppearance instead

View file

@ -123,6 +123,7 @@
<string>Help</string>
</property>
<addaction name="dactionHelp"/>
<addaction name="dactionUiTour"/>
<addaction name="dactionVideo"/>
<addaction name="dactionDiscord"/>
<addaction name="dactionMudletDiscord"/>
@ -276,6 +277,14 @@
<enum>QAction::MenuRole::AboutRole</enum>
</property>
</action>
<action name="dactionUiTour">
<property name="text">
<string>Take a UI tour</string>
</property>
<property name="toolTip">
<string>&lt;p&gt;Shows a short interactive tour of the most important parts of Mudlet's interface.&lt;/p&gt;</string>
</property>
</action>
<action name="dactionVideo">
<property name="text">
<string>Video tutorials</string>