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
16QHash<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.
20static 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
50static 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
108class MnemonicEventFilter : public QObject
109{
110 Q_OBJECT
111
112public:
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
145Q_SIGNALS:
146 void altPressed();
147 void altReleased();
148
149private:
150 MnemonicEventFilter()
151 : QObject(nullptr)
152 {
153 qGuiApp->installEventFilter(filterObj: this);
154 }
155
156 bool m_altPressed = false;
157};
158
159MnemonicAttached::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
166MnemonicAttached::~MnemonicAttached()
167{
168 s_sequenceToObject.remove(key: m_sequence);
169}
170
171QWindow *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
186void 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
203void 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
219void 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
289void 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
365void 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
376QString MnemonicAttached::richTextLabel() const
377{
378 if (!m_actualRichTextLabel.isEmpty()) {
379 return m_actualRichTextLabel;
380 } else {
381 return removeAcceleratorMarker(label_: m_label);
382 }
383}
384
385QString MnemonicAttached::mnemonicLabel() const
386{
387 return m_mnemonicLabel;
388}
389
390QString MnemonicAttached::label() const
391{
392 return m_label;
393}
394
395void 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
406bool MnemonicAttached::enabled() const
407{
408 return m_enabled;
409}
410
411void 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
445MnemonicAttached::ControlType MnemonicAttached::controlType() const
446{
447 return m_controlType;
448}
449
450QKeySequence MnemonicAttached::sequence()
451{
452 return m_sequence;
453}
454
455bool MnemonicAttached::active() const
456{
457 return m_active;
458}
459
460MnemonicAttached *MnemonicAttached::qmlAttachedProperties(QObject *object)
461{
462 return new MnemonicAttached(object);
463}
464
465void 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

source code of kirigami/src/mnemonicattached.cpp