Merge ~osomon/oxide:updated-popup-menu into oxide:master

Proposed by Olivier Tilloy
Status: Needs review
Proposed branch: ~osomon/oxide:updated-popup-menu
Merge into: oxide:master
Diff against target: 458 lines (+253/-50)
4 files modified
qt/tests/qmltests/ubuntu_ui/tst_WebViewPopupMenu.qml (+43/-15)
qt/uitk/lib/resources/WebPopupMenu.qml (+162/-24)
qt/uitk/lib/uitk_web_popup_menu.cc (+46/-9)
qt/uitk/lib/uitk_web_popup_menu.h (+2/-2)
Reviewer Review Type Date Requested Status
Oxide Developers Pending
Review via email: mp+319566@code.launchpad.net

Commit message

Improved Oxide.Ubuntu web popup menu UX.

Backported recent webbrowser-app update, by Florian Boucault.

To post a comment you must log in.
~osomon/oxide:updated-popup-menu updated
3fbf137... by Olivier Tilloy

Add unit tests for keyboard navigation in popup menu.

Unmerged commits

3fbf137... by Olivier Tilloy

Add unit tests for keyboard navigation in popup menu.

d0f2173... by Olivier Tilloy

Improved Oxide.Ubuntu web popup menu UX.

Backported recent webbrowser-app update, by Florian Boucault.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/qt/tests/qmltests/ubuntu_ui/tst_WebViewPopupMenu.qml b/qt/tests/qmltests/ubuntu_ui/tst_WebViewPopupMenu.qml
2index ca36c27..2cc02f0 100644
3--- a/qt/tests/qmltests/ubuntu_ui/tst_WebViewPopupMenu.qml
4+++ b/qt/tests/qmltests/ubuntu_ui/tst_WebViewPopupMenu.qml
5@@ -52,7 +52,7 @@ UbuntuTestWebView {
6 }
7
8 function getPopupMenuEntryAtIndex(index) {
9- return TestSupport.findItemInScene(getPopupMenu(), "webView_WebPopupMenu_item" + index);
10+ return TestSupport.findItemInScene(getPopupMenu(), "itemDelegate_%1".arg(index));
11 }
12
13 function getSelectedIndex(selector) {
14@@ -69,13 +69,13 @@ UbuntuTestWebView {
15 return [
16 { selector: "#select1",
17 items: [
18- { type: "header", text: "Group A" },
19+ { type: "header", text: "Group A", enabled: true },
20 { type: "item", text: " Test 1", enabled: true },
21 { type: "item", text: " Test 2", enabled: true },
22- { type: "header", text: "Group B" },
23+ { type: "header", text: "Group B", enabled: true },
24 { type: "item", text: " Test 3", enabled: true },
25 { type: "item", text: " Test 4", enabled: false },
26- { type: "header", text: "Group C" },
27+ { type: "header", text: "Group C", enabled: false },
28 { type: "item", text: " Test 5", enabled: false },
29 { type: "item", text: " Test 6", enabled: false }
30 ]
31@@ -102,24 +102,21 @@ UbuntuTestWebView {
32 var items = [];
33 for (var i in listView.children[0].children) {
34 var item = listView.children[0].children[i];
35- if (item.z == 0) {
36- // Ignore the highlight item. This isn't a great way to detect this
37+ if (item.objectName.indexOf("itemDelegate_") != 0 &&
38+ item.objectName.indexOf("headerDelegate_") != 0) {
39 continue;
40 }
41- items.push(listView.children[0].children[i]);
42+ items.push(item);
43 }
44 items.sort(function(a, b) { return a.y - b.y; });
45
46 compare(items.length, data.items.length);
47 for (var i in items) {
48- if (data.items[i].type == "item") {
49- compare(items[i].listItemData[0].title.text, data.items[i].text);
50- compare(items[i].enabled, data.items[i].enabled);
51- } else if (data.items[i].type == "header") {
52- compare(items[i].text, data.items[i].text);
53- } else {
54- fail();
55- }
56+ var itemType = data.items[i].type;
57+ verify(itemType == "item" || itemType == "header");
58+ compare(items[i].objectName.indexOf("%1Delegate_".arg(itemType)), 0);
59+ compare(items[i].text, data.items[i].text);
60+ compare(items[i].enabled, data.items[i].enabled);
61 }
62 }
63
64@@ -187,5 +184,36 @@ UbuntuTestWebView {
65 webView2.destroy();
66 verify(TestUtils.waitFor(function() { return helper.destroyed; }));
67 }
68+
69+ function test_WebViewPopupMenu6_arrow_key_navigation() {
70+ var r = webView.getTestApi().getBoundingClientRectForSelector("#select2");
71+ mouseClick(webView, r.x + r.width / 2, r.y + r.height / 2);
72+ verify(waitForPopupMenu());
73+
74+ var listView = TestSupport.findItemInScene(getPopupMenu(), "webView_WebPopupMenu_ListView");
75+ compare(listView.currentIndex, 2);
76+
77+ keyPress(Qt.Key_Up);
78+ compare(listView.currentIndex, 1);
79+ keyPress(Qt.Key_Up);
80+ compare(listView.currentIndex, 0);
81+ keyPress(Qt.Key_Up);
82+ compare(listView.currentIndex, 0);
83+
84+ keyPress(Qt.Key_Down);
85+ compare(listView.currentIndex, 1);
86+ keyPress(Qt.Key_Down);
87+ compare(listView.currentIndex, 2);
88+ keyPress(Qt.Key_Down);
89+ compare(listView.currentIndex, 4);
90+ keyPress(Qt.Key_Down);
91+ compare(listView.currentIndex, 5);
92+ keyPress(Qt.Key_Down);
93+ compare(listView.currentIndex, 5);
94+
95+ keyPress(Qt.Key_Enter);
96+ verify(waitForPopupMenuToClose());
97+ compare(getSelectedIndex("#select2"), 5);
98+ }
99 }
100 }
101diff --git a/qt/uitk/lib/resources/WebPopupMenu.qml b/qt/uitk/lib/resources/WebPopupMenu.qml
102index a2987d3..9fa68cf 100644
103--- a/qt/uitk/lib/resources/WebPopupMenu.qml
104+++ b/qt/uitk/lib/resources/WebPopupMenu.qml
105@@ -1,5 +1,5 @@
106 // vim:expandtab:shiftwidth=2:tabstop=2:
107-// Copyright (C) 2013-2016 Canonical Ltd.
108+// Copyright (C) 2013-2017 Canonical Ltd.
109
110 // This library is free software; you can redistribute it and/or
111 // modify it under the terms of the GNU Lesser General Public
112@@ -17,54 +17,192 @@
113
114 import QtQuick 2.4
115 import Ubuntu.Components 1.3
116-import Ubuntu.Components.ListItems 1.3 as ListItems
117-import Ubuntu.Components.Popups 1.3 as Popups
118-
119-Popups.Popover {
120- property var model
121+import com.canonical.Oxide 1.5
122
123+Item {
124 id: root
125 objectName: parent.objectName ? parent.objectName + "_WebPopupMenu" : ""
126
127 signal selectedItem(int index)
128+ signal closed()
129+
130+ property alias model: listView.model
131+ property rect elementBounds
132+
133+ readonly property var webview: parent
134
135- caller: parent
136+ property real contentHeight
137+ width: elementBounds.width
138+ height: contentHeight
139
140- contentWidth: Math.min(parent.width - units.gu(10), units.gu(40))
141+ property real listContentHeight: (listView.count + listView.sectionCount) * (listItemHeight + units.dp(1))
142+ property real listItemHeight: units.gu(4)
143+ property real addressBarHeight: webview.locationBarController.height
144
145- property real listContentHeight: 0 // intermediate property to avoid binding loop
146- contentHeight: Math.min(parent.height - units.gu(10), listContentHeight)
147+ // When the webview's size changes, dismiss the menu.
148+ // Ideally we would call updatePosition instead but because elementBounds
149+ // is not updated, it would result in incorrect positioning.
150+ Connections {
151+ target: webview
152+ onWidthChanged: closed()
153+ onHeightChanged: closed()
154+ }
155+ onListContentHeightChanged: updatePosition()
156+ onAddressBarHeightChanged: updatePosition()
157+
158+ function updatePosition() {
159+ root.x = elementBounds.x;
160+ var availableAbove = elementBounds.y - addressBarHeight;
161+ var availableBelow = webview.height - elementBounds.y - elementBounds.height;
162+
163+ if (availableBelow >= listContentHeight || availableBelow >= availableAbove) {
164+ // position popover below the box
165+ root.contentHeight = Math.min(availableBelow, listContentHeight);
166+ root.y = elementBounds.y + elementBounds.height;
167+ } else {
168+ // position popover above the box
169+ root.contentHeight = Math.min(availableAbove, listContentHeight);
170+ root.y = elementBounds.y - root.contentHeight;
171+ }
172+ }
173+
174+ Keys.onEscapePressed: closed()
175+ Keys.onPressed: {
176+ // Swallow left and right key events
177+ if (event.key == Qt.Key_Left || event.key == Qt.Key_Right) {
178+ event.accepted = true;
179+ }
180+ }
181+
182+ // Eat mouse events beneath the list so that they never reach the webview below
183+ MouseArea {
184+ anchors.fill: parent
185+ acceptedButtons: Qt.AllButtons
186+ onWheel: wheel.accepted = true
187+ }
188+
189+ // Eat mouse events around the list so that they never reach the webview below
190+ InverseMouseArea {
191+ anchors.fill: parent
192+ acceptedButtons: Qt.AllButtons
193+ onPressed: selectedItem(listView.currentIndex)
194+ onWheel: wheel.accepted = true
195+ }
196
197 Rectangle {
198 anchors.fill: parent
199- color: "#ececec"
200+ color: theme.palette.normal.overlay
201+ function newColorWithAlpha(color, alpha) {
202+ return Qt.rgba(color.r, color.g, color.b, alpha);
203+ }
204+ border.color: newColorWithAlpha(theme.palette.normal.base, 0.4)
205+ border.width: units.dp(1)
206 }
207
208 ListView {
209+ id: listView
210+ objectName: root.objectName ? root.objectName + "_ListView" : ""
211 clip: true
212- width: root.contentWidth
213- height: root.contentHeight
214+ anchors.fill: parent
215+ focus: true
216+
217+ // Forces all delegates to be instantiated so that initialIndex is
218+ // set adequately to the index of the selected item
219+ cacheBuffer: 32767
220+ property int initialIndex
221+ Component.onCompleted: {
222+ // calling forceLayout ensures that all delegates have been created and
223+ // that initialIndex is set adequately as a consequence
224+ forceLayout();
225+ currentIndex = initialIndex;
226+ positionViewAtIndex(initialIndex, ListView.Contain);
227+ }
228
229- objectName: root.objectName ? root.objectName + "_ListView" : ""
230+ property int sectionCount: 0
231
232- model: root.model
233+ Keys.onDownPressed: {
234+ var nextIndex = model.nextEnabledItem(currentIndex);
235+ if (nextIndex != -1) {
236+ currentIndex = nextIndex;
237+ }
238+ }
239+ Keys.onUpPressed: {
240+ var prevIndex = model.prevEnabledItem(currentIndex);
241+ if (prevIndex != -1) {
242+ currentIndex = prevIndex;
243+ }
244+ }
245
246 delegate: ListItem {
247- objectName: root.objectName ? root.objectName + "_item" + index : ""
248-
249+ objectName: "itemDelegate_%1".arg(model.index)
250+ readonly property string text: listItemLayout.title.text
251+ enabled: model.enabled
252+ height: listItemLayout.height + (divider.visible ? divider.height : 0)
253 ListItemLayout {
254- title.text: model.text
255+ id: listItemLayout
256+ padding {
257+ top: 0
258+ bottom: 0
259+ leading: 0
260+ trailing: 0
261+ }
262+ height: listItemHeight
263+ title {
264+ height: listItemHeight
265+ verticalAlignment: Text.AlignVCenter
266+ text: model.text
267+ textSize: Label.Small
268+ color: enabled ? theme.palette.normal.baseText : theme.palette.disabled.baseText
269+ }
270+ }
271+
272+ color: (ListView.isCurrentItem || mouseArea.pressed) ? theme.palette.normal.focus : "transparent"
273+ Component.onCompleted: if (model.checked) listView.initialIndex = model.index
274+
275+ Keys.onReturnPressed: selectedItem(model.index)
276+ Keys.onEnterPressed: selectedItem(model.index)
277+ Keys.onTabPressed: selectedItem(model.index)
278+
279+ // Use a separate MouseArea because ListItem.onClicked
280+ // is invoked when opening the popup with the Enter key.
281+ MouseArea {
282+ id: mouseArea
283+ anchors.fill: parent
284+ enabled: model.enabled
285+ onClicked: selectedItem(model.index)
286 }
287- enabled: model.enabled
288- selected: model.checked
289- onClicked: selectedItem(model.index)
290 }
291
292 section.property: "group"
293- section.delegate: ListItems.Header {
294- text: section
295+ section.delegate: ListItem {
296+ objectName: "headerDelegate_%1".arg(model.index)
297+ readonly property string text: listItemLayout.title.text
298+ enabled: model.groupEnabled(section)
299+ height: listItemHeight + (divider.visible ? divider.height : 0)
300+ Component.onCompleted: listView.sectionCount += 1
301+ ListItemLayout {
302+ id: listItemLayout
303+ padding {
304+ top: 0
305+ bottom: 0
306+ leading: 0
307+ trailing: 0
308+ }
309+ height: listItemHeight
310+ title {
311+ verticalAlignment: Text.AlignVCenter
312+ height: listItemHeight
313+ text: section
314+ textSize: Label.Small
315+ font.bold: true
316+ color: enabled ? theme.palette.normal.baseText : theme.palette.disabled.baseText
317+ }
318+ }
319 }
320+ }
321
322- onContentHeightChanged: root.listContentHeight = contentHeight
323+ Scrollbar {
324+ flickableItem: listView
325+ align: Qt.AlignTrailing
326 }
327 }
328diff --git a/qt/uitk/lib/uitk_web_popup_menu.cc b/qt/uitk/lib/uitk_web_popup_menu.cc
329index 7175169..ac18dc5 100644
330--- a/qt/uitk/lib/uitk_web_popup_menu.cc
331+++ b/qt/uitk/lib/uitk_web_popup_menu.cc
332@@ -1,5 +1,5 @@
333 // vim:expandtab:shiftwidth=2:tabstop=2:
334-// Copyright (C) 2016 Canonical Ltd.
335+// Copyright (C) 2016-2017 Canonical Ltd.
336
337 // This library is free software; you can redistribute it and/or
338 // modify it under the terms of the GNU Lesser General Public
339@@ -36,6 +36,8 @@ namespace uitk {
340 using qt::MenuItem;
341
342 class WebPopupMenu::Model : public QAbstractListModel {
343+ Q_OBJECT
344+
345 public:
346 Model(const std::vector<MenuItem>& items);
347 ~Model() override;
348@@ -55,6 +57,10 @@ class WebPopupMenu::Model : public QAbstractListModel {
349 int role = Qt::DisplayRole) const override;
350 QHash<int, QByteArray> roleNames() const override;
351
352+ Q_INVOKABLE int nextEnabledItem(int currentIndex) const;
353+ Q_INVOKABLE int prevEnabledItem(int currentIndex) const;
354+ Q_INVOKABLE bool groupEnabled(const QString& group) const;
355+
356 private:
357 struct Item {
358 QString label;
359@@ -137,12 +143,43 @@ QHash<int, QByteArray> WebPopupMenu::Model::roleNames() const {
360 return roles;
361 }
362
363+int WebPopupMenu::Model::nextEnabledItem(int currentIndex) const {
364+ int index = currentIndex + 1;
365+ while (index < items_.count()) {
366+ if (items_.at(index).enabled) {
367+ return index;
368+ }
369+ ++index;
370+ }
371+ return -1;
372+}
373+
374+int WebPopupMenu::Model::prevEnabledItem(int currentIndex) const {
375+ int index = currentIndex - 1;
376+ while (index >= 0) {
377+ if (items_.at(index).enabled) {
378+ return index;
379+ }
380+ --index;
381+ }
382+ return -1;
383+}
384+
385+bool WebPopupMenu::Model::groupEnabled(const QString& group) const {
386+ for (const auto& item : items_) {
387+ if (item.group == group && item.enabled) {
388+ return true;
389+ }
390+ }
391+ return false;
392+}
393+
394 void WebPopupMenu::Show() {
395- QMetaObject::invokeMethod(item_.get(), "show");
396+ item_->setVisible(true);
397 }
398
399 void WebPopupMenu::Hide() {
400- QMetaObject::invokeMethod(item_.get(), "hide");
401+ item_->setVisible(false);
402 }
403
404 WebPopupMenu::WebPopupMenu(const std::vector<MenuItem>& items,
405@@ -192,22 +229,20 @@ bool WebPopupMenu::Init(QQuickItem* parent) {
406 }
407
408 item_->setProperty("model", QVariant::fromValue(model_.get()));
409+ item_->setProperty("elementBounds", bounds_);
410 item_->setParentItem(parent);
411
412 component.completeCreate();
413
414- connect(item_.get(), &QQuickItem::visibleChanged,
415- this, &WebPopupMenu::OnVisibleChanged);
416+ connect(item_.get(), SIGNAL(closed()), this, SLOT(OnClosed()));
417 connect(item_.get(), SIGNAL(selectedItem(int)),
418 this, SLOT(OnItemSelected(int)));
419
420 return true;
421 }
422
423-void WebPopupMenu::OnVisibleChanged() {
424- if (!item_->isVisible()) {
425- client_->cancel();
426- }
427+void WebPopupMenu::OnClosed() {
428+ client_->cancel();
429 }
430
431 void WebPopupMenu::OnItemSelected(int index) {
432@@ -235,3 +270,5 @@ WebPopupMenu::~WebPopupMenu() = default;
433
434 } // namespace uitk
435 } // namespace oxide
436+
437+#include "uitk_web_popup_menu.moc"
438diff --git a/qt/uitk/lib/uitk_web_popup_menu.h b/qt/uitk/lib/uitk_web_popup_menu.h
439index d1553d2..5fa2772 100644
440--- a/qt/uitk/lib/uitk_web_popup_menu.h
441+++ b/qt/uitk/lib/uitk_web_popup_menu.h
442@@ -1,5 +1,5 @@
443 // vim:expandtab:shiftwidth=2:tabstop=2:
444-// Copyright (C) 2016 Canonical Ltd.
445+// Copyright (C) 2016-2017 Canonical Ltd.
446
447 // This library is free software; you can redistribute it and/or
448 // modify it under the terms of the GNU Lesser General Public
449@@ -56,8 +56,8 @@ class WebPopupMenu : public QObject,
450 ~WebPopupMenu() override;
451
452 private Q_SLOTS:
453- void OnVisibleChanged();
454 void OnItemSelected(int index);
455+ void OnClosed();
456
457 private:
458 WebPopupMenu(const std::vector<qt::MenuItem>& items,

Subscribers

People subscribed via source and target branches