diff --git a/rigo/RigoDaemon/app.py b/rigo/RigoDaemon/app.py index 9a1c594e1..c68eadcf3 100755 --- a/rigo/RigoDaemon/app.py +++ b/rigo/RigoDaemon/app.py @@ -16,8 +16,12 @@ import os os.environ['ETP_GETTEXT_DOMAIN'] = "rigo" import sys +import pwd import time import signal +import tempfile +import shutil +import subprocess from threading import Lock, Timer, Semaphore from collections import deque @@ -509,6 +513,9 @@ class RigoDaemonService(dbus.service.Object): self._acquired_exclusive = False self._acquired_exclusive_mutex = Lock() + self._config_updates = None + self._config_updates_mutex = Lock() + self._action_queue = deque() self._action_queue_length_mutex = Lock() self._action_queue_length = 0 @@ -618,6 +625,18 @@ class RigoDaemonService(dbus.service.Object): 'org.freedesktop.DBus').GetConnectionUnixProcessID( sender) + def _get_caller_user(self, sender): + """ + Return the username of the caller (through Dbus). + """ + bus = self._bus.get_object( + 'org.freedesktop.DBus', + '/org/freedesktop/DBus') + return dbus.Interface( + bus, + 'org.freedesktop.DBus').GetConnectionUnixUser( + sender) + def _authorize(self, pid, action_id): """ Authorize privileged Activity. @@ -814,6 +833,7 @@ class RigoDaemonService(dbus.service.Object): self.activity_completed, activity, success) GLib.idle_add( self.applications_managed, outcome) + self._maybe_signal_configuration_updates() is_app = True if isinstance(item, RigoDaemonService.ActionQueueItem): @@ -1561,6 +1581,62 @@ class RigoDaemonService(dbus.service.Object): count, total), debug=True) self._entropy.installed_repository().commit() + def _maybe_signal_configuration_updates(self): + """ + Signal Configuration Files Updates if needed. + """ + scandata = self._configuration_updates(_force=True) + if scandata: + GLib.idle_add( + self.configuration_updates_available, + self._dbus_prepare_configuration_files( + scandata.root(), scandata)) + + def _dbus_prepare_configuration_files(self, root, scandata): + """ + Prepare the ConfigurationFiles object for sending through + dbus. + """ + updates = [(root, source, x['destination'], \ + x['package_ids'], x['automerge']) for source, x \ + in scandata.items()] + return updates + + def _configuration_updates(self, _force=False): + """ + Return the latest (or a new one if not initialized yet) + ConfigurationFiles object. + """ + self._rwsem.reader_acquire() + try: + with self._config_updates_mutex: + if self._config_updates is None or _force: + updates = self._entropy.ConfigurationUpdates() + scandata = self._enrich_configuration_updates( + updates.get()) + self._config_updates = scandata + else: + scandata = self._config_updates + finally: + self._rwsem.reader_release() + return scandata + + def _enrich_configuration_updates(self, scandata): + """ + Enrich ConfigurationFiles object returned by Entropy Client + with extended information. + """ + _cache = {} + inst_repo = self._entropy.installed_repository() + for k, v in scandata.items(): + dest = v['destination'] + pkg_ids = _cache.get(dest) + if pkg_ids is None: + pkg_ids = list(inst_repo.searchBelongs(dest)) + _cache[dest] = pkg_ids + v['package_ids'] = pkg_ids + del _cache + return scandata def _close_local_resources(self): """ @@ -1853,6 +1929,207 @@ class RigoDaemonService(dbus.service.Object): task.name = "AcceptLicensesThread" task.start() + @dbus.service.method(BUS_NAME, in_signature='s', + out_signature='b', sender_keyword='sender') + def merge_configuration(self, source, sender=None): + """ + Move configuration file from source path over to + destination, keeping destination path permissions. + """ + write_output("move_configuration called: %s" % (locals(),), + debug=True) + pid = self._get_caller_pid(sender) + authenticated = self._auth.authenticate_sync( + pid, PolicyActions.MANAGE_CONFIGURATION) + if not authenticated: + return False + updates = self._configuration_updates() + return updates.merge(source) + + @dbus.service.method(BUS_NAME, in_signature='s', + out_signature='s', sender_keyword='sender') + def diff_configuration(self, source, sender=None): + """ + Generate a diff between destination -> source file paths and + return a path containing the output to caller. If diff cannot + be run, return empty string. + """ + write_output("diff_configuration called: %s" % (locals(),), + debug=True) + pid = self._get_caller_pid(sender) + authenticated = self._auth.authenticate_sync( + pid, PolicyActions.MANAGE_CONFIGURATION) + if not authenticated: + return "" + + updates = self._configuration_updates() + root = update.root() + obj = updates.get(source) + if obj is None: + return "" + + uid = self._get_caller_user(sender) + source_path = root + source + dest_path = root + obj['destination'] + + rc = None + tmp_fd, tmp_path = None, None + path = "" + try: + tmp_fd, tmp_path = tempfile.mkstemp(prefix="RigoDaemon") + with os.fdopen(tmp_fd, "wb") as tmp_f: + rc = subprocess.call( + ["/usr/bin/diff", "-Nu", dest_path, source_path], + stdout = tmp_f) + if rc == os.EX_OK: + path = self._prepare_configuration_file( + tmp_path, uid) + + except (OSError, IOError,) as err: + write_output("cannot diff_configuration: %s" % ( + repr(err),), debug=True) + + finally: + if tmp_fd is not None: + try: + os.close(tmp_fd) + except (OSError, IOError): + pass + if tmp_path is not None: + try: + os.remove(tmp_path) + except OSError: + pass + + return path + + def _view_configuration_file(self, source, sender, dest=False): + """ + View a source or destination configuration file + """ + pid = self._get_caller_pid(sender) + + authenticated = self._auth.authenticate_sync( + pid, PolicyActions.MANAGE_CONFIGURATION) + if not authenticated: + return "" + + updates = self._configuration_updates() + root = updates.root() + obj = updates.get(source) + if obj is None: + return "" + + uid = self._get_caller_user(sender) + if dest: + source_path = root + obj['destination'] + else: + source_path = root + source + return self._prepare_configuration_file(source_path, uid) + + def _prepare_configuration_file(self, path, uid): + """ + Prepare the given configuration file copying it to + a temporary path and setting proper permissions. + """ + tmp_fd, tmp_path = tempfile.mkstemp(prefix="RigoDaemon") + try: + with os.fdopen(tmp_fd, "wb") as tmp_f: + with open(path, "rb") as path_f: + shutil.copyfileobj(path_f, tmp_f) + # fixup perms + os.chown(tmp_path, uid, -1) + path = tmp_path + except (OSError, IOError) as err: + try: + os.close(tmp_fd) + except OSError: + pass + try: + os.remove(tmp_path) + except OSError: + pass + write_output("cannot _prepare_configuration_file: %s" % ( + repr(err),), debug=True) + path = "" + return path + + @dbus.service.method(BUS_NAME, in_signature='s', + out_signature='s', sender_keyword='sender') + def view_configuration_source(self, source, sender=None): + """ + Copy configuration source file to a temporary path featuring + caller ownership. If file cannot be copied, empty string is + returned. + """ + write_output("view_configuration_source called: %s" % (locals(),), + debug=True) + return self._view_configuration_file(source, sender) + + @dbus.service.method(BUS_NAME, in_signature='s', + out_signature='s', sender_keyword='sender') + def view_configuration_destination(self, source, sender=None): + """ + Copy configuration destination file to a temporary path featuring + caller ownership. If file cannot be copied, empty string is + returned. + """ + write_output("view_configuration_destination called: %s" % ( + locals(),), + debug=True) + return self._view_configuration_file(source, sender, dest=True) + + @dbus.service.method(BUS_NAME, in_signature='s', + out_signature='b', sender_keyword='sender') + def discard_configuration(self, source, sender=None): + """ + Remove configuration file from source path. + """ + write_output("discard_configuration called: %s" % (locals(),), + debug=True) + pid = self._get_caller_pid(sender) + authenticated = self._auth.authenticate_sync( + pid, PolicyActions.MANAGE_CONFIGURATION) + if not authenticated: + return False + updates = self._configuration_updates() + return updates.remove(source) + + @dbus.service.method(BUS_NAME, in_signature='', + out_signature='') + def configuration_updates(self): + """ + Return the last generated (if any, or create a new one) + ConfigurationFiles object. + """ + write_output("configuration_updates called", debug=True) + task = ParallelTask(self._maybe_signal_configuration_updates) + task.name = "ConfigurationUpdatesSignal" + task.daemon = True + task.start() + + @dbus.service.method(BUS_NAME, in_signature='', + out_signature='') + def reload_configuration_updates(self): + """ + Load a new ConfigurationFiles object. + """ + write_output("reload_configuration_updates called", debug=True) + def _reload(): + self._rwsem.reader_acquire() + try: + updates = self._entropy.ConfigurationUpdates() + with self._config_updates_mutex: + scandata = updates.get() + self._config_updates = scandata + finally: + self._rwsem.reader_release() + + task = ParallelTask(_reload) + task.name = "ReloadConfigurationUpdates" + task.daemon = True + task.start() + @dbus.service.method(BUS_NAME, in_signature='', out_signature='b') def exclusive(self): @@ -1950,6 +2227,17 @@ class RigoDaemonService(dbus.service.Object): write_output("unsupported_applications() issued, args:" " %s" % (locals(),), debug=True) + @dbus.service.signal(dbus_interface=BUS_NAME, + signature='a(sssaib)') + def configuration_updates_available(self, updates): + """ + Notify the presence of configuration files that should be updated. + The payload is a list of tuples, each one composed by: + (root, source, destination, installed_package_ids, auto-mergeable) + """ + write_output("configuration_updates_available() issued, args:" + " %s" % (locals(),), debug=True) + @dbus.service.signal(dbus_interface=BUS_NAME, signature='i') def restarting_system_upgrade(self, updates_amount): diff --git a/rigo/RigoDaemon/authentication.py b/rigo/RigoDaemon/authentication.py index 88eae6be0..f2f3161d6 100644 --- a/rigo/RigoDaemon/authentication.py +++ b/rigo/RigoDaemon/authentication.py @@ -71,3 +71,30 @@ class AuthenticationController(object): None, # Gio.Cancellable() _polkit_auth_callback, self._mainloop) + + def authenticate_sync(self, pid, action_id): + """ + Authenticate current User asking Administrator + passwords. + Return True if authenticated, False if not. + """ + authority = Polkit.Authority.get() + subject = Polkit.UnixProcess.new(pid) + result = authority.check_authorization_sync( + subject, + action_id, + None, + Polkit.CheckAuthorizationFlags.ALLOW_USER_INTERACTION, + None) + + authenticated = False + try: + if result.get_is_authorized(): + authenticated = True + elif result.get_is_challenge(): + authenticated = True + except GObject.GError as err: + const_debug_write( + __name__, + "_polkit_auth_callback: error: %s" % (err,)) + return authenticated diff --git a/rigo/RigoDaemon/config.py b/rigo/RigoDaemon/config.py index 069568cdd..66fd1e785 100644 --- a/rigo/RigoDaemon/config.py +++ b/rigo/RigoDaemon/config.py @@ -21,3 +21,4 @@ class PolicyActions: UPDATE_REPOSITORIES = "org.sabayon.RigoDaemon.update" UPGRADE_SYSTEM = "org.sabayon.RigoDaemon.upgrade" MANAGE_APPLICATIONS = "org.sabayon.RigoDaemon.manage" + MANAGE_CONFIGURATION = "org.sabayon.RigoDaemon.configuration" diff --git a/rigo/RigoDaemon/dbus/org.sabayon.Rigo.xml b/rigo/RigoDaemon/dbus/org.sabayon.Rigo.xml index 2af23980a..a3eeacc5d 100644 --- a/rigo/RigoDaemon/dbus/org.sabayon.Rigo.xml +++ b/rigo/RigoDaemon/dbus/org.sabayon.Rigo.xml @@ -31,6 +31,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rigo/RigoDaemon/polkit/org.sabayon.RigoDaemon.policy b/rigo/RigoDaemon/polkit/org.sabayon.RigoDaemon.policy index b75828914..fec711150 100644 --- a/rigo/RigoDaemon/polkit/org.sabayon.RigoDaemon.policy +++ b/rigo/RigoDaemon/polkit/org.sabayon.RigoDaemon.policy @@ -41,4 +41,17 @@ + + Manage Configuration + + Authentication is required to Manage System Configuration + + package-x-generic + + no + no + auth_admin_keep + + + diff --git a/rigo/data/ui/gtk3/rigo.ui b/rigo/data/ui/gtk3/rigo.ui index 67adb9449..ce8737f28 100644 --- a/rigo/data/ui/gtk3/rigo.ui +++ b/rigo/data/ui/gtk3/rigo.ui @@ -75,7 +75,6 @@ False - True True never automatic @@ -137,6 +136,32 @@ 3 + + + False + + + True + True + never + automatic + + + + + + True + True + 0 + + + + + True + True + 4 + + True @@ -522,7 +547,7 @@ True True - 4 + 5 diff --git a/rigo/rigo/controllers/daemon.py b/rigo/rigo/controllers/daemon.py index fbe0d7c6e..6df67f3d2 100644 --- a/rigo/rigo/controllers/daemon.py +++ b/rigo/rigo/controllers/daemon.py @@ -29,6 +29,7 @@ from gi.repository import Gtk, GLib, GObject from rigo.enums import AppActions, RigoViewStates, \ LocalActivityStates from rigo.models.application import Application +from rigo.models.configupdate import ConfigUpdate from rigo.ui.gtk3.widgets.notifications import NotificationBox, \ PleaseWaitNotificationBox, LicensesNotificationBox, \ OrphanedAppsNotificationBox, InstallNotificationBox, \ @@ -69,7 +70,7 @@ class RigoServiceController(GObject.Object): context_id = RigoServiceController.NOTIFICATION_CONTEXT_ID NotificationBox.__init__( self, message, - tooltip=_("Good luck!"), + tooltip=prepare_markup(_("Good luck!")), message_type=message_type, context_id=context_id) @@ -166,6 +167,7 @@ class RigoServiceController(GObject.Object): _APPLICATIONS_MANAGED_SIGNAL = "applications_managed" _UNSUPPORTED_APPLICATIONS_SIGNAL = "unsupported_applications" _RESTARTING_UPGRADE_SIGNAL = "restarting_system_upgrade" + _CONFIGURATION_UPDATES_SIGNAL = "configuration_updates_available" _SUPPORTED_APIS = [0] def __init__(self, rigo_app, activity_rwsem, @@ -174,6 +176,7 @@ class RigoServiceController(GObject.Object): self._rigo = rigo_app self._activity_rwsem = activity_rwsem self._nc = None + self._confc = None self._bottom_nc = None self._wc = None self._avc = None @@ -217,16 +220,22 @@ class RigoServiceController(GObject.Object): def set_applications_controller(self, avc): """ - Bind ApplicationsViewController object to this class. + Bind an ApplicationsViewController object to this class. """ self._avc = avc def set_application_controller(self, apc): """ - Bind ApplicationViewController object to this class. + Bind an ApplicationViewController object to this class. """ self._apc = apc + def set_configuration_controller(self, confc): + """ + Bind a ConfigUpdatesViewController object to this class. + """ + self._confc = confc + def set_terminal(self, terminal): """ Bind a TerminalWidget to this object, in order to be used with @@ -459,6 +468,13 @@ class RigoServiceController(GObject.Object): self._application_enqueued_signal, dbus_interface=self.DBUS_INTERFACE) + # RigoDaemon tells us that there are configuration + # file updates available + self.__entropy_bus.connect_to_signal( + self._CONFIGURATION_UPDATES_SIGNAL, + self._configuration_updates_available_signal, + dbus_interface=self.DBUS_INTERFACE) + return self.__entropy_bus ### GOBJECT EVENTS @@ -633,7 +649,7 @@ class RigoServiceController(GObject.Object): box = NotificationBox( msg, - tooltip=_("An error occurred"), + tooltip=prepare_markup(_("An error occurred")), message_type=Gtk.MessageType.ERROR, context_id="ApplicationOutcomeSignalError") def _show_me(*args): @@ -726,6 +742,25 @@ class RigoServiceController(GObject.Object): context_id=self.SYSTEM_UPGRADE_CONTEXT_ID) self._nc.append(box, timeout=20) + def _configuration_updates_available_signal(self, updates): + const_debug_write( + __name__, + "_configuration_updates_available_signal: " + "updates: %s" % (updates,)) + + if self._confc is not None: + config_updates = [] + for root, source, dest, pkg_ids, auto in updates: + meta = { + 'root': root, + 'destination': dest, + 'automerge': auto, + 'package_ids': pkg_ids, + } + cu = ConfigUpdate(source, meta, self) + config_updates.append(cu) + self._confc.notify_updates(config_updates) + def _unsupported_applications_signal(self, manual_package_ids, package_ids): const_debug_write( @@ -1177,6 +1212,16 @@ class RigoServiceController(GObject.Object): task.daemon = True task.start() + def configuration_updates(self): + """ + Request pending Configuration File Updates. + """ + def _config(): + return dbus.Interface( + self._entropy_bus, + dbus_interface=self.DBUS_INTERFACE).configuration_updates() + return self._execute_mainloop(_config) + def interrupt_activity(self): """ Interrupt any RigoDaemon activity. diff --git a/rigo/rigo/enums.py b/rigo/rigo/enums.py index 0ac597181..2d5b17354 100644 --- a/rigo/rigo/enums.py +++ b/rigo/rigo/enums.py @@ -36,26 +36,13 @@ class Icons: MISSING_PKG = "dialog-question" # XXX: Not used? GENERIC_MISSING = "gtk-missing-image" INSTALLED_OVERLAY = "rigo-installed" - -# visibility of non applications in the search results -class NonAppVisibility: - (ALWAYS_VISIBLE, - MAYBE_VISIBLE, - NEVER_VISIBLE) = range (3) + CONFIGURATION_FILE = "text-plain" # application actions class AppActions: INSTALL = "install" REMOVE = "remove" -# transaction types -class TransactionTypes: - INSTALL = "install" - REMOVE = "remove" - UPGRADE = "upgrade" - APPLY = "apply_changes" - REPAIR = "repair_dependencies" - from .version import VERSION, DISTRO, RELEASE, CODENAME USER_AGENT="Entropy Rigo/%s (N;) %s/%s (%s)" % ( VERSION, @@ -70,7 +57,8 @@ class RigoViewStates: STATIC_VIEW_STATE, APPLICATION_VIEW_STATE, WORK_VIEW_STATE, - ) = range(4) + CONFUPDATES_VIEW_STATE, + ) = range(5) class LocalActivityStates: ( diff --git a/rigo/rigo/models/application.py b/rigo/rigo/models/application.py index a074f27ea..5aaccd669 100644 --- a/rigo/rigo/models/application.py +++ b/rigo/rigo/models/application.py @@ -107,33 +107,6 @@ class ReviewStats(object): (self.app, self.ratings_average, self.downloads_total, self.rating_spread, self.dampened_rating)) -class CategoryRowReference: - """ A simple container for Category properties to be - displayed in a AppListStore or AppTreeStore - """ - - def __init__(self, untranslated_name, display_name, subcats, pkg_count): - self.untranslated_name = untranslated_name - self.display_name = escape_markup(display_name) - #self.subcategories = subcats - self.pkg_count = pkg_count - self.vis_count = pkg_count - return - -class UncategorisedRowRef(CategoryRowReference): - - def __init__(self, untranslated_name=None, display_name=None, pkg_count=0): - if untranslated_name is None: - untranslated_name = 'Uncategorised' - if display_name is None: - display_name = _("Uncategorized") - - CategoryRowReference.__init__(self, - untranslated_name, - display_name, - None, pkg_count) - return - class ApplicationMetadata(object): """ This is the Entropy metadata manager for Application objects. diff --git a/rigo/rigo/models/configupdate.py b/rigo/rigo/models/configupdate.py new file mode 100644 index 000000000..94f32f95d --- /dev/null +++ b/rigo/rigo/models/configupdate.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2012 Fabio Erculiani + +Authors: + Fabio Erculiani + +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; version 3. + +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, write to the Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" +from rigo.utils import escape_markup, prepare_markup + +class ConfigUpdate(object): + + def __init__(self, source, metadata, rigo_service): + self._source = source + self._metadata = metadata + self._service = rigo_service + + def source(self): + """ + Return source file path (pointer to proposed + configuration file). + """ + return self._source + + def destination(self): + """ + Return destination file path (pointer to file + to be replaced). + """ + return self._metadata['destination'] + + def root(self): + """ + Return current ROOT prefix (usually ""). + """ + return self._metadata['root'] + + def package_ids(self): + """ + Return the list of package identifiers owning the + destination file. + """ + return self._metadata['package_ids'] + + def get_markup(self): + """ + Return ConfigurationUpdate markup text. + """ + return escape_markup( + self.source() + " -> " + self.destination()) diff --git a/rigo/rigo/ui/gtk3/controllers/applications.py b/rigo/rigo/ui/gtk3/controllers/applications.py index 2b3e85776..e31a4c3a9 100644 --- a/rigo/rigo/ui/gtk3/controllers/applications.py +++ b/rigo/rigo/ui/gtk3/controllers/applications.py @@ -55,7 +55,8 @@ class ApplicationsViewController(GObject.Object): # View has been filled "view-want-change" : (GObject.SignalFlags.RUN_LAST, None, - (GObject.TYPE_PYOBJECT,), + (GObject.TYPE_PYOBJECT, + GObject.TYPE_PYOBJECT,), ), # User logged in to Entropy Web Services "logged-in" : (GObject.SignalFlags.RUN_LAST, @@ -288,12 +289,11 @@ class ApplicationsViewController(GObject.Object): return elif text == "rigo:vte": GLib.idle_add(self.emit, "view-want-change", - RigoViewStates.WORK_VIEW_STATE) + RigoViewStates.WORK_VIEW_STATE, + None) return - elif text == "rigo:output": - GLib.idle_add(self.emit, "view-want-change", - RigoViewStates.WORK_VIEW_STATE) - GLib.idle_add(self._service.output_test) + elif text == "rigo:confupdate": + self._service.configuration_updates() return elif text.startswith("rigo:simulate:i:"): sim_str = text[len("rigo:simulate:i:"):].strip() diff --git a/rigo/rigo/ui/gtk3/controllers/confupdate.py b/rigo/rigo/ui/gtk3/controllers/confupdate.py new file mode 100644 index 000000000..bd1c5f0c3 --- /dev/null +++ b/rigo/rigo/ui/gtk3/controllers/confupdate.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2012 Fabio Erculiani + +Authors: + Fabio Erculiani + +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; version 3. + +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, write to the Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" +from gi.repository import GObject + +from rigo.ui.gtk3.widgets.notifications import \ + ConfigUpdatesNotificationBox + +class ConfigUpdatesViewController(GObject.Object): + + def __init__(self, entropy_client, config_store, config_view): + GObject.Object.__init__(self) + self._entropy = entropy_client + self._store = config_store + self._view = config_view + self._nc = None + self._avc = None + + def setup(self): + """ + Setup the ConfigUpdatesViewController resources. + """ + self._view.set_model(self._store) + self._view.show() + + def set_notification_controller(self, nc): + """ + Bind a UpperNotificationViewController to this class. + """ + self._nc = nc + + def set_applications_controller(self, avc): + """ + Bind an ApplicationsViewController object to this class. + """ + self._avc = avc + + def clear(self): + """ + Clear Configuration Updates + """ + self._view.clear_model() + + def append(self, opaque): + """ + Add a ConfigUpdate object to the store. + """ + self._store.append([opaque]) + + def append_many(self, opaque_list): + """ + Append many ConfigUpdate objects to the store. + """ + for opaque in opaque_list: + self._store.append([opaque]) + + def set_many(self, opaque_list, _from_search=None): + """ + Set a new list of ConfigUpdate objects on the store. + """ + self._view.clear_model() + self.append_many(opaque_list) + + def clear_safe(self): + """ + Thread-safe version of clear() + """ + GLib.idle_add(self.clear) + + def append_safe(self, opaque): + """ + Thread-safe version of append() + """ + GLib.idle_add(self.append, opaque) + + def append_many_safe(self, opaque_list): + """ + Thread-safe version of append_many() + """ + GLib.idle_add(self.append_many, opaque_list) + + def set_many_safe(self, opaque_list): + """ + Thread-safe version of set_many() + """ + GLib.idle_add(self.set_many, opaque_list) + + def notify_updates(self, config_updates): + """ + Notify Configuration File Updates to User. + """ + # setup store + self.set_many(config_updates) + if self._nc is not None and self._avc is not None: + box = ConfigUpdatesNotificationBox( + self._entropy, self._avc, len(config_updates)) + self._nc.append(box) diff --git a/rigo/rigo/ui/gtk3/models/confupdateliststore.py b/rigo/rigo/ui/gtk3/models/confupdateliststore.py new file mode 100644 index 000000000..b02c10997 --- /dev/null +++ b/rigo/rigo/ui/gtk3/models/confupdateliststore.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2012 Fabio Erculiani + +Authors: + Fabio Erculiani + +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; version 3. + +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, write to the Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" +from gi.repository import Gtk, GObject, GLib + + +class ConfigUpdatesListStore(Gtk.ListStore): + + # ConfigUpdate object + COL_TYPES = (GObject.TYPE_PYOBJECT,) + + ICON_SIZE = 48 + + __gsignals__ = { + # Redraw signal, requesting UI update + "redraw-request" : (GObject.SignalFlags.RUN_LAST, + None, + tuple(), + ), + } + + def __init__(self): + Gtk.ListStore.__init__(self) + self.set_column_types(self.COL_TYPES) + diff --git a/rigo/rigo/ui/gtk3/widgets/apptreeview.py b/rigo/rigo/ui/gtk3/widgets/apptreeview.py index ba4edb8a6..36532836a 100644 --- a/rigo/rigo/ui/gtk3/widgets/apptreeview.py +++ b/rigo/rigo/ui/gtk3/widgets/apptreeview.py @@ -21,7 +21,6 @@ this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """ from gi.repository import Gtk, Gdk, GObject -import logging import os from entropy.i18n import _ @@ -31,7 +30,7 @@ from cellrenderers import CellRendererAppView, CellButtonRenderer, \ from rigo.em import em, StockEms from rigo.enums import Icons, AppActions -from rigo.models.application import CategoryRowReference, Application +from rigo.models.application import Application from RigoDaemon.enums import AppActions as DaemonAppActions @@ -50,7 +49,6 @@ class AppTreeView(Gtk.TreeView): def __init__(self, entropy_client, backend, apc, icons, show_ratings, icon_size, store=None): Gtk.TreeView.__init__(self) - self._logger = logging.getLogger(__name__) self._entropy = entropy_client self._apc = apc @@ -66,8 +64,9 @@ class AppTreeView(Gtk.TreeView): try: self.set_property("ubuntu-almost-fixed-height-mode", True) self.set_fixed_height_mode(True) - except: - self._logger.warn("ubuntu-almost-fixed-height-mode extension not available") + print("MEEP") + except Exception: + pass self.set_headers_visible(False) @@ -189,9 +188,6 @@ class AppTreeView(Gtk.TreeView): if path == None: return None return model[path][COL_ROW_DATA] - def rowref_is_category(self, rowref): - return isinstance(rowref, CategoryRowReference) - def _calc_row_heights(self, tr): ypad = StockEms.SMALL tr.set_property('xpad', StockEms.MEDIUM) @@ -228,10 +224,6 @@ class AppTreeView(Gtk.TreeView): if not rowref: return - if self.rowref_is_category(rowref): - window.set_cursor(None) - return - use_hand = False for btn in tr.get_buttons(): if btn.state == Gtk.StateFlags.INSENSITIVE: @@ -261,11 +253,8 @@ class AppTreeView(Gtk.TreeView): rowref = self.get_rowref(model, path) if not rowref: return - if self.has_focus(): self.grab_focus() - - if self.rowref_is_category(rowref): - self.expand_path(None) - return + if self.has_focus(): + self.grab_focus() sel.select_path(path) self._update_selected_row(view, tr, path) @@ -279,18 +268,11 @@ class AppTreeView(Gtk.TreeView): if not rows: return False row = rows[0] - if self.rowref_is_category(row): - return False # update active app, use row-ref as argument self.expand_path(row) - pkg_match = model[row][COL_ROW_DATA] - # make sure this is not a category (LP: #848085) - if self.rowref_is_category(pkg_match): - return False - action_btn = tr.get_button_by_name( CellButtonIDs.ACTION) #if not action_btn: return False @@ -338,8 +320,6 @@ class AppTreeView(Gtk.TreeView): if not rowref: return - if self.rowref_is_category(rowref): return - x, y = self.get_pointer() for btn in tr.get_buttons(): if btn.point_in(x, y): @@ -359,9 +339,7 @@ class AppTreeView(Gtk.TreeView): # check the path is valid and is not a category row path = res[0] - is_cat = self.rowref_is_category( - self.get_rowref(view.get_model(), path)) - if path is None or is_cat: + if path is None: return False # only act when the selection is already there @@ -483,13 +461,8 @@ class AppTreeView(Gtk.TreeView): return def _app_activated_cb(self, btn, btn_id, pkg_match, store, path): - if self.rowref_is_category(pkg_match): - return - # FIXME: would be nice if that would be more elegant - # because we use a treefilter we need to get the "real" - # model first - if type(store) is Gtk.TreeModelFilter: + if isinstance(store, Gtk.TreeModelFilter): store = store.get_model() app = self.appmodel.get_application(pkg_match) diff --git a/rigo/rigo/ui/gtk3/widgets/cellrenderers.py b/rigo/rigo/ui/gtk3/widgets/cellrenderers.py index 73ce0af79..d84ee17e9 100644 --- a/rigo/rigo/ui/gtk3/widgets/cellrenderers.py +++ b/rigo/rigo/ui/gtk3/widgets/cellrenderers.py @@ -23,9 +23,11 @@ this program; if not, write to the Free Software Foundation, Inc., """ from gi.repository import Gtk, Gdk, GObject, Pango +from threading import Lock + from rigo.em import Ems -from rigo.models.application import CategoryRowReference from rigo.utils import escape_markup +from rigo.enums import Icons from stars import StarRenderer, StarSize @@ -53,14 +55,13 @@ class CellRendererAppView(Gtk.CellRendererText): __gproperties__ = { 'application' : (GObject.TYPE_PYOBJECT, 'document', - 'a xapian document containing pkg information', + 'an Entropy Package Match', GObject.PARAM_READWRITE), - 'isactive' : (bool, 'isactive', 'is cell active/selected', False, + 'isactive' : (bool,'isactive', 'is cell active/selected', False, GObject.PARAM_READWRITE), } - def __init__(self, icons, layout, show_ratings, overlay_icon_name): GObject.GObject.__init__(self) @@ -100,26 +101,6 @@ class CellRendererAppView(Gtk.CellRendererText): def _layout_get_pixel_height(self, layout): return layout.get_size()[1] / Pango.SCALE - def _render_category(self, - context, cr, app, cell_area, layout, xpad, ypad, is_rtl): - - layout.set_markup('%s' % app.display_name, -1) - - # work out max allowable layout width - lw = self._layout_get_pixel_width(layout) - lh = self._layout_get_pixel_height(layout) - - if not is_rtl: - x = cell_area.x - else: - x = cell_area.x + cell_area.width - lw - y = cell_area.y + (cell_area.height - lh)/2 - #w = cell_area.width - #h = cell_area.height - - Gtk.render_layout(context, cr, x, y, layout) - return - def _render_icon(self, cr, app, cell_area, xpad, ypad, is_rtl): # calc offsets so icon is nicely centered icon = self.model.get_icon(app) @@ -359,28 +340,8 @@ class CellRendererAppView(Gtk.CellRendererText): star_width, star_height = self._stars.get_visible_size(context) is_rtl = widget.get_direction() == Gtk.TextDirection.RTL - # important! ensures correct text rendering, esp. when using hicolor theme - #~ if (flags & Gtk.CellRendererState.SELECTED) != 0: - #~ # this follows the behaviour that gtk+ uses for states in treeviews - #~ if widget.has_focus(): - #~ state = Gtk.StateFlags.SELECTED - #~ else: - #~ state = Gtk.StateFlags.ACTIVE - #~ else: - #~ state = Gtk.StateFlags.NORMAL - layout = self._layout - context.save() - #~ context.set_state(state) - - if isinstance(app, CategoryRowReference): - self._render_category(context, cr, app, - cell_area, - layout, - xpad, ypad, - is_rtl) - return self._render_icon(cr, app, cell_area, @@ -428,6 +389,223 @@ class CellRendererAppView(Gtk.CellRendererText): return +class ConfigUpdateCellButtonIDs: + + EDIT = 0 + DIFF = 2 + MERGE = 3 + DISCARD = 4 + + +class CellRendererConfigUpdateView(Gtk.CellRendererText): + + _ICON = None + _ICON_MUTEX = Lock() + + __gproperties__ = { + 'confupdate' : (GObject.TYPE_PYOBJECT, 'document', + 'a ConfigUpdate object', + GObject.PARAM_READWRITE), + + 'isactive' : (bool,'isactive', 'is cell active/selected', False, + GObject.PARAM_READWRITE), + } + + def __init__(self, icons, icon_size, layout): + GObject.GObject.__init__(self) + + # Icons + self._icons = icons + self._icon_size = icon_size + + # geometry-state values + self.pixbuf_width = 0 + self.title_width = 0 + self.title_height = 0 + self.normal_height = 0 + self.selected_height = 0 + + # button packing + self.button_spacing = 0 + self._buttons = {Gtk.PackType.START: [], + Gtk.PackType.END: []} + self._all_buttons = {} + + # cache a layout + self._layout = layout + + @property + def _icon(self): + if CellRendererConfigUpdateView._ICON is not None: + return CellRendererConfigUpdateView._ICON + with CellRendererConfigUpdateView._ICON_MUTEX: + if CellRendererConfigUpdateView._ICON is not None: + return CellRendererConfigUpdateView._ICON + _icon = self._icons.load_icon( + Icons.CONFIGURATION_FILE, + self._icon_size, 0) + CellRendererConfigUpdateView._ICON = _icon + return _icon + + def _layout_get_pixel_width(self, layout): + return layout.get_size()[0] / Pango.SCALE + + def _layout_get_pixel_height(self, layout): + return layout.get_size()[1] / Pango.SCALE + + def _render_icon(self, cr, cu, cell_area, xpad, ypad, is_rtl): + + icon = self._icon + xo = (self.pixbuf_width - icon.get_width())/2 + + if not is_rtl: + x = cell_area.x + xo + xpad + else: + x = cell_area.x + cell_area.width + xo - \ + self.pixbuf_width - xpad + y = cell_area.y + ypad + + Gdk.cairo_set_source_pixbuf(cr, icon, x, y) + cr.paint() + + def _render_summary(self, context, cr, cu, + cell_area, layout, xpad, ypad, + is_rtl): + + layout.set_markup(cu.get_markup(), -1) + + # work out max allowable layout width + layout.set_width(-1) + lw = self._layout_get_pixel_width(layout) + max_layout_width = (cell_area.width - self.pixbuf_width - + 3 * xpad) + max_layout_width = cell_area.width - self.pixbuf_width - 3 * xpad + + if lw >= max_layout_width: + layout.set_width((max_layout_width)*Pango.SCALE) + layout.set_ellipsize(Pango.EllipsizeMode.MIDDLE) + lw = max_layout_width + + self.title_width = cell_area.width - self.pixbuf_width - \ + 10 * xpad + self.title_height = Ems.EM + + if not is_rtl: + x = cell_area.x+2*xpad+self.pixbuf_width + else: + x = cell_area.x+cell_area.width-lw-self.pixbuf_width-2*xpad + + y = cell_area.y + ypad + + Gtk.render_layout(context, cr, x, y, layout) + + def _render_buttons( + self, context, cr, cell_area, layout, xpad, ypad, is_rtl): + + # layout buttons and paint + y = cell_area.y + cell_area.height - ypad + spacing = self.button_spacing + + if not is_rtl: + start = Gtk.PackType.START + end = Gtk.PackType.END + xs = cell_area.x + 2*xpad + self.pixbuf_width + xb = cell_area.x + cell_area.width - xpad + else: + start = Gtk.PackType.END + end = Gtk.PackType.START + xs = cell_area.x + xpad + xb = cell_area.x + cell_area.width - 2*xpad - \ + self.pixbuf_width + + for btn in self._buttons[start]: + btn.set_position(xs, y-btn.height) + btn.render(context, cr, layout) + xs += btn.width + spacing + + for btn in self._buttons[end]: + xb -= btn.width + btn.set_position(xb, y-btn.height) + btn.render(context, cr, layout) + + xb -= spacing + + def set_pixbuf_width(self, w): + self.pixbuf_width = w + + def set_button_spacing(self, spacing): + self.button_spacing = spacing + + def get_button_by_name(self, name): + if name in self._all_buttons: + return self._all_buttons[name] + + def get_buttons(self): + btns = () + for k, v in self._buttons.items(): + btns += tuple(v) + return btns + + def button_pack(self, btn, pack_type=Gtk.PackType.START): + self._buttons[pack_type].append(btn) + self._all_buttons[btn.name] = btn + + def button_pack_start(self, btn): + self.button_pack(btn, Gtk.PackType.START) + + def button_pack_end(self, btn): + self.button_pack(btn, Gtk.PackType.END) + + def do_set_property(self, pspec, value): + setattr(self, pspec.name, value) + + def do_get_property(self, pspec): + return getattr(self, pspec.name) + + def do_get_preferred_height_for_width(self, treeview, width): + if not self.get_properties("isactive")[0]: + return self.normal_height, self.normal_height + return self.selected_height, self.selected_height + + def do_render(self, cr, widget, bg_area, cell_area, flags): + cu = self.props.confupdate + if not cu: + return + + self.model = widget.confmodel + context = widget.get_style_context() + xpad = self.get_property('xpad') + ypad = self.get_property('ypad') + is_rtl = widget.get_direction() == Gtk.TextDirection.RTL + + layout = self._layout + context.save() + + self._render_icon(cr, cu, + cell_area, + xpad, ypad, + is_rtl) + + self._render_summary(context, cr, cu, + cell_area, + layout, + xpad, ypad, + is_rtl) + + # below is the stuff that is only done for the active cell + if not self.props.isactive: + return + + self._render_buttons(context, cr, + cell_area, + layout, + xpad, ypad, + is_rtl) + + context.restore() + return + + class CellButtonRenderer: def __init__(self, widget, name, use_max_variant_width=True): diff --git a/rigo/rigo/ui/gtk3/widgets/confupdatetreeview.py b/rigo/rigo/ui/gtk3/widgets/confupdatetreeview.py new file mode 100644 index 000000000..692f712ab --- /dev/null +++ b/rigo/rigo/ui/gtk3/widgets/confupdatetreeview.py @@ -0,0 +1,369 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2012 Fabio Erculiani + +Authors: + Fabio Erculiani + +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; version 3. + +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, write to the Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" +from gi.repository import Gtk, Gdk, GObject, Pango +import os +from threading import Lock + +from entropy.i18n import _ + +from cellrenderers import CellButtonRenderer, \ + CellRendererConfigUpdateView, ConfigUpdateCellButtonIDs + +from rigo.em import em, StockEms, Ems +from rigo.ui.gtk3.models.confupdateliststore import ConfigUpdatesListStore + + +class ConfigUpdatesTreeView(Gtk.TreeView): + + __gsignals__ = { + # Source configuration file edit signal + "source-edit" : (GObject.SignalFlags.RUN_LAST, + None, + (GObject.TYPE_PYOBJECT,), + ), + # Show diff signal + "show-diff" : (GObject.SignalFlags.RUN_LAST, + None, + (GObject.TYPE_PYOBJECT,), + ), + # Merge source configuration file signal + "source-merge" : (GObject.SignalFlags.RUN_LAST, + None, + (GObject.TYPE_PYOBJECT,), + ), + # Discard source configuration file signal + "source-discard" : (GObject.SignalFlags.RUN_LAST, + None, + (GObject.TYPE_PYOBJECT,), + ), + } + + VARIANT_EDIT = 0 + VARIANT_DIFF = 2 + VARIANT_MERGE = 3 + VARIANT_DISCARD = 4 + COL_ROW_DATA = 0 + + def __init__(self, icons, icon_size): + Gtk.TreeView.__init__(self) + self.pressed = False + self.focal_btn = None + self.expanded_path = None + self.set_headers_visible(False) + + tr = CellRendererConfigUpdateView( + icons, ConfigUpdatesListStore.ICON_SIZE, + self.create_pango_layout("")) + tr.set_pixbuf_width(icon_size) + + tr.set_button_spacing(em(0.3)) + + # create buttons and set initial strings + edit_source = CellButtonRenderer( + self, name=ConfigUpdateCellButtonIDs.EDIT) + edit_source.set_markup_variants( + {self.VARIANT_EDIT: _("Edit")}) + tr.button_pack_start(edit_source) + + edit_dest = CellButtonRenderer( + self, name=ConfigUpdateCellButtonIDs.DIFF) + edit_dest.set_markup_variants( + {self.VARIANT_DIFF: _("Difference")}) + tr.button_pack_start(edit_dest) + + merge = CellButtonRenderer( + self, name=ConfigUpdateCellButtonIDs.MERGE) + merge.set_markup_variants( + {self.VARIANT_MERGE: _("Accept")}) + tr.button_pack_end(merge) + + discard = CellButtonRenderer( + self, name=ConfigUpdateCellButtonIDs.DISCARD) + discard.set_markup_variants( + {self.VARIANT_DISCARD: _("Discard")}) + tr.button_pack_end(discard) + + column = Gtk.TreeViewColumn("ConfigUpdates", tr, + confupdate=self.COL_ROW_DATA) + + column.set_cell_data_func(tr, self._cell_data_func_cb) + column.set_fixed_width(200) + column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) + self.append_column(column) + + # custom cursor + self._cursor_hand = Gdk.Cursor.new(Gdk.CursorType.HAND2) + + self.connect("style-updated", self._on_style_updated, tr) + # button and motion are "special" + self.connect("button-press-event", self._on_button_press_event, tr) + self.connect("button-release-event", self._on_button_release_event, tr) + self.connect("motion-notify-event", self._on_motion, tr) + self.connect("cursor-changed", self._on_cursor_changed, tr) + # our own "activate" handler + self.connect("row-activated", self._on_row_activated, tr) + + @property + def confmodel(self): + model = self.get_model() + if isinstance(model, Gtk.TreeModelFilter): + return model.get_model() + return model + + def clear_model(self): + vadjustment = self.get_scrolled_window_vadjustment() + if vadjustment: + vadjustment.set_value(0) + self.expanded_path = None + confmodel = self.confmodel + if confmodel: + confmodel.clear() + + def expand_path(self, path): + if path is not None and not isinstance(path, Gtk.TreePath): + raise TypeError( + "Expects Gtk.TreePath or None, got %s" % type(path)) + + model = self.get_model() + old = self.expanded_path + self.expanded_path = path + + if old is not None: + try: + # lazy solution to Bug #846204 + model.row_changed(old, model.get_iter(old)) + except Exception: + pass + + if path is None: + return + model.row_changed(path, model.get_iter(path)) + + def get_scrolled_window_vadjustment(self): + ancestor = self.get_ancestor(Gtk.ScrolledWindow) + if ancestor: + return ancestor.get_vadjustment() + + def get_rowref(self, model, path): + if path is None: + return None + return model[path][self.COL_ROW_DATA] + + def _calc_row_heights(self, tr): + ypad = StockEms.SMALL + tr.set_property('xpad', StockEms.MEDIUM) + tr.set_property('ypad', ypad) + + for btn in tr.get_buttons(): + # recalc button geometry and cache + btn.configure_geometry(self.create_pango_layout("")) + + btn_h = btn.height + + tr.normal_height = max(32 + 4*ypad, em(2.5) + 4*ypad) + tr.selected_height = tr.normal_height + btn_h + StockEms.MEDIUM \ + + ypad + + def _on_style_updated(self, widget, tr): + self._calc_row_heights(tr) + + def _on_motion(self, tree, event, tr): + window = self.get_window() + x, y = int(event.x), int(event.y) + if not self._xy_is_over_focal_row(x, y): + window.set_cursor(None) + return + + path = tree.get_path_at_pos(x, y) + if not path: + window.set_cursor(None) + return + + rowref = self.get_rowref(tree.get_model(), path[0]) + if not rowref: + return + + use_hand = False + for btn in tr.get_buttons(): + if btn.state == Gtk.StateFlags.INSENSITIVE: + continue + + if btn.point_in(x, y): + use_hand = True + if self.focal_btn is btn: + btn.set_state(Gtk.StateFlags.ACTIVE) + elif not self.pressed: + btn.set_state(Gtk.StateFlags.PRELIGHT) + else: + if btn.state != Gtk.StateFlags.NORMAL: + btn.set_state(Gtk.StateFlags.NORMAL) + + if use_hand: + window.set_cursor(self._cursor_hand) + else: + window.set_cursor(None) + + def _on_cursor_changed(self, view, tr): + model = view.get_model() + sel = view.get_selection() + path = view.get_cursor()[0] + + rowref = self.get_rowref(model, path) + if not rowref: + return + + if self.has_focus(): + self.grab_focus() + + sel.select_path(path) + self._update_selected_row(view, tr, path) + + def _update_selected_row(self, view, tr, path=None): + sel = view.get_selection() + if not sel: + return False + model, rows = sel.get_selected_rows() + if not rows: + return False + row = rows[0] + + # update active app, use row-ref as argument + self.expand_path(row) + return False + + def _on_row_activated(self, view, path, column, tr): + rowref = self.get_rowref(view.get_model(), path) + + if not rowref: + return + + x, y = self.get_pointer() + for btn in tr.get_buttons(): + if btn.point_in(x, y): + return + # FIXME: show source? + + def _on_button_event_get_path(self, view, event): + if event.button != 1: + return False + + res = view.get_path_at_pos(int(event.x), int(event.y)) + if not res: + return False + + # check the path is valid and is not a category row + path = res[0] + if path is None: + return False + + # only act when the selection is already there + selection = view.get_selection() + if not selection.path_is_selected(path): + return False + + return path + + def _on_button_press_event(self, view, event, tr): + if not self._on_button_event_get_path(view, event): + return + + self.pressed = True + x, y = int(event.x), int(event.y) + for btn in tr.get_buttons(): + if btn.point_in(x, y) and \ + (btn.state != Gtk.StateFlags.INSENSITIVE): + self.focal_btn = btn + btn.set_state(Gtk.StateFlags.ACTIVE) + view.queue_draw() + return + self.focal_btn = None + + def _on_button_release_event(self, view, event, tr): + path = self._on_button_event_get_path(view, event) + if not path: + return + + self.pressed = False + x, y = int(event.x), int(event.y) + for btn in tr.get_buttons(): + if btn.point_in(x, y) and \ + (btn.state != Gtk.StateFlags.INSENSITIVE): + btn.set_state(Gtk.StateFlags.NORMAL) + self.get_window().set_cursor(self._cursor_hand) + if self.focal_btn is not btn: + break + self._init_activated(btn, view.get_model(), path) + view.queue_draw() + break + self.focal_btn = None + + def _init_activated(self, btn, model, path): + cu = model[path][self.COL_ROW_DATA] + s = Gtk.Settings.get_default() + GObject.timeout_add(s.get_property("gtk-timeout-initial"), + self._confupdate_activated_cb, + btn, + btn.name, + cu, + model, + path) + + def _cell_data_func_cb(self, col, cell, model, it, user_data): + + path = model.get_path(it) + + if model[path][0] is None: + indices = path.get_indices() + model.load_range(indices, 5) + + is_active = path == self.expanded_path + cell.set_property('isactive', is_active) + return + + def _confupdate_activated_cb(self, btn, btn_id, cu, store, path): + + if isinstance(store, Gtk.TreeModelFilter): + store = store.get_model() + + if btn_id == ConfigUpdateCellButtonIDs.EDIT: + self.emit("source-edit", cu) + elif btn_id == ConfigUpdateCellButtonIDs.DIFF: + self.emit("show-diff", cu) + elif btn_id == ConfigUpdateCellButtonIDs.MERGE: + self.emit("source-merge", cu) + elif btn_id == ConfigUpdateCellButtonIDs.DISCARD: + self.emit("source-discard", cu) + return False + + def _set_cursor(self, btn, cursor): + # make sure we have a window instance (LP: #617004) + window = self.get_window() + if isinstance(window, Gdk.Window): + x, y = self.get_pointer() + if btn.point_in(x, y): + window.set_cursor(cursor) + + def _xy_is_over_focal_row(self, x, y): + res = self.get_path_at_pos(x, y) + #cur = self.get_cursor() + if not res: + return False + return self.get_path_at_pos(x, y)[0] == self.get_cursor()[0] diff --git a/rigo/rigo/ui/gtk3/widgets/notifications.py b/rigo/rigo/ui/gtk3/widgets/notifications.py index e27502648..c60a5130c 100644 --- a/rigo/rigo/ui/gtk3/widgets/notifications.py +++ b/rigo/rigo/ui/gtk3/widgets/notifications.py @@ -30,7 +30,7 @@ from rigo.em import StockEms from rigo.utils import build_register_url, open_url, escape_markup, \ prepare_markup from rigo.models.application import Application -from rigo.enums import AppActions, LocalActivityStates +from rigo.enums import AppActions, LocalActivityStates, RigoViewStates from entropy.const import etpConst, const_convert_to_unicode, \ const_debug_write @@ -184,8 +184,9 @@ class UpdatesNotificationBox(NotificationBox): msg += ". " + _("What to do?") - NotificationBox.__init__(self, msg, - tooltip=_("Updates available, how about installing them?"), + NotificationBox.__init__(self, prepare_markup(msg), + tooltip=prepare_markup( + _("Updates available, how about installing them?")), message_type=Gtk.MessageType.WARNING, context_id="UpdatesNotificationBox") self.add_button(_("_Update System"), self._update) @@ -226,7 +227,7 @@ class RepositoriesUpdateNotificationBox(NotificationBox): msg = _("Repositories should be downloaded, update now?") NotificationBox.__init__(self, msg, - tooltip=_("I dunno dude, I'd say Yes"), + tooltip=prepare_markup(_("I dunno dude, I'd say Yes")), message_type=Gtk.MessageType.ERROR, context_id="RepositoriesUpdateNotificationBox") self.add_button(_("_Yes, why not?"), self._update) @@ -269,7 +270,8 @@ class LoginNotificationBox(NotificationBox): NotificationBox.__init__(self, None, message_widget=self._make_login_box(), - tooltip=_("You need to login to Entropy Web Services"), + tooltip=prepare_markup( + _("You need to login to Entropy Web Services")), message_type=Gtk.MessageType.WARNING, context_id=context_id) @@ -390,7 +392,7 @@ class ConnectivityNotificationBox(NotificationBox): "are you connected to the interweb?") NotificationBox.__init__(self, msg, - tooltip=_("Don't ask me..."), + tooltip=prepare_markup(_("Don't ask me...")), message_type=Gtk.MessageType.ERROR, context_id="ConnectivityNotificationBox") self.add_destroy_button(_("_Of course not")) @@ -401,7 +403,7 @@ class PleaseWaitNotificationBox(NotificationBox): def __init__(self, message, context_id): NotificationBox.__init__(self, message, - tooltip=_("A watched pot never boils"), + tooltip=prepare_markup(_("A watched pot never boils")), message_type=Gtk.MessageType.INFO, context_id=context_id) self._spinner = Gtk.Spinner() @@ -482,7 +484,8 @@ class LicensesNotificationBox(NotificationBox): NotificationBox.__init__( self, None, message_widget=label, - tooltip=_("Make sure to review all the licenses"), + tooltip=prepare_markup( + _("Make sure to review all the licenses")), message_type=Gtk.MessageType.WARNING, context_id="LicensesNotificationBox") @@ -950,3 +953,45 @@ class QueueActionNotificationBox(NotificationBox): This NotificationBox cannot be destroyed easily. """ return True + + +class ConfigUpdatesNotificationBox(NotificationBox): + + def __init__(self, entropy_client, avc, updates_len): + self._entropy = entropy_client + self._avc = avc + + msg = ngettext("There is %d configuration file update", + "There are %d configuration file updates", + updates_len) + msg = msg % (updates_len,) + + msg += ".\n\n" + msg += _("It is extremely important to" + " update these configuration files before" + " rebooting the System.") + msg += "" + + context_id = "ConfigUpdatesNotificationContextId" + NotificationBox.__init__( + self, prepare_markup(msg), + message_type=Gtk.MessageType.WARNING, + context_id=context_id) + + self.add_button(_("Let me see"), self._on_show_me) + self.add_destroy_button(_("Happily ignore")) + + def _on_show_me(self, widget): + """ + Show the proposed configuration file updates + """ + self._avc.emit( + "view-want-change", + RigoViewStates.CONFUPDATES_VIEW_STATE, + None) + + def is_managed(self): + """ + This NotificationBox cannot be destroyed easily. + """ + return True diff --git a/rigo/rigo_app.py b/rigo/rigo_app.py index 1245b10c0..f1ee684a6 100644 --- a/rigo/rigo_app.py +++ b/rigo/rigo_app.py @@ -41,11 +41,14 @@ from rigo.paths import DATA_DIR from rigo.enums import RigoViewStates, LocalActivityStates from rigo.entropyapi import EntropyWebService, EntropyClient as Client from rigo.ui.gtk3.widgets.apptreeview import AppTreeView +from rigo.ui.gtk3.widgets.confupdatetreeview import ConfigUpdatesTreeView from rigo.ui.gtk3.widgets.notifications import NotificationBox from rigo.ui.gtk3.controllers.applications import \ ApplicationsViewController from rigo.ui.gtk3.controllers.application import \ ApplicationViewController +from rigo.ui.gtk3.controllers.confupdate import \ + ConfigUpdatesViewController from rigo.ui.gtk3.controllers.notifications import \ UpperNotificationViewController, BottomNotificationViewController @@ -53,6 +56,7 @@ from rigo.ui.gtk3.controllers.work import \ WorkViewController from rigo.ui.gtk3.widgets.welcome import WelcomeBox from rigo.ui.gtk3.models.appliststore import AppListStore +from rigo.ui.gtk3.models.confupdateliststore import ConfigUpdatesListStore from rigo.ui.gtk3.utils import init_sc_css_provider, get_sc_icon_theme from rigo.utils import escape_markup @@ -110,6 +114,9 @@ class Rigo(Gtk.Application): RigoViewStates.WORK_VIEW_STATE: ( self._enter_work_state, self._exit_work_state), + RigoViewStates.CONFUPDATES_VIEW_STATE: ( + self._enter_confupdates_state, + self._exit_confupdates_state,) } self._state_mutex = Lock() @@ -136,6 +143,12 @@ class Rigo(Gtk.Application): self._app_view_port = self._builder.get_object("appViewVport") self._app_view_port.set_name("rigo-view") self._not_found_box = self._builder.get_object("appsViewNotFoundVbox") + + self._config_scrolled_view = self._builder.get_object( + "configViewScrolledWindow") + self._config_view = self._builder.get_object("configViewVbox") + self._config_view.set_name("rigo-view") + self._search_entry = self._builder.get_object("searchEntry") self._search_entry_completion = self._builder.get_object( "searchEntryCompletion") @@ -168,6 +181,18 @@ class Rigo(Gtk.Application): self._app_view_c.connect("application-show", self._on_application_show) + # Configuration file updates model, view and controller + self._config_store = ConfigUpdatesListStore() + self._view_config = ConfigUpdatesTreeView( + icons, ConfigUpdatesListStore.ICON_SIZE) + self._config_scrolled_view.add(self._view_config) + def _config_queue_draw(*args): + self._view_config.queue_draw() + self._config_store.connect("redraw-request", _config_queue_draw) + self._config_view_c = ConfigUpdatesViewController( + self._entropy, self._config_store, self._view_config) + self._service.set_configuration_controller(self._config_view_c) + self._welcome_box = WelcomeBox() settings = Gtk.Settings.get_default() @@ -208,6 +233,9 @@ class Rigo(Gtk.Application): self._app_view_c.set_notification_controller(self._nc) self._app_view_c.set_applications_controller(self._avc) + self._config_view_c.set_notification_controller(self._nc) + self._config_view_c.set_applications_controller(self._avc) + self._service.set_applications_controller(self._avc) self._service.set_application_controller(self._app_view_c) self._service.set_notification_controller(self._nc) @@ -349,8 +377,8 @@ class Rigo(Gtk.Application): def _on_view_filled(self, *args): self._change_view_state(RigoViewStates.BROWSER_VIEW_STATE) - def _on_view_change(self, widget, state): - self._change_view_state(state) + def _on_view_change(self, widget, state, payload): + self._change_view_state(state, payload=payload) def _on_application_show(self, *args): self._change_view_state(RigoViewStates.APPLICATION_VIEW_STATE) @@ -369,6 +397,20 @@ class Rigo(Gtk.Application): """ self._apps_view.show() + def _exit_confupdates_state(self): + """ + Action triggered when UI exits the Configuration Updates + state (or mode). + """ + self._config_view.hide() + + def _enter_confupdates_state(self): + """ + Action triggered when UI enters the Configuration Updates + state (or mode). + """ + self._config_view.show() + def _exit_static_state(self): """ Action triggered when UI exits the Static Browser @@ -427,7 +469,8 @@ class Rigo(Gtk.Application): """ self._work_view.hide() - def _change_view_state(self, state, lock=False, _ignore_lock=False): + def _change_view_state(self, state, lock=False, _ignore_lock=False, + payload=None): """ Change Rigo Application UI state. You can pass a custom widget that will be shown in case @@ -444,8 +487,9 @@ class Rigo(Gtk.Application): raise AttributeError("wrong view state") enter_st, exit_st = txc - current_enter_st, current_exit_st = self._state_transactions.get( - self._current_state) + current_enter_st, current_exit_st = \ + self._state_transactions.get( + self._current_state) # exit from current state current_exit_st() # enter the new state @@ -615,6 +659,7 @@ class Rigo(Gtk.Application): return self._thread_dumper() + self._config_view_c.setup() self._app_view_c.setup() self._avc.setup() self._nc.setup()