diff --git a/src/mumble/CMakeLists.txt b/src/mumble/CMakeLists.txt index ed82e3964b5..0a08b140ef1 100644 --- a/src/mumble/CMakeLists.txt +++ b/src/mumble/CMakeLists.txt @@ -309,6 +309,8 @@ set(MUMBLE_SOURCES "widgets/SearchDialogTree.h" "widgets/SemanticSlider.cpp" "widgets/SemanticSlider.h" + "widgets/TrayIcon.cpp" + "widgets/TrayIcon.h" "${SHARED_SOURCE_DIR}/ACL.cpp" diff --git a/src/mumble/Global.cpp b/src/mumble/Global.cpp index 6e0a54c039f..4cc5ead6457 100644 --- a/src/mumble/Global.cpp +++ b/src/mumble/Global.cpp @@ -93,6 +93,7 @@ void Global::migrateDataDir(const QDir &toDir) { Global::Global(const QString &qsConfigPath) { mw = nullptr; + trayIcon = nullptr; db = nullptr; pluginManager = nullptr; nam = nullptr; diff --git a/src/mumble/Global.h b/src/mumble/Global.h index c129d26e214..3f624e67fa4 100644 --- a/src/mumble/Global.h +++ b/src/mumble/Global.h @@ -34,6 +34,7 @@ class OverlayClient; class LogEmitter; class DeveloperConsole; class TalkingUI; +class TrayIcon; class QNetworkAccessManager; @@ -50,6 +51,7 @@ struct Global Q_DECL_FINAL { static Global &get(); MainWindow *mw; + TrayIcon *trayIcon; Settings s; boost::shared_ptr< ServerHandler > sh; boost::shared_ptr< AudioInput > ai; diff --git a/src/mumble/Log.cpp b/src/mumble/Log.cpp index 621747a6164..ee82bd8411d 100644 --- a/src/mumble/Log.cpp +++ b/src/mumble/Log.cpp @@ -809,7 +809,26 @@ void Log::log(MsgType mt, const QString &console, const QString &terse, bool own // Message notification with balloon tooltips if (flags & Settings::LogBalloon) { // Replace any instances of a "Object Replacement Character" from QTextDocumentFragment::toPlainText - // FIXME + plain = plain.replace("\xEF\xBF\xBC", tr("[embedded content]")); + + QSystemTrayIcon::MessageIcon msgIcon; + switch (mt) { + case DebugInfo: + case CriticalError: + msgIcon = QSystemTrayIcon::Critical; + break; + case Warning: + msgIcon = QSystemTrayIcon::Warning; + break; + case TextMessage: + case PrivateTextMessage: + msgIcon = QSystemTrayIcon::NoIcon; + break; + default: + msgIcon = QSystemTrayIcon::Information; + break; + } + Global::get().trayIcon->showMessage(msgName(mt), plain, msgIcon); } } diff --git a/src/mumble/LookConfig.cpp b/src/mumble/LookConfig.cpp index f54cc96eb7f..13a025ebbde 100644 --- a/src/mumble/LookConfig.cpp +++ b/src/mumble/LookConfig.cpp @@ -12,6 +12,7 @@ #include "SearchDialog.h" #include "Global.h" +#include #include #include #include @@ -27,6 +28,15 @@ static ConfigRegistrar registrar(1100, LookConfigNew); LookConfig::LookConfig(Settings &st) : ConfigWidget(st) { setupUi(this); + if (!QSystemTrayIcon::isSystemTrayAvailable()) { + qgbTray->hide(); + } + +#ifdef Q_OS_MAC + // Qt can not hide the window via the native macOS hide function. This should be re-evaluated with new Qt versions. + qcbHideTray->hide(); +#endif + qcbLanguage->addItem(tr("System default")); QDir d(QLatin1String(":"), QLatin1String("mumble_*.qm"), QDir::Name, QDir::Files); foreach (const QString &key, d.entryList()) { diff --git a/src/mumble/MainWindow.cpp b/src/mumble/MainWindow.cpp index bb54772bba0..4dd3be55dce 100644 --- a/src/mumble/MainWindow.cpp +++ b/src/mumble/MainWindow.cpp @@ -82,6 +82,7 @@ #include #include "widgets/SemanticSlider.h" +#include "widgets/TrayIcon.h" #ifdef Q_OS_WIN # include @@ -194,6 +195,10 @@ MainWindow::MainWindow(QWidget *p) QObject::connect(this, &MainWindow::serverSynchronized, Global::get().pluginManager, &PluginManager::on_serverSynchronized); + // Set up initial client side talking state without the need for the user to do anything. + // This will, for example, make sure the correct status tray icon is used on connect. + QObject::connect(this, &MainWindow::serverSynchronized, this, &MainWindow::userStateChanged); + QAccessible::installFactory(AccessibleSlider::semanticSliderFactory); } @@ -609,7 +614,6 @@ void MainWindow::closeEvent(QCloseEvent *e) { const bool minimizeDueToConnected = sh && sh->isRunning() && quitBehavior == QuitBehavior::MINIMIZE_WHEN_CONNECTED; if (!forceQuit && (alwaysAsk || askDueToConnected)) { -#ifndef Q_OS_MAC QMessageBox mb(QMessageBox::Warning, QLatin1String("Mumble"), tr("Are you sure you want to close Mumble? Perhaps you prefer to minimize it instead?"), QMessageBox::NoButton, this); @@ -622,7 +626,7 @@ void MainWindow::closeEvent(QCloseEvent *e) { mb.setCheckBox(qcbRemember); mb.exec(); if (mb.clickedButton() == qpbMinimize) { - showMinimized(); + setWindowState(windowState() | Qt::WindowMinimized); e->ignore(); // If checkbox is checked and not connected, always minimize @@ -643,9 +647,8 @@ void MainWindow::closeEvent(QCloseEvent *e) { if (qcbRemember->isChecked()) { Global::get().s.quitBehavior = QuitBehavior::ALWAYS_QUIT; } -#endif } else if (!forceQuit && (alwaysMinimize || minimizeDueToConnected)) { - showMinimized(); + setWindowState(windowState() | Qt::WindowMinimized); e->ignore(); return; } @@ -701,6 +704,20 @@ void MainWindow::showEvent(QShowEvent *e) { } void MainWindow::changeEvent(QEvent *e) { + // Hide in tray when minimized + if (Global::get().s.bHideInTray && e->type() == QEvent::WindowStateChange) { + // This code block is not triggered on (X)Wayland due to a Qt bug we can do nothing about (QTBUG-74310) + QWindowStateChangeEvent *windowStateEvent = static_cast< QWindowStateChangeEvent * >(e); + if (windowStateEvent) { + bool wasMinimizedState = (windowStateEvent->oldState() & Qt::WindowMinimized); + bool isMinimizedState = (windowState() & Qt::WindowMinimized); + if (!wasMinimizedState && isMinimizedState) { + Global::get().trayIcon->on_hideAction_triggered(); + } + return; + } + } + QWidget::changeEvent(e); } @@ -1432,9 +1449,6 @@ void MainWindow::setupView(bool toggle_minimize) { qaTransmitModeSeparator->setVisible(false); } - show(); - activateWindow(); - // If activated show the PTT window if (Global::get().s.bShowPTTButtonWindow && Global::get().s.atTransmit == Settings::PushToTalk) { if (qwPTTButtonWidget) { @@ -2532,6 +2546,8 @@ void MainWindow::updateMenuPermissions() { } void MainWindow::userStateChanged() { + emit talkingStatusChanged(); + ClientUser *user = ClientUser::get(Global::get().uiSession); if (!user) { Global::get().bAttenuateOthers = false; @@ -2602,6 +2618,7 @@ void MainWindow::on_qaAudioMute_triggered() { } updateAudioToolTips(); + emit talkingStatusChanged(); } void MainWindow::setAudioMute(bool mute) { @@ -2646,6 +2663,7 @@ void MainWindow::on_qaAudioDeaf_triggered() { } updateAudioToolTips(); + emit talkingStatusChanged(); } void MainWindow::setAudioDeaf(bool deaf) { @@ -2758,6 +2776,7 @@ void MainWindow::pttReleased() { void MainWindow::on_PushToMute_triggered(bool down, QVariant) { Global::get().bPushToMute = down; updateUserModel(); + emit talkingStatusChanged(); } void MainWindow::on_VolumeUp_triggered(bool down, QVariant) { @@ -3038,7 +3057,9 @@ void MainWindow::on_gsCycleTransmitMode_triggered(bool down, QVariant) { } void MainWindow::on_gsToggleMainWindowVisibility_triggered(bool down, QVariant) { - // FIXME + if (down) { + Global::get().trayIcon->toggleShowHide(); + } } void MainWindow::on_gsListenChannel_triggered(bool down, QVariant scdata) { @@ -3572,6 +3593,8 @@ void MainWindow::serverDisconnected(QAbstractSocket::SocketError err, QString re if (Global::get().s.bMinimalView) { qdwMinimalViewNote->show(); } + + emit disconnectedFromServer(); } void MainWindow::resolverError(QAbstractSocket::SocketError, QString reason) { @@ -3590,13 +3613,13 @@ void MainWindow::resolverError(QAbstractSocket::SocketError, QString reason) { } void MainWindow::showRaiseWindow() { - if (isMinimized()) { - setWindowState((windowState() & ~Qt::WindowMinimized) | Qt::WindowActive); - } - - show(); - raise(); - activateWindow(); + setWindowState(windowState() & ~Qt::WindowMinimized); + QTimer::singleShot(0, [this]() { + show(); + raise(); + activateWindow(); + setWindowState(windowState() | Qt::WindowActive); + }); } void MainWindow::on_qaTalkingUIToggle_triggered() { @@ -3972,8 +3995,10 @@ void MainWindow::openConfigDialog() { if (dlg->exec() == QDialog::Accepted) { setupView(false); + showRaiseWindow(); updateTransmitModeComboBox(Global::get().s.atTransmit); updateUserModel(); + emit talkingStatusChanged(); if (Global::get().s.requireRestartToApply) { if (Global::get().s.requireRestartToApply diff --git a/src/mumble/MainWindow.h b/src/mumble/MainWindow.h index 9c6c3d5ccaf..ec9f7406c35 100644 --- a/src/mumble/MainWindow.h +++ b/src/mumble/MainWindow.h @@ -390,6 +390,11 @@ public slots: void userRemovedChannelListener(ClientUser *user, Channel *channel); void transmissionModeChanged(Settings::AudioTransmit newMode); + /// Signal emitted when the local user changes their talking status either actively or passively + void talkingStatusChanged(); + /// Signal emitted when the connection was terminated and all cleanup code has been run + void disconnectedFromServer(); + public: MainWindow(QWidget *parent); ~MainWindow() Q_DECL_OVERRIDE; diff --git a/src/mumble/main.cpp b/src/mumble/main.cpp index 204b9c4a077..90cde18fdba 100644 --- a/src/mumble/main.cpp +++ b/src/mumble/main.cpp @@ -51,6 +51,8 @@ #include "VersionCheck.h" #include "Global.h" +#include "widgets/TrayIcon.h" + #include #include #include @@ -685,6 +687,8 @@ int main(int argc, char **argv) { Global::get().mw = new MainWindow(nullptr); Global::get().mw->show(); + Global::get().trayIcon = new TrayIcon(); + Global::get().talkingUI = new TalkingUI(); // Set TalkingUI's position diff --git a/src/mumble/widgets/TrayIcon.cpp b/src/mumble/widgets/TrayIcon.cpp new file mode 100644 index 00000000000..a7a57ba2cf6 --- /dev/null +++ b/src/mumble/widgets/TrayIcon.cpp @@ -0,0 +1,164 @@ +// Copyright 2024 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#include "TrayIcon.h" + +#include "../ClientUser.h" +#include "../MainWindow.h" +#include "../Global.h" + +#include + +TrayIcon::TrayIcon() : QSystemTrayIcon(Global::get().mw), m_statusIcon(Global::get().mw->qiIcon) { + setIcon(m_statusIcon); + + setToolTip("Mumble"); + + assert(Global::get().mw); + + QObject::connect(Global::get().mw, &MainWindow::talkingStatusChanged, this, &TrayIcon::on_icon_update); + QObject::connect(Global::get().mw, &MainWindow::disconnectedFromServer, this, &TrayIcon::on_icon_update); + + QObject::connect(this, &QSystemTrayIcon::activated, this, &TrayIcon::on_icon_clicked); + + // messageClicked is buggy in Qt on some platforms and we can not do anything about this (QTBUG-87329) + QObject::connect(this, &QSystemTrayIcon::messageClicked, this, &TrayIcon::on_showAction_triggered); + + m_showAction = new QAction(tr("Show"), Global::get().mw); + QObject::connect(m_showAction, &QAction::triggered, this, &TrayIcon::on_showAction_triggered); + + m_hideAction = new QAction(tr("Hide"), Global::get().mw); + QObject::connect(m_hideAction, &QAction::triggered, this, &TrayIcon::on_hideAction_triggered); + + QObject::connect(Global::get().mw->qaTalkingUIToggle, &QAction::triggered, this, &TrayIcon::updateContextMenu); + + m_contextMenu = new QMenu(Global::get().mw); + QObject::connect(m_contextMenu, &QMenu::aboutToShow, this, &TrayIcon::updateContextMenu); + + // Some window managers hate it when a tray icon sets an empty context menu... + updateContextMenu(); + + setContextMenu(m_contextMenu); + + show(); +} + +void TrayIcon::on_icon_update() { + std::reference_wrapper< QIcon > newIcon = Global::get().mw->qiIcon; + + ClientUser *p = ClientUser::get(Global::get().uiSession); + + if (Global::get().s.bDeaf) { + newIcon = Global::get().mw->qiIconDeafSelf; + } else if (p && p->bDeaf) { + newIcon = Global::get().mw->qiIconDeafServer; + } else if (Global::get().s.bMute) { + newIcon = Global::get().mw->qiIconMuteSelf; + } else if (p && p->bMute) { + newIcon = Global::get().mw->qiIconMuteServer; + } else if (p && p->bSuppress) { + newIcon = Global::get().mw->qiIconMuteSuppressed; + } else if (Global::get().s.bStateInTray && Global::get().bPushToMute) { + newIcon = Global::get().mw->qiIconMutePushToMute; + } else if (p && Global::get().s.bStateInTray) { + switch (p->tsState) { + case Settings::Talking: + case Settings::MutedTalking: + newIcon = Global::get().mw->qiTalkingOn; + break; + case Settings::Whispering: + newIcon = Global::get().mw->qiTalkingWhisper; + break; + case Settings::Shouting: + newIcon = Global::get().mw->qiTalkingShout; + break; + case Settings::Passive: + default: + newIcon = Global::get().mw->qiTalkingOff; + break; + } + } + + if (&newIcon.get() != &m_statusIcon.get()) { + m_statusIcon = newIcon; + setIcon(m_statusIcon); + } +} + +void TrayIcon::on_icon_clicked(QSystemTrayIcon::ActivationReason reason) { + switch (reason) { + case QSystemTrayIcon::Trigger: +#ifndef Q_OS_MAC + // macOS is special as it both shows the context menu AND triggers the action. + // We only want at most one of those and since we can not prevent showing + // the menu, we skip the action. + toggleShowHide(); +#endif + break; + case QSystemTrayIcon::Unknown: + case QSystemTrayIcon::Context: + case QSystemTrayIcon::DoubleClick: + case QSystemTrayIcon::MiddleClick: + break; + } +} + +void TrayIcon::updateContextMenu() { + m_contextMenu->clear(); + + if (Global::get().mw->isVisible() && !Global::get().mw->isMinimized()) { + m_hideAction->setEnabled(QSystemTrayIcon::isSystemTrayAvailable()); + m_contextMenu->addAction(m_hideAction); + } else { + m_contextMenu->addAction(m_showAction); + } + + m_contextMenu->addSeparator(); + + m_contextMenu->addAction(Global::get().mw->qaAudioMute); + m_contextMenu->addAction(Global::get().mw->qaAudioDeaf); + m_contextMenu->addAction(Global::get().mw->qaTalkingUIToggle); + m_contextMenu->addSeparator(); + m_contextMenu->addAction(Global::get().mw->qaQuit); +} + +void TrayIcon::toggleShowHide() { + if (Global::get().mw->isVisible() && !Global::get().mw->isMinimized()) { + on_hideAction_triggered(); + } else { + on_showAction_triggered(); + } +} + +void TrayIcon::on_showAction_triggered() { + Global::get().mw->showRaiseWindow(); + updateContextMenu(); +} + +void TrayIcon::on_hideAction_triggered() { + if (!QSystemTrayIcon::isSystemTrayAvailable()) { + // The system reports that no system tray is available. + // If we would hide Mumble now, there would be no way to + // get it back... + return; + } + + if (qApp->activeModalWidget() || qApp->activePopupWidget()) { + // There is one or multiple modal or popup window(s) active, which + // would not be hidden by this call. So we also do not hide + // the MainWindow... + return; + } + +#ifndef Q_OS_MAC + Global::get().mw->hide(); +#else + // Qt can not hide the window via the native macOS hide function. This should be re-evaluated with new Qt versions. + // Instead we just minimize. + Global::get().mw->setWindowState(Global::get().mw->windowState() | Qt::WindowMinimized); +#endif + + updateContextMenu(); +} diff --git a/src/mumble/widgets/TrayIcon.h b/src/mumble/widgets/TrayIcon.h new file mode 100644 index 00000000000..5222f8aa87d --- /dev/null +++ b/src/mumble/widgets/TrayIcon.h @@ -0,0 +1,39 @@ +// Copyright 2024 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_MUMBLE_WIDGETS_TRAYICCON_H_ +#define MUMBLE_MUMBLE_WIDGETS_TRAYICCON_H_ + +#include + +#include +#include +#include + +class TrayIcon : public QSystemTrayIcon { + Q_OBJECT + +public: + TrayIcon(); + + void toggleShowHide(); + +public slots: + void on_hideAction_triggered(); + void on_showAction_triggered(); + +private: + std::reference_wrapper< QIcon > m_statusIcon; + QMenu *m_contextMenu = nullptr; + QAction *m_showAction = nullptr; + QAction *m_hideAction = nullptr; + + void updateContextMenu(); + +private slots: + void on_icon_clicked(QSystemTrayIcon::ActivationReason reason); +}; + +#endif // MUMBLE_MUMBLE_WIDGETS_TRAYICCON_H_