Kirigami2

mnemonicattached.cpp
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
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(pos - 1) + strView.mid(p2 + 1);
43 } else if (p2 + 1 == len) {
44 return strView.left(p1) + strView.mid(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(QLatin1Char('&'), p);
58 if (p < 0 || p + 1 == label.length()) {
59 break;
60 }
61
62 if (label.at(p + 1).isLetterOrNumber()) {
63 // Valid accelerator.
64 const QStringView sv(label);
65 label = sv.left(p) + sv.mid(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, p);
70
71 accmarkRemoved = true;
72 } else if (label.at(p + 1) == QLatin1Char('&')) {
73 // Escaped accelerator marker.
74 const QStringView sv(label);
75 label = sv.left(p) + sv.mid(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(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(QLatin1Char('('), p);
96 if (p < 0) {
97 break;
98 }
99 label = removeReducedCJKAccMark(label, p + 1);
100 ++p;
101 }
102 }
103 }
104
105 return label;
106}
107
108class MnemonicEventFilter : public QObject
109{
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
146 void altPressed();
147 void altReleased();
148
149private:
150 MnemonicEventFilter()
151 : QObject(nullptr)
152 {
153 qGuiApp->installEventFilter(this);
154 }
155
156 bool m_altPressed = false;
157};
158
159MnemonicAttached::MnemonicAttached(QObject *parent)
160 : QObject(parent)
161{
162 connect(&MnemonicEventFilter::instance(), &MnemonicEventFilter::altPressed, this, &MnemonicAttached::onAltPressed);
163 connect(&MnemonicEventFilter::instance(), &MnemonicEventFilter::altReleased, this, &MnemonicAttached::onAltReleased);
164}
165
166MnemonicAttached::~MnemonicAttached()
167{
168 s_sequenceToObject.remove(m_sequence);
169}
170
171QWindow *MnemonicAttached::window() const
172{
173 if (auto *parentItem = qobject_cast<QQuickItem *>(parent())) {
174 if (auto *window = parentItem->window()) {
175 if (auto *renderWindow = QQuickRenderControl::renderWindowFor(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_enabled || 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 enabled or window is active here.
211
212 m_actualRichTextLabel = removeAcceleratorMarker(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(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(m_weights.cend())).key();
286 }
287}
288
289void MnemonicAttached::updateSequence()
290{
291 const QKeySequence oldSequence = m_sequence;
292
293 if (!m_sequence.isEmpty()) {
294 s_sequenceToObject.remove(m_sequence);
295 m_sequence = {};
296 }
297
298 calculateWeights();
299
300 // Preserve strings like "One & Two" where & is not an accelerator escape
301 const QString text = label().replace(QStringLiteral("& "), QStringLiteral("&& "));
302 m_actualRichTextLabel = removeAcceleratorMarker(text);
303
304 if (!m_enabled) {
305 // was the label already completely plain text? try to limit signal emission
306 if (m_mnemonicLabel != m_actualRichTextLabel) {
307 m_mnemonicLabel = m_actualRichTextLabel;
308 Q_EMIT mnemonicLabelChanged();
309 Q_EMIT richTextLabelChanged();
310 }
311
312 if (m_sequence != oldSequence) {
313 Q_EMIT sequenceChanged();
314 }
315 return;
316 }
317
318 m_mnemonicLabel = text;
319 m_mnemonicLabel.replace(QRegularExpression(QLatin1String("\\&([^\\&])")), QStringLiteral("\\1"));
320
321 if (!m_weights.isEmpty()) {
322 QMap<int, QChar>::const_iterator i = m_weights.constEnd();
323 do {
324 --i;
325 QChar c = i.value();
326
327 QKeySequence ks(QStringLiteral("Alt+") % c);
328 MnemonicAttached *otherMa = s_sequenceToObject.value(ks);
329 Q_ASSERT(otherMa != this);
330 if (!otherMa || otherMa->m_weight < m_weight) {
331 // the old shortcut is less valuable than the current: remove it
332 if (otherMa) {
333 s_sequenceToObject.remove(otherMa->sequence());
334 otherMa->m_sequence = {};
335 }
336
337 s_sequenceToObject[ks] = this;
338 m_sequence = ks;
339 m_richTextLabel = text;
340 m_richTextLabel.replace(QRegularExpression(QLatin1String("\\&([^\\&])")), QStringLiteral("\\1"));
341 m_mnemonicLabel = text;
342 const int mnemonicPos = m_mnemonicLabel.indexOf(c);
343
344 if (mnemonicPos > -1 && (mnemonicPos == 0 || m_mnemonicLabel[mnemonicPos - 1] != QLatin1Char('&'))) {
345 m_mnemonicLabel.replace(mnemonicPos, 1, QStringLiteral("&") % c);
346 }
347
348 const int richTextPos = m_richTextLabel.indexOf(c);
349 if (richTextPos > -1) {
350 m_richTextLabel.replace(richTextPos, 1, QLatin1String("<u>") % c % QLatin1String("</u>"));
351 }
352
353 // remap the sequence of the previous shortcut
354 if (otherMa) {
355 otherMa->updateSequence();
356 }
357
358 break;
359 }
360 } while (i != m_weights.constBegin());
361 }
362
363 if (m_sequence != oldSequence) {
364 Q_EMIT sequenceChanged();
365 }
366
367 Q_EMIT richTextLabelChanged();
368 Q_EMIT mnemonicLabelChanged();
369}
370
371void MnemonicAttached::setLabel(const QString &text)
372{
373 if (m_label == text) {
374 return;
375 }
376
377 m_label = text;
378 updateSequence();
379 Q_EMIT labelChanged();
380}
381
383{
384 if (!m_actualRichTextLabel.isEmpty()) {
385 return m_actualRichTextLabel;
386 } else {
387 return removeAcceleratorMarker(m_label);
388 }
389}
390
392{
393 return m_mnemonicLabel;
394}
395
397{
398 return m_label;
399}
400
401void MnemonicAttached::setEnabled(bool enabled)
402{
403 if (m_enabled == enabled) {
404 return;
405 }
406
407 m_enabled = enabled;
408 updateSequence();
409 Q_EMIT enabledChanged();
410}
411
412bool MnemonicAttached::enabled() const
413{
414 return m_enabled;
415}
416
417void MnemonicAttached::setControlType(MnemonicAttached::ControlType controlType)
418{
419 if (m_controlType == controlType) {
420 return;
421 }
422
423 m_controlType = controlType;
424
425 switch (controlType) {
426 case ActionElement:
427 m_baseWeight = ACTION_ELEMENT_WEIGHT;
428 break;
429 case DialogButton:
430 m_baseWeight = DIALOG_BUTTON_EXTRA_WEIGHT;
431 break;
432 case MenuItem:
433 m_baseWeight = MENU_ITEM_WEIGHT;
434 break;
435 case FormLabel:
436 m_baseWeight = FORM_LABEL_WEIGHT;
437 break;
438 default:
439 m_baseWeight = SECONDARY_CONTROL_WEIGHT;
440 break;
441 }
442 // update our maximum weight
443 if (m_weights.isEmpty()) {
444 m_weight = m_baseWeight;
445 } else {
446 m_weight = m_baseWeight + (std::prev(m_weights.constEnd())).key();
447 }
448 Q_EMIT controlTypeChanged();
449}
450
452{
453 return m_controlType;
454}
455
457{
458 return m_sequence;
459}
460
461bool MnemonicAttached::active() const
462{
463 return m_active;
464}
465
466MnemonicAttached *MnemonicAttached::qmlAttachedProperties(QObject *object)
467{
468 return new MnemonicAttached(object);
469}
470
471void MnemonicAttached::setActive(bool active)
472{
473 // We can't rely on previous value when it's true since it can be
474 // caused by Alt key press and we need to remove the event filter
475 // additionally. False should be ok as it's a default state.
476 if (!m_active && m_active == active) {
477 return;
478 }
479
480 m_active = active;
481
482 if (m_active) {
483 if (m_actualRichTextLabel != m_richTextLabel) {
484 m_actualRichTextLabel = m_richTextLabel;
485 Q_EMIT richTextLabelChanged();
486 }
487
488 } else {
489 m_actualRichTextLabel = removeAcceleratorMarker(m_label);
490 Q_EMIT richTextLabelChanged();
491 }
492
493 Q_EMIT activeChanged();
494}
495
496#include "mnemonicattached.moc"
This Attached property is used to calculate automated keyboard sequences to trigger actions based upo...
QString richTextLabel
The user-visible final label, which will have the shortcut letter underlined, such as "<u>O</u>k".
QString label
The label of the control we want to compute a mnemonic for, instance "Label:" or "&Ok".
@ MenuItem
Menu items.
@ DialogButton
buttons for dialogs
@ ActionElement
pushbuttons, checkboxes etc
@ FormLabel
Buddy label in a FormLayout.
bool enabled
Only if true this mnemonic will be considered for the global assignment default: true.
bool active
True when the user is pressing alt and the accelerators should be shown.
QString mnemonicLabel
The label with an "&" mnemonic in the place which will have the shortcut assigned,...
MnemonicAttached::ControlType controlType
The type of control this mnemonic is attached: different types of controls have different importance ...
QKeySequence sequence
The final key sequence assigned, if any: it will be Alt+alphanumeric char.
QString label(StandardShortcut id)
bool isLetterOrNumber(char32_t ucs4)
int key() const const
QObject(QObject *parent)
Q_EMITQ_EMIT
Q_OBJECTQ_OBJECT
Q_SIGNALSQ_SIGNALS
virtual bool event(QEvent *e)
QObject * parent() const const
T qobject_cast(QObject *object)
QWindow * renderWindowFor(QQuickWindow *win, QPoint *offset)
const QChar at(qsizetype position) const const
qsizetype indexOf(QChar ch, qsizetype from, Qt::CaseSensitivity cs) const const
QString left(qsizetype n) const const
qsizetype length() const const
QString mid(qsizetype position, qsizetype n) const const
QString & replace(QChar before, QChar after, Qt::CaseSensitivity cs)
QFuture< ArgsType< Signal > > connect(Sender *sender, Signal signal)
This file is part of the KDE documentation.
Documentation copyright © 1996-2025 The KDE developers.
Generated on Fri Jan 24 2025 11:51:21 by doxygen 1.13.2 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.