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

source code of kirigami/src/primitives/mnemonicattached.cpp