add: keyboard shortcuts to switch between game tabs (#9460)

#### Brief overview of PR changes/additions
- Ctrl+Tab / Ctrl+Shift+Tab cycle through open profile tabs (with
wraparound); Ctrl+1-9 jump straight to a tab. On macOS: physical
Ctrl+Tab and Cmd+1-9.
- All 11 shortcuts are remappable per profile in Preferences >
Shortcuts.
- A one-time dismissible callout points out the new shortcuts when a
second profile opens.
- Replaces the old command-line-only Ctrl+Tab handling, which never
worked on macOS and didn't move the tab bar highlight. The caret-mode
Ctrl+Tab accessibility toggle still takes precedence when selected.

#### Motivation for adding to Mudlet
Switching between open games needed the mouse - the long-standing ask in
#1160 - and macOS had no working way to do it at all (needs the
QShortcut path due to QTBUG-12232, fixed for shortcuts in Qt 6.7.1+).

#### Other info (issues closed, discussion etc)
Closes #1160

Needs a test on macOS before merge (physical Ctrl+Tab / Cmd+1-9 delivery
via Qt 6.8's performKeyEquivalent fix) - this was verified on Linux
only.

**Test case:** Open 3 profiles. Ctrl+Tab cycles forward and wraps,
Ctrl+Shift+Tab cycles backward, Ctrl+2 jumps to the second tab - the tab
bar highlight follows each switch. Remap "Next profile" in Preferences >
Shortcuts and confirm the new key works. With Preferences >
Accessibility caret shortcut set to Ctrl+Tab, Ctrl+Tab toggles caret
mode instead of switching.


https://github.com/user-attachments/assets/2f6a3ab7-1212-4d4a-9ad5-ed94dd0dc9b3
This commit is contained in:
Vadim Peretokin 2026-07-20 22:46:05 +02:00 committed by GitHub
parent 282d774956
commit b649b60f0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 173 additions and 29 deletions

View file

@ -62,6 +62,7 @@
#include <QtConcurrentRun>
#include <QCoreApplication>
#include <QDialog>
#include <QKeyEvent>
#include <QtUiTools>
#include <QNetworkProxy>
#include <QSettings>
@ -4860,6 +4861,26 @@ void Host::setCaretEnabled(bool enabled)
mpConsole->setCaretMode(enabled);
}
// Whether this key press is the one selected in the accessibility preferences
// to toggle caret mode - such a press must reach the caret-toggling key
// handlers instead of being swallowed by an application-wide QShortcut,
// which the user could have remapped onto any of these keys:
bool Host::caretShortcutMatches(const QKeyEvent* ke) const
{
constexpr Qt::KeyboardModifiers allModifiers = Qt::ShiftModifier | Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier | Qt::KeypadModifier | Qt::GroupSwitchModifier;
switch (mCaretShortcut) {
case CaretShortcut::None:
return false;
case CaretShortcut::Tab:
return ke->key() == Qt::Key_Tab && !(ke->modifiers() & Qt::ControlModifier);
case CaretShortcut::CtrlTab:
return ke->key() == Qt::Key_Tab && (ke->modifiers() & Qt::ControlModifier);
case CaretShortcut::F6:
return ke->key() == Qt::Key_F6 && (ke->modifiers() & allModifiers) == Qt::NoModifier;
}
return false;
}
void Host::setFocusOnHostActiveCommandLine()
{
if (mFocusTimerRunning) {

View file

@ -58,6 +58,7 @@
class QDialog;
class QDockWidget;
class QKeyEvent;
class QPushButton;
class QListWidget;
@ -465,6 +466,7 @@ public:
void setEditorShowBidi(const bool);
bool caretEnabled() const;
void setCaretEnabled(bool enabled);
bool caretShortcutMatches(const QKeyEvent*) const;
void setFocusOnHostActiveCommandLine();
void recordActiveCommandLine(TCommandLine*);
void forgetCommandLine(TCommandLine*);

View file

@ -172,6 +172,19 @@ bool TCommandLine::event(QEvent* event)
return QPlainTextEdit::event(event);
}
if (event->type() == QEvent::ShortcutOverride) {
auto* ke = static_cast<QKeyEvent*>(event);
// The accessibility caret-mode shortcut (Tab, Ctrl+Tab or F6) must
// beat any application-wide QShortcut - the profile-switching ones
// use Ctrl+Tab by default and all of them can be remapped onto these
// keys - so claim it here to make it arrive as a KeyPress for the
// caret-toggling code below:
if (mpHost->caretShortcutMatches(ke)) {
ke->accept();
return true;
}
}
const Qt::KeyboardModifiers allModifiers = Qt::ShiftModifier | Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier | Qt::KeypadModifier | Qt::GroupSwitchModifier;
if (event->type() == QEvent::KeyPress) {
auto* ke = dynamic_cast<QKeyEvent*>(event);
@ -250,17 +263,6 @@ bool TCommandLine::event(QEvent* event)
case Qt::Key_Backtab:
// <BACKTAB> is usually internally generated by SHIFT used in
// conjunction with TAB - so ignore just the SHIFT key:
if ((ke->modifiers() & (allModifiers & ~(Qt::ShiftModifier))) == Qt::ControlModifier) {
// Switch to PREVIOUS profile tab when used with <CTRL> (and
// implicit <SHIFT>):
const int currentIndex = mudlet::self()->mpTabBar->currentIndex();
const int count = mudlet::self()->mpTabBar->count();
const int newIndex = (currentIndex - 1 < 0) ? (count - 1) : (currentIndex - 1);
mudlet::self()->slot_tabChanged(newIndex);
ke->accept();
return true;
}
if ((ke->modifiers() & (allModifiers & ~(Qt::ShiftModifier))) == Qt::NoModifier) {
// Process as plain <BACKTAB> - (ignoring implicit <SHIFT>)
handleTabCompletion(false);
@ -271,29 +273,18 @@ bool TCommandLine::event(QEvent* event)
if (keybindingMatched(ke)) {
// Process as a possible key binding if there are ANY modifiers
// other than just the ignored <SHIFT> and the possible <CTRL>:
// other than just the ignored <SHIFT>:
return true;
}
break;
case Qt::Key_Tab:
if ((mpHost->mCaretShortcut == Host::CaretShortcut::Tab && !(ke->modifiers() & Qt::ControlModifier))
|| (mpHost->mCaretShortcut == Host::CaretShortcut::CtrlTab && (ke->modifiers() & Qt::ControlModifier))) {
if (mpHost->caretShortcutMatches(ke)) {
mpHost->setCaretEnabled(true);
ke->accept();
return true;
}
if ((ke->modifiers() & allModifiers) == Qt::ControlModifier) {
// Switch to NEXT profile tab
const int currentIndex = mudlet::self()->mpTabBar->currentIndex();
const int count = mudlet::self()->mpTabBar->count();
const int newIndex = (currentIndex + 1 < count) ? (currentIndex + 1) : 0;
mudlet::self()->slot_tabChanged(newIndex);
ke->accept();
return true;
}
if ((ke->modifiers() & allModifiers) == Qt::NoModifier) {
handleTabCompletion(true);
ke->accept();
@ -301,7 +292,6 @@ bool TCommandLine::event(QEvent* event)
}
// Process as a possible key binding if there are ANY modifiers
// other than just the Ctrl one
// CHECKME: What about system foreground application switching?
if (keybindingMatched(ke)) {
return true;
@ -309,7 +299,7 @@ bool TCommandLine::event(QEvent* event)
break;
case Qt::Key_F6:
if ((mpHost->mCaretShortcut == Host::CaretShortcut::F6) && ((ke->modifiers() & allModifiers) == Qt::NoModifier)) {
if (mpHost->caretShortcutMatches(ke)) {
mpHost->setCaretEnabled(true);
ke->accept();
return true;

View file

@ -3706,6 +3706,23 @@ bool TTextEdit::focusNextPrevChild(bool next)
return QWidget::focusNextPrevChild(next);
}
bool TTextEdit::event(QEvent* event)
{
if (event->type() == QEvent::ShortcutOverride) {
auto* ke = static_cast<QKeyEvent*>(event);
// While caret mode is active its shortcut (Tab, Ctrl+Tab or F6) must
// beat any application-wide QShortcut - the profile-switching ones
// use Ctrl+Tab by default and all of them can be remapped onto these
// keys - so claim it here to make it arrive as a KeyPress for the
// caret-toggling code in keyPressEvent():
if (mpHost && mpHost->caretEnabled() && mpHost->caretShortcutMatches(ke)) {
ke->accept();
return true;
}
}
return QWidget::event(event);
}
void TTextEdit::keyPressEvent(QKeyEvent* event)
{
if (!mpHost || !mpHost->caretEnabled()) {
@ -4003,8 +4020,7 @@ void TTextEdit::keyPressEvent(QKeyEvent* event)
}
break;
case Qt::Key_Tab: {
if ((mpHost->mCaretShortcut == Host::CaretShortcut::Tab && !(event->modifiers() & Qt::ControlModifier))
|| (mpHost->mCaretShortcut == Host::CaretShortcut::CtrlTab && (event->modifiers() & Qt::ControlModifier))) {
if (mpHost->caretShortcutMatches(event)) {
mpHost->setCaretEnabled(false);
break;
}
@ -4038,7 +4054,7 @@ void TTextEdit::keyPressEvent(QKeyEvent* event)
}
case Qt::Key_F6: {
if (mpHost->mCaretShortcut == Host::CaretShortcut::F6) {
if (mpHost->caretShortcutMatches(event)) {
mpHost->setCaretEnabled(false);
}
break;

View file

@ -171,6 +171,7 @@ public slots:
protected:
bool focusNextPrevChild(bool next) override;
bool event(QEvent* event) override;
void keyPressEvent(QKeyEvent* event) override;
void focusOutEvent(QFocusEvent* event) override;

View file

@ -37,6 +37,7 @@
#include "TDetachedWindow.h"
#include "TDockWidget.h"
#include "TEvent.h"
#include "TFeatureCallout.h"
#include "TMap.h"
#include "TMedia.h"
#include "TGameDetails.h"
@ -667,6 +668,13 @@ void mudlet::init()
mKeySequenceToggleReplay = QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_R);
mKeySequenceToggleLogging = QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_L);
mKeySequenceToggleEmergencyStop = QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_S);
// Physical Ctrl+Tab (Qt::META is the Ctrl key on macOS), matching the
// system-wide window-tab cycling convention there. Spelt with Key_Tab and
// not Key_Backtab because QShortcutMap never generates a Shift+Backtab
// candidate for a Shift+Tab press - the Shift is consumed producing the
// Backtab keysym, so only "...+Shift+Tab" matches:
mKeySequenceNextProfile = QKeySequence(Qt::META | Qt::Key_Tab);
mKeySequencePreviousProfile = QKeySequence(Qt::META | Qt::SHIFT | Qt::Key_Tab);
#else
mKeySequenceTriggers = QKeySequence(Qt::ALT | Qt::Key_E);
mKeySequenceShowMap = QKeySequence(Qt::ALT | Qt::Key_M);
@ -685,7 +693,18 @@ void mudlet::init()
mKeySequenceToggleReplay = QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_R);
mKeySequenceToggleLogging = QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_L);
mKeySequenceToggleEmergencyStop = QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_S);
// Spelt with Key_Tab and not Key_Backtab because QShortcutMap never
// generates a Shift+Backtab candidate for a Shift+Tab press - the Shift
// is consumed producing the Backtab keysym, so only "Ctrl+Shift+Tab"
// matches:
mKeySequenceNextProfile = QKeySequence(Qt::CTRL | Qt::Key_Tab);
mKeySequencePreviousProfile = QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab);
#endif
// Qt::CTRL is the Cmd key on macOS, so this gives the Cmd+1..9
// (Safari/Chrome/iTerm2) convention there and Ctrl+1..9 elsewhere:
for (int i = 0; i < 9; ++i) {
mKeySequencesSwitchToProfile[i] = QKeySequence(Qt::CTRL | static_cast<Qt::Key>(Qt::Key_1 + i));
}
connect(this, &mudlet::signal_menuBarVisibilityChanged, this, &mudlet::slot_updateShortcuts);
connect(this, &mudlet::signal_hostCreated, this, &mudlet::slot_assignShortcutsFromProfile);
connect(this, &mudlet::signal_profileActivated, this, &mudlet::slot_assignShortcutsFromProfile);
@ -708,6 +727,12 @@ void mudlet::init()
mpShortcutsManager->registerShortcut(qsl("Toggle Replay"), tr("Toggle Replay"), &mKeySequenceToggleReplay);
mpShortcutsManager->registerShortcut(qsl("Toggle Logging"), tr("Toggle Logging"), &mKeySequenceToggleLogging);
mpShortcutsManager->registerShortcut(qsl("Toggle Emergency Stop"), tr("Toggle Emergency Stop"), &mKeySequenceToggleEmergencyStop);
mpShortcutsManager->registerShortcut(qsl("Next profile"), tr("Next profile"), &mKeySequenceNextProfile);
mpShortcutsManager->registerShortcut(qsl("Previous profile"), tr("Previous profile"), &mKeySequencePreviousProfile);
for (int i = 0; i < 9; ++i) {
//: Name of the keyboard shortcut that switches to the numbered profile tab, %1 is that number (1 to 9)
mpShortcutsManager->registerShortcut(qsl("Switch to profile %1").arg(i + 1), tr("Switch to profile %1").arg(i + 1), &mKeySequencesSwitchToProfile[i]);
}
readLateSettings(*mpSettings);
// The previous line will set an option used in the slot method:
connect(mpMainToolBar, &QToolBar::visibilityChanged, this, &mudlet::slot_handleToolbarVisibilityChanged);
@ -2061,6 +2086,29 @@ void mudlet::reshowRequiredMainConsoles()
}
}
void mudlet::slot_nextProfile()
{
const int count = mpTabBar->count();
if (count > 1) {
mpTabBar->setCurrentIndex((mpTabBar->currentIndex() + 1) % count);
}
}
void mudlet::slot_previousProfile()
{
const int count = mpTabBar->count();
if (count > 1) {
mpTabBar->setCurrentIndex((mpTabBar->currentIndex() + count - 1) % count);
}
}
void mudlet::switchToProfileTab(int index)
{
if (index >= 0 && index < mpTabBar->count()) {
mpTabBar->setCurrentIndex(index);
}
}
// Moved as much as possible to activateProfile()...
void mudlet::slot_tabChanged(int tabID)
{
@ -2094,6 +2142,29 @@ void mudlet::slot_telnetConnectionStateChanged()
updateDetachedWindowTabIndicators();
}
// Renders a key sequence for use inside a sentence. NativeText produces the
// macOS shortcut glyphs (like the tab glyph in "⌃⇥") which many users cannot
// read, so on macOS spell the keys out the way Apple's docs do instead
// ("Control-Tab", "Command-1"):
static QString keySequenceForProse(const QKeySequence& sequence)
{
#if defined(Q_OS_MACOS)
QStringList keys = sequence.toString(QKeySequence::PortableText).split(QLatin1Char('+'));
for (auto& key : keys) {
if (key == qsl("Meta")) {
key = qsl("Control");
} else if (key == qsl("Ctrl")) {
key = qsl("Command");
} else if (key == qsl("Alt")) {
key = qsl("Option");
}
}
return keys.join(QLatin1Char('-'));
#else
return sequence.toString(QKeySequence::NativeText);
#endif
}
void mudlet::addConsoleForNewHost(Host* pH)
{
if (pH->mpConsole) {
@ -2162,6 +2233,20 @@ void mudlet::addConsoleForNewHost(Host* pH)
}
mpTabBar->repaint();
// Tab switching only becomes relevant once a second profile is open; an
// empty sequence means the player cleared the shortcut and already knows
// about the feature:
if (mpTabBar->count() == 2 && !mKeySequenceNextProfile.isEmpty()) {
//: Title of a balloon pointing out the newly added profile tab switching shortcuts
TFeatureCallout::maybeShow(
qsl("profileTabShortcuts"),
mpTabBar,
tr("Switch games with the keyboard"),
//: %1, %2 and %3 are keyboard shortcuts, e.g. Ctrl+Tab, Ctrl+1 and Ctrl+9 (Control-Tab, Command-1 and Command-9 on macOS)
tr("Press %1 to cycle through your open games, or %2 to %3 to jump straight to one. You can change these keys in the preferences.")
.arg(keySequenceForProse(mKeySequenceNextProfile), keySequenceForProse(mKeySequencesSwitchToProfile.front()), keySequenceForProse(mKeySequencesSwitchToProfile.back())));
}
// update the window title for the currently selected profile
updateMainWindowTitle();
@ -3770,6 +3855,25 @@ void mudlet::slot_updateShortcuts()
void mudlet::assignKeySequences()
{
mMenuVisibleState = !(mMenuBarVisibility == enums::visibleNever || (mMenuBarVisibility == enums::visibleOnlyWithoutLoadedProfile && mHostManager.getHostCount()));
// The profile tab switching shortcuts have no menu-action counterparts so
// they are always plain QShortcuts, whatever the menu visibility:
delete mpShortcutNextProfile.data();
mpShortcutNextProfile = new QShortcut(mKeySequenceNextProfile, this);
connect(mpShortcutNextProfile.data(), &QShortcut::activated, this, &mudlet::slot_nextProfile);
delete mpShortcutPreviousProfile.data();
mpShortcutPreviousProfile = new QShortcut(mKeySequencePreviousProfile, this);
connect(mpShortcutPreviousProfile.data(), &QShortcut::activated, this, &mudlet::slot_previousProfile);
for (int i = 0; i < 9; ++i) {
delete mpShortcutsSwitchToProfile[i].data();
mpShortcutsSwitchToProfile[i] = new QShortcut(mKeySequencesSwitchToProfile[i], this);
connect(mpShortcutsSwitchToProfile[i].data(), &QShortcut::activated, this, [this, i]() {
switchToProfileTab(i);
});
}
if (!mMenuVisibleState.value()) {
// The menu is hidden so wire the QKeySequences directly to the slots:

View file

@ -53,6 +53,7 @@
#else
#include <qt6keychain/keychain.h>
#endif
#include <array>
#include <optional>
#include <hunspell/hunspell.hxx>
#include <hunspell/hunspell.h>
@ -198,6 +199,7 @@ public:
void init();
void setupConfig();
void activateProfile(Host*);
void switchToProfileTab(int index);
void takeOwnershipOfInstanceCoordinator(std::unique_ptr<MudletInstanceCoordinator>);
MudletInstanceCoordinator* getInstanceCoordinator();
void addConsoleForNewHost(Host*);
@ -463,6 +465,8 @@ public slots:
void slot_showHelpDialogForum();
void slot_showHelpDialogIrc();
void slot_showHelpDialogVideo();
void slot_nextProfile();
void slot_previousProfile();
void slot_tabChanged(int);
void slot_timerFires();
void slot_toggleFullScreenView();
@ -622,6 +626,9 @@ private:
QKeySequence mKeySequenceToggleReplay;
QKeySequence mKeySequenceToggleLogging;
QKeySequence mKeySequenceToggleEmergencyStop;
QKeySequence mKeySequenceNextProfile;
QKeySequence mKeySequencePreviousProfile;
std::array<QKeySequence, 9> mKeySequencesSwitchToProfile;
bool mIsGoingDown = false;
// Whether multi-view is in effect:
enums::controlsVisibility mMenuBarVisibility = enums::visibleAlways;
@ -704,6 +711,9 @@ private:
QPointer<QShortcut> mpShortcutToggleReplay;
QPointer<QShortcut> mpShortcutToggleLogging;
QPointer<QShortcut> mpShortcutToggleEmergencyStop;
QPointer<QShortcut> mpShortcutNextProfile;
QPointer<QShortcut> mpShortcutPreviousProfile;
std::array<QPointer<QShortcut>, 9> mpShortcutsSwitchToProfile;
QPointer<QTimer> mpTimerReplay;
QPointer<QTimer> mpBlinkTimer;
QElapsedTimer mBlinkElapsedTimer;