1 /*
2 Copyright (c) 2020, Lukas Holecek <hluk@email.cz>
3
4 This file is part of CopyQ.
5
6 CopyQ is free software: you can redistribute it and/or modify
7 it under the terms of the GNU General Public License as published by
8 the Free Software Foundation, either version 3 of the License, or
9 (at your option) any later version.
10
11 CopyQ is distributed in the hope that it will be useful,
12 but WITHOUT ANY WARRANTY; without even the implied warranty of
13 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 GNU General Public License for more details.
15
16 You should have received a copy of the GNU General Public License
17 along with CopyQ. If not, see <http://www.gnu.org/licenses/>.
18 */
19
20 #include "traymenu.h"
21
22 #include "common/contenttype.h"
23 #include "common/common.h"
24 #include "common/display.h"
25 #include "common/mimetypes.h"
26 #include "common/textdata.h"
27 #include "common/timer.h"
28 #include "gui/icons.h"
29 #include "gui/iconfactory.h"
30
31 #include <QAction>
32 #include <QApplication>
33 #include <QKeyEvent>
34 #include <QModelIndex>
35 #include <QPixmap>
36 #include <QRegularExpression>
37
38 namespace {
39
40 const char propertyCustomAction[] = "CopyQ_tray_menu_custom_action";
41 const char propertyClipboardItemAction[] = "CopyQ_tray_menu_clipboard_item";
42
iconClipboard()43 const QIcon iconClipboard() { return getIcon("clipboard", IconPaste); }
44
canActivate(const QAction & action)45 bool canActivate(const QAction &action)
46 {
47 return !action.isSeparator() && action.isEnabled();
48 }
49
firstEnabledAction(QMenu * menu)50 QAction *firstEnabledAction(QMenu *menu)
51 {
52 // First action is active when menu pops up.
53 for (auto action : menu->actions()) {
54 if ( canActivate(*action) )
55 return action;
56 }
57
58 return nullptr;
59 }
60
lastEnabledAction(QMenu * menu)61 QAction *lastEnabledAction(QMenu *menu)
62 {
63 // First action is active when menu pops up.
64 QList<QAction *> actions = menu->actions();
65 for (int i = actions.size() - 1; i >= 0; --i) {
66 if ( canActivate(*actions[i]) )
67 return actions[i];
68 }
69
70 return nullptr;
71 }
72
73 } // namespace
74
TrayMenu(QWidget * parent)75 TrayMenu::TrayMenu(QWidget *parent)
76 : QMenu(parent)
77 , m_clipboardItemActionCount(0)
78 , m_omitPaste(false)
79 , m_viMode(false)
80 , m_numberSearch(false)
81 {
82 m_clipboardItemActionsSeparator = addSeparator();
83 m_customActionsSeparator = addSeparator();
84 initSingleShotTimer( &m_timerUpdateActiveAction, 0, this, &TrayMenu::updateActiveAction );
85 setAttribute(Qt::WA_InputMethodEnabled);
86 }
87
addClipboardItemAction(const QVariantMap & data,bool showImages)88 void TrayMenu::addClipboardItemAction(const QVariantMap &data, bool showImages)
89 {
90 // Show search text at top of the menu.
91 if ( m_clipboardItemActionCount == 0 && m_searchText.isEmpty() )
92 setSearchMenuItem( m_viMode ? tr("Press '/' to search") : tr("Type to search") );
93
94 QAction *act = addAction(QString());
95 act->setProperty(propertyClipboardItemAction, true);
96
97 act->setData(data);
98
99 insertAction(m_clipboardItemActionsSeparator, act);
100
101 QString format;
102
103 // Add number key hint.
104 const int rowNumber = m_clipboardItemActionCount + static_cast<int>(m_rowIndexFromOne);
105 if (rowNumber < 10) {
106 format = tr("&%1. %2",
107 "Key hint (number shortcut) for items in tray menu (%1 is number, %2 is item label)")
108 .arg(rowNumber);
109 }
110
111 m_clipboardItemActionCount++;
112
113 const QString label = textLabelForData( data, act->font(), format, true );
114 act->setText(label);
115
116 // Menu item icon from image.
117 if (showImages) {
118 const QStringList formats = data.keys();
119 static const QRegularExpression reImage("^image/.*");
120 const int imageIndex = formats.indexOf(reImage);
121 if (imageIndex != -1) {
122 const auto &mime = formats[imageIndex];
123 QPixmap pix;
124 pix.loadFromData( data.value(mime).toByteArray(), mime.toLatin1().data() );
125 const int iconSize = smallIconSize();
126 int x = 0;
127 int y = 0;
128 if (pix.width() > pix.height()) {
129 pix = pix.scaledToHeight(iconSize);
130 x = (pix.width() - iconSize) / 2;
131 } else {
132 pix = pix.scaledToWidth(iconSize);
133 y = (pix.height() - iconSize) / 2;
134 }
135 pix = pix.copy(x, y, iconSize, iconSize);
136 act->setIcon(pix);
137 }
138 }
139
140 if ( act->icon().isNull() ) {
141 const QString icon = data.value(mimeIcon).toString();
142 if ( !icon.isEmpty() ) {
143 const QColor color = getDefaultIconColor(*this);
144 const QString tag = data.value(COPYQ_MIME_PREFIX "item-tag").toString();
145 act->setIcon( iconFromFile(icon, tag, color) );
146 }
147 }
148
149 connect(act, &QAction::triggered, this, &TrayMenu::onClipboardItemActionTriggered);
150
151 updateActiveAction();
152 }
153
clearClipboardItems()154 void TrayMenu::clearClipboardItems()
155 {
156 clearActionsWithProperty(propertyClipboardItemAction);
157
158 m_clipboardItemActionCount = 0;
159
160 // Show search text at top of the menu.
161 if ( !m_searchText.isEmpty() )
162 setSearchMenuItem(m_searchText);
163 }
164
clearCustomActions()165 void TrayMenu::clearCustomActions()
166 {
167 clearActionsWithProperty(propertyCustomAction);
168 }
169
addCustomAction(QAction * action)170 void TrayMenu::addCustomAction(QAction *action)
171 {
172 action->setProperty(propertyCustomAction, true);
173 insertAction(m_customActionsSeparator, action);
174 updateActiveAction();
175 }
176
clearAllActions()177 void TrayMenu::clearAllActions()
178 {
179 clear();
180 m_clipboardItemActionCount = 0;
181 m_searchText.clear();
182 }
183
setViModeEnabled(bool enabled)184 void TrayMenu::setViModeEnabled(bool enabled)
185 {
186 m_viMode = enabled;
187 }
188
setNumberSearchEnabled(bool enabled)189 void TrayMenu::setNumberSearchEnabled(bool enabled)
190 {
191 m_numberSearch = enabled;
192 }
193
keyPressEvent(QKeyEvent * event)194 void TrayMenu::keyPressEvent(QKeyEvent *event)
195 {
196 const int key = event->key();
197 m_omitPaste = false;
198
199 if ( m_viMode && m_searchText.isEmpty() && handleViKey(event, this) ) {
200 return;
201 } else {
202 // Movement in tray menu.
203 switch (key) {
204 case Qt::Key_PageDown:
205 case Qt::Key_End: {
206 QAction *action = lastEnabledAction(this);
207 if (action != nullptr)
208 setActiveAction(action);
209 break;
210 }
211 case Qt::Key_PageUp:
212 case Qt::Key_Home: {
213 QAction *action = firstEnabledAction(this);
214 if (action != nullptr)
215 setActiveAction(action);
216 break;
217 }
218 case Qt::Key_Escape:
219 close();
220 break;
221 case Qt::Key_Backspace:
222 search( m_searchText.left(m_searchText.size() - 1) );
223 break;
224 case Qt::Key_Delete:
225 search(QString());
226 break;
227 case Qt::Key_Alt:
228 return;
229 default:
230 // Type text for search.
231 if ( (m_clipboardItemActionCount > 0 || !m_searchText.isEmpty())
232 && (!m_viMode || !m_searchText.isEmpty() || key == Qt::Key_Slash)
233 && !event->modifiers().testFlag(Qt::AltModifier)
234 && !event->modifiers().testFlag(Qt::ControlModifier) )
235 {
236 const QString txt = event->text();
237 if ( !txt.isEmpty() && txt[0].isPrint() ) {
238 // Activate item at row when number is entered.
239 if ( !m_numberSearch && m_searchText.isEmpty() ) {
240 bool ok;
241 const int row = txt.toInt(&ok);
242 if (ok && row < m_clipboardItemActionCount) {
243 // Allow keypad digit to activate appropriate item in context menu.
244 if (event->modifiers() == Qt::KeypadModifier)
245 event->setModifiers(Qt::NoModifier);
246 break;
247 }
248 }
249 search(m_searchText + txt);
250 return;
251 }
252 }
253 break;
254 }
255 }
256
257 QMenu::keyPressEvent(event);
258 }
259
mousePressEvent(QMouseEvent * event)260 void TrayMenu::mousePressEvent(QMouseEvent *event)
261 {
262 m_omitPaste = (event->button() == Qt::RightButton);
263 QMenu::mousePressEvent(event);
264 }
265
showEvent(QShowEvent * event)266 void TrayMenu::showEvent(QShowEvent *event)
267 {
268 search(QString());
269
270 // If appmenu is used to handle the menu, most events won't be received
271 // so search won't work.
272 // This shows the search menu item only if show event is received.
273 if ( !m_searchAction.isNull() )
274 m_searchAction->setVisible(true);
275
276 QMenu::showEvent(event);
277 }
278
hideEvent(QHideEvent * event)279 void TrayMenu::hideEvent(QHideEvent *event)
280 {
281 QMenu::hideEvent(event);
282
283 if ( !m_searchAction.isNull() )
284 m_searchAction->setVisible(false);
285 }
286
actionEvent(QActionEvent * event)287 void TrayMenu::actionEvent(QActionEvent *event)
288 {
289 QMenu::actionEvent(event);
290 m_timerUpdateActiveAction.start();
291 }
292
leaveEvent(QEvent * event)293 void TrayMenu::leaveEvent(QEvent *event)
294 {
295 // Omit clearing active action if menu is resizes and mouse pointer leaves menu.
296 auto action = activeAction();
297 QMenu::leaveEvent(event);
298 setActiveAction(action);
299 }
300
inputMethodEvent(QInputMethodEvent * event)301 void TrayMenu::inputMethodEvent(QInputMethodEvent *event)
302 {
303 if (!event->commitString().isEmpty())
304 search(m_searchText + event->commitString());
305 event->ignore();
306 }
307
clearActionsWithProperty(const char * property)308 void TrayMenu::clearActionsWithProperty(const char *property)
309 {
310 for ( auto action : actions() ) {
311 if ( action->property(property).toBool() ) {
312 removeAction(action);
313 delete action;
314 }
315 }
316 }
317
search(const QString & text)318 void TrayMenu::search(const QString &text)
319 {
320 if (m_searchText == text)
321 return;
322
323 m_searchText = text;
324 emit searchRequest(m_viMode ? m_searchText.mid(1) : m_searchText);
325 }
326
markItemInClipboard(const QVariantMap & clipboardData)327 void TrayMenu::markItemInClipboard(const QVariantMap &clipboardData)
328 {
329 const auto text = getTextData(clipboardData);
330
331 for ( auto action : actions() ) {
332 const auto actionData = action->data().toMap();
333 const auto itemText = getTextData(actionData);
334 if ( !itemText.isEmpty() ) {
335 const auto hideIcon = itemText != text;
336 const auto isIconHidden = action->icon().isNull();
337 if ( isIconHidden != hideIcon )
338 action->setIcon(hideIcon ? QIcon() : iconClipboard());
339 }
340 }
341 }
342
setSearchMenuItem(const QString & text)343 void TrayMenu::setSearchMenuItem(const QString &text)
344 {
345 if ( m_searchAction.isNull() ) {
346 const QIcon icon = getIcon("edit-find", IconSearch);
347 m_searchAction = new QAction(icon, text, this);
348 m_searchAction->setEnabled(false);
349 // Search menu item is hidden by default, see showEvent().
350 m_searchAction->setVisible( isVisible() );
351 insertAction( actions().value(0), m_searchAction );
352 } else {
353 m_searchAction->setText(text);
354 }
355 }
356
onClipboardItemActionTriggered()357 void TrayMenu::onClipboardItemActionTriggered()
358 {
359 QAction *act = qobject_cast<QAction *>(sender());
360 Q_ASSERT(act != nullptr);
361
362 const auto actionData = act->data().toMap();
363 emit clipboardItemActionTriggered(actionData, m_omitPaste);
364 close();
365 }
366
updateActiveAction()367 void TrayMenu::updateActiveAction()
368 {
369 if ( isVisible() && activeAction() != nullptr )
370 return;
371
372 const auto action = firstEnabledAction(this);
373 if (action != nullptr)
374 setActiveAction(action);
375 }
376