1 /*
2     SPDX-FileCopyrightText: 2016 Kai Uwe Broulik <kde@privat.broulik.de>
3 
4     SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
5 */
6 
7 #include "appmenuapplet.h"
8 #include "../plugin/appmenumodel.h"
9 
10 #include <QAction>
11 #include <QDBusConnection>
12 #include <QDBusConnectionInterface>
13 #include <QKeyEvent>
14 #include <QMenu>
15 #include <QMouseEvent>
16 #include <QQuickItem>
17 #include <QQuickWindow>
18 #include <QScreen>
19 #include <QTimer>
20 
21 int AppMenuApplet::s_refs = 0;
22 namespace
23 {
viewService()24 QString viewService()
25 {
26     return QStringLiteral("org.kde.kappmenuview");
27 }
28 }
29 
AppMenuApplet(QObject * parent,const QVariantList & data)30 AppMenuApplet::AppMenuApplet(QObject *parent, const QVariantList &data)
31     : Plasma::Applet(parent, data)
32 {
33     ++s_refs;
34     // if we're the first, register the service
35     if (s_refs == 1) {
36         QDBusConnection::sessionBus().interface()->registerService(viewService(),
37                                                                    QDBusConnectionInterface::QueueService,
38                                                                    QDBusConnectionInterface::DontAllowReplacement);
39     }
40     /*it registers or unregisters the service when the destroyed value of the applet change,
41       and not in the dtor, because:
42       when we "delete" an applet, it just hides it for about a minute setting its status
43       to destroyed, in order to be able to do a clean undo: if we undo, there will be
44       another destroyedchanged and destroyed will be false.
45       When this happens, if we are the only appmenu applet existing, the dbus interface
46       will have to be registered again*/
47     connect(this, &Applet::destroyedChanged, this, [](bool destroyed) {
48         if (destroyed) {
49             // if we were the last, unregister
50             if (--s_refs == 0) {
51                 QDBusConnection::sessionBus().interface()->unregisterService(viewService());
52             }
53         } else {
54             // if we're the first, register the service
55             if (++s_refs == 1) {
56                 QDBusConnection::sessionBus().interface()->registerService(viewService(),
57                                                                            QDBusConnectionInterface::QueueService,
58                                                                            QDBusConnectionInterface::DontAllowReplacement);
59             }
60         }
61     });
62 }
63 
64 AppMenuApplet::~AppMenuApplet() = default;
65 
init()66 void AppMenuApplet::init()
67 {
68 }
69 
model() const70 AppMenuModel *AppMenuApplet::model() const
71 {
72     return m_model;
73 }
74 
setModel(AppMenuModel * model)75 void AppMenuApplet::setModel(AppMenuModel *model)
76 {
77     if (m_model != model) {
78         m_model = model;
79         emit modelChanged();
80     }
81 }
82 
view() const83 int AppMenuApplet::view() const
84 {
85     return m_viewType;
86 }
87 
setView(int type)88 void AppMenuApplet::setView(int type)
89 {
90     if (m_viewType != type) {
91         m_viewType = type;
92         emit viewChanged();
93     }
94 }
95 
currentIndex() const96 int AppMenuApplet::currentIndex() const
97 {
98     return m_currentIndex;
99 }
100 
setCurrentIndex(int currentIndex)101 void AppMenuApplet::setCurrentIndex(int currentIndex)
102 {
103     if (m_currentIndex != currentIndex) {
104         m_currentIndex = currentIndex;
105         emit currentIndexChanged();
106     }
107 }
108 
buttonGrid() const109 QQuickItem *AppMenuApplet::buttonGrid() const
110 {
111     return m_buttonGrid;
112 }
113 
setButtonGrid(QQuickItem * buttonGrid)114 void AppMenuApplet::setButtonGrid(QQuickItem *buttonGrid)
115 {
116     if (m_buttonGrid != buttonGrid) {
117         m_buttonGrid = buttonGrid;
118         emit buttonGridChanged();
119     }
120 }
121 
createMenu(int idx) const122 QMenu *AppMenuApplet::createMenu(int idx) const
123 {
124     QMenu *menu = nullptr;
125     QAction *action = nullptr;
126 
127     if (view() == CompactView) {
128         menu = new QMenu();
129         for (int i = 0; i < m_model->rowCount(); i++) {
130             const QModelIndex index = m_model->index(i, 0);
131             const QVariant data = m_model->data(index, AppMenuModel::ActionRole);
132             action = (QAction *)data.value<void *>();
133             menu->addAction(action);
134         }
135         menu->setAttribute(Qt::WA_DeleteOnClose);
136     } else if (view() == FullView) {
137         const QModelIndex index = m_model->index(idx, 0);
138         const QVariant data = m_model->data(index, AppMenuModel::ActionRole);
139         action = (QAction *)data.value<void *>();
140         if (action) {
141             menu = action->menu();
142         }
143     }
144 
145     return menu;
146 }
147 
onMenuAboutToHide()148 void AppMenuApplet::onMenuAboutToHide()
149 {
150     setCurrentIndex(-1);
151 }
152 
trigger(QQuickItem * ctx,int idx)153 void AppMenuApplet::trigger(QQuickItem *ctx, int idx)
154 {
155     if (m_currentIndex == idx) {
156         return;
157     }
158 
159     if (!ctx || !ctx->window() || !ctx->window()->screen()) {
160         return;
161     }
162 
163     QMenu *actionMenu = createMenu(idx);
164     if (actionMenu) {
165         // this is a workaround where Qt will fail to realize a mouse has been released
166         // this happens if a window which does not accept focus spawns a new window that takes focus and X grab
167         // whilst the mouse is depressed
168         // https://bugreports.qt.io/browse/QTBUG-59044
169         // this causes the next click to go missing
170 
171         // by releasing manually we avoid that situation
172         auto ungrabMouseHack = [ctx]() {
173             if (ctx && ctx->window() && ctx->window()->mouseGrabberItem()) {
174                 // FIXME event forge thing enters press and hold move mode :/
175                 ctx->window()->mouseGrabberItem()->ungrabMouse();
176             }
177         };
178 
179         QTimer::singleShot(0, ctx, ungrabMouseHack);
180         // end workaround
181 
182         const auto &geo = ctx->window()->screen()->availableVirtualGeometry();
183 
184         QPoint pos = ctx->window()->mapToGlobal(ctx->mapToScene(QPointF()).toPoint());
185         if (location() == Plasma::Types::TopEdge) {
186             actionMenu->setProperty("_breeze_menu_is_top", true);
187             pos.setY(pos.y() + ctx->height());
188         }
189 
190         actionMenu->adjustSize();
191 
192         pos = QPoint(qBound(geo.x(), pos.x(), geo.x() + geo.width() - actionMenu->width()),
193                      qBound(geo.y(), pos.y(), geo.y() + geo.height() - actionMenu->height()));
194 
195         if (view() == FullView) {
196             actionMenu->installEventFilter(this);
197         }
198 
199         actionMenu->winId(); // create window handle
200         actionMenu->windowHandle()->setTransientParent(ctx->window());
201 
202         // hide the old menu only after showing the new one to avoid brief focus flickering on X11.
203         // on wayland, you can't have more than one grabbing popup at a time so we show it after
204         // the menu has hidden. thankfully, wayland doesn't have this flickering.
205         if (!KWindowSystem::isPlatformWayland()) {
206             actionMenu->popup(pos);
207         }
208 
209         if (view() == FullView) {
210             QMenu *oldMenu = m_currentMenu;
211             m_currentMenu = actionMenu;
212             if (oldMenu && oldMenu != actionMenu) {
213                 // don't initialize the currentIndex when another menu is already shown
214                 disconnect(oldMenu, &QMenu::aboutToHide, this, &AppMenuApplet::onMenuAboutToHide);
215                 oldMenu->hide();
216             }
217         }
218 
219         if (KWindowSystem::isPlatformWayland()) {
220             actionMenu->popup(pos);
221         }
222 
223         setCurrentIndex(idx);
224 
225         // FIXME TODO connect only once
226         connect(actionMenu, &QMenu::aboutToHide, this, &AppMenuApplet::onMenuAboutToHide, Qt::UniqueConnection);
227     } else { // is it just an action without a menu?
228         const QVariant data = m_model->index(idx, 0).data(AppMenuModel::ActionRole);
229         QAction *action = static_cast<QAction *>(data.value<void *>());
230         if (action) {
231             Q_ASSERT(!action->menu());
232             action->trigger();
233         }
234     }
235 }
236 
237 // FIXME TODO doesn't work on submenu
eventFilter(QObject * watched,QEvent * event)238 bool AppMenuApplet::eventFilter(QObject *watched, QEvent *event)
239 {
240     auto *menu = qobject_cast<QMenu *>(watched);
241     if (!menu) {
242         return false;
243     }
244 
245     if (event->type() == QEvent::KeyPress) {
246         auto *e = static_cast<QKeyEvent *>(event);
247 
248         // TODO right to left languages
249         if (e->key() == Qt::Key_Left) {
250             int desiredIndex = m_currentIndex - 1;
251             emit requestActivateIndex(desiredIndex);
252             return true;
253         } else if (e->key() == Qt::Key_Right) {
254             if (menu->activeAction() && menu->activeAction()->menu()) {
255                 return false;
256             }
257 
258             int desiredIndex = m_currentIndex + 1;
259             emit requestActivateIndex(desiredIndex);
260             return true;
261         }
262 
263     } else if (event->type() == QEvent::MouseMove) {
264         auto *e = static_cast<QMouseEvent *>(event);
265 
266         if (!m_buttonGrid || !m_buttonGrid->window()) {
267             return false;
268         }
269 
270         // FIXME the panel margin breaks Fitt's law :(
271         const QPointF &windowLocalPos = m_buttonGrid->window()->mapFromGlobal(e->globalPos());
272         const QPointF &buttonGridLocalPos = m_buttonGrid->mapFromScene(windowLocalPos);
273         auto *item = m_buttonGrid->childAt(buttonGridLocalPos.x(), buttonGridLocalPos.y());
274         if (!item) {
275             return false;
276         }
277 
278         bool ok;
279         const int buttonIndex = item->property("buttonIndex").toInt(&ok);
280         if (!ok) {
281             return false;
282         }
283 
284         emit requestActivateIndex(buttonIndex);
285     }
286 
287     return false;
288 }
289 
290 K_PLUGIN_CLASS_WITH_JSON(AppMenuApplet, "metadata.json")
291 
292 #include "appmenuapplet.moc"
293