Merge lp:~dylanmccall/update-manager/group-by-applications into lp:update-manager
- group-by-applications
- Merge into main
Status: | Merged |
---|---|
Merged at revision: | 2582 |
Proposed branch: | lp:~dylanmccall/update-manager/group-by-applications |
Merge into: | lp:update-manager |
Diff against target: |
859 lines (+405/-239) 2 files modified
UpdateManager/Core/UpdateList.py (+175/-100) UpdateManager/UpdatesAvailable.py (+230/-139) |
To merge this branch: | bzr merge lp:~dylanmccall/update-manager/group-by-applications |
Related bugs: | |
Related blueprints: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Dylan McCall (community) | Needs Resubmitting | ||
Barry Warsaw (community) | Needs Fixing | ||
Matthew Paul Thomas | Pending | ||
Review via email: mp+112678@code.launchpad.net |
Commit message
Description of the change
This is a first step towards grouping updates according to the specification at https:/
There are some details still to be worked out, but the feature so far is functional :)
Changes:
- Changed the list of updates to a treeview, where updates are collapsed under application names.
- UpdateList links updated packages to applications and groups them according to dependencies.
- Restart required indicator is shown for packages with "XB-Restart-
- Update origins are not displayed.
Known issues, to be resolved with future work:
- Indentation in the update tree view is presently hard coded and probably belongs (at least partly) in the light-themes package.
- The 'Ubuntu base' update group should list specific core packages, not 'everything else.' The specification is unclear on what to do with everything else, so it's like this for the time being.
- The Updates Required icon does not have its corresponding UI element below the download size text.
- The Download column does not yet show a check mark for downloaded packages.
- "Terminal" has an expander, with "gnome-terminal" as a child. Instead, group headers should simply be packages instead of their own things.
So, it isn't _finished_, but it does seem to be in a working state. The reason I'm interested in merging with trunk is because I want to keep our code in sync. Most of the crazy merge conflicts are probably done with now (especially since we seem to be standardized on 4 space indents). However, my diff spans _a lot_ of UpdateList.py and UpdatesAvailabl
- 2442. By Dylan McCall
-
Attempt to load a symbolic icon for 'restart required'.
Don't select second item in update list.
Barry Warsaw (barry) wrote : | # |
Hi Dylan,
Thanks for your contribution to Update Manager, and to Ubuntu!
I've provided some detailed comments on the code below.
One thing I'd really like to see is some unittests, especially for the new
code in UpdateList.py. Would you like to take a crack at adding a few for
some of the new methods and classes you're adding? Have you run the existing
unittests to see if your changes have broken any of them? While Update
Manager doesn't have a fantastic test suite, I'm not crazy about making things
worse by adding more untested code.
Someone should also review the UI changes before this branch lands, probably
mpt or someone else on the design team. I'll request a review from mpt to
start with.
I did notice one UI problem when I ran your branch on Quantal. I see an
update for Ubuntu base (which I gather is a catch-all for anything that
doesn't fit into any other category). However, when I expand the arrow for
that group, I'm still left with a very short window, one row that contains the
"Ubuntu base" string, and a very long and mostly unusable scroll bar. Maybe
the window should expand vertically to fit the sub-package contents better, or
maybe the details window should be taller to start with.
Other than that, I think the groupings are a nice refactoring of the
information presented by Update Manager!
I'll mark the branch Needs Fixing for now, but I will be happy to re-review it
if you make the suggested changes.
=== modified file 'UpdateManager/
--- UpdateManager/
+++ UpdateManager/
> @@ -1,25 +1,29 @@
> -# UpdateList.py
> +# UpdateList.py
> # -*- Mode: Python; indent-tabs-mode: nil; tab-width: 4; coding: utf-8 -*-
> -#
> +#
You need to be really careful about adding extra whitespace, as your patch
does here. There are several reason for this, but probably the most important
one is that such whitespace-only changes are just noise that distract from the
substantive changes, and reviews thereof. There are many options for ensuring
"whitespace normalization" in Python code, some of which can be accomplished
by your editor. E.g. I have nice functions for Emacs:
http://
> # Copyright (c) 2004-2008 Canonical
> -#
> +#
> # Author: Michael Vogt <email address hidden>
> -#
> -# This program is free software; you can redistribute it and/or
> -# modify it under the terms of the GNU General Public License as
> +#
> +# This program is free software; you can redistribute it and/or
> +# modify it under the terms of the GNU General Public License as
> # published by the Free Software Foundation; either version 2 of the
> # License, or (at your option) any later version.
> -#
> +#
> # This program is distributed in the hope that it will be useful,
> # but WITHOUT ANY WARRANTY; without even the implied warranty of
> # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
> # GNU General Public License for more details.
> -#
> +#
> # You should have received a copy of the GNU General Public License
> # along with this program; if not, wri...
- 2443. By Dylan McCall
-
Merged changes from trunk.
Removed unnecessary TODO notes in UpdateList.py - 2444. By Dylan McCall
-
Fixed unintended formatting changes.
- 2445. By Dylan McCall
-
Merged changes from trunk.
- 2446. By Dylan McCall
-
Fixed some code style errors in UpdateList.
Dylan McCall (dylanmccall) wrote : | # |
Okay, I'm finally working on this and I'll let you know when it's all done. (Couple days, I hope). A lot of the weirder issues are due to me manually merging changes, apparently to the wrong version of UpdateList.py. Unfortunate waste of time, so thank you for being so patient, Barry! :)
- 2447. By Dylan McCall
-
Fixed lines longer than 79 characters in UpdateList.py.
- 2448. By Dylan McCall
-
Fixed style and commenting issues in UpdatesAvailabl
e.py. - 2449. By Dylan McCall
-
Merged changes from trunk
Dylan McCall (dylanmccall) wrote : | # |
There, I think that covers the immediate problems. Those bizarre whitespace changes were because mterry had cleaned stuff up beautifully (along with the Python 3 update) between me starting and finishing this, and then I merged changes in the worst possible way. I'm sorry for taking so very long to get back to you.
Replacing pkg with package in UpdateList.py:
I'd like to leave "pkg" be in this branch, if that's okay, just because pkg seems to appear consistently across the application and in the previous version of UpdateList.py (even in the apt.pkg library we import). I agree it would be lovely to clean up, though. I honestly do cringe every time I abbreviate something :)
You were wondering about that weird function called _rate_applicati
get_application
GIO has a lovely function to parse a .desktop file, spitting out a class with a (localized) name, icon and such. The important bit is it's the same code we use in places like the Unity dash or the Applications menu in Gnome Panel, so it's all nice and familiar. Adding some comments to explain the purpose...
APP_INSTALL_PATH: Right now that is effectively the same thing that happens in Software Centre. I'm happy to turn it into a config parameter :)
I have some refactoring to do (and some new designs to implement!), so I think I will do that, and I will try to do tests and documentation alongside that work. I will resubmit then.
Marco Biscaro (marcobiscaro2112) wrote : | # |
I tried to merge trunk and got conflicts. You should merge trunk again and fix them (in UpdateManager/
Some details:
=== modified file 'UpdateManager/
--- UpdateManager/
+++ UpdateManager/
@@ -1,7 +1,7 @@
# UpdateList.py
# -*- Mode: Python; indent-tabs-mode: nil; tab-width: 4; coding: utf-8 -*-
#
-# Copyright (c) 2004-2012 Canonical
+# Copyright (c) 2004-2008 Canonical
I think this shouldn't be modified.
#
# Author: Michael Vogt <email address hidden>
#
You modified this file a lot, I think your name should be here too. :)
@@ -22,53 +22,103 @@
from __future__ import print_function
+import warnings
+warnings.
+ DeprecationWarning)
+
from gettext import gettext as _
-
-import apt
-import logging
import operator
-import random
+import itertools
import subprocess
+import os
+import glob
import sys
What about alphabetical order here?
Michael Terry (mterry) wrote : | # |
Dylan, I wanted to help push this over the hump. I've got a merged-with-trunk branch here: lp:~mterry/update-manager/group-by-application
I'll look into adding some unittests next and fixing the single-package headers next. Is there a team we could put a shared branch under?
Dylan McCall (dylanmccall) wrote : | # |
Cool! There is not one at the moment, but feel free to add me to one. (I'll
be able to do that in about 8 hours).
I have a few local changes that I'm aiming to finish up and push by Sunday,
so we might want to chat about those over email or IRC or something, too.
They could make things a lot easier (once they are not broken),
particularly with single packages and packages that aren't applications;)
On Mon, Dec 17, 2012 at 11:04 AM, Michael Terry <<email address hidden>
> wrote:
> Dylan, I wanted to help push this over the hump. I've got a
> merged-with-trunk branch here:
> lp:~mterry/update-manager/group-by-application
>
> I'll look into adding some unittests next and fixing the single-package
> headers next. Is there a team we could put a shared branch under?
> --
>
> https:/
> You are the owner of lp:~dylanmccall/update-manager/group-by-applications.
>
Marco Biscaro (marcobiscaro2112) wrote : | # |
I can help too!
Preview Diff
1 | === modified file 'UpdateManager/Core/UpdateList.py' |
2 | --- UpdateManager/Core/UpdateList.py 2012-08-20 15:32:45 +0000 |
3 | +++ UpdateManager/Core/UpdateList.py 2012-11-10 06:29:19 +0000 |
4 | @@ -1,7 +1,7 @@ |
5 | # UpdateList.py |
6 | # -*- Mode: Python; indent-tabs-mode: nil; tab-width: 4; coding: utf-8 -*- |
7 | # |
8 | -# Copyright (c) 2004-2012 Canonical |
9 | +# Copyright (c) 2004-2008 Canonical |
10 | # |
11 | # Author: Michael Vogt <mvo@debian.org> |
12 | # |
13 | @@ -22,53 +22,103 @@ |
14 | |
15 | from __future__ import print_function |
16 | |
17 | +import warnings |
18 | +warnings.filterwarnings("ignore", "Accessed deprecated property", |
19 | + DeprecationWarning) |
20 | + |
21 | from gettext import gettext as _ |
22 | - |
23 | -import apt |
24 | -import logging |
25 | import operator |
26 | -import random |
27 | +import itertools |
28 | import subprocess |
29 | +import os |
30 | +import glob |
31 | import sys |
32 | |
33 | - |
34 | -class UpdateOrigin(object): |
35 | +from gi.repository import Gio |
36 | + |
37 | +APP_INSTALL_PATH = "/usr/share/app-install/desktop" |
38 | + |
39 | +class UpdateGroup(): |
40 | + def __init__(self, core_pkgs, name, icon): |
41 | + self._core_pkgs = set(core_pkgs) |
42 | + self._extra_pkgs = set() |
43 | + self.name = name |
44 | + self.icon = icon |
45 | + |
46 | + @property |
47 | + def pkgs(self): |
48 | + all_pkgs = [] |
49 | + all_pkgs.extend(self._core_pkgs) |
50 | + all_pkgs.extend(self._extra_pkgs) |
51 | + return sorted(all_pkgs, key=operator.attrgetter('name')) |
52 | + |
53 | + def add_extra(self, extra_pkg): |
54 | + if self._contains(extra_pkg): |
55 | + return True |
56 | + elif self._is_dependency(extra_pkg): |
57 | + self._extra_pkgs.add(extra_pkg) |
58 | + return True |
59 | + else: |
60 | + return False |
61 | + |
62 | + def _contains(self, pkg): |
63 | + return pkg in self._core_pkgs or pkg in self._extra_pkgs |
64 | + |
65 | + def _is_dependency(self, pkg): |
66 | + for core_pkg in self._core_pkgs: |
67 | + candidate = core_pkg.candidate |
68 | + dependencies = candidate.get_dependencies('Depends', 'Recommends') |
69 | + for dependency_pkg in itertools.chain.from_iterable(dependencies): |
70 | + if dependency_pkg.name == pkg.name: |
71 | + return True |
72 | + return False |
73 | + |
74 | + def packages_are_selected(self): |
75 | + for pkg in self.pkgs: |
76 | + if pkg.marked_install or pkg.marked_upgrade: |
77 | + return True |
78 | + return False |
79 | + |
80 | + def selection_is_inconsistent(self): |
81 | + pkgs_installing = [pkg for pkg in self.pkgs |
82 | + if pkg.marked_install or pkg.marked_upgrade] |
83 | + return (len(pkgs_installing) > 0 and |
84 | + len(pkgs_installing) < len(self.pkgs)) |
85 | + |
86 | + def get_total_size(self): |
87 | + size = 0 |
88 | + for pkg in self.pkgs: |
89 | + size += getattr(pkg.candidate, "size", 0) |
90 | + return size |
91 | + |
92 | + |
93 | +class UpdateApplicationGroup(UpdateGroup): |
94 | + def __init__(self, pkgs, application): |
95 | + name = application.get_display_name() |
96 | + icon = application.get_icon() |
97 | + super(UpdateApplicationGroup, self).__init__(pkgs, name, icon) |
98 | + |
99 | + |
100 | +class UpdateSystemGroup(UpdateGroup): |
101 | + def __init__(self, system_pkgs): |
102 | + name = _("Ubuntu base") |
103 | + icon = Gio.ThemedIcon.new("distributor-logo") |
104 | + super(UpdateSystemGroup, self).__init__(system_pkgs, name, icon) |
105 | + |
106 | + |
107 | +class UpdateOrigin(): |
108 | def __init__(self, desc, importance): |
109 | self.packages = [] |
110 | self.importance = importance |
111 | self.description = desc |
112 | |
113 | |
114 | -class OriginsImportance: |
115 | - # filed in by us |
116 | - SECURITY = 10 |
117 | - UPDATES = 9 |
118 | - PROPOSED = 8 |
119 | - BACKPORTS = 7 |
120 | - ARCHIVE = 6 |
121 | - # this is filed in by MyCache |
122 | - OTHER = 0 |
123 | - # this is used by us |
124 | - OTHER_UNKNOWN = -1 |
125 | - |
126 | - |
127 | -class UpdateList(object): |
128 | +class UpdateList(): |
129 | """ |
130 | class that contains the list of available updates in |
131 | self.pkgs[origin] where origin is the user readable string |
132 | """ |
133 | |
134 | - # the key in the debian/control file used to add the phased |
135 | - # updates percentage |
136 | - PHASED_UPDATES_KEY = "Phased-Update-Percentage" |
137 | - |
138 | - # the file that contains the uniq machine id |
139 | - UNIQ_MACHINE_ID_FILE = "/var/lib/dbus/machine-id" |
140 | - |
141 | - # the configuration key to turn phased-updates always on |
142 | - ALWAYS_INCLUDE_PHASED_UPDATES = ( |
143 | - "Update-Manager::Always-Include-Phased-Updates") |
144 | - |
145 | def __init__(self, parent): |
146 | # a map of packages under their origin |
147 | try: |
148 | @@ -81,59 +131,64 @@ |
149 | "you are using.") % e) |
150 | sys.exit(1) |
151 | self.distUpgradeWouldDelete = 0 |
152 | - self.pkgs = {} |
153 | + self.pkg_groups = [] |
154 | self.num_updates = 0 |
155 | - self.matcher = self.initMatcher(dist) |
156 | - self.random = random.Random() |
157 | - # a stable machine uniq id |
158 | - with open(self.UNIQ_MACHINE_ID_FILE) as f: |
159 | - self.machine_uniq_id = f.read() |
160 | - |
161 | - def initMatcher(self, dist): |
162 | - # (origin, archive, description, importance) |
163 | - matcher_templates = [ |
164 | - ("%s-security" % dist, "Ubuntu", _("Important security updates"), |
165 | - OriginsImportance.SECURITY), |
166 | - ("%s-updates" % dist, "Ubuntu", _("Recommended updates"), |
167 | - OriginsImportance.UPDATES), |
168 | - ("%s-proposed" % dist, "Ubuntu", _("Proposed updates"), |
169 | - OriginsImportance.PROPOSED), |
170 | - ("%s-backports" % dist, "Ubuntu", _("Backports"), |
171 | - OriginsImportance.BACKPORTS), |
172 | - (dist, "Ubuntu", _("Distribution updates"), |
173 | - OriginsImportance.ARCHIVE), |
174 | - (None, None, _("Other updates"), OriginsImportance.OTHER_UNKNOWN) |
175 | - ] |
176 | - matcher = {} |
177 | - for (origin, archive, desc, importance) in matcher_templates: |
178 | - matcher[(origin, archive)] = UpdateOrigin(desc, importance) |
179 | - return matcher |
180 | - |
181 | - def is_ignored_phased_update(self, pkg): |
182 | - """ This will test if the pkg is a phased updated and if |
183 | - it needs to get installed or ignored. |
184 | - |
185 | - :return: True if the updates should be ignored |
186 | - """ |
187 | - # allow the admin to override this |
188 | - if apt.apt_pkg.config.find_b( |
189 | - self.ALWAYS_INCLUDE_PHASED_UPDATES, False): |
190 | - return False |
191 | - |
192 | - if self.PHASED_UPDATES_KEY in pkg.candidate.record: |
193 | - # its important that we always get the same result on |
194 | - # multiple runs of the update-manager, so we need to |
195 | - # feed a seed that is a combination of the pkg/ver/machine |
196 | - self.random.seed("%s-%s-%s" % ( |
197 | - pkg.name, pkg.candidate.version, |
198 | - self.machine_uniq_id)) |
199 | - threshold = pkg.candidate.record[self.PHASED_UPDATES_KEY] |
200 | - percentage = self.random.randint(0, 100) |
201 | - if percentage > int(threshold): |
202 | - logging.info("holding back phased update (%s < %s)" % ( |
203 | - threshold, percentage)) |
204 | - return True |
205 | - return False |
206 | + data_dirs = os.environ['XDG_DATA_DIRS'].split(':') |
207 | + self.application_dirs = [os.path.join(base, 'applications') |
208 | + for base in data_dirs] |
209 | + self.current_desktop = os.environ.get('XDG_CURRENT_DESKTOP') |
210 | + |
211 | + def _file_is_application(self, file_path): |
212 | + file_path = os.path.abspath(file_path) |
213 | + is_application = False |
214 | + for app_dir in self.application_dirs: |
215 | + is_application = is_application or file_path.startswith(app_dir) |
216 | + extension = os.path.splitext(file_path)[1] |
217 | + is_application = is_application and (extension == '.desktop') |
218 | + return is_application |
219 | + |
220 | + def _rate_application_for_package(self, application, pkg): |
221 | + score = 0 |
222 | + desktop_file = os.path.basename(application.get_filename()) |
223 | + application_id = os.path.splitext(desktop_file)[0] |
224 | + |
225 | + if application.should_show(): |
226 | + score += 1 |
227 | + |
228 | + if application_id == pkg.name: |
229 | + score += 5 |
230 | + |
231 | + return score |
232 | + |
233 | + def _get_application_for_package(self, pkg): |
234 | + desktop_files = [] |
235 | + rated_applications = [] |
236 | + |
237 | + for installed_file in pkg.installed_files: |
238 | + if self._file_is_application(installed_file): |
239 | + desktop_files.append(installed_file) |
240 | + |
241 | + app_install_pattern = os.path.join(APP_INSTALL_PATH, '%s:*' % pkg.name) |
242 | + for desktop_file in glob.glob(app_install_pattern): |
243 | + desktop_files.append(desktop_file) |
244 | + |
245 | + for desktop_file in desktop_files: |
246 | + try: |
247 | + application = Gio.DesktopAppInfo \ |
248 | + .new_from_filename(desktop_file) |
249 | + application.set_desktop_env(self.current_desktop) |
250 | + except Exception as e: |
251 | + print("Error loading .desktop file %s: %s" % installed_file, e) |
252 | + continue |
253 | + score = self._rate_application_for_package(application, pkg) |
254 | + if score > 0: |
255 | + rated_applications.append((score, application)) |
256 | + |
257 | + rated_applications.sort(key=lambda app: app[0], reverse=True) |
258 | + if len(rated_applications) > 0: |
259 | + return rated_applications[0][1] |
260 | + else: |
261 | + return None |
262 | |
263 | def update(self, cache): |
264 | self.held_back = [] |
265 | @@ -141,9 +196,11 @@ |
266 | # do the upgrade |
267 | self.distUpgradeWouldDelete = cache.saveDistUpgrade() |
268 | |
269 | - #dselect_upgrade_origin = UpdateOrigin(_("Previous selected"), 1) |
270 | + upgrade_pkgs = [] |
271 | + pkgs_by_source = {} |
272 | + ungrouped_pkgs = [] |
273 | |
274 | - # sort by origin |
275 | + # Find all upgradable packages |
276 | for pkg in cache: |
277 | if pkg.is_upgradable or pkg.marked_install: |
278 | if getattr(pkg.candidate, "origins", None) is None: |
279 | @@ -152,22 +209,40 @@ |
280 | print("WARNING: upgradable but no candidate.origins?!?: ", |
281 | pkg.name) |
282 | continue |
283 | - # check where the package belongs |
284 | - origin_node = cache.match_package_origin(pkg, self.matcher) |
285 | - |
286 | - # see if its a phased update and *not* a security update |
287 | - # or shadowing a security update |
288 | - if (origin_node.importance != OriginsImportance.SECURITY and |
289 | - self.is_ignored_phased_update(pkg)): |
290 | - continue |
291 | - |
292 | - if origin_node not in self.pkgs: |
293 | - self.pkgs[origin_node] = [] |
294 | - self.pkgs[origin_node].append(pkg) |
295 | + |
296 | + upgrade_pkgs.append(pkg) |
297 | self.num_updates = self.num_updates + 1 |
298 | + |
299 | if pkg.is_upgradable and not (pkg.marked_upgrade or |
300 | pkg.marked_install): |
301 | self.held_back.append(pkg.name) |
302 | - for l in self.pkgs.keys(): |
303 | - self.pkgs[l].sort(key=operator.attrgetter("name")) |
304 | + |
305 | + # Index packages by source package name |
306 | + for pkg in upgrade_pkgs: |
307 | + srcpkg = pkg.candidate.source_name |
308 | + pkgs_by_source.setdefault(srcpkg, []).append(pkg) |
309 | + |
310 | + for srcpkg, pkgs in pkgs_by_source.items(): |
311 | + for pkg in pkgs: |
312 | + app = self._get_application_for_package(pkg) |
313 | + if app is not None: |
314 | + app_group = UpdateApplicationGroup([pkg], app) |
315 | + self.pkg_groups.append(app_group) |
316 | + else: |
317 | + ungrouped_pkgs.append(pkg) |
318 | + self.pkg_groups.sort(key=operator.attrgetter('name')) |
319 | + |
320 | + # Stick together packages and their immediate dependencies |
321 | + for pkg in upgrade_pkgs: |
322 | + for group in self.pkg_groups: |
323 | + result = group.add_extra(pkg) |
324 | + if result and pkg in ungrouped_pkgs: |
325 | + ungrouped_pkgs.remove(pkg) |
326 | + |
327 | + # FIXME: system_group should only have packages that are dependencies |
328 | + # of ubuntu-meta |
329 | + system_group = UpdateSystemGroup(ungrouped_pkgs) |
330 | + self.pkg_groups.append(system_group) |
331 | + |
332 | self.keepcount = cache._depcache.keep_count |
333 | + |
334 | |
335 | === modified file 'UpdateManager/UpdatesAvailable.py' |
336 | --- UpdateManager/UpdatesAvailable.py 2012-10-10 08:13:13 +0000 |
337 | +++ UpdateManager/UpdatesAvailable.py 2012-11-10 06:29:19 +0000 |
338 | @@ -70,10 +70,12 @@ |
339 | |
340 | # FIXME: |
341 | # - kill "all_changes" and move the changes into the "Update" class |
342 | +# - screen reader does not read update toggle state |
343 | +# - screen reader does not say "Downloaded" for downloaded updates |
344 | |
345 | # list constants |
346 | -(LIST_CONTENTS, LIST_NAME, LIST_PKG, |
347 | - LIST_ORIGIN, LIST_TOGGLE_CHECKED) = range(5) |
348 | +(LIST_NAME, LIST_UPDATE_DATA, LIST_SIZE, LIST_TOGGLE_ACTIVE) = range(4) |
349 | +#LIST_UPDATE_DATA is a tuple of (is_toplevel, group, pkg) |
350 | |
351 | # NetworkManager enums |
352 | from .Core.roam import NetworkManagerHelper |
353 | @@ -134,38 +136,71 @@ |
354 | self.update_close_button() |
355 | |
356 | # the treeview (move into it's own code!) |
357 | - self.store = Gtk.ListStore(str, str, GObject.TYPE_PYOBJECT, |
358 | - GObject.TYPE_PYOBJECT, bool) |
359 | + self.store = Gtk.TreeStore(str, GObject.TYPE_PYOBJECT, str, bool) |
360 | self.treeview_update.set_model(self.store) |
361 | - self.treeview_update.set_headers_clickable(True) |
362 | + |
363 | + restart_icon_renderer = Gtk.CellRendererPixbuf() |
364 | + restart_icon_renderer.set_property("xpad", 4) |
365 | + restart_icon_renderer.set_property("ypad", 2) |
366 | + restart_icon_renderer.set_property("stock-size", Gtk.IconSize.MENU) |
367 | + restart_icon_renderer.set_property("follow-state", True) |
368 | + restart_column = Gtk.TreeViewColumn(None, restart_icon_renderer) |
369 | + restart_column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) |
370 | + self.treeview_update.append_column(restart_column) |
371 | + restart_column.set_cell_data_func(restart_icon_renderer, |
372 | + self.restart_icon_renderer_data_func) |
373 | + |
374 | + pkg_column = Gtk.TreeViewColumn() |
375 | + pkg_column.set_title(_("Install")) |
376 | + pkg_column.set_property("spacing", 4) |
377 | + pkg_column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) |
378 | + pkg_column.set_expand(True) |
379 | + self.treeview_update.append_column(pkg_column) |
380 | + |
381 | + pkg_toggle_renderer = Gtk.CellRendererToggle() |
382 | + pkg_toggle_renderer.set_property("ypad", 2) |
383 | + pkg_toggle_renderer.connect("toggled", self.on_update_toggled) |
384 | + pkg_column.pack_start(pkg_toggle_renderer, False) |
385 | + pkg_column.add_attribute(pkg_toggle_renderer, |
386 | + 'active', LIST_TOGGLE_ACTIVE) |
387 | + pkg_column.set_cell_data_func(pkg_toggle_renderer, |
388 | + self.pkg_toggle_renderer_data_func) |
389 | + |
390 | + pkg_icon_renderer = Gtk.CellRendererPixbuf() |
391 | + pkg_icon_renderer.set_property("ypad", 2) |
392 | + pkg_icon_renderer.set_property("stock-size", Gtk.IconSize.MENU) |
393 | + pkg_column.pack_start(pkg_icon_renderer, False) |
394 | + pkg_column.set_cell_data_func(pkg_icon_renderer, |
395 | + self.pkg_icon_renderer_data_func) |
396 | + |
397 | + pkg_label_renderer = Gtk.CellRendererText() |
398 | + pkg_label_renderer.set_property("ypad", 2) |
399 | + pkg_column.pack_start(pkg_label_renderer, True) |
400 | + pkg_column.set_cell_data_func(pkg_label_renderer, |
401 | + self.pkg_label_renderer_data_func) |
402 | + |
403 | + size_renderer = Gtk.CellRendererText() |
404 | + size_renderer.set_property("xpad", 6) |
405 | + size_renderer.set_property("ypad", 0) |
406 | + size_renderer.set_property("xalign", 1) |
407 | + # 1.0/1.2 == PANGO.Scale.SMALL. Constant is not (yet) introspected. |
408 | + size_renderer.set_property("scale", 1.0/1.2) |
409 | + size_column = Gtk.TreeViewColumn(_("Download"), size_renderer, |
410 | + text=LIST_SIZE) |
411 | + size_column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) |
412 | + self.treeview_update.append_column(size_column) |
413 | + |
414 | + self.treeview_update.set_headers_visible(True) |
415 | + self.treeview_update.set_headers_clickable(False) |
416 | self.treeview_update.set_direction(Gtk.TextDirection.LTR) |
417 | - |
418 | - tr = Gtk.CellRendererText() |
419 | - tr.set_property("xpad", 6) |
420 | - tr.set_property("ypad", 6) |
421 | - cr = Gtk.CellRendererToggle() |
422 | - cr.set_property("activatable", True) |
423 | - cr.set_property("xpad", 6) |
424 | - cr.connect("toggled", self.toggled) |
425 | - |
426 | - column_install = Gtk.TreeViewColumn(_("Install"), cr, |
427 | - active=LIST_TOGGLE_CHECKED) |
428 | - column_install.set_cell_data_func(cr, self.install_column_view_func) |
429 | - column = Gtk.TreeViewColumn(_("Name"), tr, markup=LIST_CONTENTS) |
430 | - column.set_resizable(True) |
431 | - |
432 | - column_install.set_sizing(Gtk.TreeViewColumnSizing.FIXED) |
433 | - column_install.set_fixed_width(30) |
434 | - column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) |
435 | - column.set_fixed_width(100) |
436 | self.treeview_update.set_fixed_height_mode(False) |
437 | - |
438 | - self.treeview_update.append_column(column_install) |
439 | - column_install.set_visible(True) |
440 | - self.treeview_update.append_column(column) |
441 | + self.treeview_update.set_expander_column(pkg_column) |
442 | self.treeview_update.set_search_column(LIST_NAME) |
443 | self.treeview_update.connect("button-press-event", |
444 | - self.show_context_menu) |
445 | + self.on_treeview_button_press) |
446 | + # Line up row details under expanders. |
447 | + # FIXME: This is arbitrary and should happen in the Gtk theme itself |
448 | + self.treeview_update.set_level_indentation(12) |
449 | |
450 | # setup the help viewer and disable the help button if there |
451 | # is no viewer available |
452 | @@ -210,26 +245,83 @@ |
453 | self.button_close.set_use_underline(False) |
454 | |
455 | def install_all_updates(self, menu, menuitem, data): |
456 | - self.select_all_updgrades(None) |
457 | + self.select_all_upgrades(None) |
458 | self.on_button_install_clicked(None) |
459 | |
460 | - def install_column_view_func(self, cell_layout, renderer, model, iter, |
461 | - data): |
462 | - pkg = model.get_value(iter, LIST_PKG) |
463 | - if pkg is None: |
464 | - renderer.set_property("activatable", True) |
465 | - return |
466 | - current_state = renderer.get_property("active") |
467 | - to_install = pkg.marked_install or pkg.marked_upgrade |
468 | - renderer.set_property("active", to_install) |
469 | - # we need to update the store as well to ensure orca knowns |
470 | - # about state changes (it will not read view_func changes) |
471 | - if to_install != current_state: |
472 | - self.store[iter][LIST_TOGGLE_CHECKED] = to_install |
473 | - if pkg.name in self.list.held_back: |
474 | - renderer.set_property("activatable", False) |
475 | - else: |
476 | - renderer.set_property("activatable", True) |
477 | + def restart_icon_renderer_data_func(self, cell_layout, renderer, model, |
478 | + iter, data): |
479 | + # List of packages we know require a restart. |
480 | + # Hack to test the feature while packages lack the needed metadata. |
481 | + # FIXME: Remove this soon. |
482 | + restart_sourcepkgs = ['linux-meta'] |
483 | + |
484 | + def pkg_requires_restart(pkg): |
485 | + restart_condition = pkg.candidate.record.get('XB-Restart-Required') |
486 | + restart_override = pkg.candidate.source_name in restart_sourcepkgs |
487 | + return restart_condition == 'system' or restart_override |
488 | + |
489 | + is_toplevel, group, pkg = model.get_value(iter, LIST_UPDATE_DATA) |
490 | + path = model.get_path(iter) |
491 | + |
492 | + requires_restart = False |
493 | + if is_toplevel: |
494 | + if not self.treeview_update.row_expanded(path): |
495 | + # A package in the group requires restart |
496 | + for group_pkg in group.pkgs: |
497 | + if pkg_requires_restart(group_pkg): |
498 | + requires_restart = True |
499 | + break |
500 | + else: |
501 | + requires_restart = pkg_requires_restart(pkg) |
502 | + |
503 | + # FIXME: Non-standard, incorrect icon name (from app category). |
504 | + # Theme support for what we want seems to be lacking. |
505 | + if requires_restart: |
506 | + restart_icon_names = ['view-refresh-symbolic', |
507 | + 'system-restart', |
508 | + 'system-reboot'] |
509 | + gicon = Gio.ThemedIcon.new_from_names(restart_icon_names) |
510 | + else: |
511 | + gicon = None |
512 | + renderer.set_property("gicon", gicon) |
513 | + |
514 | + def pkg_toggle_renderer_data_func(self, cell_layout, renderer, model, iter, data): |
515 | + is_toplevel, group, pkg = model.get_value(iter, LIST_UPDATE_DATA) |
516 | + |
517 | + if is_toplevel: |
518 | + activatable = True |
519 | + inconsistent = group.selection_is_inconsistent() |
520 | + elif pkg: |
521 | + activatable = pkg.name not in self.list.held_back |
522 | + inconsistent = False |
523 | + |
524 | + # Assumes the "active" attribute is already set, tied directly to LIST_TOGGLE_ACTIVE |
525 | + renderer.set_property("activatable", activatable) |
526 | + renderer.set_property("inconsistent", inconsistent) |
527 | + |
528 | + def pkg_icon_renderer_data_func(self, cell_layout, renderer, model, iter, data): |
529 | + is_toplevel, group, pkg = model.get_value(iter, LIST_UPDATE_DATA) |
530 | + |
531 | + if is_toplevel: |
532 | + gicon = group.icon |
533 | + else: |
534 | + gicon = Gio.ThemedIcon.new("package") |
535 | + |
536 | + renderer.set_property("gicon", gicon) |
537 | + |
538 | + def pkg_label_renderer_data_func(self, cell_layout, renderer, model, iter, data): |
539 | + is_toplevel, group, pkg = model.get_value(iter, LIST_UPDATE_DATA) |
540 | + |
541 | + if is_toplevel: |
542 | + markup = "<b>%s</b>" % group.name |
543 | + else: |
544 | + pkg_name = xml.sax.saxutils.escape(pkg.name) |
545 | + if not pkg.is_installed: |
546 | + markup = _("%s (New install)") % pkg_name |
547 | + else: |
548 | + markup = pkg_name |
549 | + |
550 | + renderer.set_property("markup", markup) |
551 | |
552 | def set_changes_buffer(self, changes_buffer, text, name, srcpkg): |
553 | changes_buffer.set_text("") |
554 | @@ -264,7 +356,7 @@ |
555 | iter = model.get_iter(path) |
556 | |
557 | # set descr |
558 | - pkg = model.get_value(iter, LIST_PKG) |
559 | + is_toplevel, group, pkg = model.get_value(iter, LIST_UPDATE_DATA) |
560 | if (pkg is None or pkg.candidate is None or |
561 | pkg.candidate.description is None): |
562 | changes_buffer = self.textview_changes.get_buffer() |
563 | @@ -350,7 +442,7 @@ |
564 | if changes: |
565 | self.set_changes_buffer(changes_buffer, changes, name, srcpkg) |
566 | |
567 | - def show_context_menu(self, widget, event): |
568 | + def on_treeview_button_press(self, widget, event): |
569 | """ |
570 | Show a context menu if a right click was performed on an update entry |
571 | """ |
572 | @@ -361,10 +453,13 @@ |
573 | self.menu = menu = Gtk.Menu() |
574 | item_select_none = \ |
575 | Gtk.MenuItem.new_with_mnemonic(_("_Deselect All")) |
576 | - item_select_none.connect("activate", self.select_none_updgrades) |
577 | + item_select_none.connect("activate", self.select_none_upgrades) |
578 | menu.append(item_select_none) |
579 | + num_updates = self.cache.install_count |
580 | + if num_updates == 0: |
581 | + item_select_none.set_property("sensitive", False) |
582 | item_select_all = Gtk.MenuItem.new_with_mnemonic(_("Select _All")) |
583 | - item_select_all.connect("activate", self.select_all_updgrades) |
584 | + item_select_all.connect("activate", self.select_all_upgrades) |
585 | menu.append(item_select_all) |
586 | menu.show_all() |
587 | menu.popup_for_device( |
588 | @@ -373,35 +468,35 @@ |
589 | return True |
590 | |
591 | # we need this for select all/unselect all |
592 | - def _toggle_origin_headers(self, new_selection_value): |
593 | - """ small helper that will set/unset the origin headers |
594 | + def _toggle_group_headers(self, new_selection_value): |
595 | + """ small helper that will set/unset the group headers |
596 | """ |
597 | model = self.treeview_update.get_model() |
598 | for row in model: |
599 | - if not model.get_value(row.iter, LIST_PKG): |
600 | - model.set_value(row.iter, LIST_TOGGLE_CHECKED, |
601 | - new_selection_value) |
602 | + is_toplevel, group, pkg = model.get_value(row.iter, LIST_UPDATE_DATA) |
603 | + if is_toplevel: |
604 | + model.set_value(row.iter, LIST_TOGGLE_ACTIVE, new_selection_value) |
605 | |
606 | - def select_all_updgrades(self, widget): |
607 | + def select_all_upgrades(self, widget): |
608 | """ |
609 | Select all updates |
610 | """ |
611 | self.setBusy(True) |
612 | self.cache.saveDistUpgrade() |
613 | - self._toggle_origin_headers(True) |
614 | + self._toggle_group_headers(True) |
615 | self.treeview_update.queue_draw() |
616 | - self.refresh_updates_count() |
617 | + self.updates_changed() |
618 | self.setBusy(False) |
619 | |
620 | - def select_none_updgrades(self, widget): |
621 | + def select_none_upgrades(self, widget): |
622 | """ |
623 | Select none updates |
624 | """ |
625 | self.setBusy(True) |
626 | self.cache.clear() |
627 | - self._toggle_origin_headers(False) |
628 | + self._toggle_group_headers(False) |
629 | self.treeview_update.queue_draw() |
630 | - self.refresh_updates_count() |
631 | + self.updates_changed() |
632 | self.setBusy(False) |
633 | |
634 | def setBusy(self, flag): |
635 | @@ -417,7 +512,17 @@ |
636 | while Gtk.events_pending(): |
637 | Gtk.main_iteration() |
638 | |
639 | - def refresh_updates_count(self): |
640 | + def _mark_selected_updates(self): |
641 | + def foreach_cb(model, path, iter, data): |
642 | + is_toplevel, group, pkg = model.get_value(iter, LIST_UPDATE_DATA) |
643 | + if is_toplevel: |
644 | + active = group.packages_are_selected() |
645 | + elif pkg: |
646 | + active = pkg.marked_install or pkg.marked_upgrade |
647 | + model.set_value(iter, LIST_TOGGLE_ACTIVE, active) |
648 | + self.store.foreach(foreach_cb, None) |
649 | + |
650 | + def _refresh_updates_count(self): |
651 | self.button_install.set_sensitive(self.cache.install_count) |
652 | try: |
653 | inst_count = self.cache.install_count |
654 | @@ -458,10 +563,15 @@ |
655 | self.hbox_downsize.show() |
656 | self.vbox_alerts.show() |
657 | |
658 | + def updates_changed(self): |
659 | + self._mark_selected_updates() |
660 | + self._refresh_updates_count() |
661 | + |
662 | def update_count(self): |
663 | """activate or disable widgets and show dialog texts correspoding to |
664 | the number of available updates""" |
665 | - self.refresh_updates_count() |
666 | + self.updates_changed() |
667 | + num_updates = self.cache.install_count |
668 | |
669 | text_header = None |
670 | text_desc = None |
671 | @@ -571,14 +681,14 @@ |
672 | # can deal with dialup connections properly |
673 | if state in NetworkManagerHelper.NM_STATE_CONNECTING_LIST: |
674 | self.label_offline.set_text(_("Connecting...")) |
675 | - self.refresh_updates_count() |
676 | + self.updates_changed() |
677 | self.hbox_offline.show() |
678 | self.vbox_alerts.show() |
679 | self.connected = False |
680 | # in doubt (STATE_UNKNOWN), assume connected |
681 | elif (state in NetworkManagerHelper.NM_STATE_CONNECTED_LIST or |
682 | state == NetworkManagerHelper.NM_STATE_UNKNOWN): |
683 | - self.refresh_updates_count() |
684 | + self.updates_changed() |
685 | self.hbox_offline.hide() |
686 | self.connected = True |
687 | # trigger re-showing the current app to get changelog info (if |
688 | @@ -588,7 +698,7 @@ |
689 | self.connected = False |
690 | self.label_offline.set_text(_("You may not be able to check for " |
691 | "updates or download new updates.")) |
692 | - self.refresh_updates_count() |
693 | + self.updates_changed() |
694 | self.hbox_offline.show() |
695 | self.vbox_alerts.show() |
696 | |
697 | @@ -611,45 +721,15 @@ |
698 | self.hbox_on_3g.hide() |
699 | self.hbox_roaming.hide() |
700 | |
701 | - def row_activated(self, treeview, path, column): |
702 | - iter = self.store.get_iter(path) |
703 | - |
704 | - pkg = self.store.get_value(iter, LIST_PKG) |
705 | - origin = self.store.get_value(iter, LIST_ORIGIN) |
706 | - if pkg is not None: |
707 | - return |
708 | - self.toggle_from_origin(pkg, origin, True) |
709 | - |
710 | - def toggle_from_origin(self, pkg, origin, select_all=True): |
711 | - self.setBusy(True) |
712 | - actiongroup = apt_pkg.ActionGroup(self.cache._depcache) |
713 | - for pkg in self.list.pkgs[origin]: |
714 | - if pkg.marked_install or pkg.marked_upgrade: |
715 | - #print("marking keep: ", pkg.name) |
716 | - pkg.mark_keep() |
717 | - elif not (pkg.name in self.list.held_back): |
718 | - #print("marking install: ", pkg.name) |
719 | - pkg.mark_install(auto_fix=False, auto_inst=False) |
720 | - # check if we left breakage |
721 | - if self.cache._depcache.broken_count: |
722 | - Fix = apt_pkg.ProblemResolver(self.cache._depcache) |
723 | - Fix.resolve_by_keep() |
724 | - self.refresh_updates_count() |
725 | - self.treeview_update.queue_draw() |
726 | - del actiongroup |
727 | - self.setBusy(False) |
728 | - |
729 | - def toggled(self, renderer, path): |
730 | + def on_update_toggled(self, renderer, path): |
731 | """ a toggle button in the listview was toggled """ |
732 | iter = self.store.get_iter(path) |
733 | - pkg = self.store.get_value(iter, LIST_PKG) |
734 | - origin = self.store.get_value(iter, LIST_ORIGIN) |
735 | + is_toplevel, group, pkg = self.store.get_value(iter, LIST_UPDATE_DATA) |
736 | # make sure that we don't allow to toggle deactivated updates |
737 | # this is needed for the call by the row activation callback |
738 | - if pkg is None: |
739 | - toggled_value = not self.store.get_value(iter, LIST_TOGGLE_CHECKED) |
740 | - self.toggle_from_origin(pkg, origin, toggled_value) |
741 | - self.store.set_value(iter, LIST_TOGGLE_CHECKED, toggled_value) |
742 | + if is_toplevel: |
743 | + self.toggle_from_group(group) |
744 | + self.updates_changed() |
745 | self.treeview_update.queue_draw() |
746 | return |
747 | if pkg is None or pkg.name in self.list.held_back: |
748 | @@ -666,8 +746,8 @@ |
749 | pkg.mark_install() |
750 | except SystemError: |
751 | pass |
752 | + self.updates_changed() |
753 | self.treeview_update.queue_draw() |
754 | - self.refresh_updates_count() |
755 | self.setBusy(False) |
756 | |
757 | def on_treeview_update_row_activated(self, treeview, path, column, *args): |
758 | @@ -675,7 +755,28 @@ |
759 | If an update row was activated (by pressing space), toggle the |
760 | install check box |
761 | """ |
762 | - self.toggled(None, path) |
763 | + self.on_update_toggled(None, path) |
764 | + |
765 | + def toggle_from_group(self, group): |
766 | + self.setBusy(True) |
767 | + actiongroup = apt_pkg.ActionGroup(self.cache._depcache) |
768 | + |
769 | + # Deselect all updates if any in group are selected |
770 | + keep_packages = group.packages_are_selected() |
771 | + for pkg in group.pkgs: |
772 | + if keep_packages: |
773 | + pkg.mark_keep() |
774 | + elif not pkg.name in self.list.held_back: |
775 | + pkg.mark_install(auto_fix=False, auto_inst=False) |
776 | + |
777 | + # check if we left breakage |
778 | + if self.cache._depcache.broken_count: |
779 | + Fix = apt_pkg.ProblemResolver(self.cache._depcache) |
780 | + Fix.resolve_by_keep() |
781 | + self.updates_changed() |
782 | + self.treeview_update.queue_draw() |
783 | + del actiongroup |
784 | + self.setBusy(False) |
785 | |
786 | def save_state(self): |
787 | """ save the state (window-size for now) """ |
788 | @@ -704,46 +805,36 @@ |
789 | self.dl_size = 0 |
790 | |
791 | self.scrolledwindow_update.show() |
792 | - origin_list = sorted( |
793 | - self.list.pkgs, key=operator.attrgetter("importance"), |
794 | - reverse=True) |
795 | - for origin in origin_list: |
796 | - self.store.append(['<b><big>%s</big></b>' % origin.description, |
797 | - origin.description, None, origin, True]) |
798 | - for pkg in self.list.pkgs[origin]: |
799 | - name = xml.sax.saxutils.escape(pkg.name) |
800 | - if not pkg.is_installed: |
801 | - name += _(" (New install)") |
802 | - summary = xml.sax.saxutils.escape(getattr(pkg.candidate, |
803 | - "summary", "")) |
804 | - if self.summary_before_name: |
805 | - contents = "%s\n<small>%s</small>" % (summary, name) |
806 | - else: |
807 | - contents = "<b>%s</b>\n<small>%s</small>" % (name, summary) |
808 | - #TRANSLATORS: the b stands for Bytes |
809 | - size = _("(Size: %s)") % humanize_size(getattr(pkg.candidate, |
810 | - "size", 0)) |
811 | - installed_version = getattr(pkg.installed, "version", None) |
812 | - candidate_version = getattr(pkg.candidate, "version", None) |
813 | - if installed_version is not None: |
814 | - version = _("From version %(old_version)s " |
815 | - "to %(new_version)s") % { |
816 | - "old_version": installed_version, |
817 | - "new_version": candidate_version} |
818 | - else: |
819 | - version = _("Version %s") % candidate_version |
820 | - if self.show_versions: |
821 | - contents = "%s\n<small>%s %s</small>" % (contents, version, |
822 | - size) |
823 | - else: |
824 | - contents = "%s <small>%s</small>" % (contents, size) |
825 | - self.store.append([contents, pkg.name, pkg, None, True]) |
826 | + |
827 | + # add update groups and packages to self.store. Each row contains: |
828 | + # row label (for screen reader), |
829 | + # update data tuple (is_toplevel, group object, package object), |
830 | + # update size, |
831 | + # update selection state |
832 | + for group in self.list.pkg_groups: |
833 | + group_row = [ |
834 | + group.name, |
835 | + (True, group, None), |
836 | + humanize_size(group.get_total_size()), |
837 | + True |
838 | + ] |
839 | + tree_parent = self.store.append(None, group_row) |
840 | + |
841 | + for pkg in group.pkgs: |
842 | + pkg_row = [ |
843 | + pkg.name, |
844 | + (False, group, pkg), |
845 | + humanize_size(getattr(pkg.candidate, "size", 0)), |
846 | + True |
847 | + ] |
848 | + self.store.append(tree_parent, pkg_row) |
849 | + |
850 | self.treeview_update.set_model(self.store) |
851 | self.update_count() |
852 | self.setBusy(False) |
853 | while Gtk.events_pending(): |
854 | Gtk.main_iteration() |
855 | - self.refresh_updates_count() |
856 | + self.updates_changed() |
857 | return False |
858 | |
859 | def main(self): |
See detailed review sent separately.