diff --git a/metomi/rose/gtk/__init__.py b/metomi/rose/gtk/__init__.py new file mode 100644 index 000000000..063f7abd5 --- /dev/null +++ b/metomi/rose/gtk/__init__.py @@ -0,0 +1,8 @@ +try: + import gi + gi.require_version('Gtk', '3.0') + from gi.repository import Gtk +except (ImportError, RuntimeError, AssertionError): + INTERACTIVE_ENABLED = False +else: + INTERACTIVE_ENABLED = True diff --git a/metomi/rose/gtk/choice.py b/metomi/rose/gtk/choice.py new file mode 100644 index 000000000..11aeca0c4 --- /dev/null +++ b/metomi/rose/gtk/choice.py @@ -0,0 +1,463 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose 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 3 of the License, or +# (at your option) any later version. +# +# Rose 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 Rose. If not, see . +# ----------------------------------------------------------------------------- + +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk, Gdk + +import rose + + +class ChoicesListView(Gtk.TreeView): + + """Class to hold and display an ordered list of strings. + + set_value is a function, accepting a new value string. + get_data is a function that accepts no arguments and returns an + ordered list of included names to display. + handle_search is a function that accepts a name and triggers a + search for it. + title is a string or Gtk.Widget displayed as the column header, if + given. + get_custom_menu_items, if given, should be a function that + accepts no arguments and returns a list of Gtk.MenuItem-derived + instances. The listview model and current TreeIter will be + available as attributes "_listview_model" and "_listview_iter" set + on each menu item to optionally use during the menu item callbacks + - this means that they can use them to modify the model + information. Menuitems that do this should connect to + "button-press-event", as the model cleanup will take place as a + connect_after to the same event. + + """ + + def __init__(self, set_value, get_data, handle_search, + title=metomi.rose.config_editor.CHOICE_TITLE_INCLUDED, + get_custom_menu_items=lambda: []): + super(ChoicesListView, self).__init__() + self._set_value = set_value + self._get_data = get_data + self._handle_search = handle_search + self._get_custom_menu_items = get_custom_menu_items + self.enable_model_drag_dest( + [('text/plain', 0, 0)], Gdk.DragAction.MOVE) + self.enable_model_drag_source( + Gdk.ModifierType.BUTTON1_MASK, [('text/plain', 0, 0)], Gdk.DragAction.MOVE) + self.connect("button-press-event", self._handle_button_press) + self.connect("drag-data-get", self._handle_drag_get) + self.connect_after("drag-data-received", + self._handle_drag_received) + self.set_rules_hint(True) + self.connect("row-activated", self._handle_activation) + self.show() + col = Gtk.TreeViewColumn() + if isinstance(title, Gtk.Widget): + col.set_widget(title) + else: + col.set_title(title) + cell_text = Gtk.CellRendererText() + cell_text.set_property('editable', True) + cell_text.connect('edited', self._handle_edited) + col.pack_start(cell_text, True, True, 0) + col.set_cell_data_func(cell_text, self._set_cell_text) + self.append_column(col) + self._populate() + + def _handle_activation(self, treeview, path, col): + """Handle a click on the main list view - start a search.""" + iter_ = treeview.get_model().get_iter(path) + name = treeview.get_model().get_value(iter_, 0) + self._handle_search(name) + return False + + def _handle_button_press(self, treeview, event): + """Handle a right click event on the main list view.""" + if not hasattr(event, "button") or event.button != 3: + return False + pathinfo = treeview.get_path_at_pos(int(event.x), int(event.y)) + if pathinfo is None: + return False + iter_ = treeview.get_model().get_iter(pathinfo[0]) + self._popup_menu(iter_, event) + return False + + def _handle_drag_get(self, treeview, drag, sel, info, time): + """Handle an outgoing drag request.""" + model, iter_ = treeview.get_selection().get_selected() + text = model.get_value(iter_, 0) + sel.set_text(text) + model.remove(iter_) # Triggers the 'row-deleted' signal, sets value + if not model.iter_n_children(None): + model.append([metomi.rose.config_editor.CHOICE_LABEL_EMPTY]) + + def _handle_drag_received( + self, treeview, drag, xpos, ypos, sel, info, time): + """Handle an incoming drag request.""" + if sel.data is None: + return False + drop_info = treeview.get_dest_row_at_pos(xpos, ypos) + model = treeview.get_model() + if drop_info: + path, position = drop_info + if (position == Gtk.TreeViewDropPosition.BEFORE or + position == Gtk.TreeViewDropPosition.INTO_OR_BEFORE): + model.insert(path[0], [sel.data]) + else: + model.insert(path[0] + 1, [sel.data]) + else: + model.append([sel.data]) + path = None + self._handle_reordering(model, path) + + def _handle_edited(self, cell, path, new_text): + """Handle cell text so it can be edited. """ + liststore = self.get_model() + iter_ = liststore.get_iter(path) + liststore.set_value(iter_, 0, new_text) + self._handle_reordering() + return + + def _handle_reordering(self, model=None, path=None): + """Handle a drag-and-drop rearrangement in the main list view.""" + if model is None: + model = self.get_model() + ok_values = [] + iter_ = model.get_iter_first() + num_entries = model.iter_n_children(None) + while iter_ is not None: + name = model.get_value(iter_, 0) + next_iter = model.iter_next(iter_) + if name == metomi.rose.config_editor.CHOICE_LABEL_EMPTY: + if num_entries > 1: + model.remove(iter_) + else: + ok_values.append(name) + iter_ = next_iter + new_value = " ".join(ok_values) + self._set_value(new_value) + + def _populate(self): + """Populate the main list view.""" + values = self._get_data() + model = Gtk.ListStore(str) + if not values: + values = [metomi.rose.config_editor.CHOICE_LABEL_EMPTY] + for value in values: + model.append([value]) + model.connect_after("row-deleted", self._handle_reordering) + self.set_model(model) + + def _popup_menu(self, iter_, event): + # Pop up a menu for the main list view. + """Launch a popup menu for add/clone/remove.""" + ui_config_string = """ + + """ + text = metomi.rose.config_editor.CHOICE_MENU_REMOVE + actions = [("Remove", Gtk.STOCK_DELETE, text)] + uimanager = Gtk.UIManager() + actiongroup = Gtk.ActionGroup('Popup') + actiongroup.add_actions(actions) + uimanager.insert_action_group(actiongroup, pos=0) + uimanager.add_ui_from_string(ui_config_string) + remove_item = uimanager.get_widget('/Popup/Remove') + remove_item.connect("activate", + lambda b: self._remove_iter(iter_)) + menu = uimanager.get_widget('/Popup') + for menuitem in self._get_custom_menu_items(): + menuitem._listview_model = self.get_model() + menuitem._listview_iter = iter_ + menuitem.connect_after( + "button-press-event", + lambda b, e: self._handle_reordering() + ) + menu.append(menuitem) + menu.popup(None, None, None, event.button, event.time) + return False + + def _remove_iter(self, iter_): + self.get_model().remove(iter_) + if self.get_model() is None: + # Removing the last iter makes get_model return None... + self._populate() + self._handle_reordering() + self._populate() + + def _set_cell_text(self, column, cell, model, r_iter): + name = model.get_value(r_iter, 0) + if name == metomi.rose.config_editor.CHOICE_LABEL_EMPTY: + cell.set_property("markup", "" + name + "") + else: + cell.set_property("markup", "" + name + "") + + def refresh(self): + """Update the model values.""" + self._populate() + + +class ChoicesTreeView(Gtk.TreeView): + + """Class to hold and display a tree of content. + + set_value is a function, accepting a new value string. + get_data is a function that accepts no arguments and returns a + list of included names. + get_available_data is a function that accepts no arguments and + returns a list of available names. + get_groups is a function that accepts a name and a list of + available names and returns groups that supercede name. + get_is_implicit is an optional function that accepts a name and + returns whether the name is implicitly included in the content. + title is a string displayed as the column header, if given. + get_is_included is an optional function that accepts a name and + an optional list of included names to test whether a + name is already included. + + """ + + def __init__(self, set_value, get_data, get_available_data, + get_groups, get_is_implicit=None, + title=metomi.rose.config_editor.CHOICE_TITLE_AVAILABLE, + get_is_included=None): + super(ChoicesTreeView, self).__init__() + # Generate the 'available' sections view. + self._set_value = set_value + self._get_data = get_data + self._get_available_data = get_available_data + self._get_groups = get_groups + self._get_is_implicit = get_is_implicit + self._get_is_included_func = get_is_included + self.set_headers_visible(True) + self.set_rules_hint(True) + self.enable_model_drag_dest( + [('text/plain', 0, 0)], Gdk.DragAction.MOVE) + self.enable_model_drag_source( + Gdk.ModifierType.BUTTON1_MASK, [('text/plain', 0, 0)], Gdk.DragAction.MOVE) + self.connect_after("button-release-event", self._handle_button) + self.connect("drag-begin", self._handle_drag_begin) + self.connect("drag-data-get", self._handle_drag_get) + self.connect("drag-end", self._handle_drag_end) + self._is_dragging = False + model = Gtk.TreeStore(str, bool, bool) + self.set_model(model) + col = Gtk.TreeViewColumn() + cell_toggle = Gtk.CellRendererToggle() + cell_toggle.connect_after("toggled", self._handle_cell_toggle) + col.pack_start(cell_toggle, False, True, 0) + col.set_cell_data_func(cell_toggle, self._set_cell_state) + self.append_column(col) + col = Gtk.TreeViewColumn() + col.set_title(title) + cell_text = Gtk.CellRendererText() + col.pack_start(cell_text, True, True, 0) + col.set_cell_data_func(cell_text, self._set_cell_text) + self.append_column(col) + self.set_expander_column(col) + self.show() + self._populate() + + def _get_is_included(self, name, ok_names=None): + if self._get_is_included_func is not None: + return self._get_is_included_func(name, ok_names) + if ok_names is None: + ok_names = self._get_available_data() + return name in ok_names + + def _populate(self): + """Populate the 'available' sections view.""" + ok_content_sections = self._get_available_data() + self._ok_content_sections = set(ok_content_sections) + ok_values = self._get_data() + model = self.get_model() + sections_left = list(ok_content_sections) + self._name_iter_map = {} + while sections_left: + name = sections_left.pop(0) + is_included = self._get_is_included(name, ok_values) + groups = self._get_groups(name, ok_content_sections) + if self._get_is_implicit is None: + is_implicit = any( + [self._get_is_included(g, ok_values) for g in groups]) + else: + is_implicit = self._get_is_implicit(name) + if groups: + iter_ = model.append(self._name_iter_map[groups[-1]], + [name, is_included, is_implicit]) + else: + iter_ = model.append(None, [name, is_included, is_implicit]) + self._name_iter_map[name] = iter_ + + def _realign(self): + """Refresh the states in the model.""" + ok_values = self._get_data() + model = self.get_model() + ok_content_sections = self._get_available_data() + for name, iter_ in list(self._name_iter_map.items()): + is_in_value = self._get_is_included(name, ok_values) + if self._get_is_implicit is None: + groups = self._get_groups(name, ok_content_sections) + is_implicit = any( + [self._get_is_included(g, ok_values) for g in groups]) + else: + is_implicit = self._get_is_implicit(name) + if model.get_value(iter_, 1) != is_in_value: + model.set_value(iter_, 1, is_in_value) + if model.get_value(iter_, 2) != is_implicit: + model.set_value(iter_, 2, is_implicit) + + def _set_cell_text(self, column, cell, model, r_iter): + """Set markup for a section depending on its status.""" + section_name = model.get_value(r_iter, 0) + is_in_value = model.get_value(r_iter, 1) + is_implicit = model.get_value(r_iter, 2) + r_iter = model.iter_children(r_iter) + while r_iter is not None: + if model.get_value(r_iter, 1): + is_in_value = True + break + r_iter = model.iter_next(r_iter) + if is_in_value: + cell.set_property("markup", "{0}".format(section_name)) + cell.set_property("sensitive", True) + elif is_implicit: + cell.set_property("markup", "{0}".format(section_name)) + cell.set_property("sensitive", False) + else: + cell.set_property("markup", section_name) + cell.set_property("sensitive", True) + + def _set_cell_state(self, column, cell, model, r_iter): + """Set the check box for a section depending on its status.""" + is_in_value = model.get_value(r_iter, 1) + is_implicit = model.get_value(r_iter, 2) + if is_in_value: + cell.set_property("active", True) + cell.set_property("sensitive", True) + elif is_implicit: + cell.set_property("active", True) + cell.set_property("sensitive", False) + else: + cell.set_property("active", False) + cell.set_property("sensitive", True) + if not self._check_can_add(r_iter): + cell.set_property("sensitive", False) + + def _handle_drag_begin(self, widget, drag): + self._is_dragging = True + + def _handle_drag_end(self, widget, drag): + self._is_dragging = False + + def _handle_drag_get(self, treeview, drag, sel, info, time): + """Handle a drag data get.""" + model, iter_ = treeview.get_selection().get_selected() + if not self._check_can_add(iter_): + return False + name = model.get_value(iter_, 0) + sel.set("text/plain", 8, name) + + def _check_can_add(self, iter_): + """Check whether a name can be added to the data.""" + model = self.get_model() + if model.get_value(iter_, 1) or model.get_value(iter_, 2): + return False + child_iter = model.iter_children(iter_) + while child_iter is not None: + if (model.get_value(child_iter, 1) or + model.get_value(child_iter, 2)): + return False + child_iter = model.iter_next(child_iter) + return True + + def _handle_button(self, treeview, event): + """Connect a left click on the available section to a toggle.""" + if event.button != 1 or self._is_dragging: + return False + pathinfo = treeview.get_path_at_pos(int(event.x), int(event.y)) + if pathinfo is None: + return False + path, col = pathinfo[0:2] + if treeview.get_columns().index(col) == 1: + self._handle_cell_toggle(None, path) + + def _handle_cell_toggle(self, cell, path, should_turn_off=None): + """Change the content variable value here. + + cell is not used. + path is the name to turn off or on. + should_turn_off is as follows: + None - toggle based on the cell value + False - toggle on + True - toggle off + + """ + text_index = 0 + model = self.get_model() + r_iter = model.get_iter(path) + this_name = model.get_value(r_iter, text_index) + ok_values = self._get_data() + model = self.get_model() + can_add = self._check_can_add(r_iter) + should_add = False + if ((should_turn_off is None or should_turn_off) and + self._get_is_included(this_name, ok_values)): + ok_values.remove(this_name) + elif should_turn_off is None or not should_turn_off: + if not can_add: + return False + should_add = True + ok_values = ok_values + [this_name] + else: + self._realign() + return False + model.set_value(r_iter, 1, should_add) + if model.iter_n_children(r_iter): + self._toggle_internal_base(r_iter, this_name, should_add) + self._set_value(" ".join(ok_values)) + self._realign() + return False + + def _toggle_internal_base(self, base_iter, base_name, added=False): + """Connect a toggle of a group to its children. + + base_iter is the iter pointing to the group + base_name is the name of the group + added is a boolean denoting toggle state + + """ + model = self.get_model() + iter_ = model.iter_children(base_iter) + skip_children = False + while iter_ is not None: + model.set_value(iter_, 2, added) + if not skip_children: + next_iter = model.iter_children(iter_) + if skip_children or next_iter is None: + next_iter = model.iter_next(iter_) + skip_children = False + if next_iter is None: + next_iter = model.iter_parent(iter_) + skip_children = True + iter_ = next_iter + return False + + def refresh(self): + """Refresh the model.""" + self._realign() diff --git a/metomi/rose/gtk/console.py b/metomi/rose/gtk/console.py new file mode 100644 index 000000000..93de4ca28 --- /dev/null +++ b/metomi/rose/gtk/console.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose 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 3 of the License, or +# (at your option) any later version. +# +# Rose 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 Rose. If not, see . +# ----------------------------------------------------------------------------- + +import datetime + +import gi +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import metomi.rose.resource + + +class ConsoleWindow(Gtk.Window): + + """Create an error console window.""" + + CATEGORY_ALL = "All" + COLUMN_TITLE_CATEGORY = "Type" + COLUMN_TITLE_MESSAGE = "Message" + COLUMN_TITLE_TIME = "Time" + DEFAULT_SIZE = (600, 300) + TITLE = "Error Console" + + def __init__(self, categories, category_message_time_tuples, + category_stock_ids, default_size=None, parent=None, + destroy_hook=None): + super(ConsoleWindow, self).__init__() + if parent is not None: + self.set_transient_for(parent) + if default_size is None: + default_size = self.DEFAULT_SIZE + self.set_default_size(*default_size) + self.set_title(self.TITLE) + self._filter_category = self.CATEGORY_ALL + self.categories = categories + self.category_icons = [] + for id_ in category_stock_ids: + self.category_icons.append( + self.render_icon(id_, Gtk.IconSize.MENU)) + self._destroy_hook = destroy_hook + top_vbox = Gtk.VBox() + top_vbox.show() + self.add(top_vbox) + + message_scrolled_window = Gtk.ScrolledWindow() + message_scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, + Gtk.PolicyType.AUTOMATIC) + message_scrolled_window.show() + self._message_treeview = Gtk.TreeView() + self._message_treeview.show() + self._message_treeview.set_rules_hint(True) + + # Set up the category column (icons). + category_column = Gtk.TreeViewColumn() + category_column.set_title(self.COLUMN_TITLE_CATEGORY) + cell_category = Gtk.CellRendererPixbuf() + category_column.pack_start(cell_category, False, True, 0) + category_column.set_cell_data_func(cell_category, + self._set_category_cell, 0) + category_column.set_clickable(True) + category_column.connect("clicked", self._sort_column, 0) + self._message_treeview.append_column(category_column) + + # Set up the message column (info text). + message_column = Gtk.TreeViewColumn() + message_column.set_title(self.COLUMN_TITLE_MESSAGE) + cell_message = Gtk.CellRendererText() + message_column.pack_start(cell_message, False, True, 0) + message_column.add_attribute(cell_message, attribute="text", + column=1) + message_column.set_clickable(True) + message_column.connect("clicked", self._sort_column, 1) + self._message_treeview.append_column(message_column) + + # Set up the time column (text). + time_column = Gtk.TreeViewColumn() + time_column.set_title(self.COLUMN_TITLE_TIME) + cell_time = Gtk.CellRendererText() + time_column.pack_start(cell_time, False, True, 0) + time_column.set_cell_data_func(cell_time, self._set_time_cell, 2) + time_column.set_clickable(True) + time_column.set_sort_indicator(True) + time_column.connect("clicked", self._sort_column, 2) + self._message_treeview.append_column(time_column) + + self._message_store = Gtk.TreeStore(str, str, int) + for category, message, time in category_message_time_tuples: + self._message_store.append(None, [category, message, time]) + filter_model = self._message_store.filter_new() + filter_model.set_visible_func(self._get_should_show) + self._message_treeview.set_model(filter_model) + + message_scrolled_window.add(self._message_treeview) + top_vbox.pack_start(message_scrolled_window, expand=True, fill=True) + + category_hbox = Gtk.HBox() + category_hbox.show() + top_vbox.pack_end(category_hbox, expand=False, fill=False) + for category in categories + [self.CATEGORY_ALL]: + togglebutton = Gtk.ToggleButton(label=category, + use_underline=False) + togglebutton.connect("toggled", + lambda b: self._set_new_filter( + b, category_hbox.get_children())) + togglebutton.show() + category_hbox.pack_start(togglebutton, expand=True, fill=True) + togglebutton.set_active(True) + self.show() + self._scroll_to_end() + self.connect("destroy", self._handle_destroy) + + def _handle_destroy(self, window): + if self._destroy_hook is not None: + self._destroy_hook() + + def _get_should_show(self, model, iter_): + # Determine whether to show a row. + category = model.get_value(iter_, 0) + if self._filter_category not in [self.CATEGORY_ALL, category]: + return False + return True + + def _scroll_to_end(self): + # Scroll the Treeview to the end of the rows. + model = self._message_treeview.get_model() + iter_ = model.get_iter_first() + if iter_ is None: + return + while True: + next_iter = model.iter_next(iter_) + if next_iter is None: + break + iter_ = next_iter + path = model.get_path(iter_) + self._message_treeview.scroll_to_cell(path) + self._message_treeview.set_cursor(path) + self._message_treeview.grab_focus() + + def _set_category_cell(self, column, cell, model, r_iter, index): + category = model.get_value(r_iter, index) + icon = self.category_icons[self.categories.index(category)] + cell.set_property("pixbuf", icon) + + def _set_new_filter(self, togglebutton, togglebuttons): + category = togglebutton.get_label() + if not togglebutton.get_active(): + return False + self._filter_category = category + self._message_treeview.get_model().refilter() + for button in togglebuttons: + if button != togglebutton: + button.set_active(False) + + def _set_time_cell(self, column, cell, model, r_iter, index): + message_time = model.get_value(r_iter, index) + text = datetime.datetime.fromtimestamp(message_time).strftime( + metomi.rose.config_editor.EVENT_TIME_LONG) + cell.set_property("text", text) + + def _sort_column(self, column, index): + # Sort a column. + new_sort_order = Gtk.SortType.ASCENDING + if column.get_sort_order() == Gtk.SortType.ASCENDING: + new_sort_order = Gtk.SortType.DESCENDING + column.set_sort_order(new_sort_order) + for other_column in self._message_treeview.get_columns(): + other_column.set_sort_indicator(column == other_column) + self._message_store.set_sort_column_id(index, new_sort_order) + + def update_messages(self, category_message_time_tuples): + # Update the messages. + self._message_store.clear() + for category, message, time in category_message_time_tuples: + self._message_store.append(None, [category, message, time]) + self._scroll_to_end() diff --git a/metomi/rose/gtk/dialog.py b/metomi/rose/gtk/dialog.py new file mode 100644 index 000000000..c72e519c9 --- /dev/null +++ b/metomi/rose/gtk/dialog.py @@ -0,0 +1,759 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose 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 3 of the License, or +# (at your option) any later version. +# +# Rose 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 Rose. If not, see . +# ----------------------------------------------------------------------------- + +from multiprocessing import Process +import os +import queue +import shlex +from subprocess import Popen, PIPE +import sys +import tempfile +import time +import traceback +import webbrowser + +import gi +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk +from gi.repository import GLib +from gi.repository import Pango + +import metomi.rose.gtk.util +import metomi.rose.resource + + +DIALOG_BUTTON_CLOSE = "Close" +DIALOG_LABEL_README = "README" +DIALOG_PADDING = 10 +DIALOG_SUB_PADDING = 5 + +DIALOG_SIZE_PROCESS = (400, 100) +DIALOG_SIZE_SCROLLED_MAX = (600, 600) +DIALOG_SIZE_SCROLLED_MIN = (300, 100) + +DIALOG_TEXT_SHUTDOWN_ASAP = "Shutdown ASAP." +DIALOG_TEXT_SHUTTING_DOWN = "Shutting down." +DIALOG_TEXT_UNCAUGHT_EXCEPTION = ("{0} has crashed. {1}" + + "\n\n{2}: {3}\n{4}") +DIALOG_TITLE_ERROR = "Error" +DIALOG_TITLE_UNCAUGHT_EXCEPTION = "Critical error" +DIALOG_TITLE_EXTRA_INFO = "Further information" +DIALOG_TYPE_ERROR = Gtk.MessageType.ERROR +DIALOG_TYPE_INFO = Gtk.MessageType.INFO +DIALOG_TYPE_WARNING = Gtk.MessageType.WARNING + + +class DialogProcess(object): + + """Run a forked process and display a dialog while it runs. + + cmd_args can either be a list of shell command components + e.g. ['sleep', '100'] or a list containing a python function + followed by any function arguments e.g. [func, '100']. + description is used for the label, if not None + title is used for the title, if not None + stock_id is used for the dialog icon + hide_progress removes the bouncing progress bar + + Returns the exit code of the process. + + """ + + DIALOG_FUNCTION_LABEL = "Executing function" + DIALOG_LOG_LABEL = "Show log" + DIALOG_PROCESS_LABEL = "Executing command" + + def __init__(self, cmd_args, description=None, title=None, + stock_id=Gtk.STOCK_EXECUTE, + hide_progress=False, modal=True, + event_queue=None): + self.proc = None + window = get_dialog_parent() + self.dialog = Gtk.Dialog(buttons=(Gtk.STOCK_OK, Gtk.ResponseType.ACCEPT), + parent=window) + self.dialog.set_modal(modal) + self.dialog.set_default_size(*DIALOG_SIZE_PROCESS) + self._is_destroyed = False + self.dialog.set_icon(self.dialog.render_icon(Gtk.STOCK_EXECUTE, + Gtk.IconSize.MENU)) + self.cmd_args = cmd_args + self.event_queue = event_queue + str_cmd_args = [metomi.rose.gtk.util.safe_str(a) for a in cmd_args] + if description is not None: + str_cmd_args = [description] + if title is None: + self.dialog.set_title(" ".join(str_cmd_args[0:2])) + else: + self.dialog.set_title(title) + if callable(cmd_args[0]): + self.label = Gtk.Label(label=self.DIALOG_FUNCTION_LABEL) + else: + self.label = Gtk.Label(label=self.DIALOG_PROCESS_LABEL) + self.label.set_use_markup(True) + self.label.show() + self.image = Gtk.Image.new_from_stock(stock_id, + Gtk.IconSize.DIALOG) + self.image.show() + image_vbox = Gtk.VBox() + image_vbox.pack_start(self.image, expand=False, fill=False) + image_vbox.show() + top_hbox = Gtk.HBox() + top_hbox.pack_start(image_vbox, expand=False, fill=False, + padding=DIALOG_PADDING) + top_hbox.show() + hbox = Gtk.HBox() + hbox.pack_start(self.label, expand=False, fill=False, + padding=DIALOG_PADDING) + hbox.show() + main_vbox = Gtk.VBox() + main_vbox.show() + main_vbox.pack_start(hbox, expand=False, fill=False, + padding=DIALOG_SUB_PADDING) + + cmd_string = str_cmd_args[0] + if str_cmd_args[1:]: + if callable(cmd_args[0]): + cmd_string += "(" + " ".join(str_cmd_args[1:]) + ")" + else: + cmd_string += " " + " ".join(str_cmd_args[1:]) + self.cmd_label = Gtk.Label() + self.cmd_label.set_markup("" + cmd_string + "") + self.cmd_label.show() + cmd_hbox = Gtk.HBox() + cmd_hbox.pack_start(self.cmd_label, expand=False, fill=False, + padding=DIALOG_PADDING) + cmd_hbox.show() + main_vbox.pack_start(cmd_hbox, expand=False, fill=True, + padding=DIALOG_SUB_PADDING) + # self.dialog.set_modal(True) + self.progress_bar = Gtk.ProgressBar() + self.progress_bar.set_pulse_step(0.1) + self.progress_bar.show() + hbox = Gtk.HBox() + hbox.pack_start(self.progress_bar, expand=True, fill=True, + padding=DIALOG_PADDING) + hbox.show() + main_vbox.pack_start(hbox, expand=False, fill=False, + padding=DIALOG_SUB_PADDING) + top_hbox.pack_start(main_vbox, expand=True, fill=True, + padding=DIALOG_PADDING) + if self.event_queue is None: + self.dialog.vbox.pack_start(top_hbox, expand=True, fill=True) + else: + text_view_scroll = Gtk.ScrolledWindow() + text_view_scroll.set_policy(Gtk.PolicyType.NEVER, + Gtk.PolicyType.AUTOMATIC) + text_view_scroll.show() + text_view = Gtk.TextView() + text_view.show() + self.text_buffer = text_view.get_buffer() + self.text_tag = self.text_buffer.create_tag() + self.text_tag.set_property("scale", Pango.SCALE_SMALL) + text_view.connect('size-allocate', self._handle_scroll_text_view) + text_view_scroll.add(text_view) + text_expander = Gtk.Expander(self.DIALOG_LOG_LABEL) + text_expander.set_spacing(DIALOG_SUB_PADDING) + text_expander.add(text_view_scroll) + text_expander.show() + top_pane = Gtk.VPaned() + top_pane.pack1(top_hbox, resize=False, shrink=False) + top_pane.show() + self.dialog.vbox.pack_start(top_pane, expand=True, fill=True, + padding=DIALOG_SUB_PADDING) + top_pane.pack2(text_expander, resize=True, shrink=True) + if hide_progress: + progress_bar.hide() + self.ok_button = self.dialog.get_action_area().get_children()[0] + self.ok_button.hide() + for child in self.dialog.vbox.get_children(): + if isinstance(child, Gtk.HSeparator): + child.hide() + self.dialog.show() + + def run(self): + """Launch dialog in child process.""" + stdout = tempfile.TemporaryFile() + stderr = tempfile.TemporaryFile() + self.proc = Process( + target=_sep_process, args=[self.cmd_args, stdout, stderr]) + self.proc.start() + self.dialog.connect("destroy", self._handle_dialog_process_destroy) + while self.proc.is_alive(): + self.progress_bar.pulse() + if self.event_queue is not None: + while True: + try: + new_text = self.event_queue.get(False) + except queue.Empty: + break + end = self.text_buffer.get_end_iter() + self.text_buffer.insert_with_tags(end, new_text, + self.text_tag) + while Gtk.events_pending(): + Gtk.main_iteration() + time.sleep(0.1) + stdout.seek(0) + stderr.seek(0) + if self.proc.exitcode != 0: + if self._is_destroyed: + return self.proc.exitcode + else: + self.image.set_from_stock(Gtk.STOCK_DIALOG_ERROR, + Gtk.IconSize.DIALOG) + self.label.hide() + self.progress_bar.hide() + self.cmd_label.set_markup( + "" + metomi.rose.gtk.util.safe_str(stderr.read()) + "") + self.ok_button.show() + for child in self.dialog.vbox.get_children(): + if isinstance(child, Gtk.HSeparator): + child.show() + self.dialog.run() + self.dialog.destroy() + return self.proc.exitcode + + def _handle_dialog_process_destroy(self, dialog): + if self.proc.is_alive(): + self.proc.terminate() + self._is_destroyed = True + return False + + def _handle_scroll_text_view(self, text_view, event=None): + """Scroll the parent scrolled window to the bottom.""" + vadj = text_view.get_parent().get_vadjustment() + if vadj.upper > vadj.lower + vadj.page_size: + vadj.set_value(vadj.upper - 0.95 * vadj.page_size) + + +def _sep_process(*args): + sys.exit(_process(*args)) + + +def _process(cmd_args, stdout=sys.stdout, stderr=sys.stderr): + if callable(cmd_args[0]): + func = cmd_args.pop(0) + try: + func(*cmd_args) + except Exception as exc: + stderr.write(type(exc).__name__ + ": " + str(exc) + "\n") + stderr.read() + return 1 + return 0 + proc = Popen(cmd_args, stdout=PIPE, stderr=PIPE) + for line in iter(proc.stdout.readline, ""): + stdout.write(line) + for line in iter(proc.stderr.readline, ""): + stderr.write(line) + proc.wait() + stdout.read() # Magically keep it alive!? + stderr.read() + return proc.poll() + + +def run_about_dialog(name=None, copyright_=None, + logo_path=None, website=None): + parent_window = get_dialog_parent() + about_dialog = Gtk.AboutDialog() + about_dialog.set_transient_for(parent_window) + about_dialog.set_name(name) + licence_path = os.path.join(os.getenv("ROSE_HOME"), + metomi.rose.FILEPATH_README) + about_dialog.set_license(open(licence_path, "r").read()) + about_dialog.set_copyright(copyright_) + resource_loc = metomi.rose.resource.ResourceLocator(paths=sys.path) + logo_path = resource_loc.locate(logo_path) + about_dialog.set_logo(GdkPixbuf.Pixbuf.new_from_file(logo_path)) + about_dialog.set_website(website) + Gtk.about_dialog_set_url_hook( + lambda u, v, w: webbrowser.open(w), about_dialog.get_website()) + about_dialog.run() + about_dialog.destroy() + + +def run_command_arg_dialog(cmd_name, help_text, run_hook): + """Launch a dialog to get extra arguments for a command.""" + checker_function = lambda t: True + dialog, container, name_entry = get_naming_dialog(cmd_name, + checker_function) + dialog.set_title(cmd_name) + help_label = Gtk.stock_lookup(Gtk.STOCK_HELP)[1].strip("_") + help_button = metomi.rose.gtk.util.CustomButton( + stock_id=Gtk.STOCK_HELP, + label=help_label, + size=Gtk.IconSize.LARGE_TOOLBAR) + help_button.connect( + "clicked", + lambda b: run_scrolled_dialog(help_text, title=help_label)) + help_hbox = Gtk.HBox() + help_hbox.pack_start(help_button, expand=False, fill=False) + help_hbox.show() + container.pack_end(help_hbox, expand=False, fill=False) + name_entry.grab_focus() + dialog.connect("response", _handle_command_arg_response, run_hook, + name_entry) + dialog.set_modal(False) + dialog.show() + + +def _handle_command_arg_response(dialog, response, run_hook, entry): + text = entry.get_text() + dialog.destroy() + if response == Gtk.ResponseType.ACCEPT: + run_hook(shlex.split(text)) + + +def run_dialog(dialog_type, text, title=None, modal=True, + cancel=False, extra_text=None): + """Run a simple dialog with an 'OK' button and some text.""" + parent_window = get_dialog_parent() + dialog = Gtk.Dialog(parent=parent_window) + if parent_window is None: + dialog.set_icon(metomi.rose.gtk.util.get_icon()) + if cancel: + dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) + if extra_text: + info_button = Gtk.Button(stock=Gtk.STOCK_INFO) + info_button.show() + info_title = DIALOG_TITLE_EXTRA_INFO + info_button.connect( + "clicked", + lambda b: run_scrolled_dialog(extra_text, title=info_title)) + dialog.action_area.pack_start(info_button, expand=False, fill=False) + ok_button = dialog.add_button(Gtk.STOCK_OK, Gtk.ResponseType.OK) + if dialog_type == Gtk.MessageType.INFO: + stock_id = Gtk.STOCK_DIALOG_INFO + elif dialog_type == Gtk.MessageType.WARNING: + stock_id = Gtk.STOCK_DIALOG_WARNING + elif dialog_type == Gtk.MessageType.QUESTION: + stock_id = Gtk.STOCK_DIALOG_QUESTION + elif dialog_type == Gtk.MessageType.ERROR: + stock_id = Gtk.STOCK_DIALOG_ERROR + else: + stock_id = None + + if stock_id is not None: + dialog.image = Gtk.Image.new_from_stock(stock_id, Gtk.IconSize.DIALOG) + dialog.image.show() + + dialog.label = Gtk.Label(label=text) + try: + Pango.parse_markup(text) + except GLib.GError: + try: + dialog.label.set_markup(metomi.rose.gtk.util.safe_str(text)) + except Exception: + dialog.label.set_text(text) + else: + dialog.label.set_markup(text) + dialog.label.show() + hbox = Gtk.HBox() + + if stock_id is not None: + image_vbox = Gtk.VBox() + image_vbox.pack_start(dialog.image, expand=False, fill=False, + padding=DIALOG_PADDING) + image_vbox.show() + hbox.pack_start(image_vbox, expand=False, fill=False, + padding=metomi.rose.config_editor.SPACING_PAGE) + + scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_border_width(0) + scrolled_window.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.NEVER) + vbox = Gtk.VBox() + vbox.pack_start(dialog.label, expand=True, fill=True) + vbox.show() + scrolled_window.add_with_viewport(vbox) + scrolled_window.get_child().set_shadow_type(Gtk.ShadowType.NONE) + scrolled_window.show() + hbox.pack_start(scrolled_window, expand=True, fill=True, + padding=metomi.rose.config_editor.SPACING_PAGE) + hbox.show() + dialog.vbox.pack_end(hbox, expand=True, fill=True) + + if "\n" in text: + dialog.label.set_line_wrap(False) + dialog.set_resizable(True) + dialog.set_modal(modal) + if title is not None: + dialog.set_title(title) + + _configure_scroll(dialog, scrolled_window) + ok_button.grab_focus() + if modal or cancel: + dialog.show() + response = dialog.run() + dialog.destroy() + return (response == Gtk.ResponseType.OK) + else: + ok_button.connect("clicked", lambda b: dialog.destroy()) + dialog.show() + + +def run_exception_dialog(exception): + """Run a dialog displaying an exception.""" + text = type(exception).__name__ + ": " + str(exception) + return run_dialog(DIALOG_TYPE_ERROR, text, DIALOG_TITLE_ERROR) + + +def run_hyperlink_dialog(stock_id=None, text="", title=None, + search_func=lambda i: False): + """Run a dialog with inserted hyperlinks.""" + parent_window = get_dialog_parent() + dialog = Gtk.Window() + dialog.set_transient_for(parent_window) + dialog.set_type_hint(Gdk.WindowTypeHint.DIALOG) + dialog.set_title(title) + dialog.set_modal(False) + top_vbox = Gtk.VBox() + top_vbox.show() + main_hbox = Gtk.HBox(spacing=DIALOG_PADDING) + main_hbox.show() + # Insert the image + image_vbox = Gtk.VBox() + image_vbox.show() + image = Gtk.Image.new_from_stock(stock_id, + size=Gtk.IconSize.DIALOG) + image.show() + image_vbox.pack_start(image, expand=False, fill=False, + padding=DIALOG_PADDING) + main_hbox.pack_start(image_vbox, expand=False, fill=False, + padding=DIALOG_PADDING) + # Apply the text + message_vbox = Gtk.VBox() + message_vbox.show() + label = metomi.rose.gtk.util.get_hyperlink_label(text, search_func) + message_vbox.pack_start(label, expand=True, fill=True, + padding=DIALOG_PADDING) + scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_border_width(DIALOG_PADDING) + scrolled_window.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.NEVER) + scrolled_window.add_with_viewport(message_vbox) + scrolled_window.get_child().set_shadow_type(Gtk.ShadowType.NONE) + scrolled_window.show() + vbox = Gtk.VBox() + vbox.pack_start(scrolled_window, expand=True, fill=True) + vbox.show() + main_hbox.pack_start(vbox, expand=True, fill=True) + top_vbox.pack_start(main_hbox, expand=True, fill=True) + # Insert the button + button_box = Gtk.HBox(spacing=DIALOG_PADDING) + button_box.show() + button = metomi.rose.gtk.util.CustomButton(label=DIALOG_BUTTON_CLOSE, + size=Gtk.IconSize.LARGE_TOOLBAR, + stock_id=Gtk.STOCK_CLOSE) + button.connect("clicked", lambda b: dialog.destroy()) + button_box.pack_end(button, expand=False, fill=False, + padding=DIALOG_PADDING) + top_vbox.pack_end(button_box, expand=False, fill=False, + padding=DIALOG_PADDING) + dialog.add(top_vbox) + if "\n" in text: + label.set_line_wrap(False) + dialog.set_resizable(True) + _configure_scroll(dialog, scrolled_window) + dialog.show() + label.set_selectable(True) + button.grab_focus() + + +def run_scrolled_dialog(text, title=None): + """Run a dialog intended for the display of a large amount of text.""" + parent_window = get_dialog_parent() + window = Gtk.Window() + window.set_transient_for(parent_window) + window.set_type_hint(Gdk.WindowTypeHint.DIALOG) + window.set_border_width(DIALOG_SUB_PADDING) + window.set_default_size(*DIALOG_SIZE_SCROLLED_MIN) + if title is not None: + window.set_title(title) + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scrolled.show() + label = Gtk.Label() + try: + Pango.parse_markup(text) + except GLib.GError: + label.set_text(text) + else: + label.set_markup(text) + label.show() + filler_eb = Gtk.EventBox() + filler_eb.show() + label_box = Gtk.VBox() + label_box.pack_start(label, expand=False, fill=False) + label_box.pack_start(filler_eb, expand=True, fill=True) + label_box.show() + width, height = label.size_request() + max_width, max_height = DIALOG_SIZE_SCROLLED_MAX + width = min([max_width, width]) + 2 * DIALOG_PADDING + height = min([max_height, height]) + 2 * DIALOG_PADDING + scrolled.add_with_viewport(label_box) + scrolled.get_child().set_shadow_type(Gtk.ShadowType.NONE) + scrolled.set_size_request(width, height) + button = Gtk.Button(stock=Gtk.STOCK_OK) + button.connect("clicked", lambda b: window.destroy()) + button.show() + button.grab_focus() + button_box = Gtk.HBox() + button_box.pack_end(button, expand=False, fill=False) + button_box.show() + main_vbox = Gtk.VBox(spacing=DIALOG_SUB_PADDING) + main_vbox.pack_start(scrolled, expand=True, fill=True) + main_vbox.pack_end(button_box, expand=False, fill=False) + main_vbox.show() + window.add(main_vbox) + window.show() + label.set_selectable(True) + return False + + +def get_naming_dialog(label, checker, ok_tip=None, + err_tip=None): + """Return a dialog, container, and entry for entering a name.""" + button_list = (Gtk.STOCK_CANCEL, Gtk.ResponseType.REJECT, + Gtk.STOCK_OK, Gtk.ResponseType.ACCEPT) + parent_window = get_dialog_parent() + dialog = Gtk.Dialog(buttons=button_list) + dialog.set_transient_for(parent_window) + dialog.set_modal(True) + ok_button = dialog.action_area.get_children()[0] + main_vbox = Gtk.VBox() + name_hbox = Gtk.HBox() + name_label = Gtk.Label() + name_label.set_text(label) + name_label.show() + name_entry = Gtk.Entry() + name_entry.set_tooltip_text(ok_tip) + name_entry.connect("changed", _name_checker, checker, ok_button, + ok_tip, err_tip) + name_entry.connect( + "activate", lambda b: dialog.response(Gtk.ResponseType.ACCEPT)) + name_entry.show() + name_hbox.pack_start(name_label, expand=False, fill=False, + padding=DIALOG_SUB_PADDING) + name_hbox.pack_start(name_entry, expand=False, fill=True, + padding=DIALOG_SUB_PADDING) + name_hbox.show() + main_vbox.pack_start(name_hbox, expand=False, fill=True, + padding=DIALOG_PADDING) + main_vbox.show() + hbox = Gtk.HBox() + hbox.pack_start(main_vbox, expand=False, fill=True, + padding=DIALOG_PADDING) + hbox.show() + dialog.vbox.pack_start(hbox, expand=False, fill=True, + padding=DIALOG_PADDING) + return dialog, main_vbox, name_entry + + +def _name_checker(entry, checker, ok_button, ok_tip, err_tip): + good_colour = ok_button.style.text[Gtk.StateType.NORMAL] + bad_colour = metomi.rose.gtk.util.color_parse( + metomi.rose.config_editor.COLOUR_VARIABLE_TEXT_ERROR) + name = entry.get_text() + if checker(name): + entry.modify_text(Gtk.StateType.NORMAL, good_colour) + entry.set_tooltip_text(ok_tip) + ok_button.set_sensitive(True) + else: + entry.modify_text(Gtk.StateType.NORMAL, bad_colour) + entry.set_tooltip_text(err_tip) + ok_button.set_sensitive(False) + return False + + +def run_choices_dialog(text, choices, title=None): + """Run a dialog for choosing between a set of options.""" + parent_window = get_dialog_parent() + dialog = Gtk.Dialog(title, + buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.REJECT, + Gtk.STOCK_OK, Gtk.ResponseType.ACCEPT), + parent=parent_window) + dialog.set_border_width(DIALOG_SUB_PADDING) + label = Gtk.Label() + try: + Pango.parse_markup(text) + except GLib.GError: + label.set_text(text) + else: + label.set_markup(text) + dialog.vbox.set_spacing(DIALOG_SUB_PADDING) + dialog.vbox.pack_start(label, expand=False, fill=False) + if len(choices) < 5: + for i, choice in enumerate(choices): + group = None + if i > 0: + group = radio_button + if i == 1: + radio_button.set_active(True) + radio_button = Gtk.RadioButton(group, + label=choice, + use_underline=False) + dialog.vbox.pack_start(radio_button, expand=False, fill=False) + getter = (lambda: + [b.get_label() for b in radio_button.get_group() + if b.get_active()].pop()) + else: + combo_box = Gtk.ComboBoxText() + for choice in choices: + combo_box.append_text(choice) + combo_box.set_active(0) + dialog.vbox.pack_start(combo_box, expand=False, fill=False) + getter = lambda: choices[combo_box.get_active()] + dialog.show_all() + response = dialog.run() + if response == Gtk.ResponseType.ACCEPT: + choice = getter() + dialog.destroy() + return choice + dialog.destroy() + return None + + +def run_edit_dialog(text, finish_hook=None, title=None): + """Run a dialog for editing some text.""" + parent_window = get_dialog_parent() + dialog = Gtk.Dialog(title, + buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.REJECT, + Gtk.STOCK_OK, Gtk.ResponseType.ACCEPT), + parent=parent_window) + + dialog.set_border_width(DIALOG_SUB_PADDING) + + scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_border_width(DIALOG_SUB_PADDING) + scrolled_window.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.NEVER) + + text_buffer = Gtk.TextBuffer() + text_buffer.set_text(text) + text_view = Gtk.TextView() + text_view.set_editable(True) + text_view.set_wrap_mode(Gtk.WrapMode.NONE) + text_view.set_buffer(text_buffer) + text_view.show() + + scrolled_window.add_with_viewport(text_view) + scrolled_window.show() + + dialog.vbox.pack_start(scrolled_window, expand=True, fill=True, + padding=0) + get_text = lambda: text_buffer.get_text(text_buffer.get_start_iter(), + text_buffer.get_end_iter()) + + max_size = metomi.rose.config_editor.SIZE_MACRO_DIALOG_MAX + # defines the minimum acceptable size for the edit dialog + min_size = DIALOG_SIZE_PROCESS + + # hacky solution to get "true" size for dialog + dialog.show() + start_size = dialog.size_request() + scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + end_size = dialog.size_request() + my_size = (max([start_size[0], end_size[0], min_size[0]]) + 20, + max([start_size[1], end_size[1], min_size[1]]) + 20) + new_size = [-1, -1] + for i in [0, 1]: + new_size[i] = min([my_size[i], max_size[i]]) + dialog.set_size_request(*new_size) + + if finish_hook is None: + response = dialog.run() + if response == Gtk.ResponseType.ACCEPT: + text = get_text().strip() + dialog.destroy() + return text + dialog.destroy() + else: + finish_func = lambda: finish_hook(get_text().strip()) + dialog.connect("response", _handle_edit_dialog_response, finish_func) + dialog.show() + + +def _handle_edit_dialog_response(dialog, response, finish_hook): + if response == Gtk.ResponseType.ACCEPT: + finish_hook() + dialog.destroy() + + +def get_dialog_parent(): + """Find the currently active window, if any, and reparent dialog.""" + ok_windows = [] + max_size = -1 + for window in Gtk.window_list_toplevels(): + if window.get_title() is not None and window.get_toplevel() == window: + ok_windows.append(window) + size_proxy = window.get_size()[0] * window.get_size()[1] + if size_proxy > max_size: + max_size = size_proxy + for window in ok_windows: + if window.is_active(): + return window + for window in ok_windows: + if window.get_size()[0] * window.get_size()[1] == max_size: + return window + + +def set_exception_hook_dialog(keep_alive=False): + """Set a dialog to run once an uncaught exception occurs.""" + prev_hook = sys.excepthook + sys.excepthook = (lambda c, i, t: + _run_exception_dialog(c, i, t, prev_hook, + keep_alive)) + + +def _configure_scroll(dialog, scrolled_window): + """Set scroll window size and scroll policy.""" + # make sure the dialog size doesn't exceed the maximum - if so change it + max_size = metomi.rose.config_editor.SIZE_MACRO_DIALOG_MAX + my_size = dialog.size_request() + new_size = [-1, -1] + for i, scrollbar_cls in [(0, Gtk.VScrollbar), (1, Gtk.HScrollbar)]: + new_size[i] = min([my_size[i], max_size[i]]) + if new_size[i] < max_size[i]: + # Factor in existence of a scrollbar in the other dimension. + # For horizontal dimension, add width of vertical scroll bar + 2 + # For vertical dimension, add height of horizontal scroll bar + 2 + new_size[i] += scrollbar_cls().size_request()[i] + 2 + scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + dialog.set_default_size(*new_size) + + +def _run_exception_dialog(exc_class, exc_inst, tback, hook, keep_alive): + # Handle an uncaught exception. + if exc_class == KeyboardInterrupt: + return False + hook(exc_class, exc_inst, tback) + program_name = metomi.rose.resource.ResourceLocator().get_util_name() + tback_text = metomi.rose.gtk.util.safe_str("".join(traceback.format_tb(tback))) + shutdown_text = DIALOG_TEXT_SHUTTING_DOWN + if keep_alive: + shutdown_text = DIALOG_TEXT_SHUTDOWN_ASAP + text = DIALOG_TEXT_UNCAUGHT_EXCEPTION.format(program_name, + shutdown_text, + exc_class.__name__, + exc_inst, + tback_text) + run_dialog(DIALOG_TYPE_ERROR, text, + title=DIALOG_TITLE_UNCAUGHT_EXCEPTION) + if not keep_alive: + try: + Gtk.main_quit() + except RuntimeError: + pass diff --git a/metomi/rose/gtk/run.py b/metomi/rose/gtk/run.py new file mode 100644 index 000000000..0338cae78 --- /dev/null +++ b/metomi/rose/gtk/run.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose 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 3 of the License, or +# (at your option) any later version. +# +# Rose 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 Rose. If not, see . +# ----------------------------------------------------------------------------- +"""Miscellaneous gtk mini-applications.""" + +import multiprocessing +from subprocess import check_output + +from metomi.rose.gtk.dialog import DialogProcess, run_dialog, DIALOG_TYPE_WARNING +from metomi.rose.opt_parse import RoseOptionParser +from metomi.rose.suite_engine_procs.cylc import CylcProcessor +from metomi.rose.suite_run import SuiteRunner +from metomi.rose.reporter import Reporter, ReporterContextQueue + + +def run_suite(*args): + """Run "rose suite-run [args]" with a GTK dialog.""" + # Set up reporter + queue = multiprocessing.Manager().Queue() + verbosity = Reporter.VV + out_ctx = ReporterContextQueue(Reporter.KIND_OUT, verbosity, queue=queue) + err_ctx = ReporterContextQueue(Reporter.KIND_ERR, verbosity, queue=queue) + event_handler = Reporter(contexts={"stdout": out_ctx, "stderr": err_ctx}, + raise_on_exc=True) + + # Parse arguments + suite_runner = SuiteRunner(event_handler=event_handler) + + # Don't use rose-suite run if Cylc Version is 8.*: + if suite_runner.suite_engine_proc.get_version()[0] == '8': + run_dialog( + DIALOG_TYPE_WARNING, + '`rose suite-run` does not work with Cylc 8 workflows: ' + 'Use `cylc install`.', + 'Cylc Version == 8' + ) + return None + + prog = "rose suite-run" + description = prog + if args: + description += " " + suite_runner.popen.list_to_shell_str(args) + opt_parse = RoseOptionParser(prog=prog) + opt_parse.add_my_options(*suite_runner.OPTIONS) + opts, args = opt_parse.parse_args(list(args)) + + # Invoke the command with a GTK dialog + dialog_process = DialogProcess([suite_runner, opts, args], + description=description, + modal=False, + event_queue=queue) + return dialog_process.run() diff --git a/metomi/rose/gtk/splash.py b/metomi/rose/gtk/splash.py new file mode 100755 index 000000000..377ec8921 --- /dev/null +++ b/metomi/rose/gtk/splash.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose 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 3 of the License, or +# (at your option) any later version. +# +# Rose 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 Rose. If not, see . +# ----------------------------------------------------------------------------- +"""Invoke a splash screen from the command line.""" + +import json +import os +from subprocess import Popen, PIPE +import sys +import threading +import time + +import gi +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk +from gi.repository import GObject +from gi.repository import Pango + +import metomi.rose.gtk.util +import metomi.rose.popen + +GObject.threads_init() + + +class SplashScreen(Gtk.Window): + + """Run a splash screen that receives update information.""" + + BACKGROUND_COLOUR = "white" # Same as logo background. + PADDING = 10 + SUB_PADDING = 5 + FONT_DESC = "8" + PULSE_FRACTION = 0.05 + TIME_WAIT_FINISH = 500 # Milliseconds. + TIME_IDLE_BEFORE_PULSE = 3000 # Milliseconds. + TIME_INTERVAL_PULSE = 50 # Milliseconds. + + def __init__(self, logo_path, title, total_number_of_events): + super(SplashScreen, self).__init__() + self.set_title(title) + self.set_decorated(False) + self.stopped = False + self.set_icon(metomi.rose.gtk.util.get_icon()) + self.modify_bg(Gtk.StateType.NORMAL, + metomi.rose.gtk.util.color_parse(self.BACKGROUND_COLOUR)) + self.set_gravity(Gdk.GRAVITY_CENTER) + self.set_position(Gtk.WindowPosition.CENTER) + main_vbox = Gtk.VBox() + main_vbox.show() + image = Gtk.image_new_from_file(logo_path) + image.show() + image_hbox = Gtk.HBox() + image_hbox.show() + image_hbox.pack_start(image, expand=False, fill=True) + main_vbox.pack_start(image_hbox, expand=False, fill=True) + self._is_progress_bar_pulsing = False + self._progress_fraction = 0.0 + self.progress_bar = Gtk.ProgressBar() + self.progress_bar.set_pulse_step(self.PULSE_FRACTION) + self.progress_bar.show() + self.progress_bar.modify_font(Pango.FontDescription(self.FONT_DESC)) + self.progress_bar.set_ellipsize(Pango.EllipsizeMode.END) + self._progress_message = None + self.event_count = 0.0 + self.total_number_of_events = float(total_number_of_events) + progress_hbox = Gtk.HBox(spacing=self.SUB_PADDING) + progress_hbox.show() + progress_hbox.pack_start(self.progress_bar, expand=True, fill=True, + padding=self.SUB_PADDING) + main_vbox.pack_start(progress_hbox, expand=False, fill=False, + padding=self.PADDING) + self.add(main_vbox) + if self.total_number_of_events > 0: + self.show() + while Gtk.events_pending(): + Gtk.main_iteration() + + def update(self, event, no_progress=False, new_total_events=None): + """Show text corresponding to an event.""" + text = str(event) + if new_total_events is not None: + self.total_number_of_events = new_total_events + self.event_count = 0.0 + + if not no_progress: + self.event_count += 1.0 + + if self.total_number_of_events == 0: + fraction = 1.0 + else: + fraction = min( + [1.0, self.event_count / self.total_number_of_events]) + self._stop_pulse() + + if not no_progress: + GObject.idle_add(self.progress_bar.set_fraction, fraction) + self._progress_fraction = fraction + + self.progress_bar.set_text(text) + self._progress_message = text + GObject.timeout_add(self.TIME_IDLE_BEFORE_PULSE, + self._start_pulse, fraction, text) + + if fraction == 1.0 and not no_progress: + GObject.timeout_add(self.TIME_WAIT_FINISH, self.finish) + + while Gtk.events_pending(): + Gtk.main_iteration() + + def _start_pulse(self, idle_fraction, idle_message): + """Start the progress bar pulsing (moving side-to-side).""" + if (self._progress_message != idle_message or + self._progress_fraction != idle_fraction): + return False + self._is_progress_bar_pulsing = True + GObject.timeout_add(self.TIME_INTERVAL_PULSE, + self._pulse) + return False + + def _stop_pulse(self): + self._is_progress_bar_pulsing = False + + def _pulse(self): + if self._is_progress_bar_pulsing: + self.progress_bar.pulse() + while Gtk.events_pending(): + Gtk.main_iteration() + return self._is_progress_bar_pulsing + + def finish(self): + """Delete the splash screen.""" + self.stopped = True + GObject.idle_add(self.destroy) + return False + + +class NullSplashScreenProcess(object): + + """Implement a null interface similar to SplashScreenProcess.""" + + def __init__(self, *args): + pass + + def update(self, *args, **kwargs): + pass + + def start(self): + pass + + def stop(self): + pass + + +class SplashScreenProcess(object): + + """Run a separate process that launches a splash screen. + + Communicate via the update method. + + """ + + def __init__(self, *args): + args = [str(a) for a in args] + self.args = args + self._buffer = [] + self._last_buffer_output_time = time.time() + self.start() + + def update(self, *args, **kwargs): + """Communicate via stdin to SplashScreenManager. + + args and kwargs are the update method args, kwargs. + + """ + if self.process is None: + self.start() + if kwargs.get("no_progress"): + return self._update_buffered(*args, **kwargs) + self._flush_buffer() + json_text = json.dumps({"args": args, "kwargs": kwargs}) + self._communicate(json_text) + + def _communicate(self, json_text): + while True: + try: + self.process.stdin.write(json_text + "\n") + except IOError: + self.start() + self.process.stdin.write(json_text + "\n") + else: + break + + def _flush_buffer(self): + if self._buffer: + self._communicate(self._buffer[-1]) + del self._buffer[:] + + def _update_buffered(self, *args, **kwargs): + tinit = time.time() + json_text = json.dumps({"args": args, "kwargs": kwargs}) + if tinit - self._last_buffer_output_time > 0.02: + self._communicate(json_text) + del self._buffer[:] + self._last_buffer_output_time = tinit + else: + self._buffer.append(json_text) + + __call__ = update + + def start(self): + file_name = __file__.rsplit(".", 1)[0] + ".py" + self.process = Popen([file_name] + list(self.args), stdin=PIPE) + + def stop(self): + if self.process is not None and not self.process.stdin.closed: + try: + self.process.communicate(input=json.dumps("stop") + "\n") + except IOError: + pass + self.process = None + + +class SplashScreenUpdaterThread(threading.Thread): + + """Update a splash screen using info from the stdin file object.""" + + def __init__(self, window, stop_event, stdin): + super(SplashScreenUpdaterThread, self).__init__() + self.window = window + self.stop_event = stop_event + self.stdin = stdin + + def run(self): + """Loop over time and wait for stdin lines.""" + GObject.timeout_add(1000, self._check_splash_screen_alive) + while not self.stop_event.is_set(): + time.sleep(0.005) + if self.stop_event.is_set(): + return False + try: + stdin_line = self.stdin.readline() + except IOError: + continue + try: + update_input = json.loads(stdin_line.strip()) + except ValueError: + continue + if update_input == "stop": + self._stop() + continue + GObject.idle_add(self._update_splash_screen, update_input) + + def _stop(self): + self.stop_event.set() + try: + Gtk.main_quit() + except RuntimeError: + # This can result from gtk having already quit. + pass + + def _check_splash_screen_alive(self): + """Check whether the splash screen is finished.""" + if self.window.stopped or self.stop_event.is_set(): + self._stop() + return False + return True + + def _update_splash_screen(self, update_input): + """Update the splash screen with info extracted from stdin.""" + self.window.update(*update_input["args"], **update_input["kwargs"]) + return False + + +def main(): + """Start splash screen.""" + sys.path.append(os.getenv('ROSE_HOME')) + splash_screen = SplashScreen(*sys.argv[1:]) + stop_event = threading.Event() + update_thread = SplashScreenUpdaterThread( + splash_screen, stop_event, sys.stdin) + update_thread.start() + try: + Gtk.main() + except KeyboardInterrupt: + pass + finally: + stop_event.set() + update_thread.join() + + +if __name__ == "__main__": + main() diff --git a/metomi/rose/gtk/util.py b/metomi/rose/gtk/util.py new file mode 100644 index 000000000..2ea5b7723 --- /dev/null +++ b/metomi/rose/gtk/util.py @@ -0,0 +1,705 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (C) 2012-2020 British Crown (Met Office) & Contributors. +# +# This file is part of Rose, a framework for meteorological suites. +# +# Rose 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 3 of the License, or +# (at your option) any later version. +# +# Rose 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 Rose. If not, see . +# ----------------------------------------------------------------------------- + +import multiprocessing +import queue +import re +import sys +import threading +import webbrowser + +import gi +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk +from gi.repository import GObject +from gi.repository import GLib +from gi.repository import Pango + +import metomi.rose.reporter +import metomi.rose.resource + + +REC_HYPERLINK_ID_OR_URL = re.compile( + r"""(?P\b) + (?P[\w:-]+=\w+|https?://[^\s<]+) + (?P\b)""", re.X) +MARKUP_URL_HTML = (r"""\g""" + + r"""\g""" + + r"""\g""") +MARKUP_URL_UNDERLINE = (r"""\g""" + + r"""\g""" + + r"""\g""") + + +class ColourParseError(ValueError): + + """An exception raised when gtk colour parsing fails.""" + + def __str__(self): + return "unable to parse colour specification: %s" % self.args[0] + + +class CustomButton(Gtk.Button): + + """Returns a custom Gtk.Button.""" + + def __init__(self, label=None, stock_id=None, + size=Gtk.IconSize.SMALL_TOOLBAR, tip_text=None, + as_tool=False, icon_at_start=False, has_menu=False): + self.hbox = Gtk.HBox() + self.size = size + self.as_tool = as_tool + self.icon_at_start = icon_at_start + if label is not None: + self.label = Gtk.Label() + self.label.set_text(label) + self.label.show() + + if self.icon_at_start: + self.hbox.pack_end(self.label, expand=False, fill=False, + padding=5) + else: + self.hbox.pack_start(self.label, expand=False, fill=False, + padding=5) + if stock_id is not None: + self.stock_id = stock_id + self.icon = Gtk.Image() + self.icon.set_from_stock(stock_id, size) + self.icon.show() + if self.icon_at_start: + self.hbox.pack_start(self.icon, expand=False, fill=False) + else: + self.hbox.pack_end(self.icon, expand=False, fill=False) + if has_menu: + arrow = Gtk.Arrow(Gtk.ArrowType.DOWN, Gtk.ShadowType.NONE) + arrow.show() + self.hbox.pack_end(arrow, expand=False, fill=False) + self.hbox.reorder_child(arrow, 0) + self.hbox.show() + super(CustomButton, self).__init__() + if self.as_tool: + self.set_relief(Gtk.ReliefStyle.NONE) + self.connect("leave", lambda b: b.set_relief(Gtk.ReliefStyle.NONE)) + self.add(self.hbox) + self.show() + if tip_text is not None: + self.set_tooltip_text(tip_text) + + def set_stock_id(self, stock_id): + """Set an icon based on the stock id.""" + if hasattr(self, "icon"): + self.hbox.remove(self.icon) + self.icon.set_from_stock(stock_id, self.size) + self.stock_id = stock_id + if self.icon_at_start: + self.hbox.pack_start(self.icon, expand=False, fill=False) + else: + self.hbox.pack_end(self.icon, expand=False, fill=False) + return False + + def set_tip_text(self, new_text): + """Set new tooltip text.""" + self.set_tooltip_text(new_text) + + def position_menu(self, menu, widget): + """Place a drop-down menu carefully below the button.""" + xpos, ypos = widget.get_window().get_origin() + allocated_rectangle = widget.get_allocation() + xpos += allocated_rectangle.x + ypos += allocated_rectangle.y + allocated_rectangle.height + return xpos, ypos, False + + +class CustomExpandButton(Gtk.Button): + + """Custom button for expanding/hiding something""" + + def __init__(self, expander_function=None, + label=None, + size=Gtk.IconSize.SMALL_TOOLBAR, + tip_text=None, + as_tool=False, + icon_at_start=False, + minimised=True): + + self.expander_function = expander_function + self.minimised = minimised + + self.expand_id = Gtk.STOCK_ADD + self.minimise_id = Gtk.STOCK_REMOVE + + if minimised: + self.stock_id = self.expand_id + else: + self.stock_id = self.minimise_id + + self.hbox = Gtk.HBox() + self.size = size + self.as_tool = as_tool + self.icon_at_start = icon_at_start + + if label is not None: + self.label = Gtk.Label() + self.label.set_text(label) + self.label.show() + + if self.icon_at_start: + self.hbox.pack_end(self.label, expand=False, fill=False, + padding=5) + else: + self.hbox.pack_start(self.label, expand=False, fill=False, + padding=5) + self.icon = Gtk.Image() + self.icon.set_from_stock(self.stock_id, size) + self.icon.show() + if self.icon_at_start: + self.hbox.pack_start(self.icon, expand=False, fill=False) + else: + self.hbox.pack_end(self.icon, expand=False, fill=False) + self.hbox.show() + super(CustomExpandButton, self).__init__() + + if self.as_tool: + self.set_relief(Gtk.ReliefStyle.NONE) + self.connect("leave", lambda b: b.set_relief(Gtk.ReliefStyle.NONE)) + self.add(self.hbox) + self.show() + if tip_text is not None: + self.set_tooltip_text(tip_text) + self.connect("clicked", self.toggle) + + def set_stock_id(self, stock_id): + """Set the icon stock_id""" + if hasattr(self, "icon"): + self.hbox.remove(self.icon) + self.icon.set_from_stock(stock_id, self.size) + self.stock_id = stock_id + if self.icon_at_start: + self.hbox.pack_start(self.icon, expand=False, fill=False) + else: + self.hbox.pack_end(self.icon, expand=False, fill=False) + return False + + def set_tip_text(self, new_text): + """Set the tip text""" + self.set_tooltip_text(new_text) + + def toggle(self, minimise=None): + """Toggle between show/hide states""" + if minimise is not None: + if minimise == self.minimised: + return + self.minimised = not self.minimised + if self.minimised: + self.stock_id = self.expand_id + else: + self.stock_id = self.minimise_id + if self.expander_function is not None: + self.expander_function(set_visibility=not self.minimised) + self.set_stock_id(self.stock_id) + + +class CustomMenuButton(Gtk.MenuToolButton): + + """Custom wrapper for the gtk Menu Tool Button.""" + + def __init__(self, label=None, stock_id=None, + size=Gtk.IconSize.SMALL_TOOLBAR, tip_text=None, + menu_items=[], menu_funcs=[]): + if stock_id is not None: + self.stock_id = stock_id + self.icon = Gtk.Image() + self.icon.set_from_stock(stock_id, size) + self.icon.show() + GObject.GObject.__init__(self, self.icon, label) + self.set_tooltip_text(tip_text) + self.show() + button_menu = Gtk.Menu() + for item_tuple, func in zip(menu_items, menu_funcs): + name = item_tuple[0] + if len(item_tuple) == 1: + new_item = Gtk.MenuItem(name) + else: + new_item = Gtk.ImageMenuItem(stock_id=item_tuple[1]) + new_item.set_label(name) + new_item._func = func + new_item.connect("activate", lambda m: m._func()) + new_item.show() + button_menu.append(new_item) + button_menu.show() + self.set_menu(button_menu) + + +class ToolBar(Gtk.Toolbar): + + """An easier-to-use Gtk.Toolbar.""" + + def __init__(self, widgets=[], sep_on_name=[]): + super(ToolBar, self).__init__() + self.item_dict = {} + self.show() + widgets.reverse() + for name, stock in widgets: + if name in sep_on_name: + separator = Gtk.SeparatorToolItem() + separator.show() + self.insert(separator, 0) + if isinstance(stock, str) and stock.startswith("Gtk."): + stock = getattr(gtk, stock.replace("Gtk.", "", 1)) + if callable(stock): + widget = stock() + widget.show() + widget.set_tooltip_text(name) + else: + widget = CustomButton(stock_id=stock, tip_text=name, + as_tool=True) + icon_tool_item = Gtk.ToolItem() + icon_tool_item.add(widget) + icon_tool_item.show() + self.item_dict[name] = {"tip": name, "widget": widget, + "func": None} + self.insert(icon_tool_item, 0) + + def set_widget_function(self, name, function, args=[]): + self.item_dict[name]["widget"].args = args + if len(args) > 0: + self.item_dict[name]["widget"].connect("clicked", + lambda b: function(*b.args)) + else: + self.item_dict[name]["widget"].connect("clicked", + lambda b: function()) + + def set_widget_sensitive(self, name, is_sensitive): + self.item_dict[name]["widget"].set_sensitive(is_sensitive) + + +class AsyncStatusbar(Gtk.Statusbar): + + """Wrapper class to add polling a file to statusbar API.""" + + def __init__(self, *args): + super(AsyncStatusbar, self).__init__(*args) + self.show() + self.queue = multiprocessing.Queue() + self.ctx_id = self.get_context_id("_all") + self.should_stop = False + self.connect("destroy", self._handle_destroy) + GObject.timeout_add(1000, self._poll) + + def _handle_destroy(self, *args): + self.should_stop = True + + def _poll(self): + self.update() + return not self.should_stop + + def update(self): + try: + message = self.queue.get(block=False) + except queue.Empty: + pass + else: + self.push(self.ctx_id, message) + + def put(self, message, instant=False): + if instant: + self.push(self.ctx_id, message) + else: + self.queue.put_nowait(message) + self.update() + + +class AsyncLabel(Gtk.Label): + + """Wrapper class to add polling a file to label API.""" + + def __init__(self, *args): + super(AsyncLabel, self).__init__(*args) + self.show() + self.queue = multiprocessing.Queue() + self.should_stop = False + self.connect("destroy", self._handle_destroy) + GObject.timeout_add(1000, self._poll) + + def _handle_destroy(self, *args): + self.should_stop = True + + def _poll(self): + self.update() + return not self.should_stop + + def update(self): + try: + message = self.queue.get(block=False) + except queue.Empty: + pass + else: + self.set_text(message) + + def put(self, message, instant=False): + if instant: + self.set_text(message) + else: + self.queue.put_nowait(message) + self.update() + + +class ThreadedProgressBar(Gtk.ProgressBar): + + """Wrapper class to allow threaded progress bar pulsing.""" + + def __init__(self, *args, **kwargs): + super(ThreadedProgressBar, self).__init__(*args, **kwargs) + self.set_fraction(0.0) + self.set_pulse_step(0.1) + + def start_pulsing(self): + self.stop = False + self.show() + self.thread = threading.Thread() + self.thread.run = lambda: GObject.timeout_add(50, self._run) + self.thread.start() + + def _run(self): + Gdk.threads_enter() + self.pulse() + if self.stop: + self.set_fraction(1.0) + while Gtk.events_pending(): + Gtk.main_iteration() + Gdk.threads_leave() + return not self.stop + + def stop_pulsing(self): + self.stop = True + self.thread.join() + GObject.idle_add(self.hide) + + +class Notebook(Gtk.Notebook): + + """Wrapper class to improve the Gtk.Notebook API.""" + + def __init__(self, *args): + super(Notebook, self).__init__(*args) + self.set_scrollable(True) + self.show() + + def get_pages(self): + """Return all 'page' container widgets.""" + pages = [] + for i in range(self.get_n_pages()): + pages.append(self.get_nth_page(i)) + return pages + + def get_page_labels(self): + """Return all first pieces of text found in page labelwidgets.""" + labels = [] + for i in range(self.get_n_pages()): + nth_page = self.get_nth_page(i) + widgets = [self.get_tab_label(nth_page)] + while not hasattr(widgets[0], "get_text"): + if hasattr(widgets[0], "get_children"): + widgets.extend(widgets[0].get_children()) + elif hasattr(widgets[0], "get_child"): + widgets.append(widgets[0].get_child()) + widgets.pop(0) + labels.append(widgets[0].get_text()) + return labels + + def get_page_ids(self): + """Return the namespace id attributes for all notebook pages.""" + ids = [] + for i in range(self.get_n_pages()): + nth_page = self.get_nth_page(i) + if hasattr(nth_page, "namespace"): + ids.append(nth_page.namespace) + return ids + + def delete_by_label(self, label): + """Remove the (unique) page with this label as title.""" + self.remove_page(self.get_page_labels().index(label)) + + def delete_by_id(self, page_id): + """Use this only with pages with the attribute 'namespace'.""" + self.remove_page(self.get_page_ids().index(page_id)) + + def set_tab_label_packing(self, page): + super(Notebook, self).set_tab_label(page) # check + + +class TooltipTreeView(Gtk.TreeView): + + """Wrapper class for Gtk.TreeView with a better tooltip API. + + It takes two keyword arguments, model as in Gtk.TreeView and + get_tooltip_func which is analogous to the 'query-tooltip' + connector in Gtk.TreeView. + + This should be overridden either at or after initialisation. + It takes four arguments - the Gtk.TreeView, a Gtk.TreeIter and + a column index for the Gtk.TreeView, and a Gtk.ToolTip. + + Return True to display the ToolTip, or False to hide it. + + """ + + def __init__(self, model=None, get_tooltip_func=None, + multiple_selection=False): + super(TooltipTreeView, self).__init__(model) + self.get_tooltip = get_tooltip_func + self.set_has_tooltip(True) + self._last_tooltip_path = None + self._last_tooltip_column = None + self.connect('query-tooltip', self._handle_tooltip) + if multiple_selection: + self.set_rubber_banding(True) + self.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) + + def _handle_tooltip(self, view, xpos, ypos, kbd_ctx, tip): + """Handle creating a tooltip for the treeview.""" + xpos, ypos = view.convert_widget_to_bin_window_coords(xpos, ypos) + pathinfo = view.get_path_at_pos(xpos, ypos) + if pathinfo is None: + return False + path, column = pathinfo[:2] + if path is None: + return False + if (path != self._last_tooltip_path or + column != self._last_tooltip_column): + self._last_tooltip_path = path + self._last_tooltip_column = column + return False + col_index = view.get_columns().index(column) + row_iter = view.get_model().get_iter(path) + if self.get_tooltip is None: + return False + return self.get_tooltip(view, row_iter, col_index, tip) + + +class TreeModelSortUtil(object): + + """This class contains useful sorting methods for TreeModelSort. + + Arguments: + sort_model_getter_func - a function accepting no arguments that + returns the TreeModelSort. This is necessary if a combination + of TreeModelFilter and TreeModelSort is used. + + Keyword Arguments: + multi_sort_num - the maximum number of columns to sort by. For + example, setting this to 2 means that a single secondary sort + may be applied based on the previous sort column. + + You must connect to both handle_sort_column_change and sort_column + for multi-column sorting. Example code: + + sort_model = Gtk.TreeModelSort(filter_model) + sort_util = TreeModelSortUtil( + lambda: sort_model, + multi_sort_num=2) + for i in range(len(columns)): + sort_model.set_sort_func(i, sort_util.sort_column, i) + sort_model.connect("sort-column-changed", + sort_util.handle_sort_column_change) + + """ + + def __init__(self, sort_model_getter_func, multi_sort_num=1): + self._get_sort_model = sort_model_getter_func + self.multi_sort_num = multi_sort_num + self._sort_columns_stored = [] + + def clear_sort_columns(self): + """Clear any multi-sort information.""" + self._sort_columns_stored = [] + + def cmp_(self, value1, value2): + """Perform a useful form of 'cmp'""" + if (isinstance(value1, str) and isinstance(value2, str)): + if value1.isdigit() and value2.isdigit(): + return cmp(float(value1), float(value2)) + return metomi.rose.config.sort_settings(value1, value2) + return cmp(value1, value2) + + def handle_sort_column_change(self, model): + """Store previous sorting information for multi-column sorts.""" + id_, order = model.get_sort_column_id() + if id_ is None and order is None: + return False + if (self._sort_columns_stored and + self._sort_columns_stored[0][0] == id_): + self._sort_columns_stored.pop(0) + self._sort_columns_stored.insert(0, (id_, order)) + if len(self._sort_columns_stored) > 2: + self._sort_columns_stored.pop() + + def sort_column(self, model, iter1, iter2, col_index): + """Multi-column sort.""" + val1 = model.get_value(iter1, col_index) + val2 = model.get_value(iter2, col_index) + rval = self.cmp_(val1, val2) + # If rval is 1 or -1, no need for a multi-column sort. + if rval == 0: + if isinstance(model, Gtk.TreeModelSort): + this_order = model.get_sort_column_id()[1] + else: + this_order = self._get_sort_model().get_sort_column_id()[1] + cmp_factor = 1 + if this_order == Gtk.SortType.DESCENDING: + # We need to de-invert the sort order for multi sorting. + cmp_factor = -1 + i = 0 + while rval == 0 and i < len(self._sort_columns_stored): + next_id, next_order = self._sort_columns_stored[i] + if next_id == col_index: + i += 1 + continue + next_cmp_factor = cmp_factor * 1 + if next_order == Gtk.SortType.DESCENDING: + # Set the correct order for multi sorting. + next_cmp_factor = cmp_factor * -1 + val1 = model.get_value(iter1, next_id) + val2 = model.get_value(iter2, next_id) + rval = next_cmp_factor * self.cmp_(val1, val2) + i += 1 + return rval + + +def color_parse(color_specification): + """Wrap Gdk.color_parse and report errors with the specification.""" + try: + return Gdk.color_parse(color_specification) + except ValueError: + metomi.rose.reporter.Reporter().report(ColourParseError(color_specification)) + # Return a noticeable colour. + return Gdk.color_parse("#0000FF") # Blue + + +def get_hyperlink_label(text, search_func=lambda i: False): + """Return a label with clickable hyperlinks.""" + label = Gtk.Label() + label.show() + try: + Pango.parse_markup(text) + except GLib.GError: + label.set_text(text) + else: + try: + label.connect("activate-link", + lambda l, u: handle_link(u, search_func)) + except TypeError: # No such signal before PyGTK 2.18 + label.connect("button-release-event", + lambda l, e: extract_link(l, search_func)) + text = REC_HYPERLINK_ID_OR_URL.sub(MARKUP_URL_UNDERLINE, text) + label.set_markup(text) + else: + text = REC_HYPERLINK_ID_OR_URL.sub(MARKUP_URL_HTML, text) + label.set_markup(text) + return label + + +def get_icon(system="rose"): + """Return a GdkPixbuf.Pixbuf for the system icon.""" + locator = metomi.rose.resource.ResourceLocator(paths=sys.path) + icon_path = locator.locate("etc/images/{0}-icon-trim.svg".format(system)) + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path) + except Exception: + icon_path = locator.locate( + "etc/images/{0}-icon-trim.png".format(system)) + pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path) + return pixbuf + + +def handle_link(url, search_function, handle_web=False): + if url.startswith("http"): + if handle_web: + webbrowser.open(url) + else: + search_function(url) + return False + + +def extract_link(label, search_function): + text = label.get_text() + bounds = label.get_selection_bounds() + if not bounds: + return None + lower_bound, upper_bound = bounds + while lower_bound > 0: + if text[lower_bound - 1].isspace(): + break + lower_bound -= 1 + while upper_bound < len(text): + if text[upper_bound].isspace(): + break + upper_bound += 1 + link = text[lower_bound: upper_bound] + if any(c.isspace() for c in link): + return None + handle_link(link, search_function, handle_web=True) + + +def rc_setup(rc_resource): + """Run Gtk.rc_parse on the resource, to setup the gtk settings.""" + Gtk.rc_parse(rc_resource) + + +def setup_scheduler_icon(ipath=None): + """Setup a 'stock' icon for the scheduler""" + new_icon_factory = Gtk.IconFactory() + locator = metomi.rose.resource.ResourceLocator(paths=sys.path) + iname = "rose-gtk-scheduler" + if ipath is None: + new_icon_factory.add( + iname, Gtk.icon_factory_lookup_default(Gtk.STOCK_MISSING_IMAGE)) + else: + path = locator.locate(ipath) + pixbuf = GdkPixbuf.Pixbuf.new_from_file(path) + new_icon_factory.add(iname, Gtk.IconSet(pixbuf)) + new_icon_factory.add_default() + + +def setup_stock_icons(): + """Setup any additional 'stock' icons.""" + new_icon_factory = Gtk.IconFactory() + locator = metomi.rose.resource.ResourceLocator(paths=sys.path) + for png_icon_name in ["gnome_add", + "gnome_add_errors", + "gnome_add_warnings", + "gnome_package_system", + "gnome_package_system_errors", + "gnome_package_system_warnings"]: + ifile = png_icon_name + ".png" + istring = png_icon_name.replace("_", "-") + path = locator.locate("etc/images/rose-config-edit/" + ifile) + pixbuf = GdkPixbuf.Pixbuf.new_from_file(path) + new_icon_factory.add("rose-gtk-" + istring, + Gtk.IconSet(pixbuf)) + exp_icon_pixbuf = get_icon() + new_icon_factory.add("rose-exp-logo", Gtk.IconSet(exp_icon_pixbuf)) + new_icon_factory.add_default() + + +def safe_str(value): + """Formats a value safely for use in pango markup.""" + string = str(value).replace("&", "&") + return string.replace(">", ">").replace("<", "<")