Line data Source code
1 : /*
2 : * SPDX-License-Identifier: GPL-3.0-only
3 : * MuseScore-CLA-applies
4 : *
5 : * MuseScore Studio
6 : * Music Composition & Notation
7 : *
8 : * Copyright (C) 2025 MuseScore Limited and others
9 : *
10 : * This program is free software: you can redistribute it and/or modify
11 : * it under the terms of the GNU General Public License version 3 as
12 : * published by the Free Software Foundation.
13 : *
14 : * This program is distributed in the hope that it will be useful,
15 : * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 : * GNU General Public License for more details.
18 : *
19 : * You should have received a copy of the GNU General Public License
20 : * along with this program. If not, see <https://www.gnu.org/licenses/>.
21 : */
22 :
23 : #include "initialletternavigation.h"
24 :
25 : #include <QRegularExpression>
26 :
27 : #include "global/log.h"
28 :
29 : #include "navigationpanel.h"
30 :
31 : using namespace muse::ui;
32 :
33 : static QSet<Qt::Key> NAVIGATION_KEYS = {
34 : Qt::Key_Enter,
35 : Qt::Key_Return,
36 : Qt::Key_Up,
37 : Qt::Key_Down,
38 : Qt::Key_Left,
39 : Qt::Key_Right,
40 : Qt::Key_Tab,
41 : Qt::Key_PageUp,
42 : Qt::Key_PageDown,
43 : };
44 :
45 0 : static QString normalizeForSearch(const QString& string)
46 : {
47 0 : return string.normalized(QString::NormalizationForm_KD).toLower();
48 : }
49 :
50 0 : InitialLetterNavigation::InitialLetterNavigation(QObject* parent)
51 0 : : QObject(parent), muse::Contextable(muse::iocCtxForQmlObject(this))
52 : {
53 0 : m_inputBufferTimer.setSingleShot(true);
54 0 : m_inputBufferTimer.setInterval(1000);
55 :
56 0 : QObject::connect(&m_inputBufferTimer, &QTimer::timeout, this, [this]() {
57 0 : clearBuffer();
58 0 : });
59 :
60 0 : qApp->installEventFilter(this);
61 0 : }
62 :
63 0 : QStringList InitialLetterNavigation::stringList() const
64 : {
65 0 : return m_stringList;
66 : }
67 :
68 0 : void InitialLetterNavigation::setStringList(const QStringList& stringList)
69 : {
70 0 : if (stringList == m_stringList) {
71 0 : return;
72 : }
73 0 : m_stringList = stringList;
74 0 : emit stringListChanged();
75 : }
76 :
77 0 : NavigationPanel* InitialLetterNavigation::panel() const
78 : {
79 0 : return m_panel;
80 : }
81 :
82 0 : void InitialLetterNavigation::setPanel(NavigationPanel* panel)
83 : {
84 0 : if (panel == m_panel) {
85 0 : return;
86 : }
87 0 : m_panel = panel;
88 0 : emit panelChanged();
89 : }
90 :
91 0 : int InitialLetterNavigation::controlColumn() const
92 : {
93 0 : return m_controlColumn;
94 : }
95 :
96 0 : void InitialLetterNavigation::setControlColumn(int controlColumn)
97 : {
98 0 : if (controlColumn == m_controlColumn) {
99 0 : return;
100 : }
101 0 : m_controlColumn = controlColumn;
102 0 : emit controlColumnChanged();
103 : }
104 :
105 0 : bool InitialLetterNavigation::eventFilter(QObject* watched, QEvent* event)
106 : {
107 0 : if (!event || event->type() != QEvent::ShortcutOverride) {
108 0 : return QObject::eventFilter(watched, event);
109 : }
110 :
111 0 : const QKeyEvent* keyEvent = static_cast<QKeyEvent*>(event);
112 :
113 0 : const Qt::Key key = static_cast<Qt::Key>(keyEvent->key());
114 0 : const bool eventKeyIsSpace = key == Qt::Key::Key_Space;
115 0 : const bool isNavKey = NAVIGATION_KEYS.contains(key);
116 0 : if (isNavKey || (eventKeyIsSpace && m_inputString.isEmpty())) {
117 : // Ignore all navigation keys apart from space (unless space is the first thing we type)...
118 0 : clearBuffer();
119 0 : return QObject::eventFilter(watched, event);
120 : }
121 :
122 0 : const QString eventText = normalizeForSearch(keyEvent->text());
123 :
124 0 : static const QRegularExpression controlCharsExceptSpace("[\\x00-\\x1F\\x7F]");
125 0 : if (eventText.isEmpty() || eventText.contains(controlCharsExceptSpace)) {
126 : // Ignore text containing ASCII control characters...
127 0 : clearBuffer();
128 0 : return QObject::eventFilter(watched, event);
129 : }
130 :
131 0 : event->accept();
132 0 : m_inputBufferTimer.start();
133 :
134 : // If the last input was the same as this one we "cycle" the entries starting at the active control...
135 0 : if (!m_prevEventText.isEmpty() && eventText != m_prevEventText) {
136 0 : m_cycling = false; // Keep it `true` on the first keypress.
137 : }
138 0 : m_inputString.append(eventText);
139 0 : m_prevEventText = eventText;
140 :
141 0 : const int indexOfMatch = indexForInput(eventText);
142 0 : if (indexOfMatch < 0) {
143 0 : return !eventKeyIsSpace; // Ignore space if it's the last character typed and there are no matches...
144 : }
145 :
146 : //! NOTE: This has to happen before navigateToIndex in some cases - the view might be
147 : //! deleting navigation controls when they're not visible...
148 0 : emit requestVisible(indexOfMatch);
149 :
150 0 : const INavigation::Index navIndex = { m_controlColumn, indexOfMatch };
151 0 : navigateToIndex(navIndex);
152 :
153 0 : return true;
154 0 : }
155 :
156 0 : int InitialLetterNavigation::indexForInput(const QString& eventText) const
157 : {
158 0 : const INavigationControl* control = navigationController()->activeControl();
159 0 : const int activeIndex = control ? control->index().row : 0;
160 0 : const int startIndex = activeIndex + (m_cycling ? 1 : 0);
161 :
162 0 : const auto indexHasMatch = [this, eventText](int index) -> bool {
163 0 : const QString& itemString = normalizeForSearch(m_stringList.at(index));
164 0 : const QString& searchString = m_cycling ? eventText : m_inputString;
165 0 : return itemString.startsWith(searchString);
166 0 : };
167 :
168 0 : for (int i = startIndex; i < m_stringList.size(); ++i) {
169 0 : if (indexHasMatch(i)) {
170 0 : return i;
171 : }
172 : }
173 :
174 0 : for (int i = 0; i < startIndex; ++i) {
175 0 : if (indexHasMatch(i)) {
176 0 : return i;
177 : }
178 : }
179 :
180 0 : return -1;
181 0 : }
182 :
183 0 : void InitialLetterNavigation::navigateToIndex(const INavigation::Index& index)
184 : {
185 0 : const INavigationSection* section = m_panel ? m_panel->section() : nullptr;
186 0 : IF_ASSERT_FAILED(section) {
187 0 : return;
188 : }
189 0 : const std::string sectionName = section->name().toStdString();
190 0 : const std::string panelName = m_panel->name().toStdString();
191 0 : const bool ok = navigationController()->requestActivateByIndex(sectionName, panelName, index);
192 0 : IF_ASSERT_FAILED(ok) {
193 0 : LOGE() << "Index not found in this panel/section - make sure nav control indices match m_stringList indices";
194 0 : return;
195 : }
196 :
197 0 : if (!navigationController()->isHighlight()) {
198 0 : navigationController()->setIsHighlight(true);
199 : }
200 0 : }
201 :
202 0 : void InitialLetterNavigation::clearBuffer()
203 : {
204 0 : m_cycling = true;
205 0 : m_inputString.clear();
206 0 : m_prevEventText.clear();
207 0 : }
|