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; +}