1 | /* |
2 | * SPDX-FileCopyrightText: 2017 Marco Martin <mart@kde.org> |
3 | * |
4 | * SPDX-License-Identifier: LGPL-2.0-or-later |
5 | */ |
6 | |
7 | #include "mnemonicattached.h" |
8 | #include <QDebug> |
9 | #include <QGuiApplication> |
10 | #include <QQuickItem> |
11 | #include <QQuickRenderControl> |
12 | #include <QQuickWindow> |
13 | #include <QRegularExpression> |
14 | #include <QWindow> |
15 | |
16 | QHash<QKeySequence, MnemonicAttached *> MnemonicAttached::s_sequenceToObject = QHash<QKeySequence, MnemonicAttached *>(); |
17 | |
18 | // If pos points to alphanumeric X in "...(X)...", which is preceded or |
19 | // followed only by non-alphanumerics, then "(X)" gets removed. |
20 | static QString removeReducedCJKAccMark(const QString &label, int pos) |
21 | { |
22 | /* clang-format off */ |
23 | if (pos > 0 && pos + 1 < label.length() |
24 | && label[pos - 1] == QLatin1Char('(') |
25 | && label[pos + 1] == QLatin1Char(')') |
26 | && label[pos].isLetterOrNumber()) { /* clang-format on */ |
27 | // Check if at start or end, ignoring non-alphanumerics. |
28 | int len = label.length(); |
29 | int p1 = pos - 2; |
30 | while (p1 >= 0 && !label[p1].isLetterOrNumber()) { |
31 | --p1; |
32 | } |
33 | ++p1; |
34 | int p2 = pos + 2; |
35 | while (p2 < len && !label[p2].isLetterOrNumber()) { |
36 | ++p2; |
37 | } |
38 | --p2; |
39 | |
40 | const QStringView strView(label); |
41 | if (p1 == 0) { |
42 | return strView.left(n: pos - 1) + strView.mid(pos: p2 + 1); |
43 | } else if (p2 + 1 == len) { |
44 | return strView.left(n: p1) + strView.mid(pos: pos + 2); |
45 | } |
46 | } |
47 | return label; |
48 | } |
49 | |
50 | static QString removeAcceleratorMarker(const QString &label_) |
51 | { |
52 | QString label = label_; |
53 | |
54 | int p = 0; |
55 | bool accmarkRemoved = false; |
56 | while (true) { |
57 | p = label.indexOf(c: QLatin1Char('&'), from: p); |
58 | if (p < 0 || p + 1 == label.length()) { |
59 | break; |
60 | } |
61 | |
62 | if (label.at(i: p + 1).isLetterOrNumber()) { |
63 | // Valid accelerator. |
64 | const QStringView sv(label); |
65 | label = sv.left(n: p) + sv.mid(pos: p + 1); |
66 | |
67 | // May have been an accelerator in CJK-style "(&X)" |
68 | // at the start or end of text. |
69 | label = removeReducedCJKAccMark(label, pos: p); |
70 | |
71 | accmarkRemoved = true; |
72 | } else if (label.at(i: p + 1) == QLatin1Char('&')) { |
73 | // Escaped accelerator marker. |
74 | const QStringView sv(label); |
75 | label = sv.left(n: p) + sv.mid(pos: p + 1); |
76 | } |
77 | |
78 | ++p; |
79 | } |
80 | |
81 | // If no marker was removed, and there are CJK characters in the label, |
82 | // also try to remove reduced CJK marker -- something may have removed |
83 | // ampersand beforehand. |
84 | if (!accmarkRemoved) { |
85 | bool hasCJK = false; |
86 | for (const QChar c : std::as_const(t&: label)) { |
87 | if (c.unicode() >= 0x2e00) { // rough, but should be sufficient |
88 | hasCJK = true; |
89 | break; |
90 | } |
91 | } |
92 | if (hasCJK) { |
93 | p = 0; |
94 | while (true) { |
95 | p = label.indexOf(c: QLatin1Char('('), from: p); |
96 | if (p < 0) { |
97 | break; |
98 | } |
99 | label = removeReducedCJKAccMark(label, pos: p + 1); |
100 | ++p; |
101 | } |
102 | } |
103 | } |
104 | |
105 | return label; |
106 | } |
107 | |
108 | class MnemonicEventFilter : public QObject |
109 | { |
110 | Q_OBJECT |
111 | |
112 | public: |
113 | static MnemonicEventFilter &instance() |
114 | { |
115 | static MnemonicEventFilter s_instance; |
116 | return s_instance; |
117 | } |
118 | |
119 | bool eventFilter(QObject *watched, QEvent *event) override |
120 | { |
121 | Q_UNUSED(watched); |
122 | |
123 | if (event->type() == QEvent::KeyPress) { |
124 | QKeyEvent *ke = static_cast<QKeyEvent *>(event); |
125 | if (ke->key() == Qt::Key_Alt) { |
126 | m_altPressed = true; |
127 | Q_EMIT altPressed(); |
128 | } |
129 | } else if (event->type() == QEvent::KeyRelease) { |
130 | QKeyEvent *ke = static_cast<QKeyEvent *>(event); |
131 | if (ke->key() == Qt::Key_Alt) { |
132 | m_altPressed = false; |
133 | Q_EMIT altReleased(); |
134 | } |
135 | } else if (event->type() == QEvent::ApplicationStateChange) { |
136 | if (m_altPressed) { |
137 | m_altPressed = false; |
138 | Q_EMIT altReleased(); |
139 | } |
140 | } |
141 | |
142 | return false; |
143 | } |
144 | |
145 | Q_SIGNALS: |
146 | void altPressed(); |
147 | void altReleased(); |
148 | |
149 | private: |
150 | MnemonicEventFilter() |
151 | : QObject(nullptr) |
152 | { |
153 | qGuiApp->installEventFilter(filterObj: this); |
154 | } |
155 | |
156 | bool m_altPressed = false; |
157 | }; |
158 | |
159 | MnemonicAttached::MnemonicAttached(QObject *parent) |
160 | : QObject(parent) |
161 | { |
162 | connect(sender: &MnemonicEventFilter::instance(), signal: &MnemonicEventFilter::altPressed, context: this, slot: &MnemonicAttached::onAltPressed); |
163 | connect(sender: &MnemonicEventFilter::instance(), signal: &MnemonicEventFilter::altReleased, context: this, slot: &MnemonicAttached::onAltReleased); |
164 | } |
165 | |
166 | MnemonicAttached::~MnemonicAttached() |
167 | { |
168 | s_sequenceToObject.remove(key: m_sequence); |
169 | } |
170 | |
171 | QWindow *MnemonicAttached::window() const |
172 | { |
173 | if (auto *parentItem = qobject_cast<QQuickItem *>(o: parent())) { |
174 | if (auto *window = parentItem->window()) { |
175 | if (auto *renderWindow = QQuickRenderControl::renderWindowFor(win: window)) { |
176 | return renderWindow; |
177 | } |
178 | |
179 | return window; |
180 | } |
181 | } |
182 | |
183 | return nullptr; |
184 | } |
185 | |
186 | void MnemonicAttached::onAltPressed() |
187 | { |
188 | if (m_active || m_richTextLabel.isEmpty()) { |
189 | return; |
190 | } |
191 | |
192 | auto *win = window(); |
193 | if (!win || !win->isActive()) { |
194 | return; |
195 | } |
196 | |
197 | m_actualRichTextLabel = m_richTextLabel; |
198 | Q_EMIT richTextLabelChanged(); |
199 | m_active = true; |
200 | Q_EMIT activeChanged(); |
201 | } |
202 | |
203 | void MnemonicAttached::onAltReleased() |
204 | { |
205 | if (!m_active || m_richTextLabel.isEmpty()) { |
206 | return; |
207 | } |
208 | |
209 | // Disabling menmonics again is always fine, e.g. on window deactivation, |
210 | // don't check for window is active here. |
211 | |
212 | m_actualRichTextLabel = removeAcceleratorMarker(label_: m_label); |
213 | Q_EMIT richTextLabelChanged(); |
214 | m_active = false; |
215 | Q_EMIT activeChanged(); |
216 | } |
217 | |
218 | // Algorithm adapted from KAccelString |
219 | void MnemonicAttached::calculateWeights() |
220 | { |
221 | m_weights.clear(); |
222 | |
223 | int pos = 0; |
224 | bool start_character = true; |
225 | bool wanted_character = false; |
226 | |
227 | while (pos < m_label.length()) { |
228 | QChar c = m_label[pos]; |
229 | |
230 | // skip non typeable characters |
231 | if (!c.isLetterOrNumber() && c != QLatin1Char('&')) { |
232 | start_character = true; |
233 | ++pos; |
234 | continue; |
235 | } |
236 | |
237 | int weight = 1; |
238 | |
239 | // add special weight to first character |
240 | if (pos == 0) { |
241 | weight += FIRST_CHARACTER_EXTRA_WEIGHT; |
242 | // add weight to word beginnings |
243 | } else if (start_character) { |
244 | weight += WORD_BEGINNING_EXTRA_WEIGHT; |
245 | start_character = false; |
246 | } |
247 | |
248 | // add weight to characters that have an & beforehand |
249 | if (wanted_character) { |
250 | weight += WANTED_ACCEL_EXTRA_WEIGHT; |
251 | wanted_character = false; |
252 | } |
253 | |
254 | // add decreasing weight to left characters |
255 | if (pos < 50) { |
256 | weight += (50 - pos); |
257 | } |
258 | |
259 | // try to preserve the wanted accelerators |
260 | /* clang-format off */ |
261 | if (c == QLatin1Char('&') |
262 | && (pos != m_label.length() - 1 |
263 | && m_label[pos + 1] != QLatin1Char('&') |
264 | && m_label[pos + 1].isLetterOrNumber())) { /* clang-format on */ |
265 | wanted_character = true; |
266 | ++pos; |
267 | continue; |
268 | } |
269 | |
270 | while (m_weights.contains(key: weight)) { |
271 | ++weight; |
272 | } |
273 | |
274 | if (c != QLatin1Char('&')) { |
275 | m_weights[weight] = c; |
276 | } |
277 | |
278 | ++pos; |
279 | } |
280 | |
281 | // update our maximum weight |
282 | if (m_weights.isEmpty()) { |
283 | m_weight = m_baseWeight; |
284 | } else { |
285 | m_weight = m_baseWeight + (std::prev(x: m_weights.cend())).key(); |
286 | } |
287 | } |
288 | |
289 | void MnemonicAttached::updateSequence() |
290 | { |
291 | if (!m_sequence.isEmpty()) { |
292 | s_sequenceToObject.remove(key: m_sequence); |
293 | m_sequence = {}; |
294 | } |
295 | |
296 | calculateWeights(); |
297 | |
298 | // Preserve strings like "One & Two" where & is not an accelerator escape |
299 | const QString text = label().replace(QStringLiteral("& " ), QStringLiteral("&& " )); |
300 | m_actualRichTextLabel = removeAcceleratorMarker(label_: text); |
301 | |
302 | if (!m_enabled) { |
303 | // was the label already completely plain text? try to limit signal emission |
304 | if (m_mnemonicLabel != m_actualRichTextLabel) { |
305 | m_mnemonicLabel = m_actualRichTextLabel; |
306 | Q_EMIT mnemonicLabelChanged(); |
307 | Q_EMIT richTextLabelChanged(); |
308 | } |
309 | return; |
310 | } |
311 | |
312 | m_mnemonicLabel = text; |
313 | m_mnemonicLabel.replace(re: QRegularExpression(QLatin1String("\\&([^\\&])" )), QStringLiteral("\\1" )); |
314 | |
315 | if (!m_weights.isEmpty()) { |
316 | QMap<int, QChar>::const_iterator i = m_weights.constEnd(); |
317 | do { |
318 | --i; |
319 | QChar c = i.value(); |
320 | |
321 | QKeySequence ks(QStringLiteral("Alt+" ) % c); |
322 | MnemonicAttached *otherMa = s_sequenceToObject.value(key: ks); |
323 | Q_ASSERT(otherMa != this); |
324 | if (!otherMa || otherMa->m_weight < m_weight) { |
325 | // the old shortcut is less valuable than the current: remove it |
326 | if (otherMa) { |
327 | s_sequenceToObject.remove(key: otherMa->sequence()); |
328 | otherMa->m_sequence = {}; |
329 | } |
330 | |
331 | s_sequenceToObject[ks] = this; |
332 | m_sequence = ks; |
333 | m_richTextLabel = text; |
334 | m_richTextLabel.replace(re: QRegularExpression(QLatin1String("\\&([^\\&])" )), QStringLiteral("\\1" )); |
335 | m_mnemonicLabel = text; |
336 | const int mnemonicPos = m_mnemonicLabel.indexOf(c); |
337 | |
338 | if (mnemonicPos > -1 && (mnemonicPos == 0 || m_mnemonicLabel[mnemonicPos - 1] != QLatin1Char('&'))) { |
339 | m_mnemonicLabel.replace(i: mnemonicPos, len: 1, QStringLiteral("&" ) % c); |
340 | } |
341 | |
342 | const int richTextPos = m_richTextLabel.indexOf(c); |
343 | if (richTextPos > -1) { |
344 | m_richTextLabel.replace(i: richTextPos, len: 1, after: QLatin1String("<u>" ) % c % QLatin1String("</u>" )); |
345 | } |
346 | |
347 | // remap the sequence of the previous shortcut |
348 | if (otherMa) { |
349 | otherMa->updateSequence(); |
350 | } |
351 | |
352 | break; |
353 | } |
354 | } while (i != m_weights.constBegin()); |
355 | } |
356 | |
357 | if (!m_sequence.isEmpty()) { |
358 | Q_EMIT sequenceChanged(); |
359 | } |
360 | |
361 | Q_EMIT richTextLabelChanged(); |
362 | Q_EMIT mnemonicLabelChanged(); |
363 | } |
364 | |
365 | void MnemonicAttached::setLabel(const QString &text) |
366 | { |
367 | if (m_label == text) { |
368 | return; |
369 | } |
370 | |
371 | m_label = text; |
372 | updateSequence(); |
373 | Q_EMIT labelChanged(); |
374 | } |
375 | |
376 | QString MnemonicAttached::richTextLabel() const |
377 | { |
378 | if (!m_actualRichTextLabel.isEmpty()) { |
379 | return m_actualRichTextLabel; |
380 | } else { |
381 | return removeAcceleratorMarker(label_: m_label); |
382 | } |
383 | } |
384 | |
385 | QString MnemonicAttached::mnemonicLabel() const |
386 | { |
387 | return m_mnemonicLabel; |
388 | } |
389 | |
390 | QString MnemonicAttached::label() const |
391 | { |
392 | return m_label; |
393 | } |
394 | |
395 | void MnemonicAttached::setEnabled(bool enabled) |
396 | { |
397 | if (m_enabled == enabled) { |
398 | return; |
399 | } |
400 | |
401 | m_enabled = enabled; |
402 | updateSequence(); |
403 | Q_EMIT enabledChanged(); |
404 | } |
405 | |
406 | bool MnemonicAttached::enabled() const |
407 | { |
408 | return m_enabled; |
409 | } |
410 | |
411 | void MnemonicAttached::setControlType(MnemonicAttached::ControlType controlType) |
412 | { |
413 | if (m_controlType == controlType) { |
414 | return; |
415 | } |
416 | |
417 | m_controlType = controlType; |
418 | |
419 | switch (controlType) { |
420 | case ActionElement: |
421 | m_baseWeight = ACTION_ELEMENT_WEIGHT; |
422 | break; |
423 | case DialogButton: |
424 | m_baseWeight = DIALOG_BUTTON_EXTRA_WEIGHT; |
425 | break; |
426 | case MenuItem: |
427 | m_baseWeight = MENU_ITEM_WEIGHT; |
428 | break; |
429 | case FormLabel: |
430 | m_baseWeight = FORM_LABEL_WEIGHT; |
431 | break; |
432 | default: |
433 | m_baseWeight = SECONDARY_CONTROL_WEIGHT; |
434 | break; |
435 | } |
436 | // update our maximum weight |
437 | if (m_weights.isEmpty()) { |
438 | m_weight = m_baseWeight; |
439 | } else { |
440 | m_weight = m_baseWeight + (std::prev(x: m_weights.constEnd())).key(); |
441 | } |
442 | Q_EMIT controlTypeChanged(); |
443 | } |
444 | |
445 | MnemonicAttached::ControlType MnemonicAttached::controlType() const |
446 | { |
447 | return m_controlType; |
448 | } |
449 | |
450 | QKeySequence MnemonicAttached::sequence() |
451 | { |
452 | return m_sequence; |
453 | } |
454 | |
455 | bool MnemonicAttached::active() const |
456 | { |
457 | return m_active; |
458 | } |
459 | |
460 | MnemonicAttached *MnemonicAttached::qmlAttachedProperties(QObject *object) |
461 | { |
462 | return new MnemonicAttached(object); |
463 | } |
464 | |
465 | void MnemonicAttached::setActive(bool active) |
466 | { |
467 | // We can't rely on previous value when it's true since it can be |
468 | // caused by Alt key press and we need to remove the event filter |
469 | // additionally. False should be ok as it's a default state. |
470 | if (!m_active && m_active == active) { |
471 | return; |
472 | } |
473 | |
474 | m_active = active; |
475 | |
476 | if (m_active) { |
477 | if (m_actualRichTextLabel != m_richTextLabel) { |
478 | m_actualRichTextLabel = m_richTextLabel; |
479 | Q_EMIT richTextLabelChanged(); |
480 | } |
481 | |
482 | } else { |
483 | m_actualRichTextLabel = removeAcceleratorMarker(label_: m_label); |
484 | Q_EMIT richTextLabelChanged(); |
485 | } |
486 | |
487 | Q_EMIT activeChanged(); |
488 | } |
489 | |
490 | #include "mnemonicattached.moc" |
491 | |