diff --git a/framework/toast/internal/toastprovider.cpp b/framework/toast/internal/toastprovider.cpp
index ba4ca6e876..521567ff87 100644
--- a/framework/toast/internal/toastprovider.cpp
+++ b/framework/toast/internal/toastprovider.cpp
@@ -77,6 +77,22 @@ void ToastProvider::executeAction(int id, ToastActionCode actionCode)
dismissToast(id);
}
+void ToastProvider::pauseToast(int id)
+{
+ auto timerIt = m_progressTimers.find(id);
+ if (timerIt != m_progressTimers.end()) {
+ timerIt->second->stop();
+ }
+}
+
+void ToastProvider::resumeToast(int id)
+{
+ auto timerIt = m_progressTimers.find(id);
+ if (timerIt != m_progressTimers.end() && !timerIt->second->isActive()) {
+ timerIt->second->start();
+ }
+}
+
void ToastProvider::cleanup(int id)
{
auto timerIt = m_progressTimers.find(id);
diff --git a/framework/toast/internal/toastprovider.h b/framework/toast/internal/toastprovider.h
index 6699a77e89..8ae1ca8cc5 100644
--- a/framework/toast/internal/toastprovider.h
+++ b/framework/toast/internal/toastprovider.h
@@ -49,6 +49,9 @@ class ToastProvider : public IToastProvider, public muse::async::Asyncable
void dismissToast(int id) override;
void executeAction(int id, ToastActionCode actionCode) override;
+ void pauseToast(int id) override;
+ void resumeToast(int id) override;
+
private:
void cleanup(int id);
void checkProgress(int id);
diff --git a/framework/toast/itoastprovider.h b/framework/toast/itoastprovider.h
index 60cf953ae9..216864501e 100644
--- a/framework/toast/itoastprovider.h
+++ b/framework/toast/itoastprovider.h
@@ -44,5 +44,8 @@ class IToastProvider : MODULE_GLOBAL_INTERFACE
virtual void dismissToast(int id) = 0;
virtual void executeAction(int id, ToastActionCode actionCode) = 0;
+
+ virtual void pauseToast(int id) = 0;
+ virtual void resumeToast(int id) = 0;
};
}
diff --git a/framework/toast/qml/Muse/Toast/ToastItem.qml b/framework/toast/qml/Muse/Toast/ToastItem.qml
index 1070812fe1..625b75f69b 100644
--- a/framework/toast/qml/Muse/Toast/ToastItem.qml
+++ b/framework/toast/qml/Muse/Toast/ToastItem.qml
@@ -54,6 +54,15 @@ Item {
property int actionButtonMargins: 6
property int actionButtonsSpacing: 8
+ property NavigationSection navigationSection: null
+ property int navigationOrder: -1
+ property int totalCount: 0
+ readonly property bool navigationFocused: toastNavPanel.active
+ readonly property bool shouldPauseTimer: hoverHandler.hovered || root.navigationFocused
+ property alias navigation: bodyNavCtrl
+
+ property string accessibleTitle: ""
+
signal dismissed
signal actionTriggered(string actionStr)
@@ -62,6 +71,41 @@ Item {
implicitHeight: mainContainer.childrenRect.height + root.verticalMargin * 2
+ NavigationPanel {
+ id: toastNavPanel
+
+ name: "Toast" + root.navigationOrder
+ section: root.navigationSection
+ order: root.navigationOrder
+ direction: NavigationPanel.Horizontal
+ enabled: root.enabled && root.visible
+
+ accessible.name: root.accessibleTitle
+ }
+
+ NavigationControl {
+ id: bodyNavCtrl
+
+ name: "ToastBody"
+ panel: toastNavPanel
+ order: 0
+ enabled: root.enabled && root.visible
+
+ accessible.role: MUAccessible.StaticText
+ accessible.name: {
+ var name = root.message.length > 0 ? root.accessibleTitle + ". " + root.message : root.accessibleTitle
+ if (root.totalCount > 0) {
+ name += ". " + qsTrc("toast", "%1 of %2").arg(root.navigationOrder + 1).arg(root.totalCount)
+ }
+ return name
+ }
+ accessible.visualItem: root
+ }
+
+ HoverHandler {
+ id: hoverHandler
+ }
+
StyledRectangularShadow {
anchors.fill: backgroundRect
@@ -76,6 +120,10 @@ Item {
color: ui.theme.popupBackgroundColor
radius: 8
+ NavigationFocusBorder {
+ navigationCtrl: bodyNavCtrl
+ }
+
Rectangle {
id: progressBar
@@ -182,6 +230,11 @@ Item {
height: root.actionButtonHeight
minWidth: root.actionButtonMinWidth
margins: root.actionButtonMargins
+
+ navigation.panel: toastNavPanel
+ navigation.order: 1 + index
+ navigation.name: "ToastAction" + index
+
onClicked: {
root.actionTriggered(root.actions[index].text)
}
@@ -200,6 +253,13 @@ Item {
icon: IconCode.CLOSE_X_ROUNDED
transparent: true
+ toolTipTitle: qsTrc("toast", "Dismiss")
+
+ navigation.panel: toastNavPanel
+ navigation.order: 1 + root.actions.length
+ navigation.name: "ToastDismiss"
+ navigation.accessible.name: dismissButton.toolTipTitle
+
onClicked: {
dismissed()
}
diff --git a/framework/toast/qml/Muse/Toast/ToastProvider.qml b/framework/toast/qml/Muse/Toast/ToastProvider.qml
index 3d382da951..ab7370f99d 100644
--- a/framework/toast/qml/Muse/Toast/ToastProvider.qml
+++ b/framework/toast/qml/Muse/Toast/ToastProvider.qml
@@ -46,6 +46,26 @@ StyledListView {
visible: root.count > 0
+ property int navigationOrder: 0
+
+ property NavigationSection navigationSection: NavigationSection {
+ id: toastNavSec
+ name: "ToastNotifications"
+ enabled: root.enabled && root.visible
+ order: root.navigationOrder
+ }
+
+ QtObject {
+ id: prv
+
+ function restoreFocus() {
+ var newestItem = root.count > 0 ? root.itemAtIndex(root.count - 1) : null
+ if (newestItem) {
+ newestItem.navigation.requestActive()
+ }
+ }
+ }
+
ToastListModel {
id: toastmodel
}
@@ -69,6 +89,7 @@ StyledListView {
opacity: 0
title: model.title
+ accessibleTitle: model.accessibleTitle
iconCode: model.iconCode
message: model.message
actions: model.actions
@@ -78,6 +99,18 @@ StyledListView {
showProgressInfo: model.showProgressInfo
timeElapsed: model.timeElapsed
+ navigationSection: toastNavSec
+ navigationOrder: root.count - 1 - model.index
+ totalCount: root.count
+
+ onShouldPauseTimerChanged: {
+ if (shouldPauseTimer) {
+ toastmodel.pauseToast(model.id)
+ } else {
+ toastmodel.resumeToast(model.id)
+ }
+ }
+
onActionTriggered: function (actionStr) {
toastmodel.executeAction(model.id, actionStr)
}
@@ -85,6 +118,14 @@ StyledListView {
Component.onCompleted: {
x = 0
opacity = 1
+
+ toastNavSec.requestPriority()
+ }
+
+ Component.onDestruction: {
+ if (itemRect.navigationFocused) {
+ Qt.callLater(prv.restoreFocus)
+ }
}
Behavior on x {
diff --git a/framework/toast/qml/Muse/Toast/toastlistmodel.cpp b/framework/toast/qml/Muse/Toast/toastlistmodel.cpp
index f58dde7116..e43ad273d4 100644
--- a/framework/toast/qml/Muse/Toast/toastlistmodel.cpp
+++ b/framework/toast/qml/Muse/Toast/toastlistmodel.cpp
@@ -19,18 +19,38 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-#include
+#include "toastlistmodel.h"
+
+#include
#include
#include
-#include "toastlistmodel.h"
+#include "global/translation.h"
#include "toast/toastitem.h"
using namespace muse::toast;
namespace {
constexpr int MAX_VISIBLE_TOASTS = 5;
+constexpr int MAX_NAVIGATION_HINT_ANNOUNCEMENTS = 2;
+
+QString accessibleTitle(const ToastItem& toast)
+{
+ const QString title = QString::fromStdString(toast.title());
+ switch (toast.iconCode()) {
+ case muse::ui::IconCode::Code::ERROR:
+ return muse::qtrc("toast", "Error") + ": " + title;
+ case muse::ui::IconCode::Code::WARNING:
+ return muse::qtrc("toast", "Warning") + ": " + title;
+ case muse::ui::IconCode::Code::INFO:
+ return muse::qtrc("toast", "Information") + ": " + title;
+ case muse::ui::IconCode::Code::TICK:
+ return muse::qtrc("toast", "Success") + ": " + title;
+ default:
+ return title;
+ }
+}
}
ToastListModel::ToastListModel(QObject* parent)
@@ -49,65 +69,92 @@ void ToastListModel::init()
m_toasts.emplace_back(toast);
endInsertRows();
+ announceToast(*toast);
+
int id = toast->id();
toast->progressChanged().onNotify(this, [this, id](){
- int toastIndex = -1;
- for (int i = 0; i < static_cast(m_toasts.size()); ++i) {
- if (m_toasts.at(i)->id() == id) {
- toastIndex = i;
- break;
- }
- }
-
- if (toastIndex == -1) {
+ const std::optional toastIndex = indexOfToast(id);
+ if (!toastIndex) {
return;
}
- emit dataChanged(index(toastIndex), this->index(toastIndex), { ProgressRole, TimeElapsedRole });
+ emit dataChanged(index(toastIndex.value()), this->index(toastIndex.value()), { ProgressRole, TimeElapsedRole });
});
}, muse::async::Asyncable::Mode::SetReplace);
toastProvider()->toastDismissed().onReceive(this, [this](int id) {
- int index = -1;
- for (int i = 0; i < static_cast(m_toasts.size()); ++i) {
- if (m_toasts.at(i)->id() == id) {
- index = i;
- break;
- }
- }
-
- if (index == -1) {
+ const std::optional toastIndex = indexOfToast(id);
+ if (!toastIndex) {
return;
}
- beginRemoveRows(QModelIndex(), index, index);
- m_toasts.erase(m_toasts.begin() + index);
+ beginRemoveRows(QModelIndex(), toastIndex.value(), toastIndex.value());
+ m_toasts.erase(m_toasts.begin() + toastIndex.value());
endRemoveRows();
}, muse::async::Asyncable::Mode::SetReplace);
}
+void ToastListModel::announceToast(const ToastItem& toast)
+{
+ QString announcement = accessibleTitle(toast);
+
+ if (!toast.message().empty()) {
+ announcement += ": " + QString::fromStdString(toast.message());
+ }
+
+ if (!toast.actions().empty() && m_navigationHintShownCount < MAX_NAVIGATION_HINT_ANNOUNCEMENTS) {
+ announcement += " " + muse::qtrc("toast", "Press F6 to go to the notification.");
+ ++m_navigationHintShownCount;
+ }
+
+ accessibilityController()->announce(announcement);
+}
+
+std::optional ToastListModel::indexOfToast(int id) const
+{
+ const auto it = std::find_if(m_toasts.cbegin(), m_toasts.cend(), [id](const std::shared_ptr& toast) {
+ return toast->id() == id;
+ });
+
+ if (it == m_toasts.cend()) {
+ return std::nullopt;
+ }
+
+ return static_cast(std::distance(m_toasts.cbegin(), it));
+}
+
void ToastListModel::dismissToast(int id)
{
toastProvider()->dismissToast(id);
}
+void ToastListModel::pauseToast(int id)
+{
+ toastProvider()->pauseToast(id);
+}
+
+void ToastListModel::resumeToast(int id)
+{
+ toastProvider()->resumeToast(id);
+}
+
void ToastListModel::executeAction(int id, QString actionStr)
{
- for (int i = 0; i < static_cast(m_toasts.size()); ++i) {
- if (m_toasts.at(i)->id() == id) {
- const auto& actions = m_toasts.at(i)->actions();
- auto it = std::find_if(actions.cbegin(), actions.cend(), [&actionStr](const ToastAction& action) {
- return action.text == actionStr.toStdString();
- });
-
- if (it == actions.cend()) {
- return;
- }
+ const std::optional toastIndex = indexOfToast(id);
+ if (!toastIndex) {
+ return;
+ }
- toastProvider()->executeAction(id, it->code);
- return;
- }
+ const auto& actions = m_toasts.at(toastIndex.value())->actions();
+ auto it = std::find_if(actions.cbegin(), actions.cend(), [&actionStr](const ToastAction& action) {
+ return action.text == actionStr.toStdString();
+ });
+
+ if (it == actions.cend()) {
+ return;
}
+
+ toastProvider()->executeAction(id, it->code);
}
int ToastListModel::rowCount(const QModelIndex& parent) const
@@ -133,6 +180,8 @@ QVariant ToastListModel::data(const QModelIndex& index, int role) const
return static_cast(toast->iconCode());
case TitleRole:
return QString::fromStdString(toast->title());
+ case AccessibleTitleRole:
+ return accessibleTitle(*toast);
case MessageRole:
return QString::fromStdString(toast->message());
case DismissableRole:
@@ -164,6 +213,7 @@ QHash ToastListModel::roleNames() const
{ IdRole, "id" },
{ IconCodeRole, "iconCode" },
{ TitleRole, "title" },
+ { AccessibleTitleRole, "accessibleTitle" },
{ MessageRole, "message" },
{ DismissableRole, "dismissable" },
{ ActionRole, "actions" },
diff --git a/framework/toast/qml/Muse/Toast/toastlistmodel.h b/framework/toast/qml/Muse/Toast/toastlistmodel.h
index 0cb067b657..1e4fe54eca 100644
--- a/framework/toast/qml/Muse/Toast/toastlistmodel.h
+++ b/framework/toast/qml/Muse/Toast/toastlistmodel.h
@@ -21,6 +21,8 @@
*/
#pragma once
+#include
+
#include
#include
#include
@@ -32,6 +34,7 @@
#include "global/modularity/ioc.h"
#include "toast/itoastprovider.h"
+#include "accessibility/iaccessibilitycontroller.h"
namespace muse::toast {
class ToastListModel : public QAbstractListModel, public muse::async::Asyncable, public muse::Contextable
@@ -42,6 +45,8 @@ class ToastListModel : public QAbstractListModel, public muse::async::Asyncable,
muse::GlobalInject toastProvider;
+ muse::ContextInject accessibilityController = { this };
+
public:
explicit ToastListModel(QObject* parent = nullptr);
~ToastListModel() override = default;
@@ -49,6 +54,8 @@ class ToastListModel : public QAbstractListModel, public muse::async::Asyncable,
Q_INVOKABLE void init();
Q_INVOKABLE void dismissToast(int id);
Q_INVOKABLE void executeAction(int id, QString actionStr);
+ Q_INVOKABLE void pauseToast(int id);
+ Q_INVOKABLE void resumeToast(int id);
int rowCount(const QModelIndex& parent = QModelIndex()) const override;
QVariant data(const QModelIndex& index, int role) const override;
@@ -59,6 +66,7 @@ class ToastListModel : public QAbstractListModel, public muse::async::Asyncable,
IdRole = Qt::UserRole + 1,
IconCodeRole,
TitleRole,
+ AccessibleTitleRole,
MessageRole,
ActionRole,
DismissableRole,
@@ -67,6 +75,10 @@ class ToastListModel : public QAbstractListModel, public muse::async::Asyncable,
ShowProgressInfoRole
};
+ void announceToast(const ToastItem& toast);
+ std::optional indexOfToast(int id) const;
+
std::vector > m_toasts;
+ int m_navigationHintShownCount = 0;
};
}
diff --git a/framework/ui/inavigationcontroller.h b/framework/ui/inavigationcontroller.h
index 6bdcd5ec55..6f45953523 100644
--- a/framework/ui/inavigationcontroller.h
+++ b/framework/ui/inavigationcontroller.h
@@ -53,6 +53,7 @@ class INavigationController : MODULE_CONTEXT_INTERFACE
const std::string& controlName) const = 0;
virtual void setDefaultNavigationControl(INavigationControl* control) = 0;
+ virtual void setPrioritySection(INavigationSection* section) = 0;
virtual async::Notification navigationChanged() const = 0;
diff --git a/framework/ui/internal/navigationcontroller.cpp b/framework/ui/internal/navigationcontroller.cpp
index 13f222be88..5e70a80cf6 100644
--- a/framework/ui/internal/navigationcontroller.cpp
+++ b/framework/ui/internal/navigationcontroller.cpp
@@ -23,11 +23,13 @@
#include
#include
+#include
#include
#include
#include
+#include "global/containers.h"
#include "global/defer.h"
#include "../navigationcommands.h"
@@ -357,6 +359,11 @@ void NavigationController::unreg(INavigationSection* section)
TRACEFUNC;
m_sections.erase(section);
section->setOnActiveRequested(nullptr);
+ section->enabledChanged().disconnect(this);
+
+ if (m_prioritySection == section) {
+ m_prioritySection = nullptr;
+ }
}
const std::set& NavigationController::sections() const
@@ -511,6 +518,10 @@ void NavigationController::doActivateSection(INavigationSection* sect, bool isAc
sect->setActive(true);
MYLOG() << "activated section: " << sect->name() << ", order: " << sect->index().order();
+ if (m_prioritySection == sect) {
+ m_prioritySection = nullptr;
+ }
+
INavigationPanel* toActivatePanel = nullptr;
if (isActivateLastPanel) {
toActivatePanel = lastEnabled(sect->panels());
@@ -530,6 +541,8 @@ void NavigationController::doDeactivateSection(INavigationSection* sect)
return;
}
+ saveLastActiveControl(sect);
+
for (INavigationPanel* panel : sect->panels()) {
doDeactivatePanel(panel);
}
@@ -708,6 +721,85 @@ void NavigationController::setDefaultNavigationControl(INavigationControl* contr
m_defaultNavigationControl = control;
}
+void NavigationController::setPrioritySection(INavigationSection* section)
+{
+ if (!section) {
+ m_prioritySection = nullptr;
+ return;
+ }
+
+ if (m_sections.find(section) == m_sections.end()) {
+ LOGW() << "unable to set priority section, section is not registered, name: " << section->name();
+ return;
+ }
+
+ m_prioritySection = section;
+
+ section->enabledChanged().onReceive(this, [this, section](bool enabled) {
+ if (!enabled && section->active()) {
+ restoreLastActiveControl(section);
+ }
+ }, async::Asyncable::Mode::SetReplace);
+}
+
+INavigationSection* NavigationController::takePrioritySection(const INavigationSection* activeSec)
+{
+ INavigationSection* prioritySec = m_prioritySection;
+ m_prioritySection = nullptr;
+
+ if (!prioritySec || prioritySec == activeSec || !prioritySec->enabled()) {
+ return nullptr;
+ }
+
+ return prioritySec;
+}
+
+void NavigationController::saveLastActiveControl(INavigationSection* sect)
+{
+ if (!sect->enabled() || sect == m_prioritySection) {
+ return;
+ }
+
+ INavigationPanel* activePanel = findActive(sect->panels());
+ INavigationControl* ctrl = activePanel ? findActive(activePanel->controls()) : nullptr;
+ if (!ctrl) {
+ return;
+ }
+
+ m_lastActiveControl = ctrl;
+}
+
+INavigationControl* NavigationController::takeLastActiveControl()
+{
+ INavigationControl* ctrl = std::exchange(m_lastActiveControl, nullptr);
+
+ const bool isAvailable = ctrl && std::any_of(m_sections.cbegin(), m_sections.cend(), [ctrl](const INavigationSection* sect) {
+ if (!sect->enabled()) {
+ return false;
+ }
+
+ const std::set& panels = sect->panels();
+ return std::any_of(panels.cbegin(), panels.cend(), [ctrl](const INavigationPanel* panel) {
+ return panel->enabled() && muse::contains(panel->controls(), ctrl);
+ });
+ });
+
+ return isAvailable && ctrl->enabled() ? ctrl : m_defaultNavigationControl;
+}
+
+void NavigationController::restoreLastActiveControl(INavigationSection* sect)
+{
+ INavigationControl* ctrl = takeLastActiveControl();
+
+ doDeactivateSection(sect);
+
+ if (ctrl) {
+ ctrl->requestActive();
+ }
+
+ m_navigationChanged.notify();
+}
+
muse::async::Notification NavigationController::navigationChanged() const
{
return m_navigationChanged;
@@ -722,12 +814,7 @@ void NavigationController::goToNextSection()
}
INavigationSection* activeSec = findActive(m_sections);
- if (!activeSec) { // no any active
- doActivateFirst();
- return;
- }
-
- if (activeSec->type() == INavigationSection::Type::Exclusive) {
+ if (activeSec && activeSec->type() == INavigationSection::Type::Exclusive) {
INavigationPanel* first = firstEnabled(activeSec->panels());
if (first) {
doActivatePanel(first);
@@ -736,6 +823,20 @@ void NavigationController::goToNextSection()
return;
}
+ if (INavigationSection* prioritySec = takePrioritySection(activeSec)) {
+ if (activeSec) {
+ doDeactivateSection(activeSec);
+ }
+ doActivateSection(prioritySec);
+ m_navigationChanged.notify();
+ return;
+ }
+
+ if (!activeSec) { // no any active
+ doActivateFirst();
+ return;
+ }
+
doDeactivateSection(activeSec);
INavigationSection* nextSec = nextEnabled(m_sections, activeSec->index());
@@ -763,12 +864,7 @@ void NavigationController::goToPrevSection(bool isActivateLastPanel)
}
INavigationSection* activeSec = findActive(m_sections);
- if (!activeSec) { // no any active
- doActivateLast();
- return;
- }
-
- if (activeSec->type() == INavigationSection::Type::Exclusive) {
+ if (activeSec && activeSec->type() == INavigationSection::Type::Exclusive) {
INavigationPanel* first = firstEnabled(activeSec->panels());
if (first) {
doActivatePanel(first);
@@ -777,6 +873,20 @@ void NavigationController::goToPrevSection(bool isActivateLastPanel)
return;
}
+ if (INavigationSection* prioritySec = takePrioritySection(activeSec)) {
+ if (activeSec) {
+ doDeactivateSection(activeSec);
+ }
+ doActivateSection(prioritySec);
+ m_navigationChanged.notify();
+ return;
+ }
+
+ if (!activeSec) { // no any active
+ doActivateLast();
+ return;
+ }
+
doDeactivateSection(activeSec);
INavigationSection* prevSec = prevEnabled(m_sections, activeSec->index());
@@ -814,6 +924,7 @@ void NavigationController::goToNextPanel()
return;
}
+ saveLastActiveControl(activeSec);
doDeactivatePanel(activePanel);
INavigationPanel* nextPanel = nextEnabled(activeSec->panels(), activePanel->index());
@@ -861,6 +972,7 @@ void NavigationController::goToPrevPanel()
return;
}
+ saveLastActiveControl(activeSec);
doDeactivatePanel(activePanel);
INavigationPanel* prevPanel = prevEnabled(activeSec->panels(), activePanel->index());
@@ -1384,6 +1496,10 @@ void NavigationController::onActiveRequested(INavigationSection* sect, INavigati
MYLOG() << "activated section: " << sect->name() << ", order: " << sect->index().order();
}
+ if (m_prioritySection == sect) {
+ m_prioritySection = nullptr;
+ }
+
if (!panel) {
panel = firstEnabled(sect->panels());
}
diff --git a/framework/ui/internal/navigationcontroller.h b/framework/ui/internal/navigationcontroller.h
index a640bdcc4e..4db96a96c1 100644
--- a/framework/ui/internal/navigationcontroller.h
+++ b/framework/ui/internal/navigationcontroller.h
@@ -76,6 +76,7 @@ class NavigationController : public QObject, public INavigationController, publi
const std::string& controlName) const override;
void setDefaultNavigationControl(INavigationControl* control) override;
+ void setPrioritySection(INavigationSection* section) override;
void resetNavigation() override;
@@ -144,6 +145,12 @@ class NavigationController : public QObject, public INavigationController, publi
void doActivateFirst();
void doActivateLast();
+ INavigationSection* takePrioritySection(const INavigationSection* activeSec);
+
+ void saveLastActiveControl(INavigationSection* prioritySec);
+ void restoreLastActiveControl(INavigationSection* prioritySec);
+ INavigationControl* takeLastActiveControl();
+
void resetIfNeed(QObject* watched);
std::set m_sections;
@@ -151,6 +158,8 @@ class NavigationController : public QObject, public INavigationController, publi
async::Notification m_highlightChanged;
INavigationControl* m_defaultNavigationControl = nullptr;
+ INavigationSection* m_prioritySection = nullptr;
+ INavigationControl* m_lastActiveControl = nullptr;
bool m_isHighlight = false;
diff --git a/framework/ui/qml/Muse/Ui/navigationsection.cpp b/framework/ui/qml/Muse/Ui/navigationsection.cpp
index 11b7ffbb12..a5fab9185d 100644
--- a/framework/ui/qml/Muse/Ui/navigationsection.cpp
+++ b/framework/ui/qml/Muse/Ui/navigationsection.cpp
@@ -182,6 +182,13 @@ void NavigationSection::requestActive(INavigationPanel* panel, INavigationContro
}
}
+void NavigationSection::requestPriority()
+{
+ if (navigationController()) {
+ navigationController()->setPrioritySection(this);
+ }
+}
+
INavigationSection::Type NavigationSection::type() const
{
return static_cast(m_type);
diff --git a/framework/ui/qml/Muse/Ui/navigationsection.h b/framework/ui/qml/Muse/Ui/navigationsection.h
index f1014f1ddc..9f3932548d 100644
--- a/framework/ui/qml/Muse/Ui/navigationsection.h
+++ b/framework/ui/qml/Muse/Ui/navigationsection.h
@@ -92,6 +92,8 @@ class NavigationSection : public AbstractNavigation, public INavigationSection
Q_INVOKABLE void requestActive(INavigationPanel* panel = nullptr, INavigationControl* control = nullptr, bool enableHighlight = false,
ActivationType activationType = ActivationType::None) override;
+ Q_INVOKABLE void requestPriority();
+
public slots:
void setType(QmlType type);
diff --git a/framework/ui/tests/mocks/navigationmocks.h b/framework/ui/tests/mocks/navigationmocks.h
index 0fbf93f2b0..e5dab5150f 100644
--- a/framework/ui/tests/mocks/navigationmocks.h
+++ b/framework/ui/tests/mocks/navigationmocks.h
@@ -130,6 +130,7 @@ class NavigationControllerMock : public INavigationController
MOCK_METHOD(const INavigationControl*, findControl, (const std::string&, const std::string&, const std::string&), (const, override));
MOCK_METHOD(void, setDefaultNavigationControl, (INavigationControl*), (override));
+ MOCK_METHOD(void, setPrioritySection, (INavigationSection*), (override));
MOCK_METHOD(async::Notification, navigationChanged, (), (const, override));
diff --git a/framework/ui/tests/navigationcontroller_tests.cpp b/framework/ui/tests/navigationcontroller_tests.cpp
index 420986d3b2..12fef4c2e4 100644
--- a/framework/ui/tests/navigationcontroller_tests.cpp
+++ b/framework/ui/tests/navigationcontroller_tests.cpp
@@ -845,3 +845,392 @@ TEST_F(Ui_NavigationControllerTests, UpWrapsToLastOnVerticalPanel)
delete sect;
}
+
+TEST_F(Ui_NavigationControllerTests, NextSectionGoesToPrioritySection)
+{
+ //! CASE A priority section is activated by the next F6, wherever the navigation is
+
+ //! [GIVEN] Three sections, the first one is active
+ Section* sect1 = make_section(1, 2, 3);
+ Section* sect2 = make_section(2, 2, 3);
+ Section* sect3 = make_section(3, 2, 3);
+
+ m_controller->reg(sect1->section);
+ m_controller->reg(sect2->section);
+ m_controller->reg(sect3->section);
+
+ sect1->section->requestActive();
+
+ //! [GIVEN] The third section is set as the priority one
+ m_controller->setPrioritySection(sect3->section);
+
+ //! [WHEN] Send action `nav-next-section`
+ m_dispatcher->dispatch(NEXT_SECTION_COMMAND);
+
+ //! [THEN] The priority section is activated instead of the second one
+ EXPECT_TRUE(sect3->section->active());
+ EXPECT_TRUE(sect3->panels[0]->panel->active());
+ EXPECT_TRUE(sect3->panels[0]->controls[0]->control->active());
+ EXPECT_FALSE(sect1->section->active());
+ EXPECT_FALSE(sect2->section->active());
+
+ //! [WHEN] Send the action again
+ m_dispatcher->dispatch(NEXT_SECTION_COMMAND);
+
+ //! [THEN] The request was one-shot: the cycle continues from the priority section
+ EXPECT_TRUE(sect1->section->active());
+ EXPECT_FALSE(sect3->section->active());
+
+ delete sect1;
+ delete sect2;
+ delete sect3;
+}
+
+TEST_F(Ui_NavigationControllerTests, PrevSectionGoesToPrioritySection)
+{
+ //! CASE A priority section is also activated by the prev section navigation (Shift+F6)
+
+ //! [GIVEN] Three sections, the first one is active
+ Section* sect1 = make_section(1, 2, 3);
+ Section* sect2 = make_section(2, 2, 3);
+ Section* sect3 = make_section(3, 2, 3);
+
+ m_controller->reg(sect1->section);
+ m_controller->reg(sect2->section);
+ m_controller->reg(sect3->section);
+
+ sect2->section->requestActive();
+
+ //! [GIVEN] The third section is set as the priority one
+ m_controller->setPrioritySection(sect3->section);
+
+ //! [WHEN] Send action `nav-prev-section` (usually Shift+F6)
+ m_dispatcher->dispatch(PREV_SECTION_COMMAND);
+
+ //! [THEN] The priority section is activated on its first panel (a jump, not a cycle step)
+ EXPECT_TRUE(sect3->section->active());
+ EXPECT_TRUE(sect3->panels[0]->panel->active());
+ EXPECT_FALSE(sect1->section->active());
+ EXPECT_FALSE(sect2->section->active());
+
+ delete sect1;
+ delete sect2;
+ delete sect3;
+}
+
+TEST_F(Ui_NavigationControllerTests, NextSectionGoesToPrioritySectionWithNoActiveSection)
+{
+ //! CASE Nothing is active and a priority section is set
+
+ //! [GIVEN] Two sections, nothing active
+ Section* sect1 = make_section(1, 2, 3);
+ Section* sect2 = make_section(2, 2, 3);
+
+ m_controller->reg(sect1->section);
+ m_controller->reg(sect2->section);
+
+ //! [GIVEN] The second section is set as the priority one
+ m_controller->setPrioritySection(sect2->section);
+
+ //! [WHEN] Send action `nav-next-section` (usually F6)
+ m_dispatcher->dispatch(NEXT_SECTION_COMMAND);
+
+ //! [THEN] The priority section is activated instead of the first one
+ EXPECT_TRUE(sect2->section->active());
+ EXPECT_FALSE(sect1->section->active());
+
+ delete sect1;
+ delete sect2;
+}
+
+TEST_F(Ui_NavigationControllerTests, NextSectionSkipsDisabledPrioritySection)
+{
+ //! CASE The priority section was disabled before the next section navigation
+
+ //! [GIVEN] Three sections, the first one is active
+ Section* sect1 = make_section(1, 2, 3);
+ Section* sect2 = make_section(2, 2, 3);
+ Section* sect3 = make_section(3, 2, 3);
+
+ m_controller->reg(sect1->section);
+ m_controller->reg(sect2->section);
+ m_controller->reg(sect3->section);
+
+ sect1->section->requestActive();
+
+ //! [GIVEN] The third section is set as the priority one, then disabled
+ m_controller->setPrioritySection(sect3->section);
+ sect3->section->setEnabled(false);
+
+ //! [WHEN] Send action `nav-next-section` (usually F6)
+ m_dispatcher->dispatch(NEXT_SECTION_COMMAND);
+
+ //! [THEN] The usual next section is activated
+ EXPECT_TRUE(sect2->section->active());
+ EXPECT_FALSE(sect3->section->active());
+
+ //! [WHEN] The priority section is enabled again and the action is sent again
+ sect3->section->setEnabled(true);
+ m_dispatcher->dispatch(NEXT_SECTION_COMMAND);
+
+ //! [THEN] The request was cleared: the cycle continues as usual
+ EXPECT_TRUE(sect3->section->active());
+
+ m_dispatcher->dispatch(NEXT_SECTION_COMMAND);
+ EXPECT_TRUE(sect1->section->active());
+
+ delete sect1;
+ delete sect2;
+ delete sect3;
+}
+
+TEST_F(Ui_NavigationControllerTests, NextSectionContinuesFromActivePrioritySection)
+{
+ //! CASE The priority section is already the active one
+
+ //! [GIVEN] Two sections, the second one is active and set as the priority one
+ Section* sect1 = make_section(1, 2, 3);
+ Section* sect2 = make_section(2, 2, 3);
+
+ m_controller->reg(sect1->section);
+ m_controller->reg(sect2->section);
+
+ sect2->section->requestActive();
+ m_controller->setPrioritySection(sect2->section);
+
+ //! [WHEN] Send action `nav-next-section` (usually F6)
+ m_dispatcher->dispatch(NEXT_SECTION_COMMAND);
+
+ //! [THEN] The cycle continues as usual, the navigation does not get stuck
+ EXPECT_TRUE(sect1->section->active());
+ EXPECT_FALSE(sect2->section->active());
+
+ delete sect1;
+ delete sect2;
+}
+
+TEST_F(Ui_NavigationControllerTests, ActivationByOtherMeansClearsPrioritySection)
+{
+ //! CASE The priority section was visited before the next section navigation
+
+ //! [GIVEN] Three sections, the first one is active
+ Section* sect1 = make_section(1, 2, 3);
+ Section* sect2 = make_section(2, 2, 3);
+ Section* sect3 = make_section(3, 2, 3);
+
+ m_controller->reg(sect1->section);
+ m_controller->reg(sect2->section);
+ m_controller->reg(sect3->section);
+
+ sect1->section->requestActive();
+
+ //! [GIVEN] The third section is set as the priority one, then activated directly
+ m_controller->setPrioritySection(sect3->section);
+ sect3->section->requestActive();
+
+ //! [WHEN] Send action `nav-next-section` (usually F6)
+ m_dispatcher->dispatch(NEXT_SECTION_COMMAND);
+
+ //! [THEN] The request was cleared by the visit: the cycle continues as usual
+ EXPECT_TRUE(sect1->section->active());
+ EXPECT_FALSE(sect3->section->active());
+
+ delete sect1;
+ delete sect2;
+ delete sect3;
+}
+
+TEST_F(Ui_NavigationControllerTests, ExclusiveSectionWinsOverPrioritySection)
+{
+ //! CASE An exclusive section is active while a priority section is set
+
+ //! [GIVEN] A regular and an exclusive section, the exclusive one is active
+ Section* sect1 = make_section(1, 2, 3);
+ Section* exclusive = make_section(2, 2, 3);
+ exclusive->section->setType(NavigationSection::QmlType::Exclusive);
+
+ m_controller->reg(sect1->section);
+ m_controller->reg(exclusive->section);
+
+ exclusive->section->requestActive();
+
+ //! [GIVEN] The regular section is set as the priority one
+ m_controller->setPrioritySection(sect1->section);
+
+ //! [WHEN] Send action `nav-next-section` (usually F6)
+ m_dispatcher->dispatch(NEXT_SECTION_COMMAND);
+
+ //! [THEN] The navigation stays trapped in the exclusive section
+ EXPECT_TRUE(exclusive->section->active());
+ EXPECT_FALSE(sect1->section->active());
+
+ //! [WHEN] The exclusive section goes away and the action is sent again
+ m_controller->unreg(exclusive->section);
+ exclusive->section->setActive(false);
+ m_dispatcher->dispatch(NEXT_SECTION_COMMAND);
+
+ //! [THEN] The request stayed armed: the priority section is activated
+ EXPECT_TRUE(sect1->section->active());
+
+ delete sect1;
+ delete exclusive;
+}
+
+TEST_F(Ui_NavigationControllerTests, UnregClearsPrioritySection)
+{
+ //! CASE The priority section was unregistered before the next section navigation
+
+ //! [GIVEN] Two sections, the first one is active
+ Section* sect1 = make_section(1, 2, 3);
+ Section* sect2 = make_section(2, 2, 3);
+
+ m_controller->reg(sect1->section);
+ m_controller->reg(sect2->section);
+
+ sect1->section->requestActive();
+
+ //! [GIVEN] The second section is set as the priority one, then unregistered
+ m_controller->setPrioritySection(sect2->section);
+ m_controller->unreg(sect2->section);
+
+ //! [WHEN] Send action `nav-next-section` (usually F6)
+ m_dispatcher->dispatch(NEXT_SECTION_COMMAND);
+
+ //! [THEN] The cycle wraps to the first section, no dangling access
+ EXPECT_TRUE(sect1->section->active());
+
+ delete sect1;
+ delete sect2;
+}
+
+TEST_F(Ui_NavigationControllerTests, DisablingActiveSectionRestoresLastActiveControl)
+{
+ //! CASE The priority-activated section is disabled while active (e.g. the last toast is dismissed)
+
+ //! [GIVEN] Two sections, the first one is active on its second panel
+ Section* sect1 = make_section(1, 2, 3);
+ Section* sect2 = make_section(2, 2, 3);
+
+ m_controller->reg(sect1->section);
+ m_controller->reg(sect2->section);
+
+ sect1->section->requestActive();
+ m_dispatcher->dispatch(NEXT_PANEL_COMMAND);
+
+ NavigationControl* prevControl = sect1->panels[1]->controls[0]->control;
+ EXPECT_TRUE(prevControl->active());
+
+ //! [GIVEN] The second section is priority-activated (usually F6)
+ m_controller->setPrioritySection(sect2->section);
+ m_dispatcher->dispatch(NEXT_SECTION_COMMAND);
+ EXPECT_TRUE(sect2->section->active());
+
+ //! [WHEN] The active section is disabled
+ sect2->section->setEnabled(false);
+
+ //! [THEN] The navigation goes back to the control that was active before
+ EXPECT_FALSE(sect2->section->active());
+ EXPECT_TRUE(sect1->section->active());
+ EXPECT_TRUE(prevControl->active());
+
+ delete sect1;
+ delete sect2;
+}
+
+TEST_F(Ui_NavigationControllerTests, DisablingSectionEnteredByPanelNavigationRestoresLastActiveControl)
+{
+ //! CASE The section is entered by panel navigation (usually Tab) after its priority request was consumed
+
+ //! [GIVEN] Two sections, the first one is active, the second one once requested priority
+ Section* sect1 = make_section(1, 2, 3);
+ Section* sect2 = make_section(2, 2, 3);
+
+ m_controller->reg(sect1->section);
+ m_controller->reg(sect2->section);
+
+ sect1->section->requestActive();
+ m_controller->setPrioritySection(sect2->section);
+
+ //! [GIVEN] The priority jump was consumed and the navigation is back on the first section
+ m_dispatcher->dispatch(NEXT_SECTION_COMMAND);
+ m_dispatcher->dispatch(NEXT_SECTION_COMMAND);
+ EXPECT_TRUE(sect1->section->active());
+
+ m_dispatcher->dispatch(NEXT_PANEL_COMMAND);
+
+ NavigationControl* prevControl = sect1->panels[1]->controls[0]->control;
+ EXPECT_TRUE(prevControl->active());
+
+ //! [GIVEN] Tab past the last panel enters the second section
+ m_dispatcher->dispatch(NEXT_PANEL_COMMAND);
+ EXPECT_TRUE(sect2->section->active());
+
+ //! [WHEN] The active section is disabled
+ sect2->section->setEnabled(false);
+
+ //! [THEN] The navigation goes back to the control that was active before
+ EXPECT_TRUE(sect1->section->active());
+ EXPECT_TRUE(prevControl->active());
+
+ delete sect1;
+ delete sect2;
+}
+
+TEST_F(Ui_NavigationControllerTests, RestoreFallsBackToDefaultControlWhenLastControlIsGone)
+{
+ //! CASE The control that was active before was destroyed in the meantime
+
+ //! [GIVEN] Two sections, the first one is active, and a default control
+ Section* sect1 = make_section(1, 2, 3);
+ Section* sect2 = make_section(2, 2, 3);
+
+ m_controller->reg(sect1->section);
+ m_controller->reg(sect2->section);
+
+ sect1->section->requestActive();
+
+ NavigationControl* defaultControl = sect1->panels[1]->controls[1]->control;
+ m_controller->setDefaultNavigationControl(defaultControl);
+
+ //! [GIVEN] The second section is priority-activated, then the remembered control is destroyed
+ m_controller->setPrioritySection(sect2->section);
+ m_dispatcher->dispatch(NEXT_SECTION_COMMAND);
+
+ delete sect1->panels[0]->controls[0]->control;
+ sect1->panels[0]->controls[0]->control = nullptr;
+
+ //! [WHEN] The active section is disabled
+ sect2->section->setEnabled(false);
+
+ //! [THEN] The navigation falls back to the default control
+ EXPECT_TRUE(sect1->section->active());
+ EXPECT_TRUE(defaultControl->active());
+
+ delete sect1;
+ delete sect2;
+}
+
+TEST_F(Ui_NavigationControllerTests, DisablingInactiveSectionDoesNotMoveNavigation)
+{
+ //! CASE A section that requested priority is disabled while another one holds the navigation
+
+ //! [GIVEN] Two sections, the first one is active
+ Section* sect1 = make_section(1, 2, 3);
+ Section* sect2 = make_section(2, 2, 3);
+
+ m_controller->reg(sect1->section);
+ m_controller->reg(sect2->section);
+
+ sect1->section->requestActive();
+ m_controller->setPrioritySection(sect2->section);
+
+ //! [WHEN] The inactive section is disabled
+ sect2->section->setEnabled(false);
+
+ //! [THEN] The navigation stays where it is
+ EXPECT_TRUE(sect1->section->active());
+ EXPECT_TRUE(sect1->panels[0]->controls[0]->control->active());
+
+ delete sect1;
+ delete sect2;
+}