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