Merge lp:~uriboni/webbrowser-app/newtabview-listviews into lp:webbrowser-app

Proposed by Ugo Riboni
Status: Needs review
Proposed branch: lp:~uriboni/webbrowser-app/newtabview-listviews
Merge into: lp:webbrowser-app
Prerequisite: lp:~artmello/webbrowser-app/webbrowser-app-bookmarks_view
Diff against target: 2461 lines (+1569/-407)
25 files modified
src/app/webbrowser/BookmarksFoldersView.qml (+151/-140)
src/app/webbrowser/BookmarksFoldersViewWide.qml (+1/-2)
src/app/webbrowser/BookmarksHeader.qml (+89/-0)
src/app/webbrowser/BookmarksSection.qml (+74/-0)
src/app/webbrowser/CMakeLists.txt (+2/-0)
src/app/webbrowser/NewTabView.qml (+50/-120)
src/app/webbrowser/UrlsList.qml (+40/-22)
src/app/webbrowser/bookmarks-folderlist-model.cpp (+5/-0)
src/app/webbrowser/bookmarks-folderlist-model.h (+1/-0)
src/app/webbrowser/bookmarks-model.cpp (+12/-0)
src/app/webbrowser/bookmarks-model.h (+5/-1)
src/app/webbrowser/list-aggregator-model.cpp (+240/-0)
src/app/webbrowser/list-aggregator-model.h (+82/-0)
src/app/webbrowser/roles-adapter-model.cpp (+111/-0)
src/app/webbrowser/roles-adapter-model.h (+50/-0)
src/app/webbrowser/webbrowser-app.cpp (+4/-0)
tests/autopilot/webbrowser_app/emulators/browser.py (+48/-17)
tests/autopilot/webbrowser_app/tests/test_bookmark_options.py (+15/-15)
tests/autopilot/webbrowser_app/tests/test_new_tab_view.py (+97/-89)
tests/unittests/CMakeLists.txt (+2/-0)
tests/unittests/bookmarks-model/tst_BookmarksModelTests.cpp (+6/-1)
tests/unittests/list-aggregator-model/CMakeLists.txt (+13/-0)
tests/unittests/list-aggregator-model/tst_ListAggregatorModelTests.cpp (+361/-0)
tests/unittests/roles-adapter-model/CMakeLists.txt (+13/-0)
tests/unittests/roles-adapter-model/tst_RolesAdapterModelTests.cpp (+97/-0)
To merge this branch: bzr merge lp:~uriboni/webbrowser-app/newtabview-listviews
Reviewer Review Type Date Requested Status
system-apps-ci-bot continuous-integration Needs Fixing
PS Jenkins bot continuous-integration Needs Fixing
Olivier Tilloy Needs Fixing
Review via email: mp+274986@code.launchpad.net

Commit message

Refactor BookmarksFoldersView to use a ListView, so performance is not dependent on number of bookmarks. Use this view properly within NewTabView.

Description of the change

Refactor BookmarksFoldersView to use a ListView, so performance is not dependent on number of bookmarks. Use this view properly within NewTabView.

To post a comment you must log in.
Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
1250. By Ugo Riboni

Fix AP tests

1251. By Ugo Riboni

Merge changes from trunk

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
1252. By Ugo Riboni

Fix more AP tests

1253. By Ugo Riboni

Merge changes from trunk

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Approve (continuous-integration)
Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Approve (continuous-integration)
1254. By Ugo Riboni

Merge changes from trunk

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Approve (continuous-integration)
1255. By Ugo Riboni

Merge changes from trunk

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Approve (continuous-integration)
Revision history for this message
Olivier Tilloy (osomon) wrote :

This works, but is a rather complex implementation.

I’ve taken a stab at something much simpler that doesn’t involve aggregator models: the idea is to use a SortFilterModel indeed to sort bookmarks by folder, but instead of filtering out bookmarks for which the folder is collapsed (which as you pointed out wouldn’t work because the corresponding section wouldn’t be displayed by the list view), I’m keeping track of expanded/collapsed state of each section in a separate dictionary, and delegates in the list view are visible or not depending on that state.

This results in much simpler code, with no need for custom C++ models, all is done in QML.

The only thing that this doesn’t achieve, afaict, is prepending the homepage to the model as a hardcoded bookmark. I’ve been thinking about that, and in fact I think modifying the model is the wrong approach. We can leverage the 'header' property of ListView to display a first hardcoded item that’s not in the model.

See a fully functional standalone example at http://pastebin.ubuntu.com/13009316/.
Comments welcome.

Revision history for this message
Olivier Tilloy (osomon) wrote :

I did some profiling on my laptop, comparing the performance of this branch and trunk with a large bookmarks database.

The first set of tests was with a database of 10000 bookmarks (100 folders with 100 URLs in each). In that case, this branch performs slightly better than trunk. The binding on internal.seeMoreBookmarksView for the 'active' property of bookmarksFolderListViewLoader takes 260ms to execute with this branch, vs 330ms with trunk. From a user perspective, there’s no perceived difference, the view takes about half a second to load and be responsive with both branches.

With a database of 100000 bookmarks (100 folders with 1000 URLs in each), trunk performs significantly better than this branch. The binding on internal.seeMoreBookmarksView for the 'active' property of bookmarksFolderListViewLoader takes 2.2s to execute with this branch, vs 1.9s with trunk. But more importantly, it takes ~7s for the view to load and be responsive with this branch, compared to ~3s with trunk.

If we want to optimize for large bookmark databases, this branch doesn’t appear to be improving things, quite the contrary. All of this is anecdotal when we look at the time it takes for the application itself to launch and become responsive though. With 100000 bookmarks, the app takes ~20s to launch. So if we really want to optimize for this use case, we need to remove the loading of the model from the critical startup path, before doing any optimization work on the bookmarks view.

The databases I used for profiling are available there: http://people.canonical.com/~osomon/lotsofbookmarks/.

review: Needs Fixing
Revision history for this message
Olivier Tilloy (osomon) wrote :

Small correction: the application takes ~20s to launch only if the current tab is a new tab view (e.g. if I pressed Ctrl+Tab in the last browsing session, then exited the browser, and launched it again). If it’s a normal tab (with a page loaded), there’s no noticeable slowdown (which is good).

Revision history for this message
Olivier Tilloy (osomon) wrote :

I have profiled the top-level bookmarks view (was profiling the one embedded in the new tab view before), and I’m seeing similar results.

Revision history for this message
Olivier Tilloy (osomon) wrote :

I have profiled the solution I was suggesting in my first comment. With a database of 100000 bookmarks, instantiating the entire view takes 600ms, and it is instantly responsive.

It works well when all sections are expanded, but it slows down considerably when all sections are collapsed (presumably because then there are 1000 invisible delegates instantiated for each section, so if there are 10 collapsed sections on screen that’s 10000 delegates instantiated at any given time).

On top of that the layout of the view is incorrect after expanding/collapsing a section, because the implementation of ListView computes an estimate of its contentHeight when using sections, instead of using the real content height.

Nested list views may not be such a bad idea after all…

1256. By Ugo Riboni

Remove another startup bottleneck by turning the UrlsList into a ListView fed by a LimitProxyModel with the home page bookmarks aggregated at the start. This keeps NewTabView lean at startup.

1257. By Ugo Riboni

Make sure that data change signals from the aggregated models are propagated correctly

1258. By Ugo Riboni

Fix some AP tests after changes in UrlsList

1259. By Ugo Riboni

Fix one more stray reference to a non-singleton BookmarksModel

Revision history for this message
Ugo Riboni (uriboni) wrote :

> I have profiled the solution I was suggesting in my first comment. With a
> database of 100000 bookmarks, instantiating the entire view takes 600ms, and
> it is instantly responsive.
>
> It works well when all sections are expanded, but it slows down considerably
> when all sections are collapsed (presumably because then there are 1000
> invisible delegates instantiated for each section, so if there are 10
> collapsed sections on screen that’s 10000 delegates instantiated at any given
> time).

All the solutions you proposed are not removing the main problem, which is that the performance is still linearly proportional to the number of bookmarks. By making the delegates leaner you are making the degrade in performance slower as the number of bookmarks grow, but the point is that we can't control the number of bookmarks, so the problem is never really solved.

Moreover you are testing with 1K bookmarks per folder, while you will see the app being completely unusable if you put 10K bookmarks in a folder and try to open it (or put them in the root folder, which is open when you open the view).

> On top of that the layout of the view is incorrect after expanding/collapsing
> a section, because the implementation of ListView computes an estimate of its
> contentHeight when using sections, instead of using the real content height.

Hiding delegates by making their height zero is essentially an hack, in my opinion: it works in some cases but it is not robust and/or scalable, and denies what is arguably the main benefit of using ListView in the first place: delegates are created and destroyed on-demand.

> Nested list views may not be such a bad idea after all…

How would you go about implementing nested ListViews ? A ListView to work needs to know the height of its delegates, and if each delegate is a ListView then they need to have height equal to contentHeight, which instantiates all the sub-list delegates. You can limit the height of each sub-ListView to a certain fixed amount, but then you have scrolling problems because either you scroll the main ListView or the sub-ListView.

Revision history for this message
Ugo Riboni (uriboni) wrote :

> If we want to optimize for large bookmark databases, this branch doesn’t
> appear to be improving things, quite the contrary. All of this is anecdotal
> when we look at the time it takes for the application itself to launch and
> become responsive though. With 100000 bookmarks, the app takes ~20s to launch [when the new tab view is visible]

The problem was that the collapsed bookmarks view was still a Column+Repeater+Loader and that was causing the long startup. This has been solved by use of a ListView backed by a LimitProxyModel.

With this change, I can't find any major bottleneck that prevent the app to be usable in any condition.

Revision history for this message
Ugo Riboni (uriboni) wrote :

> The binding on internal.seeMoreBookmarksView for the 'active' property of
> bookmarksFolderListViewLoader takes 2.2s to execute with this branch, vs 1.9s
> with trunk. But more importantly, it takes ~7s for the view to load and be
> responsive with this branch, compared to ~3s with trunk.

This bottleneck is essentially the time that it takes to expand the bookmarks view.
I did some tests with your 100K model, which has 100 folders, and another model which has 100K bookmarks but no folders.
Yours took 220ms on my machine, the other 8ms.

What is causing the difference is having to allocate each single folder/section placeholder model.

So basically the problem is now shifted from making the performance proportional to the number of bookmarks, to having the performance proportional to the number of folders. It is less of a problem but of course still a problem since we can't control the number of folders that the user can create.

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
1260. By Ugo Riboni

Prevent the home bookmark from being deleted

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
1261. By Ugo Riboni

Fix the utility method removing the first real bookmark in the list by taking into account the peculiarity of working with a LimitProxyModel

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Approve (continuous-integration)
Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Approve (continuous-integration)
1262. By Ugo Riboni

Merge changes from trunk

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Olivier Tilloy (osomon) wrote :

308 + removable: url != homeBookmarkUrl

This condition is not correct: what if the user has added the homepage as a bookmark, manually? They should be allowed to remove it. It would probably be more correct to make that depend on the index (i.e. only the first in the list cannot be removed).

Can we consider adding the homepage bookmark to the BookmarksModel at the C++ level, instead of jumping through hoops to prepend it in javascript? We could add a 'removable' role to the model.

Revision history for this message
Olivier Tilloy (osomon) wrote :

Reflecting further on the problem we’re trying to solve, I just had an idea (just thinking out loud here, let’s see if you think it can fly):

 How about writing a custom proxy model that uses the BookmarksModel as input, and outputs a list that contains folders (collapsed or expanded) and bookmarks, in the expected order? There would be an additional role that describes what an entry is (is it a bookmark, is it a collapsed folder, or an expanded folder?). The model would have to keep track of the expanded/status state of each folder, and there would be a method to toggle the expanded/collapsed state of a folder (which would result in row insertions/removals). With this, a simple list view (with no section headers) would suffice, the appearance of the delegates would depend on their nature.

If I understand correctly what you did, the idea is similar, but much less generic than your implementation, and all done in C++ as opposed to a mix of C++ and QML/JS. It also doesn’t use sections, which should make it simpler (and maybe more efficient).

This would also allow adding the hardcoded homepage bookmark without modifying the source BookmarksModel.

How does that sound? Have I overlooked important details?

Revision history for this message
Olivier Tilloy (osomon) wrote :

Here is the merge request that implements the idea I described above: https://code.launchpad.net/~osomon/webbrowser-app/bookmarks-proxy-model/+merge/279277.

With a single model, only the initial instantiation of the model is costly (260ms for 100000 entries, 40ms for 10000 entries), there’s no additional overhead.

The code ended up slightly more complex than I anticipated, because I made it possible to change at runtime whether to show empty folders, and whether to prepend the homepage bookmark. That’s more code and complexity, but it’s 100% unit tested, and shields us from future design changes.

Revision history for this message
system-apps-ci-bot (system-apps-ci-bot) wrote :
review: Needs Fixing (continuous-integration)

Unmerged revisions

1262. By Ugo Riboni

Merge changes from trunk

1261. By Ugo Riboni

Fix the utility method removing the first real bookmark in the list by taking into account the peculiarity of working with a LimitProxyModel

1260. By Ugo Riboni

Prevent the home bookmark from being deleted

1259. By Ugo Riboni

Fix one more stray reference to a non-singleton BookmarksModel

1258. By Ugo Riboni

Fix some AP tests after changes in UrlsList

1257. By Ugo Riboni

Make sure that data change signals from the aggregated models are propagated correctly

1256. By Ugo Riboni

Remove another startup bottleneck by turning the UrlsList into a ListView fed by a LimitProxyModel with the home page bookmarks aggregated at the start. This keeps NewTabView lean at startup.

1255. By Ugo Riboni

Merge changes from trunk

1254. By Ugo Riboni

Merge changes from trunk

1253. By Ugo Riboni

Merge changes from trunk

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/app/webbrowser/BookmarksFoldersView.qml'
2--- src/app/webbrowser/BookmarksFoldersView.qml 2015-10-22 08:12:32 +0000
3+++ src/app/webbrowser/BookmarksFoldersView.qml 2015-11-16 09:11:43 +0000
4@@ -27,163 +27,174 @@
5
6 property alias interactive: bookmarksFolderListView.interactive
7 property url homeBookmarkUrl
8+ property alias headerComponent: bookmarksFolderListView.header
9
10 signal bookmarkClicked(url url)
11 signal bookmarkRemoved(url url)
12
13- height: bookmarksFolderListView.contentHeight
14+ /* The main model feeding this view is based on an aggregation of all the
15+ entries for each folder in a BookmarksFolderListModel.
16+
17+ The reason we do this instead of using a SortFilterModel to re-order the
18+ BookmarkModel by folder name, is because we need to be able to "collapse"
19+ (i.e. hide) the folders. This we could still have done with a SortFilterModel
20+ by setting its filter to exclude the folders we wanted to hide.
21+
22+ However the ListView needs to have at least 1 item inside a section to
23+ display the section at all, so we need to replace the hidden elements
24+ with placeholders, and this seems impossible with a SortFilterModel.
25+
26+ We also need to aggregate the homepage bookmark to the root folder, and
27+ again using a SortFilterModel would not allow this.
28+ */
29+ ListAggregatorModel {
30+ id: aggregator
31+ }
32
33 BookmarksFolderListModel {
34- id: bookmarksFolderListModel
35+ id: folders
36 sourceModel: BookmarksModel
37+ Component.onCompleted: { for (var i = 0; i < folders.count; i++) insertFolderAt(i) }
38+ onRowsInserted: { for (var i = first; i <= last; i++) insertFolderAt(i) }
39+ // NOTE: There is currently no way to remove bookmark folders from the sourceModel,
40+ // therefore onRowsRemoved is not implemented, and placeholders are destroyed
41+ // together with the view (as they are parented to it)
42+
43+ function insertFolderAt(i) {
44+ var folder = folders.get(i)
45+
46+ /* The ListView won't render a section at all if there isn't at least one item
47+ within that section. Therefore we need to use invisible section placeholder
48+ items when the section is collapsed. We do this by removing from the aggregation
49+ the list of items and replacing it with our placeholder: an instance of a
50+ ListModel with a single item with the right folder name.
51+ We store these placeholders in the "sections" list for convenience,
52+ together with the expanded state of the section they would belong to. */
53+ var section = {
54+ // The first section starts expanded, by design. We also keep expanded any
55+ // sections where the folder has no items, so that the ListView will totally
56+ // hide them. This is also required by design.
57+ expanded: i === 0 || folder.entries.count === 0,
58+ placeholder: sectionPlaceholderModel.createObject(bookmarksFoldersViewItem, {})
59+ }
60+ section.placeholder.sourceModel.setProperty(0, "folderName", folder.folderName);
61+ sections.insert(i, section)
62+
63+ aggregator.insertModel(section.expanded ? sections.entriesFor(folder) : section.placeholder, i)
64+ }
65+ }
66+
67+ // This model will aggregate the home bookmark (from homeBookmarkModel below) and
68+ // the root folder (i.e. the "All bookmarks" folder)
69+ ListAggregatorModel {
70+ id: rootFolderModel
71+ }
72+
73+ RolesAdapterModel {
74+ id: homeBookmarkModel
75+ rolesSource: BookmarksModel
76+ sourceModel: ListModel {
77+ objectName: "rootfolder"
78+ ListElement { folderName: "__"; folder: ""; title: ""; url: ""; icon: "" }
79+ }
80+ }
81+
82+ ListModel {
83+ id: sections
84+
85+ /* Only the section property value is exposed to the section delegate.
86+ Our sections are unique since the model is ordered by section, so we
87+ can safely iterate until we find the section in the section list. */
88+ function indexOf(section) {
89+ for (var i = 0; i < folders.count; i++) {
90+ if (folders.get(i).folderName === section) {
91+ return i;
92+ }
93+ }
94+ return -1;
95+ }
96+
97+ function toggle(index) {
98+ var wasExpanded = sections.get(index).expanded
99+ aggregator.removeModel(index)
100+ var newItem = wasExpanded ? sections.get(index).placeholder :
101+ entriesFor(folders.get(index))
102+ aggregator.insertModel(newItem, index)
103+ sections.setProperty(index, "expanded", !wasExpanded)
104+ }
105+
106+ function entriesFor(folder) {
107+ if (folder.folderName === "__") {
108+ if (rootFolderModel.count === 0) {
109+ rootFolderModel.insertModel(folder.entries, 0)
110+
111+ // ListElement only accept constants at declaration, so we need
112+ // to define these here before aggregating
113+ homeBookmarkModel.sourceModel.setProperty(0, "url", homeBookmarkUrl.toString())
114+ homeBookmarkModel.sourceModel.setProperty(0, "title", i18n.tr("Homepage"))
115+ rootFolderModel.insertModel(homeBookmarkModel, 0)
116+ }
117+ return rootFolderModel
118+ } else return folder.entries
119+ }
120+ }
121+
122+ Component {
123+ id: sectionPlaceholderModel
124+ /* We need to use a RolesAdapterModel here because ListElements have
125+ roles with values starting at 0, while generally our models
126+ implemented in C++ have roles starting at Qt::UserRole + 1 */
127+ RolesAdapterModel {
128+ rolesSource: BookmarksModel
129+ sourceModel: ListModel {
130+ // Provide empty values to define role names, so we get no
131+ // warnings when using them as placeholders
132+ ListElement { folderName: ""; folder: ""; title: ""; url: ""; icon: "" }
133+ }
134+ }
135 }
136
137 ListView {
138 id: bookmarksFolderListView
139 anchors.fill: parent
140- interactive: false
141 focus: true
142
143- model: bookmarksFolderListModel
144- delegate: Loader {
145+ model: aggregator
146+ section.property: "folderName"
147+ section.delegate: BookmarksSection {
148+ objectName: "bookmarkFolderDelegate"
149+ folderName: section
150+ height: units.gu(6.5)
151+
152 anchors {
153 left: parent.left
154 right: parent.right
155- }
156-
157- height: active ? item.height : 0
158- active: entries.count > 0
159-
160- sourceComponent: Item {
161- objectName: "bookmarkFolderDelegate"
162-
163- property string folderName: folder
164-
165- anchors {
166- left: parent ? parent.left : undefined
167- right: parent ? parent.right : undefined
168- }
169-
170- height: delegateColumn.height
171-
172- Column {
173- id: delegateColumn
174-
175- property bool expanded: folderName ? false : true
176-
177- anchors {
178- left: parent.left
179- right: parent.right
180- }
181-
182- Item {
183- objectName: "bookmarkFolderHeader"
184-
185- anchors {
186- left: parent.left
187- right: parent.right
188- leftMargin: units.gu(2)
189- rightMargin: units.gu(2)
190- }
191-
192- height: units.gu(6.5)
193-
194- Row {
195- anchors {
196- left: parent.left
197- leftMargin: units.gu(1.5)
198- right: parent.right
199- }
200-
201- height: units.gu(6)
202- spacing: units.gu(1.5)
203-
204- Icon {
205- id: expandedIcon
206- name: delegateColumn.expanded ? "go-down" : "go-next"
207-
208- height: units.gu(2)
209- width: height
210-
211- anchors {
212- leftMargin: units.gu(1)
213- topMargin: units.gu(2)
214- top: parent.top
215- }
216- }
217-
218- Label {
219- width: parent.width - expandedIcon.width - units.gu(3)
220- anchors.verticalCenter: expandedIcon.verticalCenter
221-
222- text: folderName ? folderName : i18n.tr("All Bookmarks")
223- fontSize: "small"
224- }
225- }
226-
227- ListItem.ThinDivider {
228- anchors {
229- left: parent.left
230- right: parent.right
231- bottom: parent.bottom
232- bottomMargin: units.gu(1)
233- }
234- }
235-
236- MouseArea {
237- anchors.fill: parent
238- onClicked: delegateColumn.expanded = !delegateColumn.expanded
239- }
240- }
241-
242- Loader {
243- anchors {
244- left: parent.left
245- right: parent.right
246- }
247-
248- height: item ? item.contentHeight : 0
249-
250- visible: status == Loader.Ready
251-
252- active: delegateColumn.expanded
253- sourceComponent: ListView {
254- readonly property bool isAllBookmarksFolder: folder === ""
255-
256- interactive: false
257-
258- model: {
259- if (isAllBookmarksFolder) {
260- return BookmarksModelUtils.prependHomepageToBookmarks(entries, {
261- title: i18n.tr("Homepage"),
262- url: bookmarksFoldersViewItem.homeBookmarkUrl
263- })
264- }
265-
266- return entries
267- }
268-
269- delegate: UrlDelegate{
270- id: urlDelegate
271-
272- property var entry: isAllBookmarksFolder ? modelData : model
273-
274- width: parent.width
275- height: units.gu(5)
276-
277- removable: !isAllBookmarksFolder || index !== 0
278-
279- icon: entry.icon ? entry.icon : ""
280- title: entry.title ? entry.title : entry.url
281- url: entry.url
282-
283- onClicked: bookmarksFoldersViewItem.bookmarkClicked(url)
284- onRemoved: bookmarksFoldersViewItem.bookmarkRemoved(url)
285- }
286- }
287- }
288- }
289- }
290+ leftMargin: units.gu(2)
291+ rightMargin: units.gu(2)
292+ }
293+
294+ property int index: sections.indexOf(section)
295+ isExpanded: sections.get(index).expanded
296+ onTriggered: sections.toggle(index)
297+ }
298+
299+ delegate: UrlDelegate {
300+ id: urlDelegate
301+ objectName: "bookmarkDelegate_in_" + entry.folderName
302+
303+ property var entry: model
304+
305+ width: parent.width
306+ height: url === "" ? 0 : units.gu(5)
307+
308+ removable: url != homeBookmarkUrl
309+
310+ icon: entry.icon ? entry.icon : ""
311+ title: entry.title ? entry.title : entry.url
312+ url: entry.url
313+
314+ onClicked: bookmarksFoldersViewItem.bookmarkClicked(url)
315+ onRemoved: bookmarksFoldersViewItem.bookmarkRemoved(url)
316 }
317 }
318 }
319
320=== modified file 'src/app/webbrowser/BookmarksFoldersViewWide.qml'
321--- src/app/webbrowser/BookmarksFoldersViewWide.qml 2015-10-22 08:12:32 +0000
322+++ src/app/webbrowser/BookmarksFoldersViewWide.qml 2015-11-16 09:11:43 +0000
323@@ -195,8 +195,7 @@
324 bookmarksList.interactive = true
325
326 if (dragAndDrop.target && dragAndDrop.target.folderName !== folder) {
327- bookmarksFoldersViewWideItem.model.update(entry.url, entry.title,
328- dragAndDrop.target.folderName)
329+ BookmarksModel.update(entry.url, entry.title, dragAndDrop.target.folderName)
330 dragAndDrop.success = true
331 }
332 }
333
334=== added file 'src/app/webbrowser/BookmarksHeader.qml'
335--- src/app/webbrowser/BookmarksHeader.qml 1970-01-01 00:00:00 +0000
336+++ src/app/webbrowser/BookmarksHeader.qml 2015-11-16 09:11:43 +0000
337@@ -0,0 +1,89 @@
338+/*
339+ * Copyright 2015 Canonical Ltd.
340+ *
341+ * This file is part of webbrowser-app.
342+ *
343+ * webbrowser-app is free software; you can redistribute it and/or modify
344+ * it under the terms of the GNU General Public License as published by
345+ * the Free Software Foundation; version 3.
346+ *
347+ * webbrowser-app is distributed in the hope that it will be useful,
348+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
349+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
350+ * GNU General Public License for more details.
351+ *
352+ * You should have received a copy of the GNU General Public License
353+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
354+ */
355+
356+import QtQuick 2.4
357+import Ubuntu.Components 1.3
358+
359+Column {
360+ id: header
361+ property bool moreButtonVisible: false
362+ property bool moreButtonIsMore: true
363+ signal clicked()
364+
365+ height: childrenRect.height
366+
367+ Row {
368+ height: units.gu(6)
369+ spacing: units.gu(1.5)
370+
371+ anchors {
372+ left: parent.left
373+ leftMargin: units.gu(1.5)
374+ right: parent.right
375+ }
376+
377+ Icon {
378+ id: starredIcon
379+ color: "#dd4814"
380+ name: "starred"
381+
382+ height: units.gu(2)
383+ width: height
384+
385+ anchors {
386+ leftMargin: units.gu(1)
387+ topMargin: units.gu(1)
388+ verticalCenter: moreButton.verticalCenter
389+ }
390+ }
391+
392+ Label {
393+ width: parent.width - starredIcon.width - moreButton.width - units.gu(3)
394+ anchors.verticalCenter: moreButton.verticalCenter
395+
396+ text: i18n.tr("Bookmarks")
397+ fontSize: "small"
398+ }
399+
400+ Button {
401+ id: moreButton
402+ objectName: "bookmarks.moreButton"
403+ height: parent.height - units.gu(2)
404+
405+ anchors {
406+ top: parent.top
407+ topMargin: units.gu(1)
408+ }
409+
410+ opacity: moreButtonVisible ? 1.0 : 0.0
411+ strokeColor: UbuntuColors.darkGrey
412+ text: moreButtonIsMore ? i18n.tr("More") : i18n.tr("Less")
413+
414+ onClicked: header.clicked()
415+ }
416+ }
417+
418+ Rectangle {
419+ height: units.gu(0.1)
420+ anchors {
421+ left: parent.left
422+ right: parent.right
423+ }
424+ color: "#d3d3d3"
425+ }
426+}
427
428=== added file 'src/app/webbrowser/BookmarksSection.qml'
429--- src/app/webbrowser/BookmarksSection.qml 1970-01-01 00:00:00 +0000
430+++ src/app/webbrowser/BookmarksSection.qml 2015-11-16 09:11:43 +0000
431@@ -0,0 +1,74 @@
432+/*
433+ * Copyright 2015 Canonical Ltd.
434+ *
435+ * This file is part of webbrowser-app.
436+ *
437+ * webbrowser-app is free software; you can redistribute it and/or modify
438+ * it under the terms of the GNU General Public License as published by
439+ * the Free Software Foundation; version 3.
440+ *
441+ * webbrowser-app is distributed in the hope that it will be useful,
442+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
443+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
444+ * GNU General Public License for more details.
445+ *
446+ * You should have received a copy of the GNU General Public License
447+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
448+ */
449+
450+import QtQuick 2.4
451+import Ubuntu.Components 1.3
452+import Ubuntu.Components.ListItems 1.3 as ListItems
453+
454+AbstractButton {
455+ id: delegateColumn
456+ property string folderName
457+ property bool isExpanded: true
458+
459+ Column {
460+ anchors.top: parent.top
461+ anchors.left: parent.left
462+ anchors.right: parent.right
463+
464+ Row {
465+ anchors {
466+ left: parent.left
467+ leftMargin: units.gu(1.5)
468+ right: parent.right
469+ }
470+
471+ height: units.gu(6)
472+ spacing: units.gu(1.5)
473+
474+ Icon {
475+ id: expandedIcon
476+ name: delegateColumn.isExpanded ? "go-down" : "go-next"
477+
478+ height: units.gu(2)
479+ width: height
480+
481+ anchors {
482+ leftMargin: units.gu(1)
483+ topMargin: units.gu(2)
484+ top: parent.top
485+ }
486+ }
487+
488+ Label {
489+ width: parent.width - expandedIcon.width - units.gu(3)
490+ anchors.verticalCenter: expandedIcon.verticalCenter
491+
492+ text: folderName !== "__" ? folderName : i18n.tr("All bookmarks")
493+ fontSize: "small"
494+ }
495+ }
496+
497+ ListItems.ThinDivider {
498+ anchors {
499+ left: parent.left
500+ right: parent.right
501+ bottomMargin: units.gu(1)
502+ }
503+ }
504+ }
505+}
506
507=== modified file 'src/app/webbrowser/CMakeLists.txt'
508--- src/app/webbrowser/CMakeLists.txt 2015-10-18 19:21:48 +0000
509+++ src/app/webbrowser/CMakeLists.txt 2015-11-16 09:11:43 +0000
510@@ -21,6 +21,8 @@
511 history-model.cpp
512 history-timeframe-model.cpp
513 limit-proxy-model.cpp
514+ list-aggregator-model.cpp
515+ roles-adapter-model.cpp
516 tabs-model.cpp
517 text-search-filter-model.cpp
518 top-sites-model.cpp
519
520=== modified file 'src/app/webbrowser/NewTabView.qml'
521--- src/app/webbrowser/NewTabView.qml 2015-10-22 08:12:32 +0000
522+++ src/app/webbrowser/NewTabView.qml 2015-11-16 09:11:43 +0000
523@@ -63,10 +63,10 @@
524 }
525
526 Flickable {
527+ objectName: "collapsedContents"
528 anchors.fill: parent
529- contentHeight: internal.seeMoreBookmarksView ?
530- bookmarksFolderListViewLoader.height + units.gu(6) :
531- contentColumn.height
532+ contentHeight: contentColumn.height
533+ visible: !internal.seeMoreBookmarksView
534
535 Column {
536 id: contentColumn
537@@ -77,87 +77,20 @@
538 }
539 height: childrenRect.height
540
541- Row {
542- height: units.gu(6)
543- anchors {
544- left: parent.left
545- leftMargin: units.gu(1.5)
546- right: parent.right
547- }
548- spacing: units.gu(1.5)
549-
550- Icon {
551- id: starredIcon
552- color: "#dd4814"
553- name: "starred"
554-
555- height: units.gu(2)
556- width: height
557-
558- anchors {
559- leftMargin: units.gu(1)
560- topMargin: units.gu(1)
561- verticalCenter: moreButton.verticalCenter
562- }
563- }
564-
565- Label {
566- width: parent.width - starredIcon.width - moreButton.width - units.gu(3)
567- anchors.verticalCenter: moreButton.verticalCenter
568-
569- text: i18n.tr("Bookmarks")
570- fontSize: "small"
571- }
572-
573- Button {
574- id: moreButton
575- objectName: "bookmarks.moreButton"
576- height: parent.height - units.gu(2)
577-
578- anchors { top: parent.top; topMargin: units.gu(1) }
579-
580- strokeColor: UbuntuColors.darkGrey
581-
582- visible: internal.numberOfBookmarks > 4
583-
584- text: internal.seeMoreBookmarksView ? i18n.tr("Less") : i18n.tr("More")
585-
586- onClicked: internal.seeMoreBookmarksView = !internal.seeMoreBookmarksView
587- }
588- }
589-
590- Rectangle {
591- height: units.gu(0.1)
592- anchors {
593- left: parent.left
594- leftMargin: units.gu(1.5)
595- right: parent.right
596- }
597- color: "#d3d3d3"
598- }
599-
600- Loader {
601- id: bookmarksFolderListViewLoader
602-
603- anchors {
604- left: parent.left
605- right: parent.right
606- }
607-
608- height: status == Loader.Ready ? item.height : 0
609-
610- active: internal.seeMoreBookmarksView
611-
612- sourceComponent: BookmarksFoldersView {
613- homeBookmarkUrl: newTabView.settingsObject.homepage
614-
615- onBookmarkClicked: newTabView.bookmarkClicked(url)
616- onBookmarkRemoved: newTabView.bookmarkRemoved(url)
617- }
618- }
619-
620- Column {
621- id: bookmarksColumn
622+ BookmarksHeader {
623+ anchors {
624+ left: parent.left
625+ leftMargin: units.gu(1.5)
626+ right: parent.right
627+ }
628+
629+ moreButtonIsMore: true
630+ moreButtonVisible: internal.numberOfBookmarks > 4
631+ onClicked: internal.seeMoreBookmarksView = !internal.seeMoreBookmarksView
632+ }
633+
634+ UrlsList {
635+ objectName: "bookmarksList"
636 anchors {
637 left: parent.left
638 right: parent.right
639@@ -167,42 +100,12 @@
640 Behavior on opacity { UbuntuNumberAnimation {} }
641 visible: opacity > 0
642
643- // Force the height to be updated when bookmarks are removed
644- // in another new tab
645- height: units.gu(5) * (Math.min(internal.bookmarksCountLimit, internal.numberOfBookmarks) + 1)
646- spacing: 0
647-
648- UrlDelegate {
649- objectName: "homepageBookmark"
650- anchors {
651- left: parent.left
652- right: parent.right
653- }
654- height: units.gu(5)
655-
656- title: i18n.tr('Homepage')
657-
658- leadingActions: null
659-
660- url: newTabView.settingsObject.homepage
661- onClicked: newTabView.bookmarkClicked(url)
662- }
663-
664- UrlsList {
665- objectName: "bookmarksList"
666- anchors {
667- left: parent.left
668- right: parent.right
669- }
670-
671- spacing: 0
672- limit: internal.bookmarksCountLimit
673-
674- model: BookmarksModel
675-
676- onUrlClicked: newTabView.bookmarkClicked(url)
677- onUrlRemoved: newTabView.bookmarkRemoved(url)
678- }
679+ limit: internal.bookmarksCountLimit
680+ homeBookmarkUrl: newTabView.settingsObject.homepage
681+ height: contentItem.height
682+
683+ onUrlClicked: newTabView.bookmarkClicked(url)
684+ onUrlRemoved: newTabView.bookmarkRemoved(url)
685 }
686
687 Item {
688@@ -319,4 +222,31 @@
689 }
690 }
691 }
692+
693+ Loader {
694+ id: bookmarksFolderListViewLoader
695+
696+ anchors.fill: parent
697+
698+ active: internal.seeMoreBookmarksView
699+
700+ sourceComponent: BookmarksFoldersView {
701+ homeBookmarkUrl: newTabView.settingsObject.homepage
702+
703+ onBookmarkClicked: newTabView.bookmarkClicked(url)
704+ onBookmarkRemoved: newTabView.bookmarkRemoved(url)
705+
706+ headerComponent: BookmarksHeader {
707+ anchors {
708+ left: parent.left
709+ leftMargin: units.gu(1.5)
710+ right: parent.right
711+ rightMargin: units.gu(1.5)
712+ }
713+ moreButtonVisible: true
714+ moreButtonIsMore: false
715+ onClicked: internal.seeMoreBookmarksView = !internal.seeMoreBookmarksView
716+ }
717+ }
718+ }
719 }
720
721=== modified file 'src/app/webbrowser/UrlsList.qml'
722--- src/app/webbrowser/UrlsList.qml 2015-10-08 14:47:13 +0000
723+++ src/app/webbrowser/UrlsList.qml 2015-11-16 09:11:43 +0000
724@@ -18,35 +18,53 @@
725
726 import QtQuick 2.4
727 import Ubuntu.Components 1.3
728+import webbrowserapp.private 0.1
729
730-Column {
731+ListView {
732 id: urlsList
733
734- property alias model: urlsListRepeater.model
735- property int limit: -1
736+ property alias limit: bookmarks.limit
737+ property string homeBookmarkUrl
738
739 signal urlClicked(url url)
740 signal urlRemoved(url url)
741
742- spacing: units.gu(1)
743-
744- Repeater {
745- id: urlsListRepeater
746-
747- delegate: Loader {
748- active: limit < 0 || index < limit
749- sourceComponent: UrlDelegate{
750- id: urlDelegate
751- width: urlsList.width
752- height: units.gu(5)
753-
754- icon: model ? model.icon : ""
755- title: model.title ? model.title : model.url
756- url: model ? model.url : ""
757-
758- onClicked: urlsList.urlClicked(model.url)
759- onRemoved: urlsList.urlRemoved(model.url)
760- }
761+ model: ListAggregatorModel {}
762+
763+ delegate: UrlDelegate{
764+ id: urlDelegate
765+ width: urlsList.width
766+ height: units.gu(5)
767+
768+ icon: model ? model.icon : ""
769+ title: model.title ? model.title : model.url
770+ url: model ? model.url : ""
771+ removable: model.url !== homeBookmarkUrl
772+
773+ onClicked: urlsList.urlClicked(model.url)
774+ onRemoved: urlsList.urlRemoved(model.url)
775+ }
776+
777+ RolesAdapterModel {
778+ id: homeBookmark
779+ rolesSource: BookmarksModel
780+ sourceModel: ListModel {
781+ objectName: "rootfolder"
782+ ListElement { folderName: "__"; folder: ""; title: ""; url: ""; icon: "" }
783 }
784 }
785+
786+ LimitProxyModel {
787+ id: bookmarks
788+ sourceModel: BookmarksModel
789+ }
790+
791+ Component.onCompleted: {
792+ // ListElement only accept constants at declaration, so we need
793+ // to define these here before aggregating
794+ homeBookmark.sourceModel.setProperty(0, "url", homeBookmarkUrl.toString())
795+ homeBookmark.sourceModel.setProperty(0, "title", i18n.tr("Homepage"))
796+ urlsList.model.appendModel(homeBookmark)
797+ urlsList.model.appendModel(bookmarks)
798+ }
799 }
800
801=== modified file 'src/app/webbrowser/bookmarks-folderlist-model.cpp'
802--- src/app/webbrowser/bookmarks-folderlist-model.cpp 2015-08-05 16:53:36 +0000
803+++ src/app/webbrowser/bookmarks-folderlist-model.cpp 2015-11-16 09:11:43 +0000
804@@ -49,6 +49,7 @@
805 static QHash<int, QByteArray> roles;
806 if (roles.isEmpty()) {
807 roles[Folder] = "folder";
808+ roles[FolderName] = "folderName";
809 roles[Entries] = "entries";
810 }
811 return roles;
812@@ -71,6 +72,10 @@
813 switch (role) {
814 case Folder:
815 return folder;
816+ case FolderName:
817+ // Provided to support creating ListView sections when using data from
818+ // this model. See note in BookmarksModel::data() for more details.
819+ return folder.length() == 0 ? BookmarksModel::RootFolderDisplayName : folder;
820 case Entries:
821 return QVariant::fromValue(entries);
822 default:
823
824=== modified file 'src/app/webbrowser/bookmarks-folderlist-model.h'
825--- src/app/webbrowser/bookmarks-folderlist-model.h 2015-08-05 16:53:36 +0000
826+++ src/app/webbrowser/bookmarks-folderlist-model.h 2015-11-16 09:11:43 +0000
827@@ -42,6 +42,7 @@
828
829 enum Roles {
830 Folder = Qt::UserRole + 1,
831+ FolderName,
832 Entries
833 };
834
835
836=== modified file 'src/app/webbrowser/bookmarks-model.cpp'
837--- src/app/webbrowser/bookmarks-model.cpp 2015-07-27 15:36:59 +0000
838+++ src/app/webbrowser/bookmarks-model.cpp 2015-11-16 09:11:43 +0000
839@@ -52,6 +52,8 @@
840 QSqlDatabase::removeDatabase(CONNECTION_NAME);
841 }
842
843+const QString BookmarksModel::RootFolderDisplayName = QString("__");
844+
845 void BookmarksModel::resetDatabase(const QString& databaseName)
846 {
847 beginResetModel();
848@@ -174,6 +176,7 @@
849 roles[Icon] = "icon";
850 roles[Created] = "created";
851 roles[Folder] = "folder";
852+ roles[FolderName] = "folderName";
853 }
854 return roles;
855 }
856@@ -201,6 +204,15 @@
857 return entry.created;
858 case Folder:
859 return entry.folder;
860+ case FolderName:
861+ // This role exists so that sections can be created when using the data
862+ // in a ListView. By convention the name of the root folder where all
863+ // bookmarks that are not grouped within any folder go has a string of
864+ // zero length for name.
865+ // Unfortunately a ListView won't create any section for empty values of
866+ // the section property, so we have to add this role and use it as the
867+ // section property instead.
868+ return entry.folder.isEmpty() ? RootFolderDisplayName : entry.folder;
869 default:
870 return QVariant();
871 }
872
873=== modified file 'src/app/webbrowser/bookmarks-model.h'
874--- src/app/webbrowser/bookmarks-model.h 2015-06-09 13:11:21 +0000
875+++ src/app/webbrowser/bookmarks-model.h 2015-11-16 09:11:43 +0000
876@@ -46,7 +46,8 @@
877 Title,
878 Icon,
879 Created,
880- Folder
881+ Folder,
882+ FolderName
883 };
884
885 // reimplemented from QAbstractListModel
886@@ -65,12 +66,15 @@
887 Q_INVOKABLE void remove(const QUrl& url);
888 Q_INVOKABLE void update(const QUrl& url, const QString& title, const QString& folder);
889
890+ static const QString RootFolderDisplayName;
891+
892 Q_SIGNALS:
893 void databasePathChanged() const;
894 void folderAdded(const QString& folder) const;
895 void added(const QUrl& url) const;
896 void removed(const QUrl& url) const;
897 void rowCountChanged();
898+ void rootFolderNameChanged() const;
899
900 private:
901 QSqlDatabase m_database;
902
903=== added file 'src/app/webbrowser/list-aggregator-model.cpp'
904--- src/app/webbrowser/list-aggregator-model.cpp 1970-01-01 00:00:00 +0000
905+++ src/app/webbrowser/list-aggregator-model.cpp 2015-11-16 09:11:43 +0000
906@@ -0,0 +1,240 @@
907+/*
908+ * Copyright (C) 2010-2015 Canonical, Ltd.
909+ *
910+ * This file is part of webbrowser-app.
911+ *
912+ * webbrowser-app is free software; you can redistribute it and/or modify
913+ * it under the terms of the GNU General Public License as published by
914+ * the Free Software Foundation; version 3.
915+ *
916+ * webbrowser-app is distributed in the hope that it will be useful,
917+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
918+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
919+ * GNU General Public License for more details.
920+ *
921+ * You should have received a copy of the GNU General Public License
922+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
923+ */
924+
925+#include "list-aggregator-model.h"
926+
927+// Qt
928+#include <QtCore/QDebug>
929+#include <QtCore/QSortFilterProxyModel>
930+
931+ListAggregatorModel::ListAggregatorModel(QObject* parent) :
932+ QAbstractListModel(parent)
933+{
934+}
935+
936+ListAggregatorModel::~ListAggregatorModel()
937+{
938+}
939+
940+QHash<int, QByteArray> ListAggregatorModel::roleNames() const
941+{
942+ return m_currentRoles;
943+}
944+
945+void ListAggregatorModel::appendModel(const QVariant& model)
946+{
947+ insertModel(model, m_models.count());
948+}
949+
950+void ListAggregatorModel::insertModel(const QVariant& model, int index)
951+{
952+ static const char* errorMsg = "Unable to append a model that is not of type QAbstractListModel.";
953+ if (!model.isValid()) {
954+ qWarning() << errorMsg << "Invalid model.";
955+ return;
956+ }
957+ QObject* object = qvariant_cast<QObject*>(model);
958+ if (object == NULL) {
959+ qWarning() << errorMsg << model << "is of type" << model.typeName();
960+ return;
961+ }
962+
963+ QAbstractItemModel* list = qobject_cast<QAbstractListModel*>(object);
964+ if (list == NULL) {
965+ list = qobject_cast<QAbstractProxyModel*>(object);
966+ if (list == NULL) {
967+ qWarning() << errorMsg << object->objectName() << "is of type" << object->metaObject()->className();
968+ return;
969+ }
970+ }
971+ aggregateListModel(list, index);
972+}
973+
974+void ListAggregatorModel::aggregateListModel(QAbstractItemModel* model, int index)
975+{
976+ if (model == 0) {
977+ return;
978+ }
979+
980+ if (m_currentRoles.count() == 0) {
981+ beginResetModel();
982+ m_currentRoles = model->roleNames();
983+ endResetModel();
984+ }
985+
986+ index = qBound(0, index, m_models.count());
987+
988+ int modelRowCount = model->rowCount();
989+ if (modelRowCount > 0) {
990+ int first = rowCountBefore(index);
991+ int last = first + modelRowCount - 1;
992+ beginInsertRows(QModelIndex(), first, last);
993+ }
994+
995+ m_models.insert(index, model);
996+ if (modelRowCount > 0) {
997+ endInsertRows();
998+ Q_EMIT countChanged();
999+ }
1000+
1001+ connect(model, SIGNAL(rowsInserted(const QModelIndex&, int, int)),
1002+ SLOT(onRowsInserted(const QModelIndex&, int, int)));
1003+ connect(model, SIGNAL(rowsRemoved(const QModelIndex&, int, int)),
1004+ SLOT(onRowsRemoved(const QModelIndex&, int, int)));
1005+ connect(model, SIGNAL(rowsMoved(const QModelIndex&, int, int, const QModelIndex&, int)),
1006+ SLOT(onRowsMoved(const QModelIndex&, int, int, const QModelIndex&, int)));
1007+ connect(model, SIGNAL(dataChanged(QModelIndex,QModelIndex,QVector<int>)),
1008+ SLOT(onDataChanged(QModelIndex,QModelIndex,QVector<int>)));
1009+}
1010+
1011+void ListAggregatorModel::removeModel(int index)
1012+{
1013+ if (index < 0 || index >= m_models.count()) {
1014+ return;
1015+ }
1016+
1017+ QAbstractItemModel* model = m_models.at(index);
1018+ int modelRowCount = model->rowCount();
1019+ if (modelRowCount > 0) {
1020+ int first = rowCountBefore(index);
1021+ int last = first + modelRowCount - 1;
1022+
1023+ beginRemoveRows(QModelIndex(), first, last);
1024+ }
1025+
1026+ m_models.removeOne(model);
1027+ if (modelRowCount > 0) {
1028+ endRemoveRows();
1029+ Q_EMIT countChanged();
1030+ }
1031+
1032+ disconnect(model, SIGNAL(rowsInserted(const QModelIndex&, int, int)),
1033+ this, SLOT(onRowsInserted(const QModelIndex&, int, int)));
1034+ disconnect(model, SIGNAL(rowsRemoved(const QModelIndex&, int, int)),
1035+ this, SLOT(onRowsRemoved(const QModelIndex&, int, int)));
1036+ disconnect(model, SIGNAL(rowsMoved(const QModelIndex&, int, int, const QModelIndex&, int)),
1037+ this, SLOT(onRowsMoved(const QModelIndex&, int, int, const QModelIndex&, int)));
1038+ disconnect(model, SIGNAL(dataChanged(QModelIndex,QModelIndex,QVector<int>)),
1039+ this, SLOT(onDataChanged(QModelIndex,QModelIndex,QVector<int>)));
1040+}
1041+
1042+int ListAggregatorModel::computeOffset(QAbstractItemModel* model) const
1043+{
1044+ int offset = 0;
1045+ QList<QAbstractItemModel*>::const_iterator iter;
1046+ for (iter = m_models.begin(); (iter != m_models.end()) && (*iter != model); ++iter) {
1047+ offset += (*iter)->rowCount();
1048+ }
1049+ return offset;
1050+}
1051+
1052+QAbstractItemModel* ListAggregatorModel::modelAtIndex(int index) const
1053+{
1054+ int offset = index;
1055+ Q_FOREACH(QAbstractItemModel* model, m_models) {
1056+ int size = model->rowCount();
1057+ if (offset < size) {
1058+ return model;
1059+ }
1060+ offset -= size;
1061+ }
1062+ return NULL;
1063+}
1064+
1065+void ListAggregatorModel::onRowsInserted(const QModelIndex& parent, int first, int last)
1066+{
1067+ QAbstractListModel* model = static_cast<QAbstractListModel*>(sender());
1068+ int offset = computeOffset(model);
1069+ beginInsertRows(parent, first + offset, last + offset);
1070+ endInsertRows();
1071+ Q_EMIT countChanged();
1072+}
1073+
1074+void ListAggregatorModel::onRowsRemoved(const QModelIndex& parent, int first, int last)
1075+{
1076+ QAbstractListModel* model = static_cast<QAbstractListModel*>(sender());
1077+ int offset = computeOffset(model);
1078+ beginRemoveRows(parent, first + offset, last + offset);
1079+ endRemoveRows();
1080+ Q_EMIT countChanged();
1081+}
1082+
1083+void ListAggregatorModel::onRowsMoved(const QModelIndex& sourceParent, int sourceStart, int sourceEnd,
1084+ const QModelIndex& destinationParent, int destinationRow)
1085+{
1086+ QAbstractListModel* model = static_cast<QAbstractListModel*>(sender());
1087+ int offset = computeOffset(model);
1088+ beginMoveRows(sourceParent, sourceStart + offset, sourceEnd + offset,
1089+ destinationParent, destinationRow + offset);
1090+ endMoveRows();
1091+}
1092+
1093+void ListAggregatorModel::onDataChanged(const QModelIndex &topLeft,
1094+ const QModelIndex &bottomRight,
1095+ const QVector<int> &roles)
1096+{
1097+ QAbstractListModel* model = static_cast<QAbstractListModel*>(sender());
1098+ int offset = computeOffset(model);
1099+ QModelIndex top = index(topLeft.row() + offset);
1100+ QModelIndex bottom = index(bottomRight.row() + offset);
1101+ Q_EMIT dataChanged(top, bottom, roles);
1102+}
1103+
1104+int ListAggregatorModel::rowCountBefore(int index) const
1105+{
1106+ int count = 0;
1107+ for (int i = 0; i <= index - 1; ++i) {
1108+ count += m_models.at(i)->rowCount();
1109+ }
1110+ return count;
1111+}
1112+
1113+int ListAggregatorModel::rowCount(const QModelIndex& parent) const
1114+{
1115+ Q_UNUSED(parent)
1116+
1117+ return rowCountBefore(m_models.count());
1118+}
1119+
1120+QVariant ListAggregatorModel::data(const QModelIndex& index, int role) const
1121+{
1122+ if (!index.isValid()) {
1123+ return QVariant();
1124+ }
1125+
1126+ int row = index.row();
1127+ int offset = row;
1128+ QList<QAbstractItemModel*>::const_iterator iter;
1129+ for (iter = m_models.begin(); iter != m_models.end(); ++iter) {
1130+ int rowCount = (*iter)->rowCount();
1131+ if (offset >= rowCount) {
1132+ offset -= rowCount;
1133+ } else {
1134+ QModelIndex new_index = (*iter)->index(offset, 0);
1135+ return (*iter)->data(new_index, role);
1136+ }
1137+ }
1138+
1139+ // For the sake of completeness, should never happen.
1140+ return QVariant();
1141+}
1142+
1143+QVariant ListAggregatorModel::get(int row) const
1144+{
1145+ return data(QAbstractListModel::index(row), 0);
1146+}
1147
1148=== added file 'src/app/webbrowser/list-aggregator-model.h'
1149--- src/app/webbrowser/list-aggregator-model.h 1970-01-01 00:00:00 +0000
1150+++ src/app/webbrowser/list-aggregator-model.h 2015-11-16 09:11:43 +0000
1151@@ -0,0 +1,82 @@
1152+/*
1153+ * Copyright (C) 2010-2015 Canonical, Ltd.
1154+ *
1155+ * This file is part of webbrowser-app.
1156+ *
1157+ * webbrowser-app is free software; you can redistribute it and/or modify
1158+ * it under the terms of the GNU General Public License as published by
1159+ * the Free Software Foundation; version 3.
1160+ *
1161+ * webbrowser-app is distributed in the hope that it will be useful,
1162+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
1163+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1164+ * GNU General Public License for more details.
1165+ *
1166+ * You should have received a copy of the GNU General Public License
1167+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
1168+ */
1169+
1170+#ifndef __LIST_AGGREGATOR_MODEL_H__
1171+#define __LIST_AGGREGATOR_MODEL_H__
1172+
1173+#include <QtCore/QAbstractListModel>
1174+
1175+/* Aggregates the data of several models and present them to the client
1176+ as if they were one single model.
1177+ The models that can be aggregated can only be QAbstractListModels or
1178+ QSortFilterProxyModels.
1179+ For this reason there are several non public methods in this class
1180+ that handle QAbstractItemModel (which is the common ancestor of the
1181+ two classes we can aggregate), but please note that this is only for
1182+ keeping the code simpler.
1183+ The public interface checks that the models it manipulates are of the
1184+ accepted types only.
1185+*/
1186+class ListAggregatorModel : public QAbstractListModel
1187+{
1188+ Q_OBJECT
1189+ Q_PROPERTY(int count READ rowCount NOTIFY countChanged)
1190+
1191+public:
1192+ ListAggregatorModel(QObject* parent = 0);
1193+ ~ListAggregatorModel();
1194+
1195+ /* Allow test fixtures to access protected and private members. */
1196+ friend class ListAggregatorModelTest;
1197+
1198+ Q_INVOKABLE QVariant get(int row) const;
1199+
1200+ Q_INVOKABLE void appendModel(const QVariant& model);
1201+ Q_INVOKABLE void insertModel(const QVariant& model, int index);
1202+ Q_INVOKABLE void removeModel(int index);
1203+
1204+ // reimplemented from QAbstractItemModel
1205+ QHash<int, QByteArray> roleNames() const;
1206+ QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const;
1207+ int rowCount(const QModelIndex& parent = QModelIndex()) const;
1208+
1209+Q_SIGNALS:
1210+ void countChanged() const;
1211+
1212+protected:
1213+ QList<QAbstractItemModel*> m_models;
1214+ void aggregateListModel(QAbstractItemModel* model, int index);
1215+
1216+private Q_SLOTS:
1217+ void onRowsInserted(const QModelIndex& parent, int first, int last);
1218+ void onRowsRemoved(const QModelIndex& parent, int first, int last);
1219+ void onRowsMoved(const QModelIndex&, int, int, const QModelIndex&, int);
1220+ void onDataChanged(const QModelIndex &topLeft,
1221+ const QModelIndex &bottomRight,
1222+ const QVector<int> &roles);
1223+
1224+private:
1225+ int computeOffset(QAbstractItemModel* model) const;
1226+ QAbstractItemModel* modelAtIndex(int index) const;
1227+ int rowCountBefore(int index) const;
1228+
1229+ QHash<int, QByteArray> m_currentRoles;
1230+};
1231+
1232+#endif // __LIST_AGGREGATOR_MODEL_H__
1233+
1234
1235=== added file 'src/app/webbrowser/roles-adapter-model.cpp'
1236--- src/app/webbrowser/roles-adapter-model.cpp 1970-01-01 00:00:00 +0000
1237+++ src/app/webbrowser/roles-adapter-model.cpp 2015-11-16 09:11:43 +0000
1238@@ -0,0 +1,111 @@
1239+/*
1240+ * Copyright 2015 Canonical Ltd.
1241+ *
1242+ * This file is part of webbrowser-app.
1243+ *
1244+ * webbrowser-app is free software; you can redistribute it and/or modify
1245+ * it under the terms of the GNU General Public License as published by
1246+ * the Free Software Foundation; version 3.
1247+ *
1248+ * webbrowser-app is distributed in the hope that it will be useful,
1249+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
1250+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1251+ * GNU General Public License for more details.
1252+ *
1253+ * You should have received a copy of the GNU General Public License
1254+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
1255+ */
1256+
1257+#include "roles-adapter-model.h"
1258+
1259+// Qt
1260+#include <QtCore/QDebug>
1261+
1262+/*!
1263+ \class RolesAdapterModel
1264+ \brief Identity proxy model that allows access to the data from the source
1265+ model by using role indexes from another model.
1266+
1267+ RolesAdapterModel is an identity proxy model that allows accessing data from
1268+ the \a sourceModel using role indexes from another model, the \a rolesSource.
1269+ The data from the rolesSource is never accessed, only its roles map is.
1270+
1271+ Whenever the view tries to access data, the name of the requested role is
1272+ searched in the \a rolesSource roles map, and if a match is found then data
1273+ is returned from the \a dataSource using the mapped role. If no match is
1274+ found then an invalid QVariant is returned.
1275+
1276+ If no \a rolesSource is set, this model directly proxies role names and
1277+ from the \a sourceModel (i.e. it acts as a plain identity model)
1278+*/
1279+RolesAdapterModel::RolesAdapterModel(QObject* parent)
1280+ : QIdentityProxyModel(parent)
1281+ , m_rolesSource(0)
1282+{
1283+}
1284+
1285+RolesAdapterModel::~RolesAdapterModel()
1286+{
1287+}
1288+
1289+QHash<int, QByteArray> RolesAdapterModel::roleNames() const
1290+{
1291+ return m_rolesSource ? m_rolesSource->roleNames() : QIdentityProxyModel::roleNames();
1292+}
1293+
1294+QVariant RolesAdapterModel::data(const QModelIndex& index, int role) const
1295+{
1296+ if (m_rolesSource) {
1297+ QHash<int, QByteArray> from = m_rolesSource->roleNames();
1298+ if (from.contains(role)) {
1299+ const QByteArray& name = from.value(role);
1300+ QHash<int, QByteArray> to = sourceModel()->roleNames();
1301+ int targetRole = to.key(name, -1);
1302+ if (targetRole != -1) {
1303+ QModelIndex targetIndex = sourceModel()->index(index.row(), index.column());
1304+ return sourceModel()->data(targetIndex, targetRole);
1305+ }
1306+ }
1307+ return QVariant();
1308+ } else {
1309+ return QIdentityProxyModel::data(index, role);
1310+ }
1311+}
1312+
1313+QVariant RolesAdapterModel::rolesSource() const
1314+{
1315+ return QVariant::fromValue(m_rolesSource);
1316+}
1317+
1318+void RolesAdapterModel::setRolesSource(QVariant sourceModel)
1319+{
1320+ static const char* errorMsg = "The rolesSource is not a QAbstractItemModel.";
1321+ if (!sourceModel.isValid() ||
1322+ (sourceModel.type() == QMetaType::VoidStar && sourceModel.toInt() == 0)) {
1323+ // if undefined or null is passed in, clear the roles source
1324+ if (m_rolesSource != 0) {
1325+ beginResetModel();
1326+ m_rolesSource = 0;
1327+ endResetModel();
1328+ Q_EMIT rolesSourceChanged();
1329+ }
1330+ return;
1331+ }
1332+ QObject* object = qvariant_cast<QObject*>(sourceModel);
1333+ if (object == NULL) {
1334+ qWarning() << errorMsg << "It is:" << sourceModel.typeName();
1335+ return;
1336+ }
1337+ QAbstractItemModel* list = qobject_cast<QAbstractItemModel*>(object);
1338+ if (list == NULL) {
1339+ qWarning() << errorMsg << "It is:" << object->metaObject()->className();
1340+ return;
1341+ }
1342+
1343+ if (list != m_rolesSource) {
1344+ beginResetModel();
1345+ m_rolesSource = list;
1346+ endResetModel();
1347+ Q_EMIT rolesSourceChanged();
1348+ }
1349+}
1350
1351=== added file 'src/app/webbrowser/roles-adapter-model.h'
1352--- src/app/webbrowser/roles-adapter-model.h 1970-01-01 00:00:00 +0000
1353+++ src/app/webbrowser/roles-adapter-model.h 2015-11-16 09:11:43 +0000
1354@@ -0,0 +1,50 @@
1355+/*
1356+ * Copyright 2015 Canonical Ltd.
1357+ *
1358+ * This file is part of webbrowser-app.
1359+ *
1360+ * webbrowser-app is free software; you can redistribute it and/or modify
1361+ * it under the terms of the GNU General Public License as published by
1362+ * the Free Software Foundation; version 3.
1363+ *
1364+ * webbrowser-app is distributed in the hope that it will be useful,
1365+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
1366+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1367+ * GNU General Public License for more details.
1368+ *
1369+ * You should have received a copy of the GNU General Public License
1370+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
1371+ */
1372+
1373+#ifndef __ROLES_ADAPTER_MODEL_H__
1374+#define __ROLES_ADAPTER_MODEL_H__
1375+
1376+// Qt
1377+#include <QtCore/QIdentityProxyModel>
1378+#include <QtCore/QMap>
1379+#include <QtCore/QString>
1380+
1381+class RolesAdapterModel : public QIdentityProxyModel
1382+{
1383+ Q_OBJECT
1384+ Q_PROPERTY(QVariant rolesSource READ rolesSource WRITE setRolesSource NOTIFY rolesSourceChanged)
1385+
1386+public:
1387+ RolesAdapterModel(QObject* parent=0);
1388+ ~RolesAdapterModel();
1389+
1390+ // reimplemented from QAbstractListModel
1391+ QHash<int, QByteArray> roleNames() const;
1392+ QVariant data(const QModelIndex& index, int role) const;
1393+
1394+ QVariant rolesSource() const;
1395+ void setRolesSource(QVariant rolesSource);
1396+
1397+Q_SIGNALS:
1398+ void rolesSourceChanged() const;
1399+
1400+private:
1401+ QAbstractItemModel* m_rolesSource;
1402+};
1403+
1404+#endif // __ROLES_ADAPTER_MODEL_H__
1405
1406=== modified file 'src/app/webbrowser/webbrowser-app.cpp'
1407--- src/app/webbrowser/webbrowser-app.cpp 2015-10-22 15:07:26 +0000
1408+++ src/app/webbrowser/webbrowser-app.cpp 2015-11-16 09:11:43 +0000
1409@@ -28,6 +28,8 @@
1410 #include "history-model.h"
1411 #include "history-timeframe-model.h"
1412 #include "limit-proxy-model.h"
1413+#include "list-aggregator-model.h"
1414+#include "roles-adapter-model.h"
1415 #include "searchengine.h"
1416 #include "text-search-filter-model.h"
1417 #include "tabs-model.h"
1418@@ -89,10 +91,12 @@
1419 qmlRegisterType<HistoryLastVisitDateListModel>(uri, 0, 1, "HistoryLastVisitDateListModel");
1420 qmlRegisterType<HistoryLastVisitDateModel>(uri, 0, 1, "HistoryLastVisitDateModel");
1421 qmlRegisterType<LimitProxyModel>(uri, 0 , 1, "LimitProxyModel");
1422+ qmlRegisterType<ListAggregatorModel>(uri, 0, 1, "ListAggregatorModel");
1423 qmlRegisterType<TabsModel>(uri, 0, 1, "TabsModel");
1424 qmlRegisterSingletonType<BookmarksModel>(uri, 0, 1, "BookmarksModel", BookmarksModel_singleton_factory);
1425 qmlRegisterType<BookmarksFolderListModel>(uri, 0, 1, "BookmarksFolderListModel");
1426 qmlRegisterSingletonType<FileOperations>(uri, 0, 1, "FileOperations", FileOperations_singleton_factory);
1427+ qmlRegisterType<RolesAdapterModel>(uri, 0, 1, "RolesAdapterModel");
1428 qmlRegisterType<SearchEngine>(uri, 0, 1, "SearchEngine");
1429 qmlRegisterSingletonType<CacheDeleter>(uri, 0, 1, "CacheDeleter", CacheDeleter_singleton_factory);
1430 qmlRegisterType<TextSearchFilterModel>(uri, 0, 1, "TextSearchFilterModel");
1431
1432=== modified file 'tests/autopilot/webbrowser_app/emulators/browser.py'
1433--- tests/autopilot/webbrowser_app/emulators/browser.py 2015-10-22 15:07:18 +0000
1434+++ tests/autopilot/webbrowser_app/emulators/browser.py 2015-11-16 09:11:43 +0000
1435@@ -550,11 +550,20 @@
1436
1437 class NewTabView(uitk.UbuntuUIToolkitCustomProxyObjectBase):
1438
1439+ def get_collapsed_contents(self):
1440+ return self.select_single("QQuickFlickable",
1441+ objectName="collapsedContents")
1442+
1443 def get_bookmarks_more_button(self):
1444- return self.select_single("Button", objectName="bookmarks.moreButton")
1445+ collapsed = self.get_collapsed_contents()
1446+ if collapsed.visible:
1447+ return collapsed.select_single("Button",
1448+ objectName="bookmarks.moreButton")
1449+ else:
1450+ return self.get_bookmarks_folder_list_view().get_more_button()
1451
1452 def get_homepage_bookmark(self):
1453- return self.select_single(UrlDelegate, objectName="homepageBookmark")
1454+ return self.get_bookmarks_list().get_delegates()[0]
1455
1456 def get_bookmarks_list(self):
1457 return self.select_single(UrlsList, objectName="bookmarksList")
1458@@ -574,11 +583,10 @@
1459 def get_bookmarks(self, folder_name):
1460 # assumes that the "more" button has been clicked
1461 folders = self.get_bookmarks_folder_list_view()
1462- folder_delegate = folders.get_folder_delegate(folder_name)
1463- return folders.get_urls_from_folder(folder_delegate)
1464+ return folders.get_urls_from_folder(folder_name)
1465
1466 def get_folder_names(self):
1467- folders = self.get_bookmarks_folder_list_view().get_delegates()
1468+ folders = self.get_bookmarks_folder_list_view().get_folder_delegates()
1469 return [folder.folderName for folder in folders]
1470
1471
1472@@ -698,23 +706,46 @@
1473
1474 class BookmarksFoldersView(uitk.UbuntuUIToolkitCustomProxyObjectBase):
1475
1476- def get_delegates(self):
1477- return sorted(self.select_many("QQuickItem",
1478- objectName="bookmarkFolderDelegate"),
1479+ # We need to check for visible=True because the ListView sometimes seems
1480+ # to not destroy section delegates immediately when a section is removed
1481+ # and re-created, but it just hides the old instance instead.
1482+ def get_folder_delegates(self):
1483+ return sorted(self.select_many("BookmarksSection", visible=True),
1484 key=lambda delegate: delegate.globalRect.y)
1485
1486 def get_folder_delegate(self, folder):
1487- return self.select_single("QQuickItem",
1488- objectName="bookmarkFolderDelegate",
1489+ return self.select_single("BookmarksSection",
1490+ visible=True,
1491 folderName=folder)
1492
1493- def get_urls_from_folder(self, folder):
1494- return sorted(folder.select_many(UrlDelegate),
1495- key=lambda delegate: delegate.globalRect.y)
1496-
1497- def get_header_from_folder(self, folder):
1498- return folder.wait_select_single("QQuickItem",
1499- objectName="bookmarkFolderHeader")
1500+ @autopilot.logging.log_action(logger.info)
1501+ def toggle_folder_expanded(self, folder):
1502+ # We need to wait for all the delegates that existed before the click
1503+ # to be destroyed. If we don't, then any following calls to
1504+ # get_urls_from_folder may also return the delegates that are being
1505+ # removed, which will then be destroyed while the test is using them,
1506+ # causing the test to fail.
1507+ items = self.get_urls_from_folder(folder, no_placeholders=False)
1508+ self.pointing_device.click_object(self.get_folder_delegate(folder))
1509+ for item in items:
1510+ # This is safe to call if the item has been destroyed already
1511+ item.wait_until_destroyed()
1512+
1513+ def get_urls_from_folder(self, folder, no_placeholders=True):
1514+ # This seemed cleaner than adding a folderName property to the delegate
1515+ # only for testing purposes, though it is still a bit hackish
1516+ delegateName = "bookmarkDelegate_in_" + folder
1517+ folder_delegates = sorted(self.select_many(UrlDelegate,
1518+ objectName=delegateName),
1519+ key=lambda delegate: delegate.globalRect.y)
1520+ if no_placeholders:
1521+ # placeholder delegates are not visible due to zero height
1522+ return [item for item in folder_delegates if item.height > 0]
1523+ else:
1524+ return folder_delegates
1525+
1526+ def get_more_button(self):
1527+ return self.select_single("Button", objectName="bookmarks.moreButton")
1528
1529
1530 class ContextMenuBase(uitk.UbuntuUIToolkitCustomProxyObjectBase):
1531
1532=== modified file 'tests/autopilot/webbrowser_app/tests/test_bookmark_options.py'
1533--- tests/autopilot/webbrowser_app/tests/test_bookmark_options.py 2015-10-13 13:40:29 +0000
1534+++ tests/autopilot/webbrowser_app/tests/test_bookmark_options.py 2015-11-16 09:11:43 +0000
1535@@ -29,6 +29,7 @@
1536 class TestBookmarkOptions(StartOpenRemotePageTestCaseBase):
1537
1538 def setUp(self):
1539+ self.rootName = "__"
1540 self.create_temporary_profile()
1541 self.populate_bookmarks()
1542 super(TestBookmarkOptions, self).setUp()
1543@@ -95,15 +96,18 @@
1544 urls = tab.get_bookmarks(folder_name)
1545 self.assertThat(lambda: len(urls), Eventually(Equals(count)))
1546
1547+ def _assert_folder_count(self, tab, count):
1548+ folders = tab.get_bookmarks_folder_list_view()
1549+ self.assertThat(lambda: len(folders.get_folder_delegates()),
1550+ Eventually(Equals(count)))
1551+
1552 def _toggle_bookmark_folder(self, tab, folder_name):
1553 folders = tab.get_bookmarks_folder_list_view()
1554- folder_delegate = folders.get_folder_delegate(folder_name)
1555- self.pointing_device.click_object(
1556- folders.get_header_from_folder(folder_delegate))
1557+ folders.toggle_folder_expanded(folder_name)
1558
1559 def test_save_bookmarked_url_in_default_folder(self):
1560 new_tab = self.open_new_tab(open_tabs_view=True, expand_view=True)
1561- self._assert_bookmark_count_in_folder(new_tab, "", 5)
1562+ self._assert_bookmark_count_in_folder(new_tab, self.rootName, 5)
1563
1564 url = self.base_url + "/test2"
1565 self.main_window.go_to_url(url)
1566@@ -119,12 +123,11 @@
1567 self.assertThat(chrome.bookmarked, Eventually(Equals(True)))
1568
1569 new_tab = self.open_new_tab(open_tabs_view=True, expand_view=True)
1570- self._assert_bookmark_count_in_folder(new_tab, "", 6)
1571+ self._assert_bookmark_count_in_folder(new_tab, self.rootName, 6)
1572
1573 def test_save_bookmarked_url_in_existing_folder(self):
1574 new_tab = self.open_new_tab(open_tabs_view=True, expand_view=True)
1575- self.assertThat(lambda: len(new_tab.get_folder_names()),
1576- Eventually(Equals(3)))
1577+ self._assert_folder_count(new_tab, 3)
1578 if not self.main_window.wide:
1579 self._toggle_bookmark_folder(new_tab, "Actinide")
1580 self._assert_bookmark_count_in_folder(new_tab, "Actinide", 1)
1581@@ -155,16 +158,14 @@
1582 self.assertThat(chrome.bookmarked, Eventually(Equals(True)))
1583
1584 new_tab = self.open_new_tab(open_tabs_view=True, expand_view=True)
1585- self.assertThat(lambda: len(new_tab.get_folder_names()),
1586- Eventually(Equals(3)))
1587+ self._assert_folder_count(new_tab, 3)
1588 if not self.main_window.wide:
1589 self._toggle_bookmark_folder(new_tab, "Actinide")
1590 self._assert_bookmark_count_in_folder(new_tab, "Actinide", 2)
1591
1592 def test_save_bookmarked_url_in_new_folder(self):
1593 new_tab = self.open_new_tab(open_tabs_view=True, expand_view=True)
1594- self.assertThat(lambda: len(new_tab.get_folder_names()),
1595- Eventually(Equals(3)))
1596+ self._assert_folder_count(new_tab, 3)
1597
1598 url = self.base_url + "/test2"
1599 self.main_window.go_to_url(url)
1600@@ -201,8 +202,7 @@
1601 self.assertThat(chrome.bookmarked, Eventually(Equals(True)))
1602
1603 new_tab = self.open_new_tab(open_tabs_view=True, expand_view=True)
1604- self.assertThat(lambda: len(new_tab.get_folder_names()),
1605- Eventually(Equals(4)))
1606+ self._assert_folder_count(new_tab, 4)
1607 if not self.main_window.wide:
1608 self._toggle_bookmark_folder(new_tab, "NewFolder")
1609 self._assert_bookmark_count_in_folder(new_tab, "NewFolder", 1)
1610@@ -230,9 +230,9 @@
1611 self.assertThat(chrome.bookmarked, Eventually(Equals(True)))
1612
1613 new_tab = self.open_new_tab(open_tabs_view=True, expand_view=True)
1614- self._assert_bookmark_count_in_folder(new_tab, "", 6)
1615+ self._assert_bookmark_count_in_folder(new_tab, self.rootName, 6)
1616
1617- bookmark = new_tab.get_bookmarks("")[1]
1618+ bookmark = new_tab.get_bookmarks(self.rootName)[1]
1619 self.assertThat(bookmark.title, Equals("NewTitle"))
1620
1621 def test_bookmark_options_from_contextual_menu(self):
1622
1623=== modified file 'tests/autopilot/webbrowser_app/tests/test_new_tab_view.py'
1624--- tests/autopilot/webbrowser_app/tests/test_new_tab_view.py 2015-10-15 19:09:59 +0000
1625+++ tests/autopilot/webbrowser_app/tests/test_new_tab_view.py 2015-11-16 09:11:43 +0000
1626@@ -20,6 +20,7 @@
1627
1628 import testtools
1629
1630+from autopilot.exceptions import StateNotFoundError
1631 from autopilot.platform import model
1632 from autopilot.matchers import Eventually
1633 from testtools.matchers import Equals, NotEquals
1634@@ -218,6 +219,7 @@
1635 class TestNewTabViewContentsNarrow(TestNewTabViewContentsBase):
1636
1637 def setUp(self):
1638+ self.rootName = "__"
1639 super(TestNewTabViewContentsNarrow, self).setUp()
1640 if self.main_window.wide:
1641 self.skipTest("Only on narrow form factors")
1642@@ -252,11 +254,10 @@
1643 self.assertThat(more_button.visible, Equals(True))
1644 self.pointing_device.click_object(more_button)
1645 folders = self.new_tab_view.get_bookmarks_folder_list_view()
1646- folder_delegate = folders.get_folder_delegate("")
1647 self.assertThat(lambda: len(folders.get_urls_from_folder(
1648- folder_delegate)),
1649+ self.rootName)),
1650 Eventually(Equals(5)))
1651- bookmark = folders.get_urls_from_folder(folder_delegate)[0]
1652+ bookmark = folders.get_urls_from_folder(self.rootName)[0]
1653 url = bookmark.url
1654 self.pointing_device.click_object(bookmark)
1655 self.new_tab_view.wait_until_destroyed()
1656@@ -264,105 +265,127 @@
1657
1658 def test_bookmarks_section_expands_and_collapses(self):
1659 bookmarks = self.new_tab_view.get_bookmarks_list()
1660- top_sites = self.new_tab_view.get_top_sites_list()
1661- self.assertThat(top_sites.visible, Equals(True))
1662+ collapsed = self.new_tab_view.get_collapsed_contents()
1663+ self.assertThat(collapsed.visible, Equals(True))
1664 # When the bookmarks list is collapsed, it shows a maximum of 4 entries
1665+ # plus the home bookmark
1666 self.assertThat(lambda: len(bookmarks.get_delegates()),
1667- Eventually(Equals(4)))
1668+ Eventually(Equals(5)))
1669+
1670 # When expanded, it shows all entries
1671 more_button = self.new_tab_view.get_bookmarks_more_button()
1672- self.assertThat(more_button.visible, Equals(True))
1673+ self.assertThat(more_button.opacity, Equals(1.0))
1674 self.pointing_device.click_object(more_button)
1675 folders = self.new_tab_view.get_bookmarks_folder_list_view()
1676- folder_delegate = folders.get_folder_delegate("")
1677 self.assertThat(lambda: len(folders.get_urls_from_folder(
1678- folder_delegate)),
1679+ self.rootName)),
1680 Eventually(Equals(5)))
1681- self.assertThat(top_sites.visible, Eventually(Equals(False)))
1682+ self.assertThat(collapsed.visible, Eventually(Equals(False)))
1683+
1684 # Collapse again
1685- self.assertThat(more_button.visible, Equals(True))
1686+ more_button = self.new_tab_view.get_bookmarks_more_button()
1687+ self.assertThat(more_button.opacity, Equals(1.0))
1688 self.pointing_device.click_object(more_button)
1689 self.assertThat(lambda: len(bookmarks.get_delegates()),
1690- Eventually(Equals(4)))
1691- self.assertThat(top_sites.visible, Eventually(Equals(True)))
1692+ Eventually(Equals(5)))
1693+ self.assertThat(collapsed.visible, Eventually(Equals(True)))
1694
1695 def _remove_first_bookmark(self):
1696- bookmarks = self.new_tab_view.get_bookmarks_list()
1697- delegate = bookmarks.get_delegates()[0]
1698+ list = self.new_tab_view.get_bookmarks_list()
1699+ delegates = list.get_delegates()
1700+ count = len(delegates)
1701+ delegate = delegates[1]
1702 url = delegate.url
1703- delegate.trigger_leading_action("leadingAction.delete",
1704- delegate.wait_until_destroyed)
1705- self.assertThat(lambda: bookmarks.get_urls()[0],
1706- Eventually(NotEquals(url)))
1707+
1708+ # Since we are using a LimitProxyModel, when removing the bookmark
1709+ # two things can happen:
1710+ try:
1711+ # if there are enough bookmarks in the source model to keep the
1712+ # LimitProxyModel full, then dataChanged events are sent to the
1713+ # delegates, but no actual item is removed, so we can't wait for
1714+ # the delegate to be destroyed, as we would normally do.
1715+ delegate.trigger_leading_action("leadingAction.delete",
1716+ lambda: list.get_urls()[1] != url)
1717+ except StateNotFoundError:
1718+ # otherwise the LimitProxyModel actually removes the item from its
1719+ # list and the delegate gets destroyed. this causes the above code
1720+ # to fail because the delegate disappears half way into the call.
1721+ # we catch the error here and verify that the count has decreased
1722+ # instead.
1723+ self.assertThat(len(list.get_delegates()), Equals(count - 1))
1724
1725 def _remove_first_bookmark_from_folder(self, folder):
1726 folders = self.new_tab_view.get_bookmarks_folder_list_view()
1727- folder_delegate = folders.get_folder_delegate(folder)
1728- delegate = folders.get_urls_from_folder(folder_delegate)[0]
1729+ delegate = folders.get_urls_from_folder(folder)[0]
1730 url = delegate.url
1731- count = len(folders.get_urls_from_folder(folder_delegate))
1732+ count = len(folders.get_urls_from_folder(folder))
1733 delegate.trigger_leading_action("leadingAction.delete",
1734 delegate.wait_until_destroyed)
1735 if ((count - 1) > 4):
1736 self.assertThat(
1737- lambda: folders.get_urls_from_folder(folder_delegate)[0],
1738+ lambda: folders.get_urls_from_folder(folder)[0],
1739 Eventually(NotEquals(url)))
1740
1741- def _toggle_bookmark_folder(self, folder):
1742- folders = self.new_tab_view.get_bookmarks_folder_list_view()
1743- folder_delegate = folders.get_folder_delegate(folder)
1744- self.pointing_device.click_object(
1745- folders.get_header_from_folder(folder_delegate))
1746-
1747 def test_remove_bookmarks_when_collapsed(self):
1748 bookmarks = self.new_tab_view.get_bookmarks_list()
1749 self.assertThat(lambda: len(bookmarks.get_delegates()),
1750+ Eventually(Equals(5)))
1751+ more_button = self.new_tab_view.get_bookmarks_more_button()
1752+
1753+ # There are still bookmarks in the model so the list remains full
1754+ # and the more button visible
1755+ self._remove_first_bookmark()
1756+ self.assertThat(lambda: len(bookmarks.get_delegates()),
1757+ Eventually(Equals(5)))
1758+ print(more_button)
1759+ self.assertThat(more_button.opacity, Eventually(Equals(1.0)))
1760+
1761+ # The number of bookmarks in the list is now equal to the number of
1762+ # bookmarks in the model, so the "more" button is gone
1763+ self._remove_first_bookmark()
1764+ self.assertThat(lambda: len(bookmarks.get_delegates()),
1765+ Eventually(Equals(5)))
1766+ self.assertThat(more_button.opacity, Eventually(Equals(0.0)))
1767+
1768+ # The number of bookmarks is now less than the length of the list
1769+ self._remove_first_bookmark()
1770+ self.assertThat(lambda: len(bookmarks.get_delegates()),
1771 Eventually(Equals(4)))
1772- more_button = self.new_tab_view.get_bookmarks_more_button()
1773- for i in range(3):
1774- self._remove_first_bookmark()
1775- self.assertThat(more_button.visible, Eventually(Equals(i < 1)))
1776- self.assertThat(len(bookmarks.get_delegates()),
1777- Equals(4 if (i < 2) else 3))
1778+ self.assertThat(more_button.opacity, Eventually(Equals(0.0)))
1779
1780 def test_remove_bookmarks_when_expanded(self):
1781 more_button = self.new_tab_view.get_bookmarks_more_button()
1782 self.assertThat(more_button.visible, Equals(True))
1783 self.pointing_device.click_object(more_button)
1784 folders = self.new_tab_view.get_bookmarks_folder_list_view()
1785- folder_delegate = folders.get_folder_delegate("")
1786 self.assertThat(lambda: len(folders.get_urls_from_folder(
1787- folder_delegate)),
1788+ self.rootName)),
1789 Eventually(Equals(5)))
1790- more_button = self.new_tab_view.get_bookmarks_more_button()
1791- top_sites = self.new_tab_view.get_top_sites_list()
1792- self._toggle_bookmark_folder("Actinide")
1793+
1794+ collapsed_content = self.new_tab_view.get_collapsed_contents()
1795+ folders.toggle_folder_expanded("Actinide")
1796 self._remove_first_bookmark_from_folder("Actinide")
1797- self._toggle_bookmark_folder("NobleGas")
1798+ folders.toggle_folder_expanded("NobleGas")
1799 self._remove_first_bookmark_from_folder("NobleGas")
1800- self.assertThat(more_button.visible, Eventually(Equals(False)))
1801- self.assertThat(top_sites.visible, Eventually(Equals(True)))
1802+ self.assertThat(collapsed_content.visible, Eventually(Equals(True)))
1803+ self.assertThat(self.new_tab_view.get_bookmarks_more_button().opacity,
1804+ Eventually(Equals(0.0)))
1805
1806 def test_show_bookmarks_folders_when_expanded(self):
1807 more_button = self.new_tab_view.get_bookmarks_more_button()
1808- self.assertThat(more_button.visible, Equals(True))
1809+ self.assertThat(more_button.opacity, Equals(1.0))
1810 self.pointing_device.click_object(more_button)
1811 folders = self.new_tab_view.get_bookmarks_folder_list_view()
1812- self.assertThat(lambda: len(folders.get_delegates()),
1813+ self.assertThat(lambda: len(folders.get_folder_delegates()),
1814 Eventually(Equals(3)))
1815- folder_delegate = folders.get_folder_delegate("")
1816 self.assertThat(lambda: len(folders.get_urls_from_folder(
1817- folder_delegate)),
1818+ self.rootName)),
1819 Eventually(Equals(5)))
1820- self._toggle_bookmark_folder("Actinide")
1821- folder_delegate = folders.get_folder_delegate("Actinide")
1822- self.assertThat(lambda: len(folders.get_urls_from_folder(
1823- folder_delegate)),
1824+ folders.toggle_folder_expanded("Actinide")
1825+ self.assertThat(lambda: len(folders.get_urls_from_folder("Actinide")),
1826 Eventually(Equals(1)))
1827- self._toggle_bookmark_folder("NobleGas")
1828- folder_delegate = folders.get_folder_delegate("NobleGas")
1829- self.assertThat(lambda: len(folders.get_urls_from_folder(
1830- folder_delegate)),
1831+ folders.toggle_folder_expanded("NobleGas")
1832+ self.assertThat(lambda: len(folders.get_urls_from_folder("NobleGas")),
1833 Eventually(Equals(1)))
1834
1835 def test_collapsed_bookmarks_folders_when_expanded(self):
1836@@ -370,19 +393,14 @@
1837 self.assertThat(more_button.visible, Equals(True))
1838 self.pointing_device.click_object(more_button)
1839 folders = self.new_tab_view.get_bookmarks_folder_list_view()
1840- self.assertThat(lambda: len(folders.get_delegates()),
1841+ self.assertThat(lambda: len(folders.get_folder_delegates()),
1842 Eventually(Equals(3)))
1843- folder_delegate = folders.get_folder_delegate("")
1844 self.assertThat(lambda: len(folders.get_urls_from_folder(
1845- folder_delegate)),
1846+ self.rootName)),
1847 Eventually(Equals(5)))
1848- folder_delegate = folders.get_folder_delegate("Actinide")
1849- self.assertThat(lambda: len(folders.get_urls_from_folder(
1850- folder_delegate)),
1851+ self.assertThat(lambda: len(folders.get_urls_from_folder("Actinide")),
1852 Eventually(Equals(0)))
1853- folder_delegate = folders.get_folder_delegate("NobleGas")
1854- self.assertThat(lambda: len(folders.get_urls_from_folder(
1855- folder_delegate)),
1856+ self.assertThat(lambda: len(folders.get_urls_from_folder("NobleGas")),
1857 Eventually(Equals(0)))
1858
1859 def test_hide_empty_bookmarks_folders_when_expanded(self):
1860@@ -390,46 +408,36 @@
1861 self.assertThat(more_button.visible, Equals(True))
1862 self.pointing_device.click_object(more_button)
1863 folders = self.new_tab_view.get_bookmarks_folder_list_view()
1864- self.assertThat(lambda: len(folders.get_delegates()),
1865+ self.assertThat(lambda: len(folders.get_folder_delegates()),
1866 Eventually(Equals(3)))
1867- self._toggle_bookmark_folder("Actinide")
1868- folder_delegate = folders.get_folder_delegate("Actinide")
1869- self.assertThat(lambda: len(folders.get_urls_from_folder(
1870- folder_delegate)),
1871+ folders.toggle_folder_expanded("Actinide")
1872+ self.assertThat(lambda: len(folders.get_urls_from_folder("Actinide")),
1873 Eventually(Equals(1)))
1874 self._remove_first_bookmark_from_folder("Actinide")
1875- self.assertThat(lambda: len(folders.get_delegates()),
1876+ self.assertThat(lambda: len(folders.get_folder_delegates()),
1877 Eventually(Equals(2)))
1878- folder_delegate = folders.get_folder_delegate("")
1879 self.assertThat(lambda: len(folders.get_urls_from_folder(
1880- folder_delegate)),
1881+ self.rootName)),
1882 Eventually(Equals(5)))
1883- self._toggle_bookmark_folder("NobleGas")
1884- folder_delegate = folders.get_folder_delegate("NobleGas")
1885- self.assertThat(lambda: len(folders.get_urls_from_folder(
1886- folder_delegate)),
1887+ folders.toggle_folder_expanded("NobleGas")
1888+ self.assertThat(lambda: len(folders.get_urls_from_folder("NobleGas")),
1889 Eventually(Equals(1)))
1890
1891 def test_bookmarks_folder_expands_and_collapses(self):
1892 more_button = self.new_tab_view.get_bookmarks_more_button()
1893 self.assertThat(more_button.visible, Equals(True))
1894 self.pointing_device.click_object(more_button)
1895- folders = self.new_tab_view.get_bookmarks_folder_list_view()
1896- self.assertThat(lambda: len(folders.get_delegates()),
1897+ list = self.new_tab_view.get_bookmarks_folder_list_view()
1898+ self.assertThat(lambda: len(list.get_folder_delegates()),
1899 Eventually(Equals(3)))
1900- folder_delegate = folders.get_folder_delegate("")
1901- self.assertThat(lambda: len(folders.get_urls_from_folder(
1902- folder_delegate)),
1903+ self.assertThat(lambda: len(list.get_urls_from_folder(self.rootName)),
1904 Eventually(Equals(5)))
1905- self.pointing_device.click_object(
1906- folders.get_header_from_folder(folder_delegate))
1907- self.assertThat(lambda: len(folders.get_urls_from_folder(
1908- folder_delegate)),
1909+
1910+ list.toggle_folder_expanded(self.rootName)
1911+ self.assertThat(lambda: len(list.get_urls_from_folder(self.rootName)),
1912 Eventually(Equals(0)))
1913- self.pointing_device.click_object(
1914- folders.get_header_from_folder(folder_delegate))
1915- self.assertThat(lambda: len(folders.get_urls_from_folder(
1916- folder_delegate)),
1917+ list.toggle_folder_expanded(self.rootName)
1918+ self.assertThat(lambda: len(list.get_urls_from_folder(self.rootName)),
1919 Eventually(Equals(5)))
1920
1921 def test_remove_top_sites(self):
1922
1923=== modified file 'tests/unittests/CMakeLists.txt'
1924--- tests/unittests/CMakeLists.txt 2015-10-06 09:48:38 +0000
1925+++ tests/unittests/CMakeLists.txt 2015-11-16 09:11:43 +0000
1926@@ -15,9 +15,11 @@
1927 add_subdirectory(bookmarks-folder-model)
1928 add_subdirectory(bookmarks-folderlist-model)
1929 add_subdirectory(limit-proxy-model)
1930+add_subdirectory(list-aggregator-model)
1931 add_subdirectory(container-url-patterns)
1932 add_subdirectory(cookie-store)
1933 add_subdirectory(oxide-cookie-helper)
1934+add_subdirectory(roles-adapter-model)
1935 add_subdirectory(session-storage)
1936 add_subdirectory(favicon-fetcher)
1937 add_subdirectory(webapp-container-hook)
1938
1939=== modified file 'tests/unittests/bookmarks-model/tst_BookmarksModelTests.cpp'
1940--- tests/unittests/bookmarks-model/tst_BookmarksModelTests.cpp 2015-07-01 00:27:48 +0000
1941+++ tests/unittests/bookmarks-model/tst_BookmarksModelTests.cpp 2015-11-16 09:11:43 +0000
1942@@ -170,7 +170,12 @@
1943 QCOMPARE(model->data(model->index(0, 0), BookmarksModel::Icon).toUrl(), QUrl("image://webicon/123"));
1944 QVERIFY(model->data(model->index(0, 0), BookmarksModel::Created).toDateTime() <= QDateTime::currentDateTime());
1945 QCOMPARE(model->data(model->index(0, 0), BookmarksModel::Folder).toString(), QString("SampleFolder"));
1946- QVERIFY(!model->data(model->index(0, 0), BookmarksModel::Folder + 1).isValid());
1947+ QCOMPARE(model->data(model->index(0, 0), BookmarksModel::FolderName).toString(), QString("SampleFolder"));
1948+ QVERIFY(!model->data(model->index(0, 0), BookmarksModel::FolderName + 1).isValid());
1949+
1950+ model->add(QUrl("http://ubuntu.com/2"), "Ubuntu", QUrl("image://webicon/123"), "");
1951+ QCOMPARE(model->data(model->index(0, 0), BookmarksModel::Folder).toString(), QString(""));
1952+ QCOMPARE(model->data(model->index(0, 0), BookmarksModel::FolderName).toString(), BookmarksModel::RootFolderDisplayName);
1953 }
1954
1955 void shouldReturnDatabasePath()
1956
1957=== added directory 'tests/unittests/list-aggregator-model'
1958=== added file 'tests/unittests/list-aggregator-model/CMakeLists.txt'
1959--- tests/unittests/list-aggregator-model/CMakeLists.txt 1970-01-01 00:00:00 +0000
1960+++ tests/unittests/list-aggregator-model/CMakeLists.txt 2015-11-16 09:11:43 +0000
1961@@ -0,0 +1,13 @@
1962+find_package(Qt5Core REQUIRED)
1963+find_package(Qt5Sql REQUIRED)
1964+find_package(Qt5Test REQUIRED)
1965+set(TEST tst_ListAggregatorModelTests)
1966+add_executable(${TEST} tst_ListAggregatorModelTests.cpp)
1967+include_directories(${webbrowser-app_SOURCE_DIR})
1968+target_link_libraries(${TEST}
1969+ Qt5::Core
1970+ Qt5::Sql
1971+ Qt5::Test
1972+ webbrowser-app-models
1973+)
1974+add_test(${TEST} ${CMAKE_CURRENT_BINARY_DIR}/${TEST} -xunitxml -o ${TEST}.xml)
1975
1976=== added file 'tests/unittests/list-aggregator-model/tst_ListAggregatorModelTests.cpp'
1977--- tests/unittests/list-aggregator-model/tst_ListAggregatorModelTests.cpp 1970-01-01 00:00:00 +0000
1978+++ tests/unittests/list-aggregator-model/tst_ListAggregatorModelTests.cpp 2015-11-16 09:11:43 +0000
1979@@ -0,0 +1,361 @@
1980+/*
1981+ * Copyright 2011-2015 Canonical Ltd.
1982+ *
1983+ * This file is part of webbrowser-app.
1984+ *
1985+ * webbrowser-app is free software; you can redistribute it and/or modify
1986+ * it under the terms of the GNU General Public License as published by
1987+ * the Free Software Foundation; version 3.
1988+ *
1989+ * webbrowser-app is distributed in the hope that it will be useful,
1990+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
1991+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1992+ * GNU General Public License for more details.
1993+ *
1994+ * You should have received a copy of the GNU General Public License
1995+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
1996+ */
1997+
1998+// Qt
1999+#include <QtTest/QSignalSpy>
2000+#include <QtTest/QtTest>
2001+
2002+// local
2003+#include "list-aggregator-model.h"
2004+
2005+class ListAggregatorModelTest : public QObject
2006+{
2007+ Q_OBJECT
2008+
2009+private Q_SLOTS:
2010+ void testRoleNames()
2011+ {
2012+ // an aggregator with nothing in it also has no roles
2013+ ListAggregatorModel model;
2014+ QHash<int, QByteArray> roleNames = model.roleNames();
2015+ QCOMPARE(roleNames.size(), 0);
2016+
2017+ // the first model that gets aggregated will reset the model and roles
2018+ // will be taken from it
2019+ QSignalSpy spyOnModelReset(&model, SIGNAL(modelReset()));
2020+ QStringListModel list(QStringList() << "aa");
2021+ QHash<int, QByteArray> listRoles = list.roleNames();
2022+ model.appendModel(QVariant::fromValue(&list));
2023+ QCOMPARE(spyOnModelReset.count(), 1);
2024+ roleNames = model.roleNames();
2025+ QCOMPARE(roleNames.size(), listRoles.size());
2026+ Q_FOREACH(int key, roleNames.keys()) {
2027+ QVERIFY(listRoles.contains(key));
2028+ QCOMPARE(roleNames[key], listRoles[key]);
2029+ }
2030+ }
2031+
2032+ void testAppendModelWrongType()
2033+ {
2034+ ListAggregatorModel model;
2035+ QVERIFY(model.m_models.isEmpty());
2036+ QTest::ignoreMessage(QtWarningMsg, "Unable to append a model that is not of type QAbstractListModel. Invalid model.");
2037+ model.appendModel(QVariant());
2038+ QVERIFY(model.m_models.isEmpty());
2039+ QTest::ignoreMessage(QtWarningMsg, "Unable to append a model that is not of type QAbstractListModel. QVariant(bool, true) is of type bool");
2040+ model.appendModel(QVariant(true));
2041+ QVERIFY(model.m_models.isEmpty());
2042+ QTest::ignoreMessage(QtWarningMsg, "Unable to append a model that is not of type QAbstractListModel. \"\" is of type QObject");
2043+ model.appendModel(QVariant::fromValue(new QObject));
2044+ QVERIFY(model.m_models.isEmpty());
2045+ }
2046+
2047+ void testAppendModel()
2048+ {
2049+ ListAggregatorModel model;
2050+ QVERIFY(model.m_models.isEmpty());
2051+ QCOMPARE(model.rowCount(), 0);
2052+
2053+ qRegisterMetaType<QModelIndex>("QModelIndex");
2054+ QSignalSpy spyOnRowsAboutToBeInserted(&model, SIGNAL(rowsAboutToBeInserted(const QModelIndex&, int, int)));
2055+ QSignalSpy spyOnRowsInserted(&model, SIGNAL(rowsInserted(const QModelIndex&, int, int)));
2056+ QList<QVariant> signal;
2057+
2058+ QStringListModel list1(QStringList() << "aa" << "ab" << "ac");
2059+ model.appendModel(QVariant::fromValue(&list1));
2060+ QCOMPARE(model.m_models.size(), 1);
2061+ QCOMPARE(qobject_cast<QStringListModel*>(model.m_models[0]), &list1);
2062+ QCOMPARE(model.rowCount(), 3);
2063+ QCOMPARE(spyOnRowsAboutToBeInserted.count(), 1);
2064+ signal = spyOnRowsAboutToBeInserted.takeFirst();
2065+ QCOMPARE(signal[1].toInt(), 0);
2066+ QCOMPARE(signal[2].toInt(), 2);
2067+ QCOMPARE(spyOnRowsInserted.count(), 1);
2068+ signal = spyOnRowsInserted.takeFirst();
2069+ QCOMPARE(signal[1].toInt(), 0);
2070+ QCOMPARE(signal[2].toInt(), 2);
2071+
2072+ QStringListModel list2(QStringList() << "ba" << "bb" << "bc" << "bd");
2073+ model.appendModel(QVariant::fromValue(&list2));
2074+ QCOMPARE(model.m_models.size(), 2);
2075+ QCOMPARE(qobject_cast<QStringListModel*>(model.m_models[1]), &list2);
2076+ QCOMPARE(model.rowCount(), 7);
2077+ QCOMPARE(spyOnRowsAboutToBeInserted.count(), 1);
2078+ signal = spyOnRowsAboutToBeInserted.takeFirst();
2079+ QCOMPARE(signal[1].toInt(), 3);
2080+ QCOMPARE(signal[2].toInt(), 6);
2081+ QCOMPARE(spyOnRowsInserted.count(), 1);
2082+ signal = spyOnRowsInserted.takeFirst();
2083+ QCOMPARE(signal[1].toInt(), 3);
2084+ QCOMPARE(signal[2].toInt(), 6);
2085+ }
2086+
2087+ void testInsertModel()
2088+ {
2089+ ListAggregatorModel model;
2090+ QVERIFY(model.m_models.isEmpty());
2091+ QCOMPARE(model.rowCount(), 0);
2092+
2093+ qRegisterMetaType<QModelIndex>("QModelIndex");
2094+ QSignalSpy spyOnRowsAboutToBeInserted(&model, SIGNAL(rowsAboutToBeInserted(const QModelIndex&, int, int)));
2095+ QSignalSpy spyOnRowsInserted(&model, SIGNAL(rowsInserted(const QModelIndex&, int, int)));
2096+ QList<QVariant> signal;
2097+
2098+ QStringListModel list1(QStringList() << "aa" << "ab" << "ac");
2099+ model.appendModel(QVariant::fromValue(&list1));
2100+ QCOMPARE(model.m_models.size(), 1);
2101+ QCOMPARE(qobject_cast<QStringListModel*>(model.m_models[0]), &list1);
2102+ QCOMPARE(model.rowCount(), 3);
2103+ QCOMPARE(spyOnRowsAboutToBeInserted.count(), 1);
2104+ signal = spyOnRowsAboutToBeInserted.takeFirst();
2105+ QCOMPARE(signal[1].toInt(), 0);
2106+ QCOMPARE(signal[2].toInt(), 2);
2107+ QCOMPARE(spyOnRowsInserted.count(), 1);
2108+ signal = spyOnRowsInserted.takeFirst();
2109+ QCOMPARE(signal[1].toInt(), 0);
2110+ QCOMPARE(signal[2].toInt(), 2);
2111+
2112+ QStringListModel list2(QStringList() << "ba" << "bb" << "bc" << "bd");
2113+ model.insertModel(QVariant::fromValue(&list2), 0);
2114+ QCOMPARE(model.m_models.size(), 2);
2115+ QCOMPARE(qobject_cast<QStringListModel*>(model.m_models[0]), &list2);
2116+ QCOMPARE(model.rowCount(), 7);
2117+ QCOMPARE(spyOnRowsAboutToBeInserted.count(), 1);
2118+ signal = spyOnRowsAboutToBeInserted.takeFirst();
2119+ QCOMPARE(signal[1].toInt(), 0);
2120+ QCOMPARE(signal[2].toInt(), 3);
2121+ QCOMPARE(spyOnRowsInserted.count(), 1);
2122+ signal = spyOnRowsInserted.takeFirst();
2123+ QCOMPARE(signal[1].toInt(), 0);
2124+ QCOMPARE(signal[2].toInt(), 3);
2125+
2126+ // test that insert index is clamped correctly to existing list boundaries
2127+ QStringListModel list3(QStringList() << "xx");
2128+ model.insertModel(QVariant::fromValue(&list3), -1);
2129+ QCOMPARE(qobject_cast<QStringListModel*>(model.m_models[0]), &list3);
2130+
2131+ QStringListModel list4(QStringList() << "yy");
2132+ model.insertModel(QVariant::fromValue(&list4), model.m_models.size() + 1);
2133+ QCOMPARE(qobject_cast<QStringListModel*>(model.m_models[model.m_models.size() - 1]), &list4);
2134+ }
2135+
2136+ void testRemoveModel()
2137+ {
2138+ ListAggregatorModel model;
2139+
2140+ qRegisterMetaType<QModelIndex>("QModelIndex");
2141+ QSignalSpy spyOnRowsAboutToBeRemoved(&model, SIGNAL(rowsAboutToBeRemoved(const QModelIndex&, int, int)));
2142+ QSignalSpy spyOnRowsRemoved(&model, SIGNAL(rowsRemoved(const QModelIndex&, int, int)));
2143+ QList<QVariant> signal;
2144+
2145+ QStringListModel list1(QStringList() << "aa" << "ab" << "ac");
2146+ model.appendModel(QVariant::fromValue(&list1));
2147+ QStringListModel list2(QStringList() << "ba" << "bb" << "bc" << "bd");
2148+ model.appendModel(QVariant::fromValue(&list2));
2149+ QStringListModel list3(QStringList() << "ca" << "cb");
2150+ model.appendModel(QVariant::fromValue(&list3));
2151+
2152+ model.removeModel(1);
2153+ QCOMPARE(model.m_models.size(), 2);
2154+ QCOMPARE(qobject_cast<QStringListModel*>(model.m_models[0]), &list1);
2155+ QCOMPARE(qobject_cast<QStringListModel*>(model.m_models[1]), &list3);
2156+ QCOMPARE(model.rowCount(), 5);
2157+ QCOMPARE(spyOnRowsAboutToBeRemoved.count(), 1);
2158+ signal = spyOnRowsAboutToBeRemoved.takeFirst();
2159+ QCOMPARE(signal[1].toInt(), 3);
2160+ QCOMPARE(signal[2].toInt(), 6);
2161+ QCOMPARE(spyOnRowsRemoved.count(), 1);
2162+ signal = spyOnRowsRemoved.takeFirst();
2163+ QCOMPARE(signal[1].toInt(), 3);
2164+ QCOMPARE(signal[2].toInt(), 6);
2165+
2166+ // test that nothing happens if we remove out of bounds
2167+ model.removeModel(-1);
2168+ QCOMPARE(model.m_models.size(), 2);
2169+ QCOMPARE(spyOnRowsRemoved.count(), 0);
2170+ model.removeModel(3);
2171+ QCOMPARE(model.m_models.size(), 2);
2172+ QCOMPARE(spyOnRowsRemoved.count(), 0);
2173+ }
2174+
2175+ void testData()
2176+ {
2177+ ListAggregatorModel model;
2178+ model.appendModel(QVariant::fromValue(new QStringListModel(QStringList() << "aa" << "ab" << "ac", &model)));
2179+ model.appendModel(QVariant::fromValue(new QStringListModel(QStringList() << "ba" << "bb" << "bc" << "bd", &model)));
2180+ model.appendModel(QVariant::fromValue(new QStringListModel(QStringList() << "ca" << "cb", &model)));
2181+ QStringList data = QStringList() << "aa" << "ab" << "ac" << "ba" << "bb" << "bc" << "bd" << "ca" << "cb";
2182+ for (int i = 0; i < model.rowCount(); ++i) {
2183+ QCOMPARE(model.data(model.index(i)).toString(), data[i]);
2184+ }
2185+ }
2186+
2187+ void testGet()
2188+ {
2189+ ListAggregatorModel model;
2190+ model.appendModel(QVariant::fromValue(new QStringListModel(QStringList() << "aa" << "ab" << "ac", &model)));
2191+ model.appendModel(QVariant::fromValue(new QStringListModel(QStringList() << "ba" << "bb" << "bc" << "bd", &model)));
2192+ model.appendModel(QVariant::fromValue(new QStringListModel(QStringList() << "ca" << "cb", &model)));
2193+ QStringList data = QStringList() << "aa" << "ab" << "ac" << "ba" << "bb" << "bc" << "bd" << "ca" << "cb";
2194+ for (int i = 0; i < model.rowCount(); ++i) {
2195+ QCOMPARE(model.get(i).toString(), data[i]);
2196+ }
2197+ }
2198+
2199+ void testComputeOffset()
2200+ {
2201+ ListAggregatorModel model;
2202+ QStringListModel list1(QStringList() << "aa" << "ab" << "ac");
2203+ model.appendModel(QVariant::fromValue(&list1));
2204+ QStringListModel list2(QStringList() << "ba" << "bb" << "bc" << "bd");
2205+ model.appendModel(QVariant::fromValue(&list2));
2206+ QStringListModel list3(QStringList() << "ca" << "cb");
2207+ model.appendModel(QVariant::fromValue(&list3));
2208+
2209+ QCOMPARE(model.computeOffset(&list1), 0);
2210+ QCOMPARE(model.computeOffset(&list2), 3);
2211+ QCOMPARE(model.computeOffset(&list3), 7);
2212+ }
2213+
2214+ void testModelAtIndex()
2215+ {
2216+ ListAggregatorModel model;
2217+ QStringListModel list1(QStringList() << "aa" << "ab" << "ac");
2218+ model.appendModel(QVariant::fromValue(&list1));
2219+ QStringListModel list2(QStringList() << "ba" << "bb" << "bc" << "bd");
2220+ model.appendModel(QVariant::fromValue(&list2));
2221+ QStringListModel list3(QStringList() << "ca" << "cb");
2222+ model.appendModel(QVariant::fromValue(&list3));
2223+
2224+ QCOMPARE(model.modelAtIndex(0), &list1);
2225+ QCOMPARE(model.modelAtIndex(1), &list1);
2226+ QCOMPARE(model.modelAtIndex(2), &list1);
2227+ QCOMPARE(model.modelAtIndex(3), &list2);
2228+ QCOMPARE(model.modelAtIndex(4), &list2);
2229+ QCOMPARE(model.modelAtIndex(5), &list2);
2230+ QCOMPARE(model.modelAtIndex(6), &list2);
2231+ QCOMPARE(model.modelAtIndex(7), &list3);
2232+ QCOMPARE(model.modelAtIndex(8), &list3);
2233+ }
2234+
2235+ void testRemoveFromAggregatedModel()
2236+ {
2237+ // Test that removing from any of the aggregate models will also remove
2238+ // from the aggregator at the right position
2239+
2240+ ListAggregatorModel model;
2241+ QStringListModel list1(QStringList() << "aa" << "ab");
2242+ model.appendModel(QVariant::fromValue(&list1));
2243+ QStringListModel list2(QStringList() << "ba" << "bb");
2244+ model.appendModel(QVariant::fromValue(&list2));
2245+
2246+ qRegisterMetaType<QModelIndex>("QModelIndex");
2247+ QSignalSpy spyOnRowsRemoved(&model, SIGNAL(rowsRemoved(const QModelIndex&, int, int)));
2248+ QList<QVariant> signal;
2249+
2250+ list1.removeRow(1);
2251+ QCOMPARE(spyOnRowsRemoved.count(), 1);
2252+ signal = spyOnRowsRemoved.takeFirst();
2253+ QCOMPARE(signal[1].toInt(), 1);
2254+ QCOMPARE(signal[2].toInt(), 1);
2255+ QCOMPARE(model.rowCount(), 3);
2256+ QStringList data = QStringList() << "aa" << "ba" << "bb";
2257+ for (int i = 0; i < model.rowCount(); ++i) {
2258+ QCOMPARE(model.data(model.index(i)).toString(), data[i]);
2259+ }
2260+
2261+ list2.removeRows(0, 2);
2262+ QCOMPARE(spyOnRowsRemoved.count(), 1);
2263+ signal = spyOnRowsRemoved.takeFirst();
2264+ QCOMPARE(signal[1].toInt(), 1);
2265+ QCOMPARE(signal[2].toInt(), 2);
2266+ QCOMPARE(model.rowCount(), 1);
2267+ QCOMPARE(model.data(model.index(0)).toString(), QString("aa"));
2268+ }
2269+
2270+ void testInsertAndChangeDataInAggregatedModel()
2271+ {
2272+ // Test that inserting in any of the aggregate models will also insert
2273+ // into the aggregator at the right position.
2274+ // Due to the nature of the QStringListModel used to do these tests,
2275+ // which first requires inserting empty rows then updating their data,
2276+ // we also test here that data change is reported correctly, instead of
2277+ // doing so in a separate test.
2278+
2279+ ListAggregatorModel model;
2280+ QStringListModel list1(QStringList() << "ab");
2281+ model.appendModel(QVariant::fromValue(&list1));
2282+ QStringListModel list2;
2283+ model.appendModel(QVariant::fromValue(&list2));
2284+
2285+ qRegisterMetaType<QModelIndex>("QModelIndex");
2286+ QSignalSpy spyOnRowsInserted(&model, SIGNAL(rowsInserted(QModelIndex,int,int)));
2287+ QSignalSpy spyOnDataChanged(&model, SIGNAL(dataChanged(QModelIndex,QModelIndex,QVector<int>)));
2288+ QList<QVariant> signal;
2289+
2290+ list1.insertRow(0);
2291+ QCOMPARE(spyOnRowsInserted.count(), 1);
2292+ signal = spyOnRowsInserted.takeFirst();
2293+ QCOMPARE(signal[1].toInt(), 0);
2294+ QCOMPARE(signal[2].toInt(), 0);
2295+ QCOMPARE(model.rowCount(), 2);
2296+ QStringList data = QStringList() << "" << "ab";
2297+ for (int i = 0; i < model.rowCount(); ++i) {
2298+ QCOMPARE(model.data(model.index(i)).toString(), data[i]);
2299+ }
2300+
2301+ list1.setData(list1.index(0), QVariant("aa"));
2302+ QCOMPARE(spyOnDataChanged.count(), 1);
2303+ signal = spyOnDataChanged.takeFirst();
2304+ QCOMPARE(qvariant_cast<QModelIndex>(signal[0]).row(), 0);
2305+ QCOMPARE(qvariant_cast<QModelIndex>(signal[1]).row(), 0);
2306+ QCOMPARE(model.rowCount(), 2);
2307+ data = QStringList() << "aa" << "ab";
2308+ for (int i = 0; i < model.rowCount(); ++i) {
2309+ QCOMPARE(model.data(model.index(i)).toString(), data[i]);
2310+ }
2311+
2312+ list2.insertRows(0, 2);
2313+ QCOMPARE(spyOnRowsInserted.count(), 1);
2314+ signal = spyOnRowsInserted.takeFirst();
2315+ QCOMPARE(signal[1].toInt(), 2);
2316+ QCOMPARE(signal[2].toInt(), 3);
2317+ QCOMPARE(model.rowCount(), 4);
2318+ data = QStringList() << "aa" << "ab" << "" << "";
2319+ for (int i = 0; i < model.rowCount(); ++i) {
2320+ QCOMPARE(model.data(model.index(i)).toString(), data[i]);
2321+ }
2322+
2323+ list2.setData(list2.index(0), QVariant("ba"));
2324+ list2.setData(list2.index(1), QVariant("bb"));
2325+ QCOMPARE(spyOnDataChanged.count(), 2);
2326+ signal = spyOnDataChanged.takeFirst();
2327+ QCOMPARE(qvariant_cast<QModelIndex>(signal[0]).row(), 2);
2328+ QCOMPARE(qvariant_cast<QModelIndex>(signal[1]).row(), 2);
2329+ signal = spyOnDataChanged.takeFirst();
2330+ QCOMPARE(qvariant_cast<QModelIndex>(signal[0]).row(), 3);
2331+ QCOMPARE(qvariant_cast<QModelIndex>(signal[1]).row(), 3);
2332+ data = QStringList() << "aa" << "ab" << "ba" << "bb";
2333+ for (int i = 0; i < model.rowCount(); ++i) {
2334+ QCOMPARE(model.data(model.index(i)).toString(), data[i]);
2335+ }
2336+ }
2337+};
2338+
2339+QTEST_MAIN(ListAggregatorModelTest)
2340+#include "tst_ListAggregatorModelTests.moc"
2341
2342=== added directory 'tests/unittests/roles-adapter-model'
2343=== added file 'tests/unittests/roles-adapter-model/CMakeLists.txt'
2344--- tests/unittests/roles-adapter-model/CMakeLists.txt 1970-01-01 00:00:00 +0000
2345+++ tests/unittests/roles-adapter-model/CMakeLists.txt 2015-11-16 09:11:43 +0000
2346@@ -0,0 +1,13 @@
2347+find_package(Qt5Core REQUIRED)
2348+find_package(Qt5Sql REQUIRED)
2349+find_package(Qt5Test REQUIRED)
2350+set(TEST tst_RolesAdapterModelTests)
2351+add_executable(${TEST} tst_RolesAdapterModelTests.cpp)
2352+include_directories(${webbrowser-app_SOURCE_DIR})
2353+target_link_libraries(${TEST}
2354+ Qt5::Core
2355+ Qt5::Sql
2356+ Qt5::Test
2357+ webbrowser-app-models
2358+)
2359+add_test(${TEST} ${CMAKE_CURRENT_BINARY_DIR}/${TEST} -xunitxml -o ${TEST}.xml)
2360
2361=== added file 'tests/unittests/roles-adapter-model/tst_RolesAdapterModelTests.cpp'
2362--- tests/unittests/roles-adapter-model/tst_RolesAdapterModelTests.cpp 1970-01-01 00:00:00 +0000
2363+++ tests/unittests/roles-adapter-model/tst_RolesAdapterModelTests.cpp 2015-11-16 09:11:43 +0000
2364@@ -0,0 +1,97 @@
2365+/*
2366+ * Copyright 2011-2015 Canonical Ltd.
2367+ *
2368+ * This file is part of webbrowser-app.
2369+ *
2370+ * webbrowser-app is free software; you can redistribute it and/or modify
2371+ * it under the terms of the GNU General Public License as published by
2372+ * the Free Software Foundation; version 3.
2373+ *
2374+ * webbrowser-app is distributed in the hope that it will be useful,
2375+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
2376+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
2377+ * GNU General Public License for more details.
2378+ *
2379+ * You should have received a copy of the GNU General Public License
2380+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
2381+ */
2382+
2383+// Qt
2384+#include <QtCore/QStringListModel>
2385+#include <QtTest/QSignalSpy>
2386+#include <QtTest/QtTest>
2387+
2388+// local
2389+#include "roles-adapter-model.h"
2390+
2391+class CustomRolesModel : public QStringListModel
2392+{
2393+ Q_OBJECT
2394+
2395+public:
2396+ QHash<int, QByteArray> roleNames() const {
2397+ return roles;
2398+ }
2399+
2400+ QVariant data(const QModelIndex& index, int role) const {
2401+ if (roles.contains(role)) {
2402+ QString key = roles.value(role);
2403+ if (item.contains(key)) {
2404+ return item.value(key);
2405+ }
2406+ }
2407+ return QVariant();
2408+ }
2409+
2410+ QHash<int, QByteArray> roles;
2411+ QMap<QString, QString> item;
2412+};
2413+
2414+class RolesAdapterModelTest : public QObject
2415+{
2416+ Q_OBJECT
2417+
2418+private Q_SLOTS:
2419+
2420+ void testAdapter()
2421+ {
2422+ RolesAdapterModel adapter;
2423+ QVERIFY(!adapter.data(adapter.index(0, 0), 0).isValid());
2424+
2425+ QSignalSpy spyOnRolesSourceChanged(&adapter, SIGNAL(rolesSourceChanged()));
2426+ QSignalSpy spyOnModelReset(&adapter, SIGNAL(modelReset()));
2427+
2428+ CustomRolesModel rolesSource;
2429+ rolesSource.roles.insert(Qt::UserRole + 1, "foo");
2430+
2431+ CustomRolesModel dataSource;
2432+ dataSource.roles.insert(0, "foo");
2433+ dataSource.item.insert("foo", "something");
2434+ adapter.setSourceModel(&dataSource);
2435+ QCOMPARE(spyOnModelReset.count(), 1);
2436+
2437+ // verify that without a roles source we respond with the roles from
2438+ // the source model.
2439+ QVERIFY(!adapter.data(adapter.index(0, 0), Qt::UserRole + 1).isValid());
2440+ QCOMPARE(adapter.data(adapter.index(0, 0), 0).toString(), QString("something"));
2441+
2442+ // once we set the roles source, we will respond to roles from it and
2443+ // not from the source model.
2444+ adapter.setRolesSource(QVariant::fromValue(&rolesSource));
2445+ QVERIFY(!adapter.data(adapter.index(0, 0), 0).isValid());
2446+ QCOMPARE(adapter.data(adapter.index(0, 0), Qt::UserRole + 1).toString(), QString("something"));
2447+ QCOMPARE(spyOnRolesSourceChanged.count(), 1);
2448+ QCOMPARE(spyOnModelReset.count(), 2);
2449+
2450+ // verify that we can reset the roles rouce by setting roles to undefined
2451+ adapter.setRolesSource(QVariant());
2452+ QVERIFY(!adapter.data(adapter.index(0, 0), Qt::UserRole + 1).isValid());
2453+ QCOMPARE(adapter.data(adapter.index(0, 0), 0).toString(), QString("something"));
2454+ QCOMPARE(spyOnRolesSourceChanged.count(), 2);
2455+ QCOMPARE(spyOnModelReset.count(), 3);
2456+ }
2457+};
2458+
2459+QTEST_MAIN(RolesAdapterModelTest)
2460+#include "tst_RolesAdapterModelTests.moc"
2461+

Subscribers

People subscribed via source and target branches

to status/vote changes: