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("<", "<")